Repository: strimzi/strimzi-kafka-operator (2026-04-02T20:30:14Z)

Issue:
Add CRD `@ExternalLink` annotation as a generic alternative to `@KubeLink`
In CRDs, we have currently the `@KubeLink` annotation that we can use to link from our documentation to the Kubernetes documentation. That is useful in situations when we directly intebrate parts of the Kubernetes API into our API and we want to link to Kubernetes documentation.

`@KubeLink` uses the Kubernetes resource identification (group, kind, version) to generate the URL automatically. But in some cases, it might be useful to have a way to use a random URL. That might be useful, for example, when linking to resources that are part of the Gateway API, which is not directly part of the Kubernetes API. In these cases, we cannot use `@KubeLinker`. And there migth be more such cases in the future as this seems to be the Kubernetes trend to do some of the new APIs separately as CRDs. This could be implemented by adding a new `@ExternalLink` annotation that would take the URL as a parameter and use it in the docs generator.


Discussion:
This is a good improvement and make sense (generally) ... but maybe name annotation `ExternalLink` more like`@ExternalDocLink` that could be better (but that's just a nit)? 
`@ExternalLink` corresponds to the existing naming.
> @ExternalLink corresponds to the existing naming.

@scholzj sorry I am confused, because in the description you said "adding a new @ExternalLink annotation", so it @ExternalLink new or existing? which existing naming are you talking about?
> > [@ExternalLink](https://github.com/ExternalLink) corresponds to the existing naming.
> 
> [@scholzj](https://github.com/scholzj) sorry I am confused, because in the description you said "adding a new [@ExternalLink](https://github.com/ExternalLink) annotation", so it [@ExternalLink](https://github.com/ExternalLink) new or existing? which existing naming are you talking about?

I think Jakub meant that we use `@KubeLink` and so it would be good to be a consistent in a way that we would have new annotation named similarly (i.e., `@ExternalLink`)
The existing naming is the `@KubeLink` (and not `@KubeDocLink`)
Tbh it wasn't totally clear to me you were comparing the two when saying "corresponds to the existing naming", so my question.
> Tbh it wasn't totally clear to me you were comparing the two when saying "corresponds to the existing naming", so my question.

Yeah, that is fine. I realized after your question that it probably was not obvious. That is why I added the explanation 😉
I think this makes sense, and am happy with `@ExternalLink` to correspond with the existing `@KubeLink` 
Hey i heard you guys needed me? I got a few mentions here.
Ups, sorry 🤣
Marking this as triaged based on the rules of asynchronous issue triage. We should go with `@ExternalLink`.



Patch:
diff --git a/api/pom.xml b/api/pom.xml
index 8539ec67a2a..29f01cbbd27 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -321,8 +321,7 @@
                                 <argument>-classpath</argument>
                                 <argument>${project.basedir}${file.separator}target${file.separator}classes${path.separator}${project.basedir}${file.separator}..${file.separator}crd-generator${file.separator}target${file.separator}crd-generator-${project.version}.jar</argument>
                                 <argument>io.strimzi.crdgenerator.DocGenerator</argument>
-                                <argument>--linker</argument>
-                                <argument>io.strimzi.crdgenerator.KubeLinker</argument>
+                                <argument>--kubernetes-base-url</argument>
                                 <argument>https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/</argument>
                                 <argument>modules/appendix_crds.adoc</argument>
                                 <argument>io.strimzi.api.kafka.model.kafka.Kafka</argument>
diff --git a/crd-generator/src/main/java/io/strimzi/crdgenerator/DocGenerator.java b/crd-generator/src/main/java/io/strimzi/crdgenerator/DocGenerator.java
index b1b31231f5e..195cca121c9 100644
--- a/crd-generator/src/main/java/io/strimzi/crdgenerator/DocGenerator.java
+++ b/crd-generator/src/main/java/io/strimzi/crdgenerator/DocGenerator.java
@@ -13,6 +13,7 @@
 import io.strimzi.crdgenerator.annotations.Crd;
 import io.strimzi.crdgenerator.annotations.Description;
 import io.strimzi.crdgenerator.annotations.DescriptionFile;
+import io.strimzi.crdgenerator.annotations.ExternalLink;
 import io.strimzi.crdgenerator.annotations.KubeLink;
 import io.strimzi.crdgenerator.annotations.PresentInVersions;
 
@@ -89,7 +90,7 @@ private void usedIn(Class<?> cls, Map<Class<?>, Set<Class<?>>> usedIn) {
         Set<Property> memorableProperties = new HashSet<>();
 
         for (Property property : properties(crApiVersion, cls).values()) {
-            if (property.isAnnotationPresent(KubeLink.class)) {
+            if (property.isAnnotationPresent(KubeLink.class) || property.isAnnotationPresent(ExternalLink.class)) {
                 continue;
             }
 
@@ -151,9 +152,8 @@ private void appendCommonTypeDoc(Crd crd, Class<?> cls) throws IOException {
             final Property property = entry.getValue();
             PropertyType propertyType = property.getType();
         
-            // Set the external link to Kubernetes docs or the link for fields distinguished by `type`
-            KubeLink kubeLink = property.getAnnotation(KubeLink.class);
-            String externalUrl = linker != null && kubeLink != null ? linker.link(kubeLink) : null;
+            // Set the external link to some other docs
+            String externalUrl = linker.link(property);
         
             // Add the property name
             out.append("|").append(propertyName);
@@ -497,18 +497,8 @@ public static void main(String[] args) throws IOException, ClassNotFoundExceptio
         for (int i = 0; i < args.length; i++) {
             String arg = args[i];
             if (arg.startsWith("-")) {
-                if ("--linker".equals(arg)) {
-                    String className = args[++i];
-                    Class<? extends Linker> linkerClass = classInherits(Class.forName(className), Linker.class);
-                    if (linkerClass != null) {
-                        try {
-                            linker = linkerClass.getConstructor(String.class).newInstance(args[++i]);
-                        } catch (ReflectiveOperationException e) {
-                            throw new RuntimeException("--linker option can't be handled", e);
-                        }
-                    } else {
-                        System.err.println(className + " is not a subclass of " + Linker.class.getName());
-                    }
+                if ("--kubernetes-base-url".equals(arg)) {
+                    linker = new Linker(args[++i]);
                 } else {
                     throw new RuntimeException("Unsupported option " + arg);
                 }
diff --git a/crd-generator/src/main/java/io/strimzi/crdgenerator/KubeLinker.java b/crd-generator/src/main/java/io/strimzi/crdgenerator/KubeLinker.java
deleted file mode 100644
index def33621ed4..00000000000
--- a/crd-generator/src/main/java/io/strimzi/crdgenerator/KubeLinker.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright Strimzi authors.
- * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html).
- */
-package io.strimzi.crdgenerator;
-
-import io.strimzi.crdgenerator.annotations.KubeLink;
-
-/**
- * CLass for handling links to Kubernetes documentation
- */
-public class KubeLinker implements Linker {
-    private final String baseUrl;
-
-    /**
-     * Constructs the KubeLinker and initializes the base URL to the Kubernetes documentation
-     *
-     * @param baseUrl   The base URL to the Kubernetes documentation which should be used
-     */
-    public KubeLinker(String baseUrl) {
-        // E.g. https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.11/
-        this.baseUrl = baseUrl;
-    }
-
-    /**
-     * Generates an URL to the Kubernetes documentation to some specific part of the Kube API
-     *
-     * @param kubeLink  Specifies the Kubernetes API which should the link point to
-     *
-     * @return  An HTTP link deep-linking to the Kubernetes documentation
-     */
-    @Override
-    public String link(KubeLink kubeLink) {
-        // E.g. https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.11/#networkpolicyingressrule-v1-networking-k8s-io
-        return baseUrl + "#" + kubeLink.kind() + "-" + kubeLink.version() + "-" + kubeLink.group().replace(".", "-");
-    }
-}
diff --git a/crd-generator/src/main/java/io/strimzi/crdgenerator/Linker.java b/crd-generator/src/main/java/io/strimzi/crdgenerator/Linker.java
index 64909db263e..c2fcc0dd733 100644
--- a/crd-generator/src/main/java/io/strimzi/crdgenerator/Linker.java
+++ b/crd-generator/src/main/java/io/strimzi/crdgenerator/Linker.java
@@ -4,18 +4,65 @@
  */
 package io.strimzi.crdgenerator;
 
+import io.strimzi.crdgenerator.annotations.ExternalLink;
 import io.strimzi.crdgenerator.annotations.KubeLink;
 
 /**
- * Interface for handling links in the documentation generator
+ * Class for handling links in the documentation generator
  */
-interface Linker {
+public class Linker {
+    private final String baseUrl;
+
+    /**
+     * Constructs the Linker and initializes the base URL to the Kubernetes documentation
+     *
+     * @param baseUrl   The base URL to the Kubernetes documentation which should be used
+     */
+    public Linker(String baseUrl) {
+        // E.g. https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.11/
+        this.baseUrl = baseUrl;
+    }
+
+    /**
+     * Creates the link based on the annotations on the property
+     *
+     * @param property  Property to check the annotation on
+     *
+     * @return  Returns the URL based on the annotation or null if no link annotation is present
+     */
+    public String link(Property property)    {
+        KubeLink kubeLink = property.getAnnotation(KubeLink.class);
+        ExternalLink externalLink = property.getAnnotation(ExternalLink.class);
+
+        if (kubeLink != null) {
+            return link(kubeLink);
+        } else if (externalLink != null) {
+            return link(externalLink);
+        } else {
+            return null;
+        }
+    }
+
     /**
-     * Generates URL to some specific documentation
+     * Generates URL to a specific page in the Kubernetes API reference.
      *
-     * @param kubeLink  Specifies the Kubernetes API which should the link point to
+     * @param kubeLink  The KubeLink annotation which specifies the Kubernetes API to link to
      *
      * @return  An HTTP link deep-linking to the Kubernetes documentation
      */
-    String link(KubeLink kubeLink);
+    private String link(KubeLink kubeLink)   {
+        // E.g. https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.11/#networkpolicyingressrule-v1-networking-k8s-io
+        return baseUrl + "#" + kubeLink.kind() + "-" + kubeLink.version() + "-" + kubeLink.group().replace(".", "-");
+    }
+
+    /**
+     * Returns URL to a specific page in the API reference documentation of some other project.
+     *
+     * @param externalLink  The ExternalLink annotation which specifies the external URL
+     *
+     * @return  An HTTP URL
+     */
+    private String link(ExternalLink externalLink)   {
+        return externalLink.url();
+    }
 }
diff --git a/crd-generator/src/main/java/io/strimzi/crdgenerator/annotations/ExternalLink.java b/crd-generator/src/main/java/io/strimzi/crdgenerator/annotations/ExternalLink.java
new file mode 100644
index 00000000000..72ffe462ad8
--- /dev/null
+++ b/crd-generator/src/main/java/io/strimzi/crdgenerator/annotations/ExternalLink.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright Strimzi authors.
+ * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html).
+ */
+package io.strimzi.crdgenerator.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Configure a link to some external API description. For a link to Kubernetes APi use the @{code @KubeLink} annotation.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.METHOD, ElementType.FIELD})
+public @interface ExternalLink {
+    /**
+     * Gets the URL to which the link should point.
+     *
+     * @return  The URL to which the link should point
+     */
+    String url();
+}


Test patch:
diff --git a/crd-generator/src/test/java/io/strimzi/crdgenerator/DocGeneratorTest.java b/crd-generator/src/test/java/io/strimzi/crdgenerator/DocGeneratorTest.java
index 502148f1137..f2fe9cd31fb 100644
--- a/crd-generator/src/test/java/io/strimzi/crdgenerator/DocGeneratorTest.java
+++ b/crd-generator/src/test/java/io/strimzi/crdgenerator/DocGeneratorTest.java
@@ -10,19 +10,14 @@
 import java.io.IOException;
 import java.io.StringWriter;
 
-import static io.strimzi.crdgenerator.DocGenerator.classInherits;
 import static java.util.Collections.singletonList;
-import static org.hamcrest.CoreMatchers.is;
-import static org.hamcrest.CoreMatchers.notNullValue;
-import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 public class DocGeneratorTest {
     @Test
-    public void simpleTest() throws IOException, ClassNotFoundException {
-        assertThat(classInherits(Class.forName("io.strimzi.crdgenerator.KubeLinker"), Linker.class), is(notNullValue()));
+    public void simpleTest() throws IOException {
         StringWriter w = new StringWriter();
-        DocGenerator crdGenerator = new DocGenerator(ApiVersion.V1, 1, singletonList(ExampleCrd.class), w, new KubeLinker("{KubeApiReferenceBase}"));
+        DocGenerator crdGenerator = new DocGenerator(ApiVersion.V1, 1, singletonList(ExampleCrd.class), w, new Linker("{KubeApiReferenceBase}"));
         crdGenerator.generate(ExampleCrd.class);
         String s = w.toString();
         assertEquals(CrdTestUtils.readResource("simpleTest.adoc"), s);
diff --git a/crd-generator/src/test/java/io/strimzi/crdgenerator/ExampleCrd.java b/crd-generator/src/test/java/io/strimzi/crdgenerator/ExampleCrd.java
index 462082779a0..8265f55dd5f 100644
--- a/crd-generator/src/test/java/io/strimzi/crdgenerator/ExampleCrd.java
+++ b/crd-generator/src/test/java/io/strimzi/crdgenerator/ExampleCrd.java
@@ -18,6 +18,7 @@
 import io.strimzi.crdgenerator.annotations.Crd;
 import io.strimzi.crdgenerator.annotations.Description;
 import io.strimzi.crdgenerator.annotations.Example;
+import io.strimzi.crdgenerator.annotations.ExternalLink;
 import io.strimzi.crdgenerator.annotations.KubeLink;
 import io.strimzi.crdgenerator.annotations.Minimum;
 import io.strimzi.crdgenerator.annotations.MinimumItems;
@@ -66,7 +67,7 @@
     "rawList", "listOfRawList", "arrayOfList", "arrayOfRawList", "listOfArray", "arrayOfTypeVar", "listOfTypeVar",
     "arrayOfBoundTypeVar", "listOfBoundTypeVar", "arrayOfBoundTypeVar2", "listOfBoundTypeVar2",
     "listOfWildcardTypeVar1", "listOfWildcardTypeVar2", "listOfWildcardTypeVar3", "listOfWildcardTypeVar4",
-    "listOfCustomizedEnum", "listOfNormalEnum", "listOfMaps", "either", "or", "status", "spec"})
+    "listOfCustomizedEnum", "listOfNormalEnum", "listOfMaps", "either", "or", "status", "spec", "external"})
 public class ExampleCrd<T, U extends Number, V extends U> extends CustomResource {
 
     private String ignored;
@@ -143,6 +144,8 @@ public class ExampleCrd<T, U extends Number, V extends U> extends CustomResource
 
     public String or;
 
+    private String external;
+
     @Description("Example of complex type.")
     @JsonInclude(JsonInclude.Include.NON_NULL)
     @JsonPropertyOrder({"foo", "bar"})
@@ -353,4 +356,13 @@ public Affinity getAffinity() {
     public void setAffinity(Affinity affinity) {
         this.affinity = affinity;
     }
+
+    @ExternalLink(url = "https://gateway-api.sigs.k8s.io/reference/spec/#parentreference")
+    public String getExternal() {
+        return external;
+    }
+
+    public void setExternal(String external) {
+        this.external = external;
+    }
 }
diff --git a/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTest.adoc b/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTest.adoc
index 838d8246bae..eab209b7234 100644
--- a/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTest.adoc
+++ b/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTest.adoc
@@ -128,6 +128,9 @@
 |spec
 |object
 |
+|external
+|https://gateway-api.sigs.k8s.io/reference/spec/#parentreference[String]
+|
 |====
 
 [id='type-ObjectProperty-{context}']
diff --git a/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTest.yaml b/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTest.yaml
index f1f84a2e6be..1d586bf91ed 100644
--- a/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTest.yaml
+++ b/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTest.yaml
@@ -607,6 +607,8 @@ spec:
           spec:
             type: object
             properties: {}
+          external:
+            type: string
         oneOf:
         - properties:
             either: {}
@@ -1214,6 +1216,8 @@ spec:
           spec:
             type: object
             properties: {}
+          external:
+            type: string
         oneOf:
         - properties:
             either: {}
diff --git a/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTestHelmMetadata.yaml b/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTestHelmMetadata.yaml
index 54516c3f91d..fd914966f9a 100644
--- a/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTestHelmMetadata.yaml
+++ b/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTestHelmMetadata.yaml
@@ -613,6 +613,8 @@ spec:
           spec:
             type: object
             properties: {}
+          external:
+            type: string
         oneOf:
         - properties:
             either: {}
@@ -1220,6 +1222,8 @@ spec:
           spec:
             type: object
             properties: {}
+          external:
+            type: string
         oneOf:
         - properties:
             either: {}
diff --git a/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTestWithoutDescriptions.yaml b/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTestWithoutDescriptions.yaml
index 09062a6f67f..253e91fe24d 100644
--- a/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTestWithoutDescriptions.yaml
+++ b/crd-generator/src/test/resources/io/strimzi/crdgenerator/simpleTestWithoutDescriptions.yaml
@@ -600,6 +600,8 @@ spec:
           spec:
             type: object
             properties: {}
+          external:
+            type: string
         oneOf:
         - properties:
             either: {}
@@ -1202,6 +1204,8 @@ spec:
           spec:
             type: object
             properties: {}
+          external:
+            type: string
         oneOf:
         - properties:
             either: {}