From 65a2a618684b3eec64177f0a7a5983c84ef203ae Mon Sep 17 00:00:00 2001 From: Predrag Knezevic Date: Fri, 13 Mar 2026 18:33:20 +0100 Subject: [PATCH] Preserve externalDocs and example in CRD structural schema conversion Thread externalDocs and example fields through the structural schema so they flow to published OpenAPI specs. Previously, both were intentionally dropped during JSONSchemaProps to Structural conversion, making them unavailable to clients like kubectl explain. --- .../pkg/apiserver/schema/convert.go | 13 ++++++--- .../pkg/apiserver/schema/convert_test.go | 4 --- .../pkg/apiserver/schema/kubeopenapi.go | 7 +++++ .../pkg/apiserver/schema/kubeopenapi_test.go | 29 +++++++++++++++++-- .../pkg/apiserver/schema/structural.go | 18 +++++++++--- .../pkg/apiserver/schema/validation_test.go | 3 ++ .../apiserver/schema/zz_generated.deepcopy.go | 22 ++++++++++++++ .../controller/openapi/v2/conversion_test.go | 2 -- 8 files changed, 82 insertions(+), 16 deletions(-) diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert.go index 78350c135fc..a4a35354088 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert.go @@ -35,10 +35,6 @@ import ( // - dependencies // - additionalItems // - definitions. -// -// The follow fields are not preserved: -// - externalDocs -// - example. func NewStructural(s *apiextensions.JSONSchemaProps) (*Structural, error) { if s == nil { return nil, nil @@ -126,6 +122,15 @@ func newGenerics(s *apiextensions.JSONSchemaProps) (*Generic, error) { if s.Default != nil { g.Default = JSON{interface{}(*s.Default)} } + if s.ExternalDocs != nil { + g.ExternalDocs = &ExternalDocumentation{ + Description: s.ExternalDocs.Description, + URL: s.ExternalDocs.URL, + } + } + if s.Example != nil { + g.Example = JSON{interface{}(*s.Example)} + } return g, nil } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert_test.go index 5eb7fba2727..e16f0f2cad5 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert_test.go @@ -70,10 +70,6 @@ func TestStructuralRoundtripOrError(t *testing.T) { origSchema := &apiextensions.JSONSchemaProps{} x := reflect.ValueOf(origSchema).Elem() n := rand.Intn(x.NumField()) - if name := x.Type().Field(n).Name; name == "Example" || name == "ExternalDocs" { - // we drop these intentionally - continue - } f.Fill(x.Field(n).Addr().Interface()) // it roundtrips or NewStructural errors out. We should never drop anything diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/kubeopenapi.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/kubeopenapi.go index df78ba77e61..a8b29a1e61d 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/kubeopenapi.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/kubeopenapi.go @@ -62,6 +62,13 @@ func (g *Generic) toKubeOpenAPI(ret *spec.Schema) { ret.Description = g.Description ret.Title = g.Title ret.Default = g.Default.Object + if g.ExternalDocs != nil { + ret.ExternalDocs = &spec.ExternalDocumentation{ + Description: g.ExternalDocs.Description, + URL: g.ExternalDocs.URL, + } + } + ret.Example = g.Example.Object } func (x *Extensions) toKubeOpenAPI(ret *spec.Schema) { diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/kubeopenapi_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/kubeopenapi_test.go index b327e52d561..1ffc89e1f66 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/kubeopenapi_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/kubeopenapi_test.go @@ -30,6 +30,23 @@ import ( "k8s.io/apimachinery/pkg/util/json" ) +// isJSONEmptyForOmitempty returns true if the JSON value would be dropped by the +// kube-openapi v2 JSON encoder's omitempty tag. Unlike omitzero (used for Default), +// the Example field uses omitempty which considers empty strings, empty slices, +// and empty maps as empty. +func isJSONEmptyForOmitempty(j JSON) bool { + switch v := j.Object.(type) { + case string: + return v == "" + case []interface{}: + return len(v) == 0 + case map[string]interface{}: + return len(v) == 0 + default: + return j.Object == nil + } +} + func TestStructuralKubeOpenAPIRoundtrip(t *testing.T) { f := randfill.New() seed := time.Now().UnixNano() @@ -64,13 +81,21 @@ func TestStructuralKubeOpenAPIRoundtrip(t *testing.T) { f.Fill(orig) // normalize Structural.ValueValidation to zero values if it was nil before + // and normalize Example values that are considered empty by omitempty + // (the kube-openapi Example field uses omitempty, not omitzero, so + // empty strings/slices/maps get dropped during JSON roundtrip) normalizer := Visitor{ Structural: func(s *Structural) bool { + changed := false if s.ValueValidation == nil { s.ValueValidation = &ValueValidation{} - return true + changed = true } - return false + if isJSONEmptyForOmitempty(s.Generic.Example) { + s.Generic.Example = JSON{} + changed = true + } + return changed }, } normalizer.Visit(orig) diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/structural.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/structural.go index 5688c2ac40b..9965718b014 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/structural.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/structural.go @@ -46,6 +46,14 @@ type StructuralOrBool struct { // +k8s:deepcopy-gen=true +// ExternalDocumentation allows referencing an external resource for extended documentation. +type ExternalDocumentation struct { + Description string + URL string +} + +// +k8s:deepcopy-gen=true + // Generic contains the generic schema fields not allowed in value validation. type Generic struct { Description string @@ -53,10 +61,12 @@ type Generic struct { // It can be object, array, number, integer, boolean, string. // It is optional only if x-kubernetes-preserve-unknown-fields // or x-kubernetes-int-or-string is true. - Type string - Title string - Default JSON - Nullable bool + Type string + Title string + Default JSON + Nullable bool + ExternalDocs *ExternalDocumentation + Example JSON } // +k8s:deepcopy-gen=true diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation_test.go index a83e50ff958..734cec1325f 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation_test.go @@ -402,6 +402,9 @@ func TestValidateNestedValueValidationComplete(t *testing.T) { // check that we didn't forget to check any forbidden generic field tt := reflect.TypeOf(Generic{}) for i := 0; i < tt.NumField(); i++ { + if name := tt.Field(i).Name; name == "ExternalDocs" || name == "Example" { + continue // allowed in nested value validations + } vv := &NestedValueValidation{} x := reflect.ValueOf(&vv.ForbiddenGenerics).Elem() fuzzer.Fill(x.Field(i).Addr().Interface()) diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/zz_generated.deepcopy.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/zz_generated.deepcopy.go index 9cec0262b53..c2154a05fd5 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/zz_generated.deepcopy.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/zz_generated.deepcopy.go @@ -56,10 +56,32 @@ func (in *Extensions) DeepCopy() *Extensions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalDocumentation) DeepCopyInto(out *ExternalDocumentation) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalDocumentation. +func (in *ExternalDocumentation) DeepCopy() *ExternalDocumentation { + if in == nil { + return nil + } + out := new(ExternalDocumentation) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Generic) DeepCopyInto(out *Generic) { *out = *in out.Default = in.Default.DeepCopy() + if in.ExternalDocs != nil { + in, out := &in.ExternalDocs, &out.ExternalDocs + *out = new(ExternalDocumentation) + **out = **in + } + out.Example = in.Example.DeepCopy() return } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/controller/openapi/v2/conversion_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/controller/openapi/v2/conversion_test.go index 76363d945b4..3f375956217 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/controller/openapi/v2/conversion_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/controller/openapi/v2/conversion_test.go @@ -632,7 +632,6 @@ func Test_ConvertJSONSchemaPropsToOpenAPIv2SchemaByType(t *testing.T) { }, expected: new(spec.Schema). WithExternalDocs(testStr, testStr2), - expectDiff: true, }, { name: "example", @@ -641,7 +640,6 @@ func Test_ConvertJSONSchemaPropsToOpenAPIv2SchemaByType(t *testing.T) { }, expected: new(spec.Schema). WithExample(testStr), - expectDiff: true, }, { name: "preserve-unknown-fields in arrays",