diff --git a/.gitignore b/.gitignore index 01a75264a..7bbd24d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ hack/tools/bin junit-report.xml /artifacts +tmp \ No newline at end of file diff --git a/go.mod b/go.mod index 45b9dc85b..d249e7189 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gobuffalo/flect v1.0.3 github.com/google/go-cmp v0.7.0 github.com/onsi/ginkgo v1.16.5 + github.com/onsi/ginkgo/v2 v2.23.3 github.com/onsi/gomega v1.37.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 @@ -29,8 +30,10 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index 89e1cd567..5fa6781f3 100644 --- a/go.sum +++ b/go.sum @@ -21,7 +21,6 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= diff --git a/pkg/crd/markers/crd.go b/pkg/crd/markers/crd.go index bd3cef563..ca335b5d0 100644 --- a/pkg/crd/markers/crd.go +++ b/pkg/crd/markers/crd.go @@ -18,9 +18,13 @@ package markers import ( "fmt" + "path/filepath" + "regexp" "strings" apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-tools/pkg/markers" ) @@ -57,6 +61,9 @@ var CRDMarkers = []*definitionWithHelp{ must(markers.MakeDefinition("kubebuilder:selectablefield", markers.DescribesType, SelectableField{})). WithHelp(SelectableField{}.Help()), + + must(markers.MakeDefinition("kubebuilder:schemaModifier", markers.DescribesType, SchemaModifier{})). + WithHelp(SchemaModifier{}.Help()), } // TODO: categories and singular used to be annotations types @@ -419,3 +426,183 @@ func (s SelectableField) ApplyToCRD(crd *apiext.CustomResourceDefinitionSpec, ve return nil } + +// +controllertools:marker:generateHelp:category=CRD + +// SchemaModifier allows modifying JSONSchemaProps for CRDs. +// +// The PathPattern field defines the rule for selecting target fields within the CRD structure. +// This rule is specified as a path in a JSONPath-like format and supports special wildcard characters: +// - `*`: matches any single field name (e.g., `/spec/*/field`). +// - `**`: matches fields at any depth, across multiple levels of nesting (e.g., `/spec/**/field`). +// +// Example: +// +kubebuilder:schemaModifier:pathPattern=/spec/exampleField/*,description="" +// +// In this example, all fields matching the path `/spec/exampleField/*` will have the empty description applied. +// +// Any specified values (e.g., Description, Format, Maximum, etc.) will be applied to all schemas matching the given path. +type SchemaModifier struct { + // PathPattern defines the path for selecting JSON schemas. + // Supports `*` and `**` for matching nested fields. + PathPattern string `marker:"pathPattern"` + + // Description sets a new value for JSONSchemaProps.Description. + Description *string `marker:",optional"` + // Format sets a new value for JSONSchemaProps.Format. + Format *string `marker:",optional"` + // Maximum sets a new value for JSONSchemaProps.Maximum. + Maximum *float64 `marker:",optional"` + // ExclusiveMaximum sets a new value for JSONSchemaProps.ExclusiveMaximum. + ExclusiveMaximum *bool `marker:",optional"` + // Minimum sets a new value for JSONSchemaProps.Minimum. + Minimum *float64 `marker:",optional"` + // ExclusiveMinimum sets a new value for JSONSchemaProps.ExclusiveMinimum. + ExclusiveMinimum *bool `marker:",optional"` + // MaxLength sets a new value for JSONSchemaProps.MaxLength. + MaxLength *int `marker:",optional"` + // MinLength sets a new value for JSONSchemaProps.MinLength. + MinLength *int `marker:",optional"` + // Pattern sets a new value for JSONSchemaProps.Pattern. + Pattern *string `marker:",optional"` + // MaxItems sets a new value for JSONSchemaProps.MaxItems. + MaxItems *int `marker:",optional"` + // MinItems sets a new value for JSONSchemaProps.MinItems. + MinItems *int `marker:",optional"` + // UniqueItems sets a new value for JSONSchemaProps.UniqueItems. + UniqueItems *bool `marker:",optional"` + // MultipleOf sets a new value for JSONSchemaProps.MultipleOf. + MultipleOf *float64 `marker:",optional"` + // MaxProperties sets a new value for JSONSchemaProps.MaxProperties. + MaxProperties *int `marker:",optional"` + // MinProperties sets a new value for JSONSchemaProps.MinProperties. + MinProperties *int `marker:",optional"` + // Required sets a new value for JSONSchemaProps.Required. + Required *[]string `marker:",optional"` + // Nullable sets a new value for JSONSchemaProps.Nullable. + Nullable *bool `marker:",optional"` +} + +func (s SchemaModifier) ApplyToCRD(crd *apiext.CustomResourceDefinitionSpec, _ string) error { + ruleRegex, err := s.ParsePattern() + if err != nil { + return fmt.Errorf("failed to parse rule: %w", err) + } + + for i := range crd.Versions { + ver := &crd.Versions[i] + if err = s.applyRuleToSchema(ver.Schema.OpenAPIV3Schema, ruleRegex, "/"); err != nil { + return err + } + } + return nil +} + +func (s SchemaModifier) applyRuleToSchema(schema *apiext.JSONSchemaProps, ruleRegex *regexp.Regexp, path string) error { + if schema == nil { + return nil + } + + if ruleRegex.MatchString(path) { + s.applyToSchema(schema) + } + + if schema.Properties != nil { + for key := range schema.Properties { + prop := schema.Properties[key] + + newPath := filepath.Join(path, key) + + if err := s.applyRuleToSchema(&prop, ruleRegex, newPath); err != nil { + return err + } + schema.Properties[key] = prop + } + } + + if schema.Items != nil { + if schema.Items.Schema != nil { + if err := s.applyRuleToSchema(schema.Items.Schema, ruleRegex, path+"/items"); err != nil { + return err + } + } else if len(schema.Items.JSONSchemas) > 0 { + for i, item := range schema.Items.JSONSchemas { + newPath := fmt.Sprintf("%s/items/%d", path, i) + if err := s.applyRuleToSchema(&item, ruleRegex, newPath); err != nil { + return err + } + } + } + } + + return nil +} + +func (s SchemaModifier) applyToSchema(schema *apiext.JSONSchemaProps) { + if schema == nil { + return + } + if s.Description != nil { + schema.Description = *s.Description + } + if s.Format != nil { + schema.Format = *s.Format + } + if s.Maximum != nil { + schema.Maximum = s.Maximum + } + if s.ExclusiveMaximum != nil { + schema.ExclusiveMaximum = *s.ExclusiveMaximum + } + if s.Minimum != nil { + schema.Minimum = s.Minimum + } + if s.ExclusiveMinimum != nil { + schema.ExclusiveMinimum = *s.ExclusiveMinimum + } + if s.MaxLength != nil { + schema.MaxLength = ptr.To(int64(*s.MaxLength)) + } + if s.MinLength != nil { + schema.MinLength = ptr.To(int64(*s.MinLength)) + } + if s.Pattern != nil { + schema.Pattern = *s.Pattern + } + if s.MaxItems != nil { + schema.MaxItems = ptr.To(int64(*s.MaxItems)) + } + if s.MinItems != nil { + schema.MinItems = ptr.To(int64(*s.MinItems)) + } + if s.UniqueItems != nil { + schema.UniqueItems = *s.UniqueItems + } + if s.MultipleOf != nil { + schema.MultipleOf = s.MultipleOf + } + if s.MaxProperties != nil { + schema.MaxProperties = ptr.To(int64(*s.MaxProperties)) + } + if s.MinProperties != nil { + schema.MinProperties = ptr.To(int64(*s.MinProperties)) + } + if s.Required != nil { + schema.Required = *s.Required + } + if s.Nullable != nil { + schema.Nullable = *s.Nullable + } +} + +func (s SchemaModifier) ParsePattern() (*regexp.Regexp, error) { + pattern := strings.NewReplacer("**", ".*", "*", "[^/]+").Replace(s.PathPattern) + regexStr := "^" + pattern + "$" + + compiledRegex, err := regexp.Compile(regexStr) + if err != nil { + return nil, fmt.Errorf("invalid rule: %w", err) + } + + return compiledRegex, nil +} diff --git a/pkg/crd/markers/crd_test.go b/pkg/crd/markers/crd_test.go new file mode 100644 index 000000000..04daac7bd --- /dev/null +++ b/pkg/crd/markers/crd_test.go @@ -0,0 +1,239 @@ +package markers_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/utils/ptr" + + "sigs.k8s.io/controller-tools/pkg/crd/markers" +) + +var _ = Describe("SchemaModifierMarker", func() { + baseCRD := func() *apiext.CustomResourceDefinitionSpec { + return &apiext.CustomResourceDefinitionSpec{ + Versions: []apiext.CustomResourceDefinitionVersion{ + { + Name: "v1", + Schema: &apiext.CustomResourceValidation{ + OpenAPIV3Schema: &apiext.JSONSchemaProps{ + Properties: map[string]apiext.JSONSchemaProps{ + "spec": { + Properties: map[string]apiext.JSONSchemaProps{}, + }, + }, + }, + }, + }, + }, + } + } + addToSpec := func(crd *apiext.CustomResourceDefinitionSpec, key string, props apiext.JSONSchemaProps) { + crd.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties[key] = props + } + + Context("Pattern matching", func() { + const ( + descOrig = "original" + descExpected = "modified" + ) + barProps := func(desc string) map[string]apiext.JSONSchemaProps { + return map[string]apiext.JSONSchemaProps{ + "foo": { + Description: desc, + Properties: map[string]apiext.JSONSchemaProps{ + "bar": { + Description: desc, + }, + }, + }, + "baz": { + Description: desc, + }, + } + } + + It("should match only direct children with pattern /*", func() { + crdOrig := baseCRD() + crdExpected := baseCRD() + + addToSpec(crdOrig, "foo", apiext.JSONSchemaProps{Description: descOrig}) + addToSpec(crdOrig, "bar", apiext.JSONSchemaProps{ + Description: descOrig, + Properties: barProps(descOrig), + }) + + addToSpec(crdExpected, "foo", apiext.JSONSchemaProps{Description: descExpected}) + addToSpec(crdExpected, "bar", apiext.JSONSchemaProps{ + Description: descExpected, + Properties: barProps(descOrig), + }) + + marker := &markers.SchemaModifier{ + PathPattern: "/spec/*", + Description: ptr.To(descExpected), + } + + Expect(marker.ApplyToCRD(crdOrig, "v1")).To(Succeed()) + Expect(crdOrig).To(Equal(crdExpected)) + }) + + It("should match deep nested fields with /**", func() { + crdOrig := baseCRD() + crdExpected := baseCRD() + + addToSpec(crdOrig, "foo", apiext.JSONSchemaProps{Description: descOrig}) + addToSpec(crdOrig, "bar", apiext.JSONSchemaProps{ + Description: descOrig, + Properties: barProps(descOrig), + }) + + addToSpec(crdExpected, "foo", apiext.JSONSchemaProps{Description: descExpected}) + addToSpec(crdExpected, "bar", apiext.JSONSchemaProps{ + Description: descExpected, + Properties: barProps(descExpected), + }) + + marker := &markers.SchemaModifier{ + PathPattern: "/spec/**", + Description: ptr.To(descExpected), + } + + Expect(marker.ApplyToCRD(crdOrig, "v1")).To(Succeed()) + Expect(crdOrig).To(Equal(crdExpected)) + }) + + It("should return error on invalid path pattern", func() { + crd := baseCRD() + addToSpec(crd, "foo", apiext.JSONSchemaProps{Description: descOrig}) + + marker := &markers.SchemaModifier{ + PathPattern: "[invalid-regex", + } + + err := marker.ApplyToCRD(crd, "v1") + Expect(err).To(HaveOccurred()) + }) + }) + + DescribeTable("Should modify crd /spec/foo", func(origFooProps, expectedFooProps apiext.JSONSchemaProps, marker *markers.SchemaModifier) { + crdOrig := baseCRD() + addToSpec(crdOrig, "foo", origFooProps) + + crdExpected := baseCRD() + addToSpec(crdExpected, "foo", expectedFooProps) + + marker.PathPattern = "/spec/foo" + Expect(marker.ApplyToCRD(crdOrig, "v1")).To(Succeed()) + Expect(crdOrig).To(Equal(crdExpected)) + }, + Entry("should trim description", + apiext.JSONSchemaProps{Description: "foo"}, + apiext.JSONSchemaProps{Description: ""}, + &markers.SchemaModifier{Description: ptr.To("")}, + ), + Entry("should replace format", + apiext.JSONSchemaProps{Format: "foo"}, + apiext.JSONSchemaProps{Format: "bar"}, + &markers.SchemaModifier{Format: ptr.To("bar")}, + ), + Entry("should replace maximum", + apiext.JSONSchemaProps{Maximum: ptr.To(1.0)}, + apiext.JSONSchemaProps{Maximum: ptr.To(2.0)}, + &markers.SchemaModifier{Maximum: ptr.To(2.0)}, + ), + Entry("should replace exclusiveMaximum", + apiext.JSONSchemaProps{ExclusiveMaximum: true}, + apiext.JSONSchemaProps{ExclusiveMaximum: false}, + &markers.SchemaModifier{ExclusiveMaximum: ptr.To(false)}, + ), + Entry("should replace minimum", + apiext.JSONSchemaProps{Minimum: ptr.To(1.0)}, + apiext.JSONSchemaProps{Minimum: ptr.To(2.0)}, + &markers.SchemaModifier{Minimum: ptr.To(2.0)}, + ), + Entry("should replace exclusiveMinimum", + apiext.JSONSchemaProps{ExclusiveMinimum: true}, + apiext.JSONSchemaProps{ExclusiveMinimum: false}, + &markers.SchemaModifier{ExclusiveMinimum: ptr.To(false)}, + ), + Entry("should replace maxLength", + apiext.JSONSchemaProps{MaxLength: ptr.To[int64](1)}, + apiext.JSONSchemaProps{MaxLength: ptr.To[int64](2)}, + &markers.SchemaModifier{MaxLength: ptr.To(2)}, + ), + Entry("should replace minLength", + apiext.JSONSchemaProps{MinLength: ptr.To[int64](1)}, + apiext.JSONSchemaProps{MinLength: ptr.To[int64](2)}, + &markers.SchemaModifier{MinLength: ptr.To(2)}, + ), + Entry("should replace pattern", + apiext.JSONSchemaProps{Pattern: "foo"}, + apiext.JSONSchemaProps{Pattern: "bar"}, + &markers.SchemaModifier{Pattern: ptr.To("bar")}, + ), + Entry("should replace maxItems", + apiext.JSONSchemaProps{MaxItems: ptr.To[int64](1)}, + apiext.JSONSchemaProps{MaxItems: ptr.To[int64](2)}, + &markers.SchemaModifier{MaxItems: ptr.To(2)}, + ), + Entry("should replace minItems", + apiext.JSONSchemaProps{MinItems: ptr.To[int64](1)}, + apiext.JSONSchemaProps{MinItems: ptr.To[int64](2)}, + &markers.SchemaModifier{MinItems: ptr.To(2)}, + ), + Entry("should replace uniqueItems", + apiext.JSONSchemaProps{UniqueItems: true}, + apiext.JSONSchemaProps{UniqueItems: false}, + &markers.SchemaModifier{UniqueItems: ptr.To(false)}, + ), + Entry("should replace multipleOf", + apiext.JSONSchemaProps{MultipleOf: ptr.To(1.0)}, + apiext.JSONSchemaProps{MultipleOf: ptr.To(2.0)}, + &markers.SchemaModifier{MultipleOf: ptr.To(2.0)}, + ), + Entry("should replace maxProperties", + apiext.JSONSchemaProps{MaxProperties: ptr.To[int64](1)}, + apiext.JSONSchemaProps{MaxProperties: ptr.To[int64](2)}, + &markers.SchemaModifier{MaxProperties: ptr.To(2)}, + ), + Entry("should replace minProperties", + apiext.JSONSchemaProps{MinProperties: ptr.To[int64](1)}, + apiext.JSONSchemaProps{MinProperties: ptr.To[int64](2)}, + &markers.SchemaModifier{MinProperties: ptr.To(2)}, + ), + Entry("should replace required", + apiext.JSONSchemaProps{Required: []string{"foo"}}, + apiext.JSONSchemaProps{Required: []string{"bar"}}, + &markers.SchemaModifier{Required: ptr.To([]string{"bar"})}, + ), + Entry("should replace nullable", + apiext.JSONSchemaProps{Nullable: true}, + apiext.JSONSchemaProps{Nullable: false}, + &markers.SchemaModifier{Nullable: ptr.To(false)}, + ), + ) + + Context("Parse pattern", func() { + It("should convert * to [^/]+", func() { + sm := markers.SchemaModifier{PathPattern: "/spec/*"} + re, err := sm.ParsePattern() + Expect(err).NotTo(HaveOccurred()) + Expect(re.String()).To(Equal("^/spec/[^/]+$")) + }) + + It("should convert ** to .*", func() { + sm := markers.SchemaModifier{PathPattern: "/spec/**/field"} + re, err := sm.ParsePattern() + Expect(err).NotTo(HaveOccurred()) + Expect(re.String()).To(Equal("^/spec/.*/field$")) + }) + + It("should fail with invalid rule", func() { + sm := markers.SchemaModifier{PathPattern: "[invalid-regex"} + _, err := sm.ParsePattern() + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("error parsing regexp"))) + }) + }) +}) diff --git a/pkg/crd/markers/markers_suite_test.go b/pkg/crd/markers/markers_suite_test.go new file mode 100644 index 000000000..1bffabd3f --- /dev/null +++ b/pkg/crd/markers/markers_suite_test.go @@ -0,0 +1,13 @@ +package markers_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCRDMarkers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CRD Markers Suite") +} diff --git a/pkg/crd/markers/zz_generated.markerhelp.go b/pkg/crd/markers/zz_generated.markerhelp.go index 7d8282cfc..0358b6874 100644 --- a/pkg/crd/markers/zz_generated.markerhelp.go +++ b/pkg/crd/markers/zz_generated.markerhelp.go @@ -374,6 +374,90 @@ func (Resource) Help() *markers.DefinitionHelp { } } +func (SchemaModifier) Help() *markers.DefinitionHelp { + return &markers.DefinitionHelp{ + Category: "CRD", + DetailedHelp: markers.DetailedHelp{ + Summary: "allows modifying JSONSchemaProps for CRDs.", + Details: "The PathPattern field defines the rule for selecting target fields within the CRD structure.\nThis rule is specified as a path in a JSONPath-like format and supports special wildcard characters:\n- `*`: matches any single field name (e.g., `/spec/*/field`).\n- `**`: matches fields at any depth, across multiple levels of nesting (e.g., `/spec/**/field`).\n\nExample:\n\nIn this example, all fields matching the path `/spec/exampleField/*` will have the empty description applied.\n\nAny specified values (e.g., Description, Format, Maximum, etc.) will be applied to all schemas matching the given path.", + }, + FieldHelp: map[string]markers.DetailedHelp{ + "PathPattern": { + Summary: "defines the path for selecting JSON schemas.", + Details: "Supports `*` and `**` for matching nested fields.", + }, + "Description": { + Summary: "sets a new value for JSONSchemaProps.Description.", + Details: "", + }, + "Format": { + Summary: "sets a new value for JSONSchemaProps.Format.", + Details: "", + }, + "Maximum": { + Summary: "sets a new value for JSONSchemaProps.Maximum.", + Details: "", + }, + "ExclusiveMaximum": { + Summary: "sets a new value for JSONSchemaProps.ExclusiveMaximum.", + Details: "", + }, + "Minimum": { + Summary: "sets a new value for JSONSchemaProps.Minimum.", + Details: "", + }, + "ExclusiveMinimum": { + Summary: "sets a new value for JSONSchemaProps.ExclusiveMinimum.", + Details: "", + }, + "MaxLength": { + Summary: "sets a new value for JSONSchemaProps.MaxLength.", + Details: "", + }, + "MinLength": { + Summary: "sets a new value for JSONSchemaProps.MinLength.", + Details: "", + }, + "Pattern": { + Summary: "sets a new value for JSONSchemaProps.Pattern.", + Details: "", + }, + "MaxItems": { + Summary: "sets a new value for JSONSchemaProps.MaxItems.", + Details: "", + }, + "MinItems": { + Summary: "sets a new value for JSONSchemaProps.MinItems.", + Details: "", + }, + "UniqueItems": { + Summary: "sets a new value for JSONSchemaProps.UniqueItems.", + Details: "", + }, + "MultipleOf": { + Summary: "sets a new value for JSONSchemaProps.MultipleOf.", + Details: "", + }, + "MaxProperties": { + Summary: "sets a new value for JSONSchemaProps.MaxProperties.", + Details: "", + }, + "MinProperties": { + Summary: "sets a new value for JSONSchemaProps.MinProperties.", + Details: "", + }, + "Required": { + Summary: "sets a new value for JSONSchemaProps.Required.", + Details: "", + }, + "Nullable": { + Summary: "sets a new value for JSONSchemaProps.Nullable.", + Details: "", + }, + }, + } +} + func (Schemaless) Help() *markers.DefinitionHelp { return &markers.DefinitionHelp{ Category: "CRD validation",