From 916f3cafefa6751123d20f4a7f297760ae00ca53 Mon Sep 17 00:00:00 2001 From: Predrag Knezevic Date: Tue, 10 Feb 2026 14:12:56 +0100 Subject: [PATCH] feat: add `kubebuilder:externalDocs` marker Implement the `kubebuilder:externalDocs` marker to allow specifying external documentation (url and description) on fields and types, populating the externalDocs field in the generated OpenAPI schema. The url field is required and validated to be a well-formed URL. The description field is optional. Ref: https://spec.openapis.org/oas/v3.0.0.html#external-documentation-object Co-Authored-By: Claude Opus 4.6 --- pkg/crd/markers/crd.go | 31 +++++++++ pkg/crd/markers/zz_generated.markerhelp.go | 20 ++++++ pkg/crd/parser_integration_test.go | 10 +++ pkg/crd/testdata/external_docs/types.go | 59 +++++++++++++++++ .../testdata.kubebuilder.io_externaldocs.yaml | 65 +++++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 pkg/crd/testdata/external_docs/types.go create mode 100644 pkg/crd/testdata/testdata.kubebuilder.io_externaldocs.yaml diff --git a/pkg/crd/markers/crd.go b/pkg/crd/markers/crd.go index f02e8f7ee..fe482fe12 100644 --- a/pkg/crd/markers/crd.go +++ b/pkg/crd/markers/crd.go @@ -18,6 +18,7 @@ package markers import ( "fmt" + "net/url" "strings" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -57,6 +58,11 @@ var CRDMarkers = []*definitionWithHelp{ must(markers.MakeDefinition("kubebuilder:selectablefield", markers.DescribesType, SelectableField{})). WithHelp(SelectableField{}.Help()), + + must(markers.MakeDefinition("kubebuilder:externalDocs", markers.DescribesField, ExternalDocs{})). + WithHelp(ExternalDocs{}.Help()), + must(markers.MakeDefinition("kubebuilder:externalDocs", markers.DescribesType, ExternalDocs{})). + WithHelp(ExternalDocs{}.Help()), } // TODO: categories and singular used to be annotations types @@ -419,3 +425,28 @@ func (s SelectableField) ApplyToCRD(crd *apiextensionsv1.CustomResourceDefinitio return nil } + +// +controllertools:marker:generateHelp:category=CRD + +// ExternalDocs specifies external documentation for this field or type. +// +// The url is required and must be a valid URL. The description is optional +// and provides a short description of the external documentation. +type ExternalDocs struct { + // URL specifies the URL for the target documentation. + URL string `marker:"url"` + + // Description is a short description of the target documentation. + Description string `marker:",optional"` +} + +func (m ExternalDocs) ApplyToSchema(schema *apiextensionsv1.JSONSchemaProps) error { + if _, err := url.ParseRequestURI(m.URL); err != nil { + return fmt.Errorf("invalid url %q in kubebuilder:externalDocs marker: %w", m.URL, err) + } + schema.ExternalDocs = &apiextensionsv1.ExternalDocumentation{ + URL: m.URL, + Description: m.Description, + } + return nil +} diff --git a/pkg/crd/markers/zz_generated.markerhelp.go b/pkg/crd/markers/zz_generated.markerhelp.go index bf650ab23..61b667685 100644 --- a/pkg/crd/markers/zz_generated.markerhelp.go +++ b/pkg/crd/markers/zz_generated.markerhelp.go @@ -78,6 +78,26 @@ func (DeprecatedVersion) Help() *markers.DefinitionHelp { } } +func (ExternalDocs) Help() *markers.DefinitionHelp { + return &markers.DefinitionHelp{ + Category: "CRD", + DetailedHelp: markers.DetailedHelp{ + Summary: "specifies external documentation for this field or type.", + Details: "The url is required and must be a valid URL. The description is optional\nand provides a short description of the external documentation.", + }, + FieldHelp: map[string]markers.DetailedHelp{ + "URL": { + Summary: "specifies the URL for the target documentation.", + Details: "", + }, + "Description": { + Summary: "is a short description of the target documentation.", + Details: "", + }, + }, + } +} + func (Enum) Help() *markers.DefinitionHelp { return &markers.DefinitionHelp{ Category: "CRD validation", diff --git a/pkg/crd/parser_integration_test.go b/pkg/crd/parser_integration_test.go index 6b3223db4..7df9e1cdd 100644 --- a/pkg/crd/parser_integration_test.go +++ b/pkg/crd/parser_integration_test.go @@ -224,6 +224,16 @@ var _ = Describe("CRD Generation From Parsing to CustomResourceDefinition", func }) }) + Context("ExternalDoc API", func() { + BeforeEach(func() { + pkgPaths = []string{"./external_docs/..."} + expPkgLen = 1 + }) + It("should successfully generate the CRD with external documentation", func() { + assertCRD(pkgs[0], "ExternalDoc", "testdata.kubebuilder.io_externaldocs.yaml") + }) + }) + Context("CronJob API without group", func() { BeforeEach(func() { pkgPaths = []string{"./nogroup"} diff --git a/pkg/crd/testdata/external_docs/types.go b/pkg/crd/testdata/external_docs/types.go new file mode 100644 index 000000000..75d554809 --- /dev/null +++ b/pkg/crd/testdata/external_docs/types.go @@ -0,0 +1,59 @@ +/* + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// +groupName=testdata.kubebuilder.io +// +versionName=v1 +package external_docs + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:root=true + +// ExternalDocSpec defines the desired state of ExternalDoc +type ExternalDocSpec struct { + // This tests that external documentation can be attached to a field with url and description. + // +kubebuilder:externalDocs:url="https://example.com/docs",description="external docs description" + FieldWithExternalDoc string `json:"fieldWithExternalDoc,omitempty"` + + // This tests that external documentation can be attached with only url. + // +kubebuilder:externalDocs:url="https://example.com/docs" + FieldWithExternalDocURLOnly string `json:"fieldWithExternalDocURLOnly,omitempty"` + + // This tests that external documentation from a type is propagated. + TypeWithExternalDoc TypeWithExternalDoc `json:"typeWithExternalDoc,omitempty"` +} + +// TypeWithExternalDoc is a type with external documentation. +// +kubebuilder:externalDocs:url="https://example.com/type-docs",description="type-level external docs" +type TypeWithExternalDoc string + +// ExternalDoc is the Schema for the external docs API +type ExternalDoc struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ExternalDocSpec `json:"spec,omitempty"` +} + +// +kubebuilder:object:root=true + +// ExternalDocList contains a list of ExternalDoc +type ExternalDocList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ExternalDoc `json:"items"` +} diff --git a/pkg/crd/testdata/testdata.kubebuilder.io_externaldocs.yaml b/pkg/crd/testdata/testdata.kubebuilder.io_externaldocs.yaml new file mode 100644 index 000000000..a9410e47f --- /dev/null +++ b/pkg/crd/testdata/testdata.kubebuilder.io_externaldocs.yaml @@ -0,0 +1,65 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: externaldocs.testdata.kubebuilder.io +spec: + group: testdata.kubebuilder.io + names: + kind: ExternalDoc + listKind: ExternalDocList + plural: externaldocs + singular: externaldoc + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: ExternalDoc is the Schema for the external docs API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ExternalDocSpec defines the desired state of ExternalDoc + properties: + fieldWithExternalDoc: + description: This tests that external documentation can be attached + to a field with url and description. + externalDocs: + description: external docs description + url: https://example.com/docs + type: string + fieldWithExternalDocURLOnly: + description: This tests that external documentation can be attached + with only url. + externalDocs: + url: https://example.com/docs + type: string + typeWithExternalDoc: + description: This tests that external documentation from a type is + propagated. + externalDocs: + description: type-level external docs + url: https://example.com/type-docs + type: string + type: object + type: object + served: true + storage: true