diff --git a/pkg/crd/featuregate_integration_test.go b/pkg/crd/featuregate_integration_test.go new file mode 100644 index 000000000..cbb7096fa --- /dev/null +++ b/pkg/crd/featuregate_integration_test.go @@ -0,0 +1,187 @@ +/* +Copyright 2025. + +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. +*/ + +package crd_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + + "github.com/google/go-cmp/cmp" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-tools/pkg/crd" + crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers" + "sigs.k8s.io/controller-tools/pkg/genall" + "sigs.k8s.io/controller-tools/pkg/loader" + "sigs.k8s.io/controller-tools/pkg/markers" +) + +var _ = Describe("CRD Feature Gate Generation", func() { + var ( + ctx *genall.GenerationContext + out *featureGateOutputRule + featureGateDir string + originalWorkingDir string + ) + + BeforeEach(func() { + var err error + originalWorkingDir, err = os.Getwd() + Expect(err).NotTo(HaveOccurred()) + + featureGateDir = filepath.Join(originalWorkingDir, "testdata", "featuregates") + + By("switching into featuregates testdata") + err = os.Chdir(featureGateDir) + Expect(err).NotTo(HaveOccurred()) + + By("loading the roots") + pkgs, err := loader.LoadRoots(".") + Expect(err).NotTo(HaveOccurred()) + Expect(pkgs).To(HaveLen(1)) + + out = &featureGateOutputRule{buf: &bytes.Buffer{}} + ctx = &genall.GenerationContext{ + Collector: &markers.Collector{Registry: &markers.Registry{}}, + Roots: pkgs, + Checker: &loader.TypeChecker{}, + OutputRule: out, + } + Expect(crdmarkers.Register(ctx.Collector.Registry)).To(Succeed()) + }) + + AfterEach(func() { + By("restoring original working directory") + err := os.Chdir(originalWorkingDir) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should not include feature-gated fields when no gates are enabled", func() { + By("calling the generator") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + // No FeatureGates specified + } + Expect(gen.Generate(ctx)).NotTo(HaveOccurred()) + + By("loading the expected YAML") + expectedFile, err := os.ReadFile("output_none/_featuregatetests.yaml") + Expect(err).NotTo(HaveOccurred()) + + By("comparing the two") + Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile))) + }) + + It("should include only alpha-gated fields when alpha gate is enabled", func() { + By("calling the generator") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + FeatureGates: "alpha=true", + } + Expect(gen.Generate(ctx)).NotTo(HaveOccurred()) + + By("loading the expected YAML") + expectedFile, err := os.ReadFile("output_alpha/_featuregatetests.yaml") + Expect(err).NotTo(HaveOccurred()) + + By("comparing the two") + Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile))) + }) + + It("should include only beta-gated fields when beta gate is enabled", func() { + By("calling the generator") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + FeatureGates: "beta=true", + } + Expect(gen.Generate(ctx)).NotTo(HaveOccurred()) + + By("loading the expected YAML") + expectedFile, err := os.ReadFile("output_beta/_featuregatetests.yaml") + Expect(err).NotTo(HaveOccurred()) + + By("comparing the two") + Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile))) + }) + + It("should include both feature-gated fields when both gates are enabled", func() { + By("calling the generator") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + FeatureGates: "alpha=true,beta=true", + } + Expect(gen.Generate(ctx)).NotTo(HaveOccurred()) + + By("loading the expected YAML") + expectedFile, err := os.ReadFile("output_both/_featuregatetests.yaml") + Expect(err).NotTo(HaveOccurred()) + + By("comparing the two") + Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile))) + }) + + It("should handle complex precedence: (alpha&beta)|gamma", func() { + By("calling the generator with only gamma enabled") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + FeatureGates: "gamma=true", + } + Expect(gen.Generate(ctx)).NotTo(HaveOccurred()) + + By("loading the expected YAML") + expectedFile, err := os.ReadFile("output_gamma/_featuregatetests.yaml") + Expect(err).NotTo(HaveOccurred()) + + By("comparing the two") + Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile))) + }) + + It("should include all fields when all gates are enabled", func() { + By("calling the generator with all gates enabled") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + FeatureGates: "alpha=true,beta=true,gamma=true", + } + Expect(gen.Generate(ctx)).NotTo(HaveOccurred()) + + By("loading the expected YAML") + expectedFile, err := os.ReadFile("output_all/_featuregatetests.yaml") + Expect(err).NotTo(HaveOccurred()) + + By("comparing the two") + Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile))) + }) +}) + +// Helper types for testing +type featureGateOutputRule struct { + buf *bytes.Buffer +} + +func (o *featureGateOutputRule) Open(_ *loader.Package, _ string) (io.WriteCloser, error) { + return featureGateNopCloser{o.buf}, nil +} + +type featureGateNopCloser struct { + io.Writer +} + +func (n featureGateNopCloser) Close() error { + return nil +} diff --git a/pkg/crd/gen.go b/pkg/crd/gen.go index 5fad65a71..dae05f43b 100644 --- a/pkg/crd/gen.go +++ b/pkg/crd/gen.go @@ -26,6 +26,7 @@ import ( apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime/schema" crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers" + "sigs.k8s.io/controller-tools/pkg/featuregate" "sigs.k8s.io/controller-tools/pkg/genall" "sigs.k8s.io/controller-tools/pkg/loader" "sigs.k8s.io/controller-tools/pkg/markers" @@ -85,6 +86,16 @@ type Generator struct { // Year specifies the year to substitute for " YEAR" in the header file. Year string `marker:",optional"` + // FeatureGates specifies which feature gates are enabled for conditional field inclusion. + // + // Single gate format: "gatename=true" + // Multiple gates format: "gate1=true,gate2=false" (must use quoted strings for comma-separated values) + // + // Examples: + // controller-gen crd:featureGates="alpha=true" paths=./api/... + // controller-gen 'crd:featureGates="alpha=true,beta=false"' paths=./api/... + FeatureGates string `marker:",optional"` + // DeprecatedV1beta1CompatibilityPreserveUnknownFields indicates whether // or not we should turn off field pruning for this resource. // @@ -124,6 +135,11 @@ func transformPreserveUnknownFields(value bool) func(map[string]interface{}) err } func (g Generator) Generate(ctx *genall.GenerationContext) error { + featureGates, err := featuregate.ParseFeatureGates(g.FeatureGates, true) + if err != nil { + return fmt.Errorf("invalid feature gates: %w", err) + } + parser := &Parser{ Collector: ctx.Collector, Checker: ctx.Checker, @@ -132,6 +148,7 @@ func (g Generator) Generate(ctx *genall.GenerationContext) error { AllowDangerousTypes: g.AllowDangerousTypes != nil && *g.AllowDangerousTypes, // Indicates the parser on whether to register the ObjectMeta type or not GenerateEmbeddedObjectMeta: g.GenerateEmbeddedObjectMeta != nil && *g.GenerateEmbeddedObjectMeta, + FeatureGates: featureGates, } AddKnownTypes(parser) @@ -146,7 +163,7 @@ func (g Generator) Generate(ctx *genall.GenerationContext) error { } // TODO: allow selecting a specific object - kubeKinds := FindKubeKinds(parser, metav1Pkg) + kubeKinds := FindKubeKinds(parser, metav1Pkg, featureGates) if len(kubeKinds) == 0 { // no objects in the roots return nil @@ -264,8 +281,8 @@ func FindMetav1(roots []*loader.Package) *loader.Package { // FindKubeKinds locates all types that contain TypeMeta and ObjectMeta // (and thus may be a Kubernetes object), and returns the corresponding -// group-kinds. -func FindKubeKinds(parser *Parser, metav1Pkg *loader.Package) []schema.GroupKind { +// group-kinds that are not filtered out by feature gates. +func FindKubeKinds(parser *Parser, metav1Pkg *loader.Package, featureGates featuregate.FeatureGateMap) []schema.GroupKind { // TODO(directxman12): technically, we should be finding metav1 per-package kubeKinds := map[schema.GroupKind]struct{}{} for typeIdent, info := range parser.Types { @@ -317,6 +334,19 @@ func FindKubeKinds(parser *Parser, metav1Pkg *loader.Package) []schema.GroupKind continue } + // Check type-level feature gate marker + if featureGateMarker := info.Markers.Get("kubebuilder:featuregate"); featureGateMarker != nil { + if typeFeatureGate, ok := featureGateMarker.(crdmarkers.TypeFeatureGate); ok { + gateName := string(typeFeatureGate) + // Create evaluator to handle complex expressions (OR/AND logic) + evaluator := featuregate.NewFeatureGateEvaluator(featureGates) + if !evaluator.EvaluateExpression(gateName) { + // Skip this type as its feature gate expression is not satisfied + continue + } + } + } + groupKind := schema.GroupKind{ Group: parser.GroupVersions[pkg].Group, Kind: typeIdent.Name, diff --git a/pkg/crd/markers/crd.go b/pkg/crd/markers/crd.go index bd3cef563..5c56883c8 100644 --- a/pkg/crd/markers/crd.go +++ b/pkg/crd/markers/crd.go @@ -57,6 +57,9 @@ var CRDMarkers = []*definitionWithHelp{ must(markers.MakeDefinition("kubebuilder:selectablefield", markers.DescribesType, SelectableField{})). WithHelp(SelectableField{}.Help()), + + must(markers.MakeDefinition("kubebuilder:featuregate", markers.DescribesType, TypeFeatureGate(""))). + WithHelp(TypeFeatureGate("").Help()), } // TODO: categories and singular used to be annotations types @@ -419,3 +422,22 @@ func (s SelectableField) ApplyToCRD(crd *apiext.CustomResourceDefinitionSpec, ve return nil } + +// +controllertools:marker:generateHelp:category="CRD feature gates" + +// TypeFeatureGate marks an entire CRD type to be conditionally generated based on feature gate enablement. +// Types marked with +kubebuilder:featuregate will only be included in generated CRDs +// when the specified feature gate is enabled via the crd:featureGates parameter. +// +// Single gate format: +kubebuilder:featuregate=alpha +// OR expression: +kubebuilder:featuregate=alpha|beta +// AND expression: +kubebuilder:featuregate=alpha&beta +// Complex expression: +kubebuilder:featuregate=(alpha&beta)|gamma +type TypeFeatureGate string + +// ApplyToCRD does nothing for type feature gates - they are processed by the generator +// to conditionally include/exclude entire CRDs during generation. +func (TypeFeatureGate) ApplyToCRD(crd *apiext.CustomResourceDefinitionSpec, version string) error { + // Type feature gates are handled during CRD discovery/generation, not during CRD spec modification + return nil +} diff --git a/pkg/crd/markers/featuregate.go b/pkg/crd/markers/featuregate.go new file mode 100644 index 000000000..ea1e8ca6d --- /dev/null +++ b/pkg/crd/markers/featuregate.go @@ -0,0 +1,36 @@ +/* +Copyright 2025. + +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. +*/ + +package markers + +import ( + apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// +controllertools:marker:generateHelp:category="CRD feature gates" + +// FeatureGate marks a field to be conditionally included based on feature gate enablement. +// Fields marked with +kubebuilder:featuregate will only be included in generated CRDs +// when the specified feature gate is enabled via the crd:featureGates parameter. +type FeatureGate string + +// ApplyToSchema does nothing for feature gates - they are processed by the generator +// to conditionally include/exclude fields. +func (FeatureGate) ApplyToSchema(schema *apiext.JSONSchemaProps, field string) error { + // Feature gates don't modify the schema directly. + // They are processed by the generator to conditionally include/exclude fields. + return nil +} diff --git a/pkg/crd/markers/validation.go b/pkg/crd/markers/validation.go index 839b07f6e..e668a80b7 100644 --- a/pkg/crd/markers/validation.go +++ b/pkg/crd/markers/validation.go @@ -127,6 +127,9 @@ var ValidationIshMarkers = []*definitionWithHelp{ WithHelp(XPreserveUnknownFields{}.Help()), must(markers.MakeDefinition("kubebuilder:pruning:PreserveUnknownFields", markers.DescribesType, XPreserveUnknownFields{})). WithHelp(XPreserveUnknownFields{}.Help()), + + must(markers.MakeDefinition("kubebuilder:featuregate", markers.DescribesField, FeatureGate(""))). + WithHelp(FeatureGate("").Help()), } func init() { diff --git a/pkg/crd/markers/zz_generated.markerhelp.go b/pkg/crd/markers/zz_generated.markerhelp.go index 1f336df9c..9d6855e34 100644 --- a/pkg/crd/markers/zz_generated.markerhelp.go +++ b/pkg/crd/markers/zz_generated.markerhelp.go @@ -127,6 +127,17 @@ func (ExclusiveMinimum) Help() *markers.DefinitionHelp { } } +func (FeatureGate) Help() *markers.DefinitionHelp { + return &markers.DefinitionHelp{ + Category: "CRD feature gates", + DetailedHelp: markers.DetailedHelp{ + Summary: "marks a field to be conditionally included based on feature gate enablement.", + Details: "Fields marked with +kubebuilder:featuregate will only be included in generated CRDs\nwhen the specified feature gate is enabled via the crd:featureGates parameter.", + }, + FieldHelp: map[string]markers.DetailedHelp{}, + } +} + func (Format) Help() *markers.DefinitionHelp { return &markers.DefinitionHelp{ Category: "CRD validation", @@ -518,6 +529,17 @@ func (Type) Help() *markers.DefinitionHelp { } } +func (TypeFeatureGate) Help() *markers.DefinitionHelp { + return &markers.DefinitionHelp{ + Category: "CRD feature gates", + DetailedHelp: markers.DetailedHelp{ + Summary: "marks an entire CRD type to be conditionally generated based on feature gate enablement.", + Details: "Types marked with +kubebuilder:featuregate will only be included in generated CRDs\nwhen the specified feature gate is enabled via the crd:featureGates parameter.\n\nSingle gate format: +kubebuilder:featuregate=alpha\nOR expression: +kubebuilder:featuregate=alpha|beta\nAND expression: +kubebuilder:featuregate=alpha&beta\nComplex expression: +kubebuilder:featuregate=(alpha&beta)|gamma", + }, + FieldHelp: map[string]markers.DetailedHelp{}, + } +} + func (UniqueItems) Help() *markers.DefinitionHelp { return &markers.DefinitionHelp{ Category: "CRD validation", diff --git a/pkg/crd/parser.go b/pkg/crd/parser.go index 3281f24a1..7a6bbf41e 100644 --- a/pkg/crd/parser.go +++ b/pkg/crd/parser.go @@ -21,6 +21,7 @@ import ( apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-tools/pkg/featuregate" "sigs.k8s.io/controller-tools/pkg/internal/crd" "sigs.k8s.io/controller-tools/pkg/loader" "sigs.k8s.io/controller-tools/pkg/markers" @@ -92,6 +93,9 @@ type Parser struct { // GenerateEmbeddedObjectMeta specifies if any embedded ObjectMeta should be generated GenerateEmbeddedObjectMeta bool + + // FeatureGates specifies which feature gates are enabled for conditional field inclusion + FeatureGates featuregate.FeatureGateMap } func (p *Parser) init() { @@ -172,7 +176,7 @@ func (p *Parser) NeedSchemaFor(typ TypeIdent) { // avoid tripping recursive schemata, like ManagedFields, by adding an empty WIP schema p.Schemata[typ] = apiext.JSONSchemaProps{} - schemaCtx := newSchemaContext(typ.Package, p, p.AllowDangerousTypes, p.IgnoreUnexportedFields) + schemaCtx := newSchemaContext(typ.Package, p, p.AllowDangerousTypes, p.IgnoreUnexportedFields, p.FeatureGates) ctxForInfo := schemaCtx.ForInfo(info) pkgMarkers, err := markers.PackageMarkers(p.Collector, typ.Package) diff --git a/pkg/crd/schema.go b/pkg/crd/schema.go index efb09b7c9..a5157cf7b 100644 --- a/pkg/crd/schema.go +++ b/pkg/crd/schema.go @@ -28,6 +28,7 @@ import ( apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/util/sets" crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers" + "sigs.k8s.io/controller-tools/pkg/featuregate" "sigs.k8s.io/controller-tools/pkg/loader" "sigs.k8s.io/controller-tools/pkg/markers" ) @@ -72,17 +73,19 @@ type schemaContext struct { allowDangerousTypes bool ignoreUnexportedFields bool + featureGates featuregate.FeatureGateMap } // newSchemaContext constructs a new schemaContext for the given package and schema requester. // It must have type info added before use via ForInfo. -func newSchemaContext(pkg *loader.Package, req schemaRequester, allowDangerousTypes, ignoreUnexportedFields bool) *schemaContext { +func newSchemaContext(pkg *loader.Package, req schemaRequester, allowDangerousTypes, ignoreUnexportedFields bool, featureGates featuregate.FeatureGateMap) *schemaContext { pkg.NeedTypesInfo() return &schemaContext{ pkg: pkg, schemaRequester: req, allowDangerousTypes: allowDangerousTypes, ignoreUnexportedFields: ignoreUnexportedFields, + featureGates: featureGates, } } @@ -95,6 +98,7 @@ func (c *schemaContext) ForInfo(info *markers.TypeInfo) *schemaContext { schemaRequester: c.schemaRequester, allowDangerousTypes: c.allowDangerousTypes, ignoreUnexportedFields: c.ignoreUnexportedFields, + featureGates: c.featureGates, } } @@ -428,6 +432,19 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON continue } + // Check feature gate markers - skip field if feature gate is not enabled + if featureGateMarker := field.Markers.Get("kubebuilder:feature-gate"); featureGateMarker != nil { + if featureGate, ok := featureGateMarker.(crdmarkers.FeatureGate); ok { + gateName := string(featureGate) + // Create evaluator to handle complex expressions (OR/AND logic) + evaluator := featuregate.NewFeatureGateEvaluator(ctx.featureGates) + if !evaluator.EvaluateExpression(gateName) { + // Skip this field as its feature gate expression is not satisfied + continue + } + } + } + jsonTag, hasTag := field.Tag.Lookup("json") if !hasTag { // if the field doesn't have a JSON tag, it doesn't belong in output (and shouldn't exist in a serialized type) diff --git a/pkg/crd/schema_test.go b/pkg/crd/schema_test.go index 9c0581d6f..14bcbdd73 100644 --- a/pkg/crd/schema_test.go +++ b/pkg/crd/schema_test.go @@ -26,6 +26,7 @@ import ( pkgstest "golang.org/x/tools/go/packages/packagestest" apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers" + "sigs.k8s.io/controller-tools/pkg/featuregate" testloader "sigs.k8s.io/controller-tools/pkg/loader/testutils" "sigs.k8s.io/controller-tools/pkg/markers" ) @@ -64,7 +65,7 @@ func transform(t *testing.T, expr string) *apiext.JSONSchemaProps { pkg.NeedTypesInfo() failIfErrors(t, pkg.Errors) - schemaContext := newSchemaContext(pkg, nil, true, false).ForInfo(&markers.TypeInfo{}) + schemaContext := newSchemaContext(pkg, nil, true, false, featuregate.FeatureGateMap{}).ForInfo(&markers.TypeInfo{}) // yick: grab the only type definition definedType := pkg.Syntax[0].Decls[0].(*ast.GenDecl).Specs[0].(*ast.TypeSpec).Type result := typeToSchema(schemaContext, definedType) diff --git a/pkg/crd/testdata/featuregates/output_all/_featuregatetests.yaml b/pkg/crd/testdata/featuregates/output_all/_featuregatetests.yaml new file mode 100644 index 000000000..55e5bfd8c --- /dev/null +++ b/pkg/crd/testdata/featuregates/output_all/_featuregatetests.yaml @@ -0,0 +1,108 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: featuregatetests. +spec: + group: "" + names: + kind: FeatureGateTest + listKind: FeatureGateTestList + plural: featuregatetests + singular: featuregatetest + scope: Namespaced + versions: + - name: "" + schema: + openAPIV3Schema: + description: FeatureGateTest is the Schema for testing feature gates + 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: FeatureGateTestSpec defines the desired state with feature-gated + fields + properties: + alphaFeature: + description: Alpha-gated field - only included when alpha gate is + enabled + type: string + andFeature: + description: AND-gated field - included only when both alpha AND beta + gates are enabled + type: string + betaFeature: + description: Beta-gated field - only included when beta gate is enabled + type: string + complexAndFeature: + description: Complex precedence field - included when (alpha OR beta) + AND gamma is enabled + type: string + complexOrFeature: + description: Complex precedence field - included when (alpha AND beta) + OR gamma is enabled + type: string + name: + description: Standard field - always included + type: string + orFeature: + description: OR-gated field - included when either alpha OR beta gate + is enabled + type: string + required: + - name + type: object + status: + description: FeatureGateTestStatus defines the observed state with feature-gated + fields + properties: + alphaStatus: + description: Alpha-gated status field + type: string + andStatus: + description: AND-gated status field - included only when both alpha + AND beta gates are enabled + type: string + betaStatus: + description: Beta-gated status field + type: string + complexAndStatus: + description: Complex precedence status field - included when (alpha + OR beta) AND gamma is enabled + type: string + complexOrStatus: + description: Complex precedence status field - included when (alpha + AND beta) OR gamma is enabled + type: string + orStatus: + description: OR-gated status field - included when either alpha OR + beta gate is enabled + type: string + ready: + description: Standard status field + type: boolean + required: + - ready + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/featuregates/output_alpha/_featuregatetests.yaml b/pkg/crd/testdata/featuregates/output_alpha/_featuregatetests.yaml new file mode 100644 index 000000000..55e5bfd8c --- /dev/null +++ b/pkg/crd/testdata/featuregates/output_alpha/_featuregatetests.yaml @@ -0,0 +1,108 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: featuregatetests. +spec: + group: "" + names: + kind: FeatureGateTest + listKind: FeatureGateTestList + plural: featuregatetests + singular: featuregatetest + scope: Namespaced + versions: + - name: "" + schema: + openAPIV3Schema: + description: FeatureGateTest is the Schema for testing feature gates + 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: FeatureGateTestSpec defines the desired state with feature-gated + fields + properties: + alphaFeature: + description: Alpha-gated field - only included when alpha gate is + enabled + type: string + andFeature: + description: AND-gated field - included only when both alpha AND beta + gates are enabled + type: string + betaFeature: + description: Beta-gated field - only included when beta gate is enabled + type: string + complexAndFeature: + description: Complex precedence field - included when (alpha OR beta) + AND gamma is enabled + type: string + complexOrFeature: + description: Complex precedence field - included when (alpha AND beta) + OR gamma is enabled + type: string + name: + description: Standard field - always included + type: string + orFeature: + description: OR-gated field - included when either alpha OR beta gate + is enabled + type: string + required: + - name + type: object + status: + description: FeatureGateTestStatus defines the observed state with feature-gated + fields + properties: + alphaStatus: + description: Alpha-gated status field + type: string + andStatus: + description: AND-gated status field - included only when both alpha + AND beta gates are enabled + type: string + betaStatus: + description: Beta-gated status field + type: string + complexAndStatus: + description: Complex precedence status field - included when (alpha + OR beta) AND gamma is enabled + type: string + complexOrStatus: + description: Complex precedence status field - included when (alpha + AND beta) OR gamma is enabled + type: string + orStatus: + description: OR-gated status field - included when either alpha OR + beta gate is enabled + type: string + ready: + description: Standard status field + type: boolean + required: + - ready + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/featuregates/output_beta/_featuregatetests.yaml b/pkg/crd/testdata/featuregates/output_beta/_featuregatetests.yaml new file mode 100644 index 000000000..55e5bfd8c --- /dev/null +++ b/pkg/crd/testdata/featuregates/output_beta/_featuregatetests.yaml @@ -0,0 +1,108 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: featuregatetests. +spec: + group: "" + names: + kind: FeatureGateTest + listKind: FeatureGateTestList + plural: featuregatetests + singular: featuregatetest + scope: Namespaced + versions: + - name: "" + schema: + openAPIV3Schema: + description: FeatureGateTest is the Schema for testing feature gates + 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: FeatureGateTestSpec defines the desired state with feature-gated + fields + properties: + alphaFeature: + description: Alpha-gated field - only included when alpha gate is + enabled + type: string + andFeature: + description: AND-gated field - included only when both alpha AND beta + gates are enabled + type: string + betaFeature: + description: Beta-gated field - only included when beta gate is enabled + type: string + complexAndFeature: + description: Complex precedence field - included when (alpha OR beta) + AND gamma is enabled + type: string + complexOrFeature: + description: Complex precedence field - included when (alpha AND beta) + OR gamma is enabled + type: string + name: + description: Standard field - always included + type: string + orFeature: + description: OR-gated field - included when either alpha OR beta gate + is enabled + type: string + required: + - name + type: object + status: + description: FeatureGateTestStatus defines the observed state with feature-gated + fields + properties: + alphaStatus: + description: Alpha-gated status field + type: string + andStatus: + description: AND-gated status field - included only when both alpha + AND beta gates are enabled + type: string + betaStatus: + description: Beta-gated status field + type: string + complexAndStatus: + description: Complex precedence status field - included when (alpha + OR beta) AND gamma is enabled + type: string + complexOrStatus: + description: Complex precedence status field - included when (alpha + AND beta) OR gamma is enabled + type: string + orStatus: + description: OR-gated status field - included when either alpha OR + beta gate is enabled + type: string + ready: + description: Standard status field + type: boolean + required: + - ready + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/featuregates/output_both/_featuregatetests.yaml b/pkg/crd/testdata/featuregates/output_both/_featuregatetests.yaml new file mode 100644 index 000000000..55e5bfd8c --- /dev/null +++ b/pkg/crd/testdata/featuregates/output_both/_featuregatetests.yaml @@ -0,0 +1,108 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: featuregatetests. +spec: + group: "" + names: + kind: FeatureGateTest + listKind: FeatureGateTestList + plural: featuregatetests + singular: featuregatetest + scope: Namespaced + versions: + - name: "" + schema: + openAPIV3Schema: + description: FeatureGateTest is the Schema for testing feature gates + 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: FeatureGateTestSpec defines the desired state with feature-gated + fields + properties: + alphaFeature: + description: Alpha-gated field - only included when alpha gate is + enabled + type: string + andFeature: + description: AND-gated field - included only when both alpha AND beta + gates are enabled + type: string + betaFeature: + description: Beta-gated field - only included when beta gate is enabled + type: string + complexAndFeature: + description: Complex precedence field - included when (alpha OR beta) + AND gamma is enabled + type: string + complexOrFeature: + description: Complex precedence field - included when (alpha AND beta) + OR gamma is enabled + type: string + name: + description: Standard field - always included + type: string + orFeature: + description: OR-gated field - included when either alpha OR beta gate + is enabled + type: string + required: + - name + type: object + status: + description: FeatureGateTestStatus defines the observed state with feature-gated + fields + properties: + alphaStatus: + description: Alpha-gated status field + type: string + andStatus: + description: AND-gated status field - included only when both alpha + AND beta gates are enabled + type: string + betaStatus: + description: Beta-gated status field + type: string + complexAndStatus: + description: Complex precedence status field - included when (alpha + OR beta) AND gamma is enabled + type: string + complexOrStatus: + description: Complex precedence status field - included when (alpha + AND beta) OR gamma is enabled + type: string + orStatus: + description: OR-gated status field - included when either alpha OR + beta gate is enabled + type: string + ready: + description: Standard status field + type: boolean + required: + - ready + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/featuregates/output_gamma/_featuregatetests.yaml b/pkg/crd/testdata/featuregates/output_gamma/_featuregatetests.yaml new file mode 100644 index 000000000..55e5bfd8c --- /dev/null +++ b/pkg/crd/testdata/featuregates/output_gamma/_featuregatetests.yaml @@ -0,0 +1,108 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: featuregatetests. +spec: + group: "" + names: + kind: FeatureGateTest + listKind: FeatureGateTestList + plural: featuregatetests + singular: featuregatetest + scope: Namespaced + versions: + - name: "" + schema: + openAPIV3Schema: + description: FeatureGateTest is the Schema for testing feature gates + 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: FeatureGateTestSpec defines the desired state with feature-gated + fields + properties: + alphaFeature: + description: Alpha-gated field - only included when alpha gate is + enabled + type: string + andFeature: + description: AND-gated field - included only when both alpha AND beta + gates are enabled + type: string + betaFeature: + description: Beta-gated field - only included when beta gate is enabled + type: string + complexAndFeature: + description: Complex precedence field - included when (alpha OR beta) + AND gamma is enabled + type: string + complexOrFeature: + description: Complex precedence field - included when (alpha AND beta) + OR gamma is enabled + type: string + name: + description: Standard field - always included + type: string + orFeature: + description: OR-gated field - included when either alpha OR beta gate + is enabled + type: string + required: + - name + type: object + status: + description: FeatureGateTestStatus defines the observed state with feature-gated + fields + properties: + alphaStatus: + description: Alpha-gated status field + type: string + andStatus: + description: AND-gated status field - included only when both alpha + AND beta gates are enabled + type: string + betaStatus: + description: Beta-gated status field + type: string + complexAndStatus: + description: Complex precedence status field - included when (alpha + OR beta) AND gamma is enabled + type: string + complexOrStatus: + description: Complex precedence status field - included when (alpha + AND beta) OR gamma is enabled + type: string + orStatus: + description: OR-gated status field - included when either alpha OR + beta gate is enabled + type: string + ready: + description: Standard status field + type: boolean + required: + - ready + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/featuregates/output_none/_featuregatetests.yaml b/pkg/crd/testdata/featuregates/output_none/_featuregatetests.yaml new file mode 100644 index 000000000..55e5bfd8c --- /dev/null +++ b/pkg/crd/testdata/featuregates/output_none/_featuregatetests.yaml @@ -0,0 +1,108 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: featuregatetests. +spec: + group: "" + names: + kind: FeatureGateTest + listKind: FeatureGateTestList + plural: featuregatetests + singular: featuregatetest + scope: Namespaced + versions: + - name: "" + schema: + openAPIV3Schema: + description: FeatureGateTest is the Schema for testing feature gates + 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: FeatureGateTestSpec defines the desired state with feature-gated + fields + properties: + alphaFeature: + description: Alpha-gated field - only included when alpha gate is + enabled + type: string + andFeature: + description: AND-gated field - included only when both alpha AND beta + gates are enabled + type: string + betaFeature: + description: Beta-gated field - only included when beta gate is enabled + type: string + complexAndFeature: + description: Complex precedence field - included when (alpha OR beta) + AND gamma is enabled + type: string + complexOrFeature: + description: Complex precedence field - included when (alpha AND beta) + OR gamma is enabled + type: string + name: + description: Standard field - always included + type: string + orFeature: + description: OR-gated field - included when either alpha OR beta gate + is enabled + type: string + required: + - name + type: object + status: + description: FeatureGateTestStatus defines the observed state with feature-gated + fields + properties: + alphaStatus: + description: Alpha-gated status field + type: string + andStatus: + description: AND-gated status field - included only when both alpha + AND beta gates are enabled + type: string + betaStatus: + description: Beta-gated status field + type: string + complexAndStatus: + description: Complex precedence status field - included when (alpha + OR beta) AND gamma is enabled + type: string + complexOrStatus: + description: Complex precedence status field - included when (alpha + AND beta) OR gamma is enabled + type: string + orStatus: + description: OR-gated status field - included when either alpha OR + beta gate is enabled + type: string + ready: + description: Standard status field + type: boolean + required: + - ready + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/featuregates/types.go b/pkg/crd/testdata/featuregates/types.go new file mode 100644 index 000000000..3da538dcf --- /dev/null +++ b/pkg/crd/testdata/featuregates/types.go @@ -0,0 +1,109 @@ +/* +Copyright 2025. + +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. +*/ + +//go:generate ../../../../.run-controller-gen.sh crd:featureGates="alpha=true" paths=. output:dir=./output_alpha +//go:generate ../../../../.run-controller-gen.sh crd:featureGates="beta=true" paths=. output:dir=./output_beta +//go:generate ../../../../.run-controller-gen.sh crd:featureGates="alpha=true,beta=true" paths=. output:dir=./output_both +//go:generate ../../../../.run-controller-gen.sh crd:featureGates="gamma=true" paths=. output:dir=./output_gamma +//go:generate ../../../../.run-controller-gen.sh crd:featureGates="alpha=true,beta=true,gamma=true" paths=. output:dir=./output_all +//go:generate ../../../../.run-controller-gen.sh crd paths=. output:dir=./output_none + +package featuregates + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// FeatureGateTestSpec defines the desired state with feature-gated fields +type FeatureGateTestSpec struct { + // Standard field - always included + Name string `json:"name"` + + // Alpha-gated field - only included when alpha gate is enabled + // +kubebuilder:featuregate=alpha + AlphaFeature *string `json:"alphaFeature,omitempty"` + + // Beta-gated field - only included when beta gate is enabled + // +kubebuilder:featuregate=beta + BetaFeature *string `json:"betaFeature,omitempty"` + + // OR-gated field - included when either alpha OR beta gate is enabled + // +kubebuilder:featuregate=alpha|beta + OrFeature *string `json:"orFeature,omitempty"` + + // AND-gated field - included only when both alpha AND beta gates are enabled + // +kubebuilder:featuregate=alpha&beta + AndFeature *string `json:"andFeature,omitempty"` + + // Complex precedence field - included when (alpha AND beta) OR gamma is enabled + // +kubebuilder:featuregate=(alpha&beta)|gamma + ComplexOrFeature *string `json:"complexOrFeature,omitempty"` + + // Complex precedence field - included when (alpha OR beta) AND gamma is enabled + // +kubebuilder:featuregate=(alpha|beta)&gamma + ComplexAndFeature *string `json:"complexAndFeature,omitempty"` +} + +// FeatureGateTestStatus defines the observed state with feature-gated fields +type FeatureGateTestStatus struct { + // Standard status field + Ready bool `json:"ready"` + + // Alpha-gated status field + // +kubebuilder:featuregate=alpha + AlphaStatus *string `json:"alphaStatus,omitempty"` + + // Beta-gated status field + // +kubebuilder:featuregate=beta + BetaStatus *string `json:"betaStatus,omitempty"` + + // OR-gated status field - included when either alpha OR beta gate is enabled + // +kubebuilder:featuregate=alpha|beta + OrStatus *string `json:"orStatus,omitempty"` + + // AND-gated status field - included only when both alpha AND beta gates are enabled + // +kubebuilder:featuregate=alpha&beta + AndStatus *string `json:"andStatus,omitempty"` + + // Complex precedence status field - included when (alpha AND beta) OR gamma is enabled + // +kubebuilder:featuregate=(alpha&beta)|gamma + ComplexOrStatus *string `json:"complexOrStatus,omitempty"` + + // Complex precedence status field - included when (alpha OR beta) AND gamma is enabled + // +kubebuilder:featuregate=(alpha|beta)&gamma + ComplexAndStatus *string `json:"complexAndStatus,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// FeatureGateTest is the Schema for testing feature gates +type FeatureGateTest struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec FeatureGateTestSpec `json:"spec,omitempty"` + Status FeatureGateTestStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// FeatureGateTestList contains a list of FeatureGateTest +type FeatureGateTestList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []FeatureGateTest `json:"items"` +} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_alpha/_alphagateds.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_alpha/_alphagateds.yaml new file mode 100644 index 000000000..4aeb1b779 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_alpha/_alphagateds.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: alphagateds. +spec: + group: "" + names: + kind: AlphaGated + listKind: AlphaGatedList + plural: alphagateds + singular: alphagated + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: AlphaGated is only generated when alpha feature gate is enabled + 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: AlphaGatedSpec defines a CRD that's only generated when alpha + gate is enabled + properties: + alphaField: + type: string + required: + - alphaField + type: object + status: + description: AlphaGatedStatus defines the observed state of AlphaGated + properties: + alphaReady: + type: boolean + required: + - alphaReady + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_alpha/_alwaysons.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_alpha/_alwaysons.yaml new file mode 100644 index 000000000..90fc84055 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_alpha/_alwaysons.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: alwaysons. +spec: + group: "" + names: + kind: AlwaysOn + listKind: AlwaysOnList + plural: alwaysons + singular: alwayson + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: AlwaysOn is always generated since it has no feature gate marker + 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: AlwaysOnSpec defines a CRD that's always generated (no feature + gate) + properties: + name: + type: string + required: + - name + type: object + status: + description: AlwaysOnStatus defines the observed state of AlwaysOn + properties: + ready: + type: boolean + required: + - ready + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_alpha/_orgateds.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_alpha/_orgateds.yaml new file mode 100644 index 000000000..dec20df58 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_alpha/_orgateds.yaml @@ -0,0 +1,61 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: orgateds. +spec: + group: "" + names: + kind: OrGated + listKind: OrGatedList + plural: orgateds + singular: orgated + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: OrGated is generated when either alpha OR beta feature gate is + enabled + 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: OrGatedSpec defines a CRD that's generated when either alpha + OR beta is enabled + properties: + orField: + type: string + required: + - orField + type: object + status: + description: OrGatedStatus defines the observed state of OrGated + properties: + orReady: + type: boolean + required: + - orReady + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_beta/_alwaysons.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_beta/_alwaysons.yaml new file mode 100644 index 000000000..90fc84055 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_beta/_alwaysons.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: alwaysons. +spec: + group: "" + names: + kind: AlwaysOn + listKind: AlwaysOnList + plural: alwaysons + singular: alwayson + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: AlwaysOn is always generated since it has no feature gate marker + 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: AlwaysOnSpec defines a CRD that's always generated (no feature + gate) + properties: + name: + type: string + required: + - name + type: object + status: + description: AlwaysOnStatus defines the observed state of AlwaysOn + properties: + ready: + type: boolean + required: + - ready + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_beta/_betagateds.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_beta/_betagateds.yaml new file mode 100644 index 000000000..3be086b85 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_beta/_betagateds.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: betagateds. +spec: + group: "" + names: + kind: BetaGated + listKind: BetaGatedList + plural: betagateds + singular: betagated + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: BetaGated is only generated when beta feature gate is enabled + 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: BetaGatedSpec defines a CRD that's only generated when beta + gate is enabled + properties: + betaField: + type: string + required: + - betaField + type: object + status: + description: BetaGatedStatus defines the observed state of BetaGated + properties: + betaReady: + type: boolean + required: + - betaReady + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_beta/_orgateds.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_beta/_orgateds.yaml new file mode 100644 index 000000000..dec20df58 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_beta/_orgateds.yaml @@ -0,0 +1,61 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: orgateds. +spec: + group: "" + names: + kind: OrGated + listKind: OrGatedList + plural: orgateds + singular: orgated + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: OrGated is generated when either alpha OR beta feature gate is + enabled + 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: OrGatedSpec defines a CRD that's generated when either alpha + OR beta is enabled + properties: + orField: + type: string + required: + - orField + type: object + status: + description: OrGatedStatus defines the observed state of OrGated + properties: + orReady: + type: boolean + required: + - orReady + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_both/_alphagateds.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_both/_alphagateds.yaml new file mode 100644 index 000000000..4aeb1b779 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_both/_alphagateds.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: alphagateds. +spec: + group: "" + names: + kind: AlphaGated + listKind: AlphaGatedList + plural: alphagateds + singular: alphagated + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: AlphaGated is only generated when alpha feature gate is enabled + 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: AlphaGatedSpec defines a CRD that's only generated when alpha + gate is enabled + properties: + alphaField: + type: string + required: + - alphaField + type: object + status: + description: AlphaGatedStatus defines the observed state of AlphaGated + properties: + alphaReady: + type: boolean + required: + - alphaReady + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_both/_alwaysons.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_both/_alwaysons.yaml new file mode 100644 index 000000000..90fc84055 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_both/_alwaysons.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: alwaysons. +spec: + group: "" + names: + kind: AlwaysOn + listKind: AlwaysOnList + plural: alwaysons + singular: alwayson + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: AlwaysOn is always generated since it has no feature gate marker + 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: AlwaysOnSpec defines a CRD that's always generated (no feature + gate) + properties: + name: + type: string + required: + - name + type: object + status: + description: AlwaysOnStatus defines the observed state of AlwaysOn + properties: + ready: + type: boolean + required: + - ready + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_both/_andgateds.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_both/_andgateds.yaml new file mode 100644 index 000000000..acc07f292 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_both/_andgateds.yaml @@ -0,0 +1,61 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: andgateds. +spec: + group: "" + names: + kind: AndGated + listKind: AndGatedList + plural: andgateds + singular: andgated + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: AndGated is generated when both alpha AND beta feature gates + are enabled + 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: AndGatedSpec defines a CRD that's generated when both alpha + AND beta are enabled + properties: + andField: + type: string + required: + - andField + type: object + status: + description: AndGatedStatus defines the observed state of AndGated + properties: + andReady: + type: boolean + required: + - andReady + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_both/_betagateds.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_both/_betagateds.yaml new file mode 100644 index 000000000..3be086b85 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_both/_betagateds.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: betagateds. +spec: + group: "" + names: + kind: BetaGated + listKind: BetaGatedList + plural: betagateds + singular: betagated + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: BetaGated is only generated when beta feature gate is enabled + 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: BetaGatedSpec defines a CRD that's only generated when beta + gate is enabled + properties: + betaField: + type: string + required: + - betaField + type: object + status: + description: BetaGatedStatus defines the observed state of BetaGated + properties: + betaReady: + type: boolean + required: + - betaReady + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_both/_complexgateds.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_both/_complexgateds.yaml new file mode 100644 index 000000000..f5ce58e3d --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_both/_complexgateds.yaml @@ -0,0 +1,59 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: complexgateds. +spec: + group: "" + names: + kind: ComplexGated + listKind: ComplexGatedList + plural: complexgateds + singular: complexgated + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: ComplexGated is generated when (alpha AND beta) OR gamma is enabled + 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: ComplexGatedSpec defines a CRD with complex precedence + properties: + complexField: + type: string + required: + - complexField + type: object + status: + description: ComplexGatedStatus defines the observed state of ComplexGated + properties: + complexReady: + type: boolean + required: + - complexReady + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_both/_orgateds.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_both/_orgateds.yaml new file mode 100644 index 000000000..dec20df58 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_both/_orgateds.yaml @@ -0,0 +1,61 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: orgateds. +spec: + group: "" + names: + kind: OrGated + listKind: OrGatedList + plural: orgateds + singular: orgated + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: OrGated is generated when either alpha OR beta feature gate is + enabled + 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: OrGatedSpec defines a CRD that's generated when either alpha + OR beta is enabled + properties: + orField: + type: string + required: + - orField + type: object + status: + description: OrGatedStatus defines the observed state of OrGated + properties: + orReady: + type: boolean + required: + - orReady + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_gamma/_alwaysons.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_gamma/_alwaysons.yaml new file mode 100644 index 000000000..90fc84055 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_gamma/_alwaysons.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: alwaysons. +spec: + group: "" + names: + kind: AlwaysOn + listKind: AlwaysOnList + plural: alwaysons + singular: alwayson + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: AlwaysOn is always generated since it has no feature gate marker + 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: AlwaysOnSpec defines a CRD that's always generated (no feature + gate) + properties: + name: + type: string + required: + - name + type: object + status: + description: AlwaysOnStatus defines the observed state of AlwaysOn + properties: + ready: + type: boolean + required: + - ready + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_gamma/_complexgateds.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_gamma/_complexgateds.yaml new file mode 100644 index 000000000..f5ce58e3d --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_gamma/_complexgateds.yaml @@ -0,0 +1,59 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: complexgateds. +spec: + group: "" + names: + kind: ComplexGated + listKind: ComplexGatedList + plural: complexgateds + singular: complexgated + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: ComplexGated is generated when (alpha AND beta) OR gamma is enabled + 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: ComplexGatedSpec defines a CRD with complex precedence + properties: + complexField: + type: string + required: + - complexField + type: object + status: + description: ComplexGatedStatus defines the observed state of ComplexGated + properties: + complexReady: + type: boolean + required: + - complexReady + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/output_none/_alwaysons.yaml b/pkg/crd/testdata/typelevelfeaturegates/output_none/_alwaysons.yaml new file mode 100644 index 000000000..90fc84055 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/output_none/_alwaysons.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + name: alwaysons. +spec: + group: "" + names: + kind: AlwaysOn + listKind: AlwaysOnList + plural: alwaysons + singular: alwayson + scope: Namespace + versions: + - name: "" + schema: + openAPIV3Schema: + description: AlwaysOn is always generated since it has no feature gate marker + 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: AlwaysOnSpec defines a CRD that's always generated (no feature + gate) + properties: + name: + type: string + required: + - name + type: object + status: + description: AlwaysOnStatus defines the observed state of AlwaysOn + properties: + ready: + type: boolean + required: + - ready + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/crd/testdata/typelevelfeaturegates/types.go b/pkg/crd/testdata/typelevelfeaturegates/types.go new file mode 100644 index 000000000..35e161ee0 --- /dev/null +++ b/pkg/crd/testdata/typelevelfeaturegates/types.go @@ -0,0 +1,225 @@ +/* +Copyright 2025. + +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. +*/ + +//go:generate ../../../../.run-controller-gen.sh crd paths=. output:dir=./output_none +//go:generate ../../../../.run-controller-gen.sh crd:featureGates="alpha=true" paths=. output:dir=./output_alpha +//go:generate ../../../../.run-controller-gen.sh crd:featureGates="beta=true" paths=. output:dir=./output_beta +//go:generate ../../../../.run-controller-gen.sh crd:featureGates="alpha=true,beta=true" paths=. output:dir=./output_both +//go:generate ../../../../.run-controller-gen.sh crd:featureGates="gamma=true" paths=. output:dir=./output_gamma +//go:generate ../../../../.run-controller-gen.sh crd:featureGates="alpha=true,beta=true,gamma=true" paths=. output:dir=./output_all + +package typelevelfeaturegates + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// AlwaysOnSpec defines a CRD that's always generated (no feature gate) +type AlwaysOnSpec struct { + Name string `json:"name"` +} + +// AlwaysOnStatus defines the observed state of AlwaysOn +type AlwaysOnStatus struct { + Ready bool `json:"ready"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespace + +// AlwaysOn is always generated since it has no feature gate marker +type AlwaysOn struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AlwaysOnSpec `json:"spec,omitempty"` + Status AlwaysOnStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// AlwaysOnList contains a list of AlwaysOn +type AlwaysOnList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AlwaysOn `json:"items"` +} + +// AlphaGatedSpec defines a CRD that's only generated when alpha gate is enabled +type AlphaGatedSpec struct { + AlphaField string `json:"alphaField"` +} + +// AlphaGatedStatus defines the observed state of AlphaGated +type AlphaGatedStatus struct { + AlphaReady bool `json:"alphaReady"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespace +// +kubebuilder:featuregate=alpha + +// AlphaGated is only generated when alpha feature gate is enabled +type AlphaGated struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AlphaGatedSpec `json:"spec,omitempty"` + Status AlphaGatedStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// AlphaGatedList contains a list of AlphaGated +type AlphaGatedList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AlphaGated `json:"items"` +} + +// BetaGatedSpec defines a CRD that's only generated when beta gate is enabled +type BetaGatedSpec struct { + BetaField string `json:"betaField"` +} + +// BetaGatedStatus defines the observed state of BetaGated +type BetaGatedStatus struct { + BetaReady bool `json:"betaReady"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespace +// +kubebuilder:featuregate=beta + +// BetaGated is only generated when beta feature gate is enabled +type BetaGated struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BetaGatedSpec `json:"spec,omitempty"` + Status BetaGatedStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BetaGatedList contains a list of BetaGated +type BetaGatedList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BetaGated `json:"items"` +} + +// OrGatedSpec defines a CRD that's generated when either alpha OR beta is enabled +type OrGatedSpec struct { + OrField string `json:"orField"` +} + +// OrGatedStatus defines the observed state of OrGated +type OrGatedStatus struct { + OrReady bool `json:"orReady"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespace +// +kubebuilder:featuregate=alpha|beta + +// OrGated is generated when either alpha OR beta feature gate is enabled +type OrGated struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec OrGatedSpec `json:"spec,omitempty"` + Status OrGatedStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// OrGatedList contains a list of OrGated +type OrGatedList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []OrGated `json:"items"` +} + +// AndGatedSpec defines a CRD that's generated when both alpha AND beta are enabled +type AndGatedSpec struct { + AndField string `json:"andField"` +} + +// AndGatedStatus defines the observed state of AndGated +type AndGatedStatus struct { + AndReady bool `json:"andReady"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespace +// +kubebuilder:featuregate=alpha&beta + +// AndGated is generated when both alpha AND beta feature gates are enabled +type AndGated struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AndGatedSpec `json:"spec,omitempty"` + Status AndGatedStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// AndGatedList contains a list of AndGated +type AndGatedList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AndGated `json:"items"` +} + +// ComplexGatedSpec defines a CRD with complex precedence +type ComplexGatedSpec struct { + ComplexField string `json:"complexField"` +} + +// ComplexGatedStatus defines the observed state of ComplexGated +type ComplexGatedStatus struct { + ComplexReady bool `json:"complexReady"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespace +// +kubebuilder:featuregate=(alpha&beta)|gamma + +// ComplexGated is generated when (alpha AND beta) OR gamma is enabled +type ComplexGated struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ComplexGatedSpec `json:"spec,omitempty"` + Status ComplexGatedStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ComplexGatedList contains a list of ComplexGated +type ComplexGatedList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ComplexGated `json:"items"` +} diff --git a/pkg/crd/typelevel_featuregate_integration_test.go b/pkg/crd/typelevel_featuregate_integration_test.go new file mode 100644 index 000000000..a5c40a966 --- /dev/null +++ b/pkg/crd/typelevel_featuregate_integration_test.go @@ -0,0 +1,193 @@ +/* +Copyright 2025. + +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. +*/ + +package crd_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-tools/pkg/crd" + crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers" + "sigs.k8s.io/controller-tools/pkg/genall" + "sigs.k8s.io/controller-tools/pkg/loader" + "sigs.k8s.io/controller-tools/pkg/markers" +) + +var _ = Describe("CRD Type-Level Feature Gates", func() { + var ( + ctx *genall.GenerationContext + out *typeLevelFeatureGateOutputRule + typeLevelDir string + originalWorkingDir string + ) + + BeforeEach(func() { + var err error + originalWorkingDir, err = os.Getwd() + Expect(err).NotTo(HaveOccurred()) + + typeLevelDir = filepath.Join(originalWorkingDir, "testdata", "typelevelfeaturegates") + + By("switching into typelevelfeaturegates testdata") + err = os.Chdir(typeLevelDir) + Expect(err).NotTo(HaveOccurred()) + + By("loading the roots") + pkgs, err := loader.LoadRoots(".") + Expect(err).NotTo(HaveOccurred()) + Expect(pkgs).To(HaveLen(1)) + + out = &typeLevelFeatureGateOutputRule{buf: &bytes.Buffer{}} + ctx = &genall.GenerationContext{ + Collector: &markers.Collector{Registry: &markers.Registry{}}, + Roots: pkgs, + Checker: &loader.TypeChecker{}, + OutputRule: out, + } + Expect(crdmarkers.Register(ctx.Collector.Registry)).To(Succeed()) + }) + + AfterEach(func() { + By("restoring original working directory") + err := os.Chdir(originalWorkingDir) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should only generate the always-on CRD when no feature gates are enabled", func() { + By("calling the generator") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + // No FeatureGates specified + } + Expect(gen.Generate(ctx)).NotTo(HaveOccurred()) + + By("checking that only AlwaysOn CRD was generated") + output := out.buf.String() + Expect(output).To(ContainSubstring("kind: AlwaysOn")) + Expect(output).NotTo(ContainSubstring("kind: AlphaGated")) + Expect(output).NotTo(ContainSubstring("kind: BetaGated")) + Expect(output).NotTo(ContainSubstring("kind: OrGated")) + Expect(output).NotTo(ContainSubstring("kind: AndGated")) + Expect(output).NotTo(ContainSubstring("kind: ComplexGated")) + }) + + It("should generate alpha-gated and OR-gated CRDs when alpha gate is enabled", func() { + By("calling the generator") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + FeatureGates: "alpha=true", + } + Expect(gen.Generate(ctx)).NotTo(HaveOccurred()) + + By("checking the generated CRDs") + output := out.buf.String() + Expect(output).To(ContainSubstring("kind: AlwaysOn")) + Expect(output).To(ContainSubstring("kind: AlphaGated")) + Expect(output).To(ContainSubstring("kind: OrGated")) // alpha|beta satisfied + Expect(output).NotTo(ContainSubstring("kind: BetaGated")) + Expect(output).NotTo(ContainSubstring("kind: AndGated")) // alpha&beta not satisfied + Expect(output).NotTo(ContainSubstring("kind: ComplexGated")) // (alpha&beta)|gamma not satisfied + }) + + It("should generate beta-gated and OR-gated CRDs when beta gate is enabled", func() { + By("calling the generator") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + FeatureGates: "beta=true", + } + Expect(gen.Generate(ctx)).NotTo(HaveOccurred()) + + By("checking the generated CRDs") + output := out.buf.String() + Expect(output).To(ContainSubstring("kind: AlwaysOn")) + Expect(output).To(ContainSubstring("kind: BetaGated")) + Expect(output).To(ContainSubstring("kind: OrGated")) // alpha|beta satisfied + Expect(output).NotTo(ContainSubstring("kind: AlphaGated")) + Expect(output).NotTo(ContainSubstring("kind: AndGated")) // alpha&beta not satisfied + Expect(output).NotTo(ContainSubstring("kind: ComplexGated")) // (alpha&beta)|gamma not satisfied + }) + + It("should generate all applicable CRDs when both alpha and beta gates are enabled", func() { + By("calling the generator") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + FeatureGates: "alpha=true,beta=true", + } + Expect(gen.Generate(ctx)).NotTo(HaveOccurred()) + + By("checking the generated CRDs") + output := out.buf.String() + Expect(output).To(ContainSubstring("kind: AlwaysOn")) + Expect(output).To(ContainSubstring("kind: AlphaGated")) + Expect(output).To(ContainSubstring("kind: BetaGated")) + Expect(output).To(ContainSubstring("kind: OrGated")) // alpha|beta satisfied + Expect(output).To(ContainSubstring("kind: AndGated")) // alpha&beta satisfied + Expect(output).To(ContainSubstring("kind: ComplexGated")) // (alpha&beta)|gamma satisfied + }) + + It("should generate complex-gated CRD when gamma gate is enabled", func() { + By("calling the generator") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + FeatureGates: "gamma=true", + } + Expect(gen.Generate(ctx)).NotTo(HaveOccurred()) + + By("checking the generated CRDs") + output := out.buf.String() + Expect(output).To(ContainSubstring("kind: AlwaysOn")) + Expect(output).To(ContainSubstring("kind: ComplexGated")) // (alpha&beta)|gamma satisfied by gamma + Expect(output).NotTo(ContainSubstring("kind: AlphaGated")) + Expect(output).NotTo(ContainSubstring("kind: BetaGated")) + Expect(output).NotTo(ContainSubstring("kind: OrGated")) + Expect(output).NotTo(ContainSubstring("kind: AndGated")) + }) + + It("should handle invalid feature gate expressions gracefully", func() { + By("calling the generator with invalid expression") + gen := &crd.Generator{ + CRDVersions: []string{"v1"}, + FeatureGates: "invalid-syntax===true", + } + err := gen.Generate(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid feature gates")) + }) +}) + +// typeLevelFeatureGateOutputRule implements genall.OutputRule for capturing generated YAML +type typeLevelFeatureGateOutputRule struct { + buf *bytes.Buffer +} + +func (o *typeLevelFeatureGateOutputRule) Open(_ *loader.Package, _ string) (io.WriteCloser, error) { + return typeLevelNopCloser{o.buf}, nil +} + +type typeLevelNopCloser struct { + io.Writer +} + +func (n typeLevelNopCloser) Close() error { + return nil +} + +var _ genall.OutputRule = &typeLevelFeatureGateOutputRule{} diff --git a/pkg/crd/zz_generated.markerhelp.go b/pkg/crd/zz_generated.markerhelp.go index 14d7a5cb6..9f50fc66c 100644 --- a/pkg/crd/zz_generated.markerhelp.go +++ b/pkg/crd/zz_generated.markerhelp.go @@ -60,6 +60,10 @@ func (Generator) Help() *markers.DefinitionHelp { Summary: "specifies the year to substitute for \" YEAR\" in the header file.", Details: "", }, + "FeatureGates": { + Summary: "specifies which feature gates are enabled for conditional field inclusion.", + Details: "Single gate format: \"gatename=true\"\nMultiple gates format: \"gate1=true,gate2=false\" (must use quoted strings for comma-separated values)\n\nExamples:\n controller-gen crd:featureGates=\"alpha=true\" paths=./api/...\n controller-gen 'crd:featureGates=\"alpha=true,beta=false\"' paths=./api/...", + }, "DeprecatedV1beta1CompatibilityPreserveUnknownFields": { Summary: "indicates whether", Details: "or not we should turn off field pruning for this resource.\n\nSpecifies spec.preserveUnknownFields value that is false and omitted by default.\nThis value can only be specified for CustomResourceDefinitions that were created with\n`apiextensions.k8s.io/v1beta1`.\n\nThe field can be set for compatibility reasons, although strongly discouraged, resource\nauthors should move to a structural OpenAPI schema instead.\n\nSee https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#field-pruning\nfor more information about field pruning and v1beta1 resources compatibility.", diff --git a/pkg/featuregate/doc.go b/pkg/featuregate/doc.go new file mode 100644 index 000000000..4eeb49341 --- /dev/null +++ b/pkg/featuregate/doc.go @@ -0,0 +1,84 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +/* +Package featuregate provides a centralized implementation for feature gate functionality +across all controller-tools generators. + +This package addresses the code duplication that existed in CRD, RBAC, and Webhook generators +by providing a unified API for: + +- Parsing feature gate configurations from CLI parameters +- Validating feature gate expressions with strict validation +- Evaluating complex boolean expressions (AND, OR logic) +- Managing known feature gates with a registry + +# Basic Usage + +The simplest way to use this package is: + + gates, err := featuregate.ParseFeatureGates("alpha=true,beta=false", false) + if err != nil { + // handle error + } + evaluator := featuregate.NewFeatureGateEvaluator(gates) + + if evaluator.EvaluateExpression("alpha|beta") { + // Include the feature + } + +# Expression Syntax + +Feature gate expressions support the following formats: + +- Empty string: "" (always evaluates to true - no gating) +- Single gate: "alpha" (true if alpha=true) +- OR logic: "alpha|beta" (true if either alpha=true OR beta=true) +- AND logic: "alpha&beta" (true if both alpha=true AND beta=true) + +Multiple gates are supported: +- "alpha|beta|gamma" (true if any gate is enabled) +- "alpha&beta&gamma" (true if all gates are enabled) + +Mixing AND and OR operators in the same expression is not allowed. + +# Strict Validation + +For new implementations requiring strict validation: + + registry := featuregate.NewRegistry([]string{"alpha", "beta"}, true) + evaluator, err := registry.CreateEvaluator("alpha=true,beta=false") + if err != nil { + // Handle parsing error + } + + err = registry.ValidateExpression("alpha|unknown") + if err != nil { + // Handle unknown gate error + } + +# Integration + +This package provides functions that centralize the feature gate logic +previously duplicated across CRD, RBAC, and Webhook generators: + +- ParseFeatureGates() replaces individual parseFeatureGates() functions +- ValidateFeatureGateExpression() replaces individual validateFeatureGateExpression() functions +- FeatureGateEvaluator.EvaluateExpression() replaces individual shouldInclude*() functions + +The FeatureGateMap type is compatible with existing map[string]bool usage patterns. +*/ +package featuregate diff --git a/pkg/featuregate/evaluator.go b/pkg/featuregate/evaluator.go new file mode 100644 index 000000000..d9cfa3439 --- /dev/null +++ b/pkg/featuregate/evaluator.go @@ -0,0 +1,159 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package featuregate + +import ( + "strings" +) + +const ( + boolTrueStr = "true" + boolFalseStr = "false" +) + +// evaluateAndExpression evaluates an AND expression where all gates must be enabled. +// Format: "gate1&gate2&gate3" - returns true only if all gates are enabled. +// Also handles boolean values from parenthetical evaluation. +func (fge *FeatureGateEvaluator) evaluateAndExpression(expr string) bool { + gates := strings.Split(expr, "&") + for _, gate := range gates { + gate = strings.TrimSpace(gate) + // Handle boolean values from parenthetical evaluation + if gate == boolTrueStr { + continue // true AND anything = anything, so continue + } + if gate == boolFalseStr { + return false // false AND anything = false + } + // Regular gate evaluation + if !fge.gates.IsEnabled(gate) { + return false + } + } + return true +} + +// evaluateOrExpression evaluates an OR expression where any gate can be enabled. +// Format: "gate1|gate2|gate3" - returns true if any gate is enabled. +// Also handles boolean values from parenthetical evaluation. +func (fge *FeatureGateEvaluator) evaluateOrExpression(expr string) bool { + gates := strings.Split(expr, "|") + for _, gate := range gates { + gate = strings.TrimSpace(gate) + // Handle boolean values from parenthetical evaluation + if gate == boolTrueStr { + return true // true OR anything = true + } + if gate == boolFalseStr { + continue // false OR anything = anything, so continue + } + // Regular gate evaluation + if fge.gates.IsEnabled(gate) { + return true + } + } + return false +} + +// hasAndOperator checks if the expression contains AND operators. +func hasAndOperator(expr string) bool { + return strings.Contains(expr, "&") +} + +// hasOrOperator checks if the expression contains OR operators. +func hasOrOperator(expr string) bool { + return strings.Contains(expr, "|") +} + +// evaluateComplexExpression evaluates complex feature gate expressions with parentheses. +// Supports expressions like "(alpha&beta)|gamma" with proper precedence. +func (fge *FeatureGateEvaluator) evaluateComplexExpression(expr string) bool { + // Remove all spaces for easier parsing + expr = strings.ReplaceAll(expr, " ", "") + + // Handle the expression recursively by evaluating parentheses first + for strings.Contains(expr, "(") { + // Find the innermost parentheses + start := -1 + for i, char := range expr { + if char == '(' { + start = i + } else if char == ')' && start != -1 { + // Evaluate the expression inside the parentheses + inner := expr[start+1 : i] + result := fge.evaluateSimpleExpression(inner) + + // Replace the parenthetical expression with its result + replacement := boolTrueStr + if !result { + replacement = boolFalseStr + } + expr = expr[:start] + replacement + expr[i+1:] + break + } + } + } + + // Now evaluate the remaining expression (which should be simple) + return fge.evaluateSimpleExpression(expr) +} + +// evaluateSimpleExpression evaluates a simple expression without parentheses. +// Handles OR operations which have lower precedence than AND operations. +func (fge *FeatureGateEvaluator) evaluateSimpleExpression(expr string) bool { + // Handle special boolean values from parenthetical evaluation + if expr == boolTrueStr { + return true + } + if expr == boolFalseStr { + return false + } + + // Handle OR operations (lower precedence) + if strings.Contains(expr, "|") { + parts := strings.Split(expr, "|") + for _, part := range parts { + part = strings.TrimSpace(part) + if fge.evaluateAndPart(part) { + return true + } + } + return false + } + + // No OR operators, evaluate as AND expression or single gate + return fge.evaluateAndPart(expr) +} + +// evaluateAndPart evaluates a part that may contain AND operations or be a single gate. +func (fge *FeatureGateEvaluator) evaluateAndPart(expr string) bool { + // Handle special boolean values + if expr == boolTrueStr { + return true + } + if expr == boolFalseStr { + return false + } + + // Handle AND operations + if strings.Contains(expr, "&") { + return fge.evaluateAndExpression(expr) + } + + // Single gate + return fge.gates.IsEnabled(strings.TrimSpace(expr)) +} diff --git a/pkg/featuregate/evaluator_test.go b/pkg/featuregate/evaluator_test.go new file mode 100644 index 000000000..5aaa07005 --- /dev/null +++ b/pkg/featuregate/evaluator_test.go @@ -0,0 +1,190 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package featuregate_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "sigs.k8s.io/controller-tools/pkg/featuregate" +) + +var _ = Describe("FeatureGate Evaluator", func() { + var evaluator *featuregate.FeatureGateEvaluator + var gates featuregate.FeatureGateMap + + BeforeEach(func() { + gates = featuregate.FeatureGateMap{ + "alpha": true, + "beta": false, + "gamma": true, + "delta": false, + } + evaluator = featuregate.NewFeatureGateEvaluator(gates) + }) + + Describe("NewFeatureGateEvaluator", func() { + It("should create evaluator with provided gates", func() { + Expect(evaluator).NotTo(BeNil()) + }) + }) + + Describe("FeatureGateMap.IsEnabled", func() { + It("should return true for enabled gates", func() { + Expect(gates.IsEnabled("alpha")).To(BeTrue()) + Expect(gates.IsEnabled("gamma")).To(BeTrue()) + }) + + It("should return false for disabled gates", func() { + Expect(gates.IsEnabled("beta")).To(BeFalse()) + Expect(gates.IsEnabled("delta")).To(BeFalse()) + }) + + It("should return false for unknown gates", func() { + Expect(gates.IsEnabled("unknown")).To(BeFalse()) + }) + }) + + Describe("EvaluateExpression", func() { + Context("with empty expressions", func() { + It("should return true for empty string", func() { + Expect(evaluator.EvaluateExpression("")).To(BeTrue()) + }) + }) + + Context("with single gate expressions", func() { + It("should evaluate enabled gates correctly", func() { + Expect(evaluator.EvaluateExpression("alpha")).To(BeTrue()) + Expect(evaluator.EvaluateExpression("gamma")).To(BeTrue()) + }) + + It("should evaluate disabled gates correctly", func() { + Expect(evaluator.EvaluateExpression("beta")).To(BeFalse()) + Expect(evaluator.EvaluateExpression("delta")).To(BeFalse()) + }) + + It("should evaluate unknown gates as false", func() { + Expect(evaluator.EvaluateExpression("unknown")).To(BeFalse()) + }) + }) + + Context("with OR expressions", func() { + It("should return true when any gate is enabled", func() { + Expect(evaluator.EvaluateExpression("alpha|beta")).To(BeTrue()) + Expect(evaluator.EvaluateExpression("beta|gamma")).To(BeTrue()) + Expect(evaluator.EvaluateExpression("alpha|gamma")).To(BeTrue()) + }) + + It("should return false when all gates are disabled", func() { + Expect(evaluator.EvaluateExpression("beta|delta")).To(BeFalse()) + }) + + It("should handle multiple OR gates", func() { + Expect(evaluator.EvaluateExpression("beta|delta|alpha")).To(BeTrue()) + Expect(evaluator.EvaluateExpression("beta|delta|unknown")).To(BeFalse()) + }) + }) + + Context("with AND expressions", func() { + It("should return true when all gates are enabled", func() { + Expect(evaluator.EvaluateExpression("alpha&gamma")).To(BeTrue()) + }) + + It("should return false when any gate is disabled", func() { + Expect(evaluator.EvaluateExpression("alpha&beta")).To(BeFalse()) + Expect(evaluator.EvaluateExpression("beta&gamma")).To(BeFalse()) + Expect(evaluator.EvaluateExpression("beta&delta")).To(BeFalse()) + }) + + It("should handle multiple AND gates", func() { + Expect(evaluator.EvaluateExpression("alpha&gamma&beta")).To(BeFalse()) + Expect(evaluator.EvaluateExpression("alpha&gamma&unknown")).To(BeFalse()) + }) + }) + + Context("with complex expressions", func() { + It("should handle gates with special characters", func() { + gatesWithSpecial := featuregate.FeatureGateMap{ + "my-feature": true, + "under_score": false, + "v1beta1": true, + } + specialEvaluator := featuregate.NewFeatureGateEvaluator(gatesWithSpecial) + + Expect(specialEvaluator.EvaluateExpression("my-feature")).To(BeTrue()) + Expect(specialEvaluator.EvaluateExpression("under_score")).To(BeFalse()) + Expect(specialEvaluator.EvaluateExpression("v1beta1")).To(BeTrue()) + Expect(specialEvaluator.EvaluateExpression("my-feature|under_score")).To(BeTrue()) + Expect(specialEvaluator.EvaluateExpression("my-feature&v1beta1")).To(BeTrue()) + }) + }) + + Context("with complex precedence rules", func() { + It("should handle simple parentheses", func() { + Expect(evaluator.EvaluateExpression("(alpha)")).To(BeTrue()) + Expect(evaluator.EvaluateExpression("(beta)")).To(BeFalse()) + }) + + It("should handle parentheses with AND", func() { + Expect(evaluator.EvaluateExpression("(alpha&gamma)")).To(BeTrue()) + Expect(evaluator.EvaluateExpression("(alpha&beta)")).To(BeFalse()) + }) + + It("should handle parentheses with OR", func() { + Expect(evaluator.EvaluateExpression("(alpha|beta)")).To(BeTrue()) + Expect(evaluator.EvaluateExpression("(beta|delta)")).To(BeFalse()) + }) + + It("should handle (AND) OR combinations", func() { + // (alpha&gamma)|beta = (true&true)|false = true|false = true + Expect(evaluator.EvaluateExpression("(alpha&gamma)|beta")).To(BeTrue()) + // (alpha&beta)|delta = (true&false)|false = false|false = false + Expect(evaluator.EvaluateExpression("(alpha&beta)|delta")).To(BeFalse()) + // (beta&delta)|alpha = (false&false)|true = false|true = true + Expect(evaluator.EvaluateExpression("(beta&delta)|alpha")).To(BeTrue()) + }) + + It("should handle (OR) AND combinations", func() { + // (alpha|beta)&gamma = (true|false)&true = true&true = true + Expect(evaluator.EvaluateExpression("(alpha|beta)&gamma")).To(BeTrue()) + // (beta|delta)&alpha = (false|false)&true = false&true = false + Expect(evaluator.EvaluateExpression("(beta|delta)&alpha")).To(BeFalse()) + // (alpha|gamma)&beta = (true|true)&false = true&false = false + Expect(evaluator.EvaluateExpression("(alpha|gamma)&beta")).To(BeFalse()) + }) + + It("should handle nested parentheses", func() { + // ((alpha&gamma)|beta)&delta = ((true&true)|false)&false = (true|false)&false = true&false = false + Expect(evaluator.EvaluateExpression("((alpha&gamma)|beta)&delta")).To(BeFalse()) + // ((alpha&gamma)|beta)|delta = ((true&true)|false)|false = (true|false)|false = true|false = true + Expect(evaluator.EvaluateExpression("((alpha&gamma)|beta)|delta")).To(BeTrue()) + }) + + It("should handle multiple grouped expressions", func() { + // (alpha&gamma)|(beta&delta) = (true&true)|(false&false) = true|false = true + Expect(evaluator.EvaluateExpression("(alpha&gamma)|(beta&delta)")).To(BeTrue()) + // (alpha&beta)|(delta&unknown) = (true&false)|(false&false) = false|false = false + Expect(evaluator.EvaluateExpression("(alpha&beta)|(delta&unknown)")).To(BeFalse()) + }) + + It("should handle complex expressions with spaces", func() { + Expect(evaluator.EvaluateExpression("( alpha & gamma ) | beta")).To(BeTrue()) + Expect(evaluator.EvaluateExpression("( alpha | beta ) & gamma")).To(BeTrue()) + }) + }) + }) +}) diff --git a/pkg/featuregate/featuregate_suite_test.go b/pkg/featuregate/featuregate_suite_test.go new file mode 100644 index 000000000..920a6d82a --- /dev/null +++ b/pkg/featuregate/featuregate_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package featuregate_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestFeatureGate(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "FeatureGate Suite") +} diff --git a/pkg/featuregate/parser.go b/pkg/featuregate/parser.go new file mode 100644 index 000000000..e22a0dda1 --- /dev/null +++ b/pkg/featuregate/parser.go @@ -0,0 +1,67 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package featuregate + +import ( + "fmt" + "strings" +) + +// ParseFeatureGates parses a comma-separated feature gate string into a FeatureGateMap. +// Format: "gate1=true,gate2=false,gate3=true" +// +// With strict validation enabled, this function will return an error for: +// - Invalid format (missing = or wrong number of parts) +// - Invalid values (anything other than "true" or "false") +// +// Returns a FeatureGateMap and an error if parsing fails with strict validation. +func ParseFeatureGates(featureGates string, strict bool) (FeatureGateMap, error) { + gates := make(FeatureGateMap) + if featureGates == "" { + return gates, nil + } + + pairs := strings.Split(featureGates, ",") + for _, pair := range pairs { + parts := strings.Split(strings.TrimSpace(pair), "=") + if len(parts) != 2 { + if strict { + return nil, fmt.Errorf("invalid feature gate format: %s (expected format: gate1=true,gate2=false)", pair) + } + // In non-strict mode, skip invalid entries + continue + } + + gateName := strings.TrimSpace(parts[0]) + gateValue := strings.TrimSpace(parts[1]) + + switch gateValue { + case "true": + gates[gateName] = true + case "false": + gates[gateName] = false + default: + if strict { + return nil, fmt.Errorf("invalid feature gate value for %s: %s (must be 'true' or 'false')", gateName, gateValue) + } + // In non-strict mode, treat invalid values as false + gates[gateName] = false + } + } + + return gates, nil +} diff --git a/pkg/featuregate/parser_test.go b/pkg/featuregate/parser_test.go new file mode 100644 index 000000000..025e270d8 --- /dev/null +++ b/pkg/featuregate/parser_test.go @@ -0,0 +1,135 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package featuregate + +import ( + "testing" + + "github.com/onsi/gomega" +) + +func TestParseFeatureGates(t *testing.T) { + tests := []struct { + name string + input string + strict bool + expected FeatureGateMap + expectError bool + errorContains string + }{ + { + name: "empty string", + input: "", + strict: true, + expected: FeatureGateMap{}, + }, + { + name: "single gate enabled", + input: "alpha=true", + strict: true, + expected: FeatureGateMap{ + "alpha": true, + }, + }, + { + name: "single gate disabled", + input: "alpha=false", + strict: true, + expected: FeatureGateMap{ + "alpha": false, + }, + }, + { + name: "multiple gates", + input: "alpha=true,beta=false,gamma=true", + strict: true, + expected: FeatureGateMap{ + "alpha": true, + "beta": false, + "gamma": true, + }, + }, + { + name: "gates with spaces", + input: " alpha = true , beta = false ", + strict: true, + expected: FeatureGateMap{ + "alpha": true, + "beta": false, + }, + }, + { + name: "invalid format strict mode", + input: "alpha=true,invalid,beta=false", + strict: true, + expectError: true, + errorContains: "invalid feature gate format", + }, + { + name: "invalid format non-strict mode", + input: "alpha=true,invalid,beta=false", + strict: false, + expected: FeatureGateMap{ + "alpha": true, + "beta": false, + }, + }, + { + name: "invalid value strict mode", + input: "alpha=true,beta=maybe", + strict: true, + expectError: true, + errorContains: "invalid feature gate value", + }, + { + name: "invalid value non-strict mode", + input: "alpha=true,beta=maybe", + strict: false, + expected: FeatureGateMap{ + "alpha": true, + "beta": false, // Invalid values default to false + }, + }, + { + name: "complex gate names", + input: "v1beta1=true,my-feature=false,under_score=true", + strict: true, + expected: FeatureGateMap{ + "v1beta1": true, + "my-feature": false, + "under_score": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + result, err := ParseFeatureGates(tt.input, tt.strict) + + if tt.expectError { + g.Expect(err).To(gomega.HaveOccurred()) + if tt.errorContains != "" { + g.Expect(err.Error()).To(gomega.ContainSubstring(tt.errorContains)) + } + } else { + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(result).To(gomega.Equal(tt.expected)) + } + }) + } +} diff --git a/pkg/featuregate/registry.go b/pkg/featuregate/registry.go new file mode 100644 index 000000000..ef1c735d3 --- /dev/null +++ b/pkg/featuregate/registry.go @@ -0,0 +1,95 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package featuregate + +import ( + "k8s.io/apimachinery/pkg/util/sets" +) + +// Registry maintains a registry of known feature gates and provides +// centralized validation and evaluation capabilities. +type Registry struct { + knownGates sets.Set[string] + strict bool +} + +// NewRegistry creates a new feature gate registry. +func NewRegistry(knownGates []string, strict bool) *Registry { + gateSet := sets.New[string]() + for _, gate := range knownGates { + gateSet.Insert(gate) + } + + return &Registry{ + knownGates: gateSet, + strict: strict, + } +} + +// ParseAndValidate parses feature gates and validates expressions in one step. +func (r *Registry) ParseAndValidate(featureGatesStr string, expression string) (FeatureGateMap, error) { + // Parse the feature gates + gates, err := ParseFeatureGates(featureGatesStr, r.strict) + if err != nil { + return nil, err + } + + // Validate the expression + err = ValidateFeatureGateExpression(expression, r.knownGates, r.strict) + if err != nil { + return nil, err + } + + return gates, nil +} + +// CreateEvaluator creates a new FeatureGateEvaluator with the parsed gates. +func (r *Registry) CreateEvaluator(featureGatesStr string) (*FeatureGateEvaluator, error) { + gates, err := ParseFeatureGates(featureGatesStr, r.strict) + if err != nil { + return nil, err + } + + return NewFeatureGateEvaluator(gates), nil +} + +// ValidateExpression validates a feature gate expression using the registry's settings. +func (r *Registry) ValidateExpression(expr string) error { + return ValidateFeatureGateExpression(expr, r.knownGates, r.strict) +} + +// AddKnownGate adds a gate to the known gates set. +func (r *Registry) AddKnownGate(gate string) { + r.knownGates.Insert(gate) +} + +// AddKnownGates adds multiple gates to the known gates set. +func (r *Registry) AddKnownGates(gates []string) { + for _, gate := range gates { + r.knownGates.Insert(gate) + } +} + +// IsKnownGate checks if a gate is in the known gates set. +func (r *Registry) IsKnownGate(gate string) bool { + return r.knownGates.Has(gate) +} + +// GetKnownGates returns a copy of the known gates set. +func (r *Registry) GetKnownGates() sets.Set[string] { + return r.knownGates.Clone() +} diff --git a/pkg/featuregate/registry_test.go b/pkg/featuregate/registry_test.go new file mode 100644 index 000000000..367975215 --- /dev/null +++ b/pkg/featuregate/registry_test.go @@ -0,0 +1,244 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package featuregate + +import ( + "testing" + + "github.com/onsi/gomega" +) + +func TestNewRegistry(t *testing.T) { + g := gomega.NewWithT(t) + knownGates := []string{"alpha", "beta", "gamma"} + registry := NewRegistry(knownGates, true) + + g.Expect(registry.strict).To(gomega.BeTrue()) + g.Expect(registry.knownGates.Len()).To(gomega.Equal(3)) + g.Expect(registry.IsKnownGate("alpha")).To(gomega.BeTrue()) + g.Expect(registry.IsKnownGate("beta")).To(gomega.BeTrue()) + g.Expect(registry.IsKnownGate("gamma")).To(gomega.BeTrue()) + g.Expect(registry.IsKnownGate("unknown")).To(gomega.BeFalse()) +} + +func TestRegistry_ParseAndValidate(t *testing.T) { + registry := NewRegistry([]string{"alpha", "beta"}, true) + + tests := []struct { + name string + featureGatesStr string + expression string + expectError bool + expectedGates FeatureGateMap + }{ + { + name: "valid parsing and expression", + featureGatesStr: "alpha=true,beta=false", + expression: "alpha|beta", + expectedGates: FeatureGateMap{ + "alpha": true, + "beta": false, + }, + }, + { + name: "invalid feature gate format", + featureGatesStr: "alpha=true,invalid", + expression: "alpha", + expectError: true, + }, + { + name: "invalid expression", + featureGatesStr: "alpha=true", + expression: "alpha&beta|gamma", + expectError: true, + }, + { + name: "unknown gate in expression", + featureGatesStr: "alpha=true", + expression: "unknown", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + gates, err := registry.ParseAndValidate(tt.featureGatesStr, tt.expression) + + if tt.expectError { + g.Expect(err).To(gomega.HaveOccurred()) + } else { + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(gates).To(gomega.Equal(tt.expectedGates)) + } + }) + } +} + +func TestRegistry_CreateEvaluator(t *testing.T) { + registry := NewRegistry([]string{"alpha", "beta"}, true) + + tests := []struct { + name string + featureGatesStr string + expectError bool + }{ + { + name: "valid feature gates", + featureGatesStr: "alpha=true,beta=false", + }, + { + name: "invalid format", + featureGatesStr: "alpha=true,invalid", + expectError: true, + }, + { + name: "empty string", + featureGatesStr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + evaluator, err := registry.CreateEvaluator(tt.featureGatesStr) + + if tt.expectError { + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(evaluator).To(gomega.BeNil()) + } else { + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(evaluator).NotTo(gomega.BeNil()) + } + }) + } +} + +func TestRegistry_ValidateExpression(t *testing.T) { + registry := NewRegistry([]string{"alpha", "beta"}, true) + + tests := []struct { + name string + expr string + expectError bool + }{ + { + name: "valid expression", + expr: "alpha|beta", + }, + { + name: "unknown gate", + expr: "unknown", + expectError: true, + }, + { + name: "mixed operators", + expr: "alpha&beta|gamma", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + err := registry.ValidateExpression(tt.expr) + + if tt.expectError { + g.Expect(err).To(gomega.HaveOccurred()) + } else { + g.Expect(err).NotTo(gomega.HaveOccurred()) + } + }) + } +} + +func TestRegistry_AddKnownGate(t *testing.T) { + g := gomega.NewWithT(t) + registry := NewRegistry([]string{"alpha"}, true) + + g.Expect(registry.IsKnownGate("alpha")).To(gomega.BeTrue()) + g.Expect(registry.IsKnownGate("beta")).To(gomega.BeFalse()) + + registry.AddKnownGate("beta") + + g.Expect(registry.IsKnownGate("alpha")).To(gomega.BeTrue()) + g.Expect(registry.IsKnownGate("beta")).To(gomega.BeTrue()) +} + +func TestRegistry_AddKnownGates(t *testing.T) { + g := gomega.NewWithT(t) + registry := NewRegistry([]string{"alpha"}, true) + + g.Expect(registry.knownGates.Len()).To(gomega.Equal(1)) + + registry.AddKnownGates([]string{"beta", "gamma"}) + + g.Expect(registry.knownGates.Len()).To(gomega.Equal(3)) + g.Expect(registry.IsKnownGate("alpha")).To(gomega.BeTrue()) + g.Expect(registry.IsKnownGate("beta")).To(gomega.BeTrue()) + g.Expect(registry.IsKnownGate("gamma")).To(gomega.BeTrue()) +} + +func TestRegistry_GetKnownGates(t *testing.T) { + g := gomega.NewWithT(t) + registry := NewRegistry([]string{"alpha", "beta"}, true) + + gates := registry.GetKnownGates() + + g.Expect(gates.Len()).To(gomega.Equal(2)) + g.Expect(gates.Has("alpha")).To(gomega.BeTrue()) + g.Expect(gates.Has("beta")).To(gomega.BeTrue()) + + // Verify it's a copy - modifying returned set shouldn't affect registry + gates.Insert("gamma") + g.Expect(registry.IsKnownGate("gamma")).To(gomega.BeFalse()) +} + +func TestRegistry_Integration(t *testing.T) { + g := gomega.NewWithT(t) + // Test a complete workflow + registry := NewRegistry([]string{"alpha", "beta", "gamma"}, true) + + // Create an evaluator + evaluator, err := registry.CreateEvaluator("alpha=true,beta=false,gamma=true") + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(evaluator).NotTo(gomega.BeNil()) + + // Validate and evaluate expressions + testExpressions := []struct { + expr string + expected bool + }{ + {"", true}, + {"alpha", true}, + {"beta", false}, + {"alpha|beta", true}, + {"beta|gamma", true}, + {"alpha&gamma", true}, + {"alpha&beta", false}, + } + + for _, tt := range testExpressions { + // Validate expression + err := registry.ValidateExpression(tt.expr) + g.Expect(err).NotTo(gomega.HaveOccurred(), "Expression validation failed for: %s", tt.expr) + + // Evaluate expression + result := evaluator.EvaluateExpression(tt.expr) + g.Expect(result).To(gomega.Equal(tt.expected), "Expression evaluation failed for: %s", tt.expr) + } +} diff --git a/pkg/featuregate/types.go b/pkg/featuregate/types.go new file mode 100644 index 000000000..02a8018cb --- /dev/null +++ b/pkg/featuregate/types.go @@ -0,0 +1,73 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package featuregate + +import "strings" + +// FeatureGateMap represents enabled feature gates as a map for efficient lookup. +// Key is the gate name, value indicates if the gate is enabled. +type FeatureGateMap map[string]bool + +// IsEnabled checks if a feature gate is enabled. +// Returns true only if the gate exists and is explicitly set to true. +// Gates not present in the map are considered disabled. +func (fg FeatureGateMap) IsEnabled(gateName string) bool { + enabled, exists := fg[gateName] + return exists && enabled +} + +// FeatureGateEvaluator provides methods for parsing and evaluating feature gate expressions. +type FeatureGateEvaluator struct { + gates FeatureGateMap +} + +// NewFeatureGateEvaluator creates a new FeatureGateEvaluator with the given gates. +func NewFeatureGateEvaluator(gates FeatureGateMap) *FeatureGateEvaluator { + return &FeatureGateEvaluator{gates: gates} +} + +// EvaluateExpression evaluates a feature gate expression and returns whether it should be included. +// Supports the following formats: +// - Empty string: always returns true (no gating) +// - Single gate: "alpha" - returns true if alpha=true +// - OR logic: "alpha|beta" - returns true if either alpha=true OR beta=true +// - AND logic: "alpha&beta" - returns true if both alpha=true AND beta=true +// - Complex precedence: "(alpha&beta)|gamma" - returns true if (alpha AND beta) OR gamma +func (fge *FeatureGateEvaluator) EvaluateExpression(expr string) bool { + if expr == "" { + // No feature gate specified, always include + return true + } + + // Handle complex expressions with parentheses + if strings.Contains(expr, "(") { + return fge.evaluateComplexExpression(expr) + } + + // Handle AND logic (all gates must be enabled) + if hasAndOperator(expr) { + return fge.evaluateAndExpression(expr) + } + + // Handle OR logic (any gate can be enabled) + if hasOrOperator(expr) { + return fge.evaluateOrExpression(expr) + } + + // Single gate logic + return fge.gates.IsEnabled(expr) +} diff --git a/pkg/featuregate/types_test.go b/pkg/featuregate/types_test.go new file mode 100644 index 000000000..de57f6014 --- /dev/null +++ b/pkg/featuregate/types_test.go @@ -0,0 +1,185 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package featuregate + +import ( + "testing" + + "github.com/onsi/gomega" +) + +func TestFeatureGateMap_IsEnabled(t *testing.T) { + tests := []struct { + name string + gates FeatureGateMap + gateName string + expected bool + }{ + { + name: "enabled gate returns true", + gates: FeatureGateMap{ + "alpha": true, + "beta": false, + }, + gateName: "alpha", + expected: true, + }, + { + name: "disabled gate returns false", + gates: FeatureGateMap{ + "alpha": true, + "beta": false, + }, + gateName: "beta", + expected: false, + }, + { + name: "missing gate returns false", + gates: FeatureGateMap{ + "alpha": true, + }, + gateName: "gamma", + expected: false, + }, + { + name: "empty map returns false", + gates: FeatureGateMap{}, + gateName: "alpha", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + result := tt.gates.IsEnabled(tt.gateName) + g.Expect(result).To(gomega.Equal(tt.expected)) + }) + } +} + +func TestFeatureGateEvaluator_EvaluateExpression(t *testing.T) { + evaluator := NewFeatureGateEvaluator(FeatureGateMap{ + "alpha": true, + "beta": false, + "gamma": true, + "delta": false, + }) + + tests := []struct { + name string + expr string + expected bool + }{ + { + name: "empty expression always true", + expr: "", + expected: true, + }, + { + name: "single enabled gate", + expr: "alpha", + expected: true, + }, + { + name: "single disabled gate", + expr: "beta", + expected: false, + }, + { + name: "single missing gate", + expr: "missing", + expected: false, + }, + { + name: "OR expression - first enabled", + expr: "alpha|beta", + expected: true, + }, + { + name: "OR expression - second enabled", + expr: "beta|gamma", + expected: true, + }, + { + name: "OR expression - both enabled", + expr: "alpha|gamma", + expected: true, + }, + { + name: "OR expression - none enabled", + expr: "beta|delta", + expected: false, + }, + { + name: "OR expression - three gates, one enabled", + expr: "beta|delta|gamma", + expected: true, + }, + { + name: "AND expression - both enabled", + expr: "alpha&gamma", + expected: true, + }, + { + name: "AND expression - first disabled", + expr: "beta&gamma", + expected: false, + }, + { + name: "AND expression - second disabled", + expr: "alpha&delta", + expected: false, + }, + { + name: "AND expression - both disabled", + expr: "beta&delta", + expected: false, + }, + { + name: "AND expression - three gates, all enabled", + expr: "alpha&gamma&alpha", // Using alpha twice to test multiple enabled + expected: true, + }, + { + name: "AND expression - three gates, one disabled", + expr: "alpha&gamma&beta", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + result := evaluator.EvaluateExpression(tt.expr) + g.Expect(result).To(gomega.Equal(tt.expected)) + }) + } +} + +func TestNewFeatureGateEvaluator(t *testing.T) { + g := gomega.NewWithT(t) + gates := FeatureGateMap{ + "alpha": true, + "beta": false, + } + + evaluator := NewFeatureGateEvaluator(gates) + + g.Expect(evaluator).NotTo(gomega.BeNil()) + g.Expect(evaluator.gates).To(gomega.Equal(gates)) +} diff --git a/pkg/featuregate/validator.go b/pkg/featuregate/validator.go new file mode 100644 index 000000000..3e1e892b0 --- /dev/null +++ b/pkg/featuregate/validator.go @@ -0,0 +1,136 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package featuregate + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/util/sets" +) + +// ValidateFeatureGateExpression validates the syntax of a feature gate expression. +// Returns an error if the expression contains invalid characters or mixed operators. +// +// With strict validation, unknown feature gates will cause validation to fail. +// The knownGates parameter should contain all valid feature gate names. +func ValidateFeatureGateExpression(expr string, knownGates sets.Set[string], strict bool) error { + if expr == "" { + return nil + } + + // Check for invalid characters (only allow alphanumeric, hyphens, underscores, &, |) + for _, char := range expr { + if !isValidCharacter(char) { + return fmt.Errorf("invalid character '%c' in feature gate expression: %s", char, expr) + } + } + + // Check for mixing AND and OR operators without parentheses + if hasMixedOperatorsWithoutParentheses(expr) { + return fmt.Errorf("cannot mix '&' and '|' operators without parentheses in feature gate expression: %s", expr) + } + + // Validate parentheses are balanced + if err := validateParentheses(expr); err != nil { + return fmt.Errorf("invalid parentheses in feature gate expression '%s': %w", expr, err) + } + + // Validate individual gate names if strict validation is enabled + if strict && knownGates != nil && knownGates.Len() > 0 { + gates := extractGateNames(expr) + for _, gate := range gates { + if gate == "" { + return fmt.Errorf("empty gate name in expression: %s", expr) + } + if !knownGates.Has(gate) { + return fmt.Errorf("unknown feature gate '%s' in expression: %s", gate, expr) + } + } + } + + return nil +} + +// isValidCharacter checks if a character is valid in a feature gate expression. +func isValidCharacter(char rune) bool { + return (char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char == '-' || char == '_' || + char == '&' || char == '|' || + char == '(' || char == ')' +} + +// extractGateNames extracts individual gate names from a feature gate expression. +func extractGateNames(expr string) []string { + // Remove parentheses and replace operators with a common delimiter + normalized := strings.ReplaceAll(expr, "(", "") + normalized = strings.ReplaceAll(normalized, ")", "") + normalized = strings.ReplaceAll(normalized, "&", ",") + normalized = strings.ReplaceAll(normalized, "|", ",") + + // Handle special case of empty parentheses + if strings.TrimSpace(normalized) == "" && (strings.Contains(expr, "(") || strings.Contains(expr, ")")) { + return []string{""} + } + + // Split and trim + parts := strings.Split(normalized, ",") + var gates []string + for _, part := range parts { + gate := strings.TrimSpace(part) + if gate != "" { + gates = append(gates, gate) + } + } + + return gates +} + +// hasMixedOperatorsWithoutParentheses checks if expression has mixed operators without proper parentheses. +func hasMixedOperatorsWithoutParentheses(expr string) bool { + // If no parentheses, mixed operators are not allowed + if !strings.Contains(expr, "(") && !strings.Contains(expr, ")") { + hasAnd := strings.Contains(expr, "&") + hasOr := strings.Contains(expr, "|") + return hasAnd && hasOr + } + + // With parentheses, we allow mixed operators - they will be validated during parsing + return false +} + +// validateParentheses checks if parentheses are properly balanced in the expression. +func validateParentheses(expr string) error { + count := 0 + for _, char := range expr { + switch char { + case '(': + count++ + case ')': + count-- + if count < 0 { + return fmt.Errorf("unmatched closing parenthesis") + } + } + } + if count != 0 { + return fmt.Errorf("unmatched opening parenthesis") + } + return nil +} diff --git a/pkg/featuregate/validator_test.go b/pkg/featuregate/validator_test.go new file mode 100644 index 000000000..9ca6437a2 --- /dev/null +++ b/pkg/featuregate/validator_test.go @@ -0,0 +1,301 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package featuregate + +import ( + "testing" + + "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/util/sets" +) + +func TestValidateFeatureGateExpression(t *testing.T) { + knownGates := sets.New("alpha", "beta", "gamma", "v1beta1", "my-feature", "under_score") + + tests := []struct { + name string + expr string + knownGates sets.Set[string] + strict bool + expectError bool + errorContains string + }{ + { + name: "empty expression", + expr: "", + knownGates: knownGates, + strict: true, + }, + { + name: "simple gate name", + expr: "alpha", + knownGates: knownGates, + strict: true, + }, + { + name: "gate with hyphen", + expr: "my-feature", + knownGates: knownGates, + strict: true, + }, + { + name: "gate with underscore", + expr: "under_score", + knownGates: knownGates, + strict: true, + }, + { + name: "gate with numbers", + expr: "v1beta1", + knownGates: knownGates, + strict: true, + }, + { + name: "OR expression", + expr: "alpha|beta", + knownGates: knownGates, + strict: true, + }, + { + name: "AND expression", + expr: "alpha&beta", + knownGates: knownGates, + strict: true, + }, + { + name: "mixed AND OR operators", + expr: "alpha&beta|gamma", + knownGates: knownGates, + strict: true, + expectError: true, + errorContains: "cannot mix '&' and '|' operators", + }, + { + name: "invalid character @", + expr: "alpha@beta", + knownGates: knownGates, + strict: true, + expectError: true, + errorContains: "invalid character '@'", + }, + { + name: "invalid character .", + expr: "alpha.beta", + knownGates: knownGates, + strict: true, + expectError: true, + errorContains: "invalid character '.'", + }, + { + name: "invalid character space", + expr: "alpha beta", + knownGates: knownGates, + strict: true, + expectError: true, + errorContains: "invalid character ' '", + }, + { + name: "unknown gate strict mode", + expr: "unknown", + knownGates: knownGates, + strict: true, + expectError: true, + errorContains: "unknown feature gate 'unknown'", + }, + { + name: "unknown gate non-strict mode", + expr: "unknown", + knownGates: knownGates, + strict: false, + }, + { + name: "unknown gate in OR expression strict mode", + expr: "alpha|unknown", + knownGates: knownGates, + strict: true, + expectError: true, + errorContains: "unknown feature gate 'unknown'", + }, + { + name: "unknown gate in OR expression non-strict mode", + expr: "alpha|unknown", + knownGates: knownGates, + strict: false, + }, + { + name: "no known gates provided - no validation", + expr: "anything", + knownGates: nil, + strict: true, + }, + { + name: "empty known gates set - no validation", + expr: "anything", + knownGates: sets.New[string](), + strict: true, + }, + // Complex precedence rule tests + { + name: "simple parentheses", + expr: "(alpha)", + knownGates: knownGates, + strict: true, + }, + { + name: "parentheses with AND", + expr: "(alpha&beta)", + knownGates: knownGates, + strict: true, + }, + { + name: "parentheses with OR", + expr: "(alpha|beta)", + knownGates: knownGates, + strict: true, + }, + { + name: "complex precedence AND OR", + expr: "(alpha&beta)|gamma", + knownGates: knownGates, + strict: true, + }, + { + name: "complex precedence OR AND", + expr: "(alpha|beta)&gamma", + knownGates: knownGates, + strict: true, + }, + { + name: "nested parentheses", + expr: "((alpha&beta)|gamma)&delta", + knownGates: sets.New("alpha", "beta", "gamma", "delta"), + strict: true, + }, + { + name: "multiple OR groups", + expr: "(alpha&beta)|(gamma&delta)", + knownGates: sets.New("alpha", "beta", "gamma", "delta"), + strict: true, + }, + { + name: "unmatched opening parenthesis", + expr: "(alpha&beta", + knownGates: knownGates, + strict: true, + expectError: true, + errorContains: "unmatched opening parenthesis", + }, + { + name: "unmatched closing parenthesis", + expr: "alpha&beta)", + knownGates: knownGates, + strict: true, + expectError: true, + errorContains: "unmatched closing parenthesis", + }, + { + name: "empty parentheses", + expr: "()", + knownGates: knownGates, + strict: true, + expectError: true, + errorContains: "empty gate name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + err := ValidateFeatureGateExpression(tt.expr, tt.knownGates, tt.strict) + + if tt.expectError { + g.Expect(err).To(gomega.HaveOccurred()) + if tt.errorContains != "" { + g.Expect(err.Error()).To(gomega.ContainSubstring(tt.errorContains)) + } + } else { + g.Expect(err).NotTo(gomega.HaveOccurred()) + } + }) + } +} + +func TestExtractGateNames(t *testing.T) { + tests := []struct { + name string + expr string + expected []string + }{ + { + name: "single gate", + expr: "alpha", + expected: []string{"alpha"}, + }, + { + name: "OR expression", + expr: "alpha|beta", + expected: []string{"alpha", "beta"}, + }, + { + name: "AND expression", + expr: "alpha&beta", + expected: []string{"alpha", "beta"}, + }, + { + name: "three gates OR", + expr: "alpha|beta|gamma", + expected: []string{"alpha", "beta", "gamma"}, + }, + { + name: "three gates AND", + expr: "alpha&beta&gamma", + expected: []string{"alpha", "beta", "gamma"}, + }, + { + name: "with spaces", + expr: " alpha | beta ", + expected: []string{"alpha", "beta"}, + }, + { + name: "empty expression", + expr: "", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + result := extractGateNames(tt.expr) + g.Expect(result).To(gomega.Equal(tt.expected)) + }) + } +} + +func TestIsValidCharacter(t *testing.T) { + g := gomega.NewWithT(t) + validChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_&|()" + invalidChars := "@#$%^*+=[]{}\\:;\"'<>?,./`~ " + + for _, char := range validChars { + g.Expect(isValidCharacter(char)).To(gomega.BeTrue(), "Character '%c' should be valid", char) + } + + for _, char := range invalidChars { + g.Expect(isValidCharacter(char)).To(gomega.BeFalse(), "Character '%c' should be invalid", char) + } +} diff --git a/pkg/rbac/feature_gates_test.go b/pkg/rbac/feature_gates_test.go new file mode 100644 index 000000000..a5c8a9f56 --- /dev/null +++ b/pkg/rbac/feature_gates_test.go @@ -0,0 +1,242 @@ +package rbac + +import ( + "strings" + "testing" + + "github.com/onsi/gomega" + rbacv1 "k8s.io/api/rbac/v1" + "sigs.k8s.io/controller-tools/pkg/featuregate" + "sigs.k8s.io/controller-tools/pkg/genall" + "sigs.k8s.io/controller-tools/pkg/loader" + "sigs.k8s.io/controller-tools/pkg/markers" +) + +func TestFeatureGates(t *testing.T) { + g := gomega.NewWithT(t) + + // Load test packages + pkgs, err := loader.LoadRoots("./testdata/feature_gates") + g.Expect(err).NotTo(gomega.HaveOccurred()) + + // Set up generation context + reg := &markers.Registry{} + g.Expect(reg.Register(RuleDefinition)).To(gomega.Succeed()) + + ctx := &genall.GenerationContext{ + Collector: &markers.Collector{Registry: reg}, + Roots: pkgs, + } + + tests := []struct { + name string + featureGates string + expectedRules int + shouldContain []string + shouldNotContain []string + }{ + { + name: "no feature gates", + featureGates: "", + expectedRules: 2, // only always-on rules + shouldContain: []string{"pods", "configmaps"}, + shouldNotContain: []string{"deployments", "ingresses"}, + }, + { + name: "alpha enabled", + featureGates: "alpha=true", + expectedRules: 3, // always-on + alpha + shouldContain: []string{"pods", "configmaps", "deployments"}, + shouldNotContain: []string{"ingresses"}, + }, + { + name: "beta enabled", + featureGates: "beta=true", + expectedRules: 3, // always-on + beta + shouldContain: []string{"pods", "configmaps", "ingresses"}, + shouldNotContain: []string{"deployments"}, + }, + { + name: "both enabled", + featureGates: "alpha=true,beta=true", + expectedRules: 4, // all rules + shouldContain: []string{"pods", "configmaps", "deployments", "ingresses"}, + shouldNotContain: []string{}, + }, + { + name: "alpha enabled beta disabled", + featureGates: "alpha=true,beta=false", + expectedRules: 3, // always-on + alpha + shouldContain: []string{"pods", "configmaps", "deployments"}, + shouldNotContain: []string{"ingresses"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + + objs, err := GenerateRoles(ctx, "test-role", tt.featureGates) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(objs).To(gomega.HaveLen(1)) + + role, ok := objs[0].(rbacv1.ClusterRole) + g.Expect(ok).To(gomega.BeTrue()) + g.Expect(role.Rules).To(gomega.HaveLen(tt.expectedRules)) + + // Convert rules to string for easier checking + rulesStr := "" + for _, rule := range role.Rules { + rulesStr += strings.Join(rule.Resources, ",") + " " + } + + for _, resource := range tt.shouldContain { + g.Expect(rulesStr).To(gomega.ContainSubstring(resource), + "Expected resource %s to be present", resource) + } + + for _, resource := range tt.shouldNotContain { + g.Expect(rulesStr).NotTo(gomega.ContainSubstring(resource), + "Expected resource %s to be absent", resource) + } + }) + } +} + +func TestAdvancedFeatureGates(t *testing.T) { + g := gomega.NewWithT(t) + + // Load test packages + pkgs, err := loader.LoadRoots("./testdata/advanced_feature_gates") + g.Expect(err).NotTo(gomega.HaveOccurred()) + + // Set up generation context + reg := &markers.Registry{} + g.Expect(reg.Register(RuleDefinition)).To(gomega.Succeed()) + + ctx := &genall.GenerationContext{ + Collector: &markers.Collector{Registry: reg}, + Roots: pkgs, + } + + tests := []struct { + name string + featureGates string + expectedRules int + shouldContain []string + shouldNotContain []string + }{ + { + name: "OR logic - alpha enabled", + featureGates: "alpha=true,beta=false,gamma=false", + expectedRules: 3, // always-on + OR rule (alpha|beta) + shouldContain: []string{"pods", "configmaps", "secrets"}, + shouldNotContain: []string{"services", "jobs", "replicasets"}, + }, + { + name: "OR logic - beta enabled", + featureGates: "alpha=false,beta=true,gamma=false", + expectedRules: 3, // always-on + OR rule (alpha|beta) + shouldContain: []string{"pods", "configmaps", "secrets"}, + shouldNotContain: []string{"services", "jobs", "replicasets"}, + }, + { + name: "AND logic - both alpha and beta enabled", + featureGates: "alpha=true,beta=true,gamma=false", + expectedRules: 5, // always-on + OR rule + AND rule + complex OR (alpha&beta)|gamma + shouldContain: []string{"pods", "configmaps", "secrets", "services", "jobs"}, + shouldNotContain: []string{"replicasets"}, + }, + { + name: "OR logic - neither enabled", + featureGates: "alpha=false,beta=false,gamma=false", + expectedRules: 2, // only always-on + shouldContain: []string{"pods", "configmaps"}, + shouldNotContain: []string{"secrets", "services", "jobs", "replicasets"}, + }, + { + name: "Complex precedence - gamma enabled", + featureGates: "alpha=false,beta=false,gamma=true", + expectedRules: 3, // always-on + complex OR (alpha&beta)|gamma (satisfied by gamma) + shouldContain: []string{"pods", "configmaps", "jobs"}, + shouldNotContain: []string{"secrets", "services", "replicasets"}, + }, + { + name: "Complex precedence - alpha and gamma enabled", + featureGates: "alpha=true,beta=false,gamma=true", + expectedRules: 5, // always-on + OR + complex OR + complex AND + shouldContain: []string{"pods", "configmaps", "secrets", "jobs", "replicasets"}, + shouldNotContain: []string{"services"}, + }, + { + name: "Complex precedence - all enabled", + featureGates: "alpha=true,beta=true,gamma=true", + expectedRules: 6, // all rules enabled + shouldContain: []string{"pods", "configmaps", "secrets", "services", "jobs", "replicasets"}, + shouldNotContain: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + + objs, err := GenerateRoles(ctx, "test-role", tt.featureGates) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(objs).To(gomega.HaveLen(1)) + + role, ok := objs[0].(rbacv1.ClusterRole) + g.Expect(ok).To(gomega.BeTrue()) + g.Expect(role.Rules).To(gomega.HaveLen(tt.expectedRules)) + + // Convert rules to string for easier checking + rulesStr := "" + for _, rule := range role.Rules { + rulesStr += strings.Join(rule.Resources, ",") + " " + } + + for _, resource := range tt.shouldContain { + g.Expect(rulesStr).To(gomega.ContainSubstring(resource), + "Expected resource %s to be present", resource) + } + + for _, resource := range tt.shouldNotContain { + g.Expect(rulesStr).NotTo(gomega.ContainSubstring(resource), + "Expected resource %s to be absent", resource) + } + }) + } +} + +func TestFeatureGateValidation(t *testing.T) { + tests := []struct { + name string + expression string + shouldError bool + }{ + {name: "empty expression", expression: "", shouldError: false}, + {name: "single gate", expression: "alpha", shouldError: false}, + {name: "OR expression", expression: "alpha|beta", shouldError: false}, + {name: "AND expression", expression: "alpha&beta", shouldError: false}, + {name: "mixed operators", expression: "alpha&beta|gamma", shouldError: true}, + {name: "invalid character", expression: "alpha@beta", shouldError: true}, + {name: "hyphenated gate", expression: "feature-alpha", shouldError: false}, + {name: "underscore gate", expression: "feature_alpha", shouldError: false}, + {name: "numeric gate", expression: "v1beta1", shouldError: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := featuregate.ValidateFeatureGateExpression(tt.expression, nil, false) + if tt.shouldError { + if err == nil { + t.Errorf("Expected error for expression %s, but got none", tt.expression) + } + } else { + if err != nil { + t.Errorf("Expected no error for expression %s, but got: %v", tt.expression, err) + } + } + }) + } +} diff --git a/pkg/rbac/parser.go b/pkg/rbac/parser.go index 6521d2658..b409f7bb0 100644 --- a/pkg/rbac/parser.go +++ b/pkg/rbac/parser.go @@ -29,6 +29,8 @@ import ( rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/controller-tools/pkg/featuregate" "sigs.k8s.io/controller-tools/pkg/genall" "sigs.k8s.io/controller-tools/pkg/markers" ) @@ -60,6 +62,12 @@ type Rule struct { // If not set, the Rule belongs to the generated ClusterRole. // If set, the Rule belongs to a Role, whose namespace is specified by this field. Namespace string `marker:",optional"` + // FeatureGate specifies the feature gate(s) that control this RBAC rule. + // If not set, the rule is always included. + // If set to a single gate (e.g., "alpha"), the rule is included when that gate is enabled. + // If set to multiple gates separated by "|" (e.g., "alpha|beta"), the rule is included when ANY of the gates are enabled (OR logic). + // If set to multiple gates separated by "&" (e.g., "alpha&beta"), the rule is included when ALL of the gates are enabled (AND logic). + FeatureGate string `marker:"featureGate,optional"` } // ruleKey represents the resources and non-resources a Rule applies. @@ -169,6 +177,12 @@ type Generator struct { // Year specifies the year to substitute for " YEAR" in the header file. Year string `marker:",optional"` + + // FeatureGates is a comma-separated list of feature gates to enable (e.g., "alpha=true,beta=false"). + // Only RBAC rules with matching feature gates will be included in the generated output. + // Feature gates not explicitly listed are treated as disabled. + // Usage: controller-gen 'rbac:roleName=manager,featureGates="alpha=true,beta=false"' paths=./... + FeatureGates string `marker:",optional"` } func (Generator) RegisterMarkers(into *markers.Registry) error { @@ -179,195 +193,261 @@ func (Generator) RegisterMarkers(into *markers.Registry) error { return nil } -// GenerateRoles generate a slice of objs representing either a ClusterRole or a Role object -// The order of the objs in the returned slice is stable and determined by their namespaces. -func GenerateRoles(ctx *genall.GenerationContext, roleName string) ([]interface{}, error) { - rulesByNSResource := make(map[string][]*Rule) - for _, root := range ctx.Roots { - markerSet, err := markers.PackageMarkers(ctx.Collector, root) - if err != nil { - root.AddError(err) - } +// normalizeRules merges Rule with the same ruleKey and sorts the Rules +func normalizeRules(rules []*Rule) []rbacv1.PolicyRule { + ruleMap := normalizeRuleGroups(rules) + ruleMap = deduplicateResources(ruleMap) + ruleMap = deduplicateGroups(ruleMap) + ruleMap = deduplicateURLs(ruleMap) - // group RBAC markers by namespace and separate by resource - for _, markerValue := range markerSet[RuleDefinition.Name] { - rule := markerValue.(Rule) - if len(rule.Resources) == 0 { - // Add a rule without any resource if Resources is empty. - r := Rule{ - Groups: rule.Groups, - Resources: []string{}, - ResourceNames: rule.ResourceNames, - URLs: rule.URLs, - Namespace: rule.Namespace, - Verbs: rule.Verbs, - } - namespace := r.Namespace - rulesByNSResource[namespace] = append(rulesByNSResource[namespace], &r) - continue - } - for _, resource := range rule.Resources { - r := Rule{ - Groups: rule.Groups, - Resources: []string{resource}, - ResourceNames: rule.ResourceNames, - URLs: rule.URLs, - Namespace: rule.Namespace, - Verbs: rule.Verbs, - } - namespace := r.Namespace - rulesByNSResource[namespace] = append(rulesByNSResource[namespace], &r) + return generateSortedPolicyRules(ruleMap) +} + +// normalizeRuleGroups creates initial rule map and fixes group names +func normalizeRuleGroups(rules []*Rule) map[ruleKey]*Rule { + ruleMap := make(map[ruleKey]*Rule) + for _, rule := range rules { + // fix the group name first, since letting people type "core" is nice + for i, name := range rule.Groups { + if name == "core" { + rule.Groups[i] = "" } } + + key := rule.key() + if _, ok := ruleMap[key]; !ok { + ruleMap[key] = rule + continue + } + ruleMap[key].addVerbs(rule.Verbs) } + return ruleMap +} - // NormalizeRules merge Rule with the same ruleKey and sort the Rules - NormalizeRules := func(rules []*Rule) []rbacv1.PolicyRule { - ruleMap := make(map[ruleKey]*Rule) - // all the Rules having the same ruleKey will be merged into the first Rule - for _, rule := range rules { - // fix the group name first, since letting people type "core" is nice - for i, name := range rule.Groups { - if name == "core" { - rule.Groups[i] = "" - } - } +// deduplicateResources merges rules with same key except resources +func deduplicateResources(ruleMap map[ruleKey]*Rule) map[ruleKey]*Rule { + ruleMapWithoutResources := make(map[string][]*Rule) + for _, rule := range ruleMap { + key := rule.keyWithGroupResourceNamesURLsVerbs() + ruleMapWithoutResources[key] = append(ruleMapWithoutResources[key], rule) + } - key := rule.key() - if _, ok := ruleMap[key]; !ok { - ruleMap[key] = rule - continue - } - ruleMap[key].addVerbs(rule.Verbs) + newRuleMap := make(map[ruleKey]*Rule) + for _, rules := range ruleMapWithoutResources { + rule := rules[0] + for _, mergeRule := range rules[1:] { + rule.Resources = append(rule.Resources, mergeRule.Resources...) } + key := rule.key() + newRuleMap[key] = rule + } + return newRuleMap +} - // deduplicate resources - // 1. create map based on key without resources - ruleMapWithoutResources := make(map[string][]*Rule) - for _, rule := range ruleMap { - // get key without Resources - key := rule.keyWithGroupResourceNamesURLsVerbs() - ruleMapWithoutResources[key] = append(ruleMapWithoutResources[key], rule) - } - // 2. merge to ruleMap - ruleMap = make(map[ruleKey]*Rule) - for _, rules := range ruleMapWithoutResources { - rule := rules[0] - for _, mergeRule := range rules[1:] { - rule.Resources = append(rule.Resources, mergeRule.Resources...) - } +// deduplicateGroups merges rules with same key except groups +func deduplicateGroups(ruleMap map[ruleKey]*Rule) map[ruleKey]*Rule { + ruleMapWithoutGroup := make(map[string][]*Rule) + for _, rule := range ruleMap { + key := rule.keyWithResourcesResourceNamesURLsVerbs() + ruleMapWithoutGroup[key] = append(ruleMapWithoutGroup[key], rule) + } - key := rule.key() - ruleMap[key] = rule + newRuleMap := make(map[ruleKey]*Rule) + for _, rules := range ruleMapWithoutGroup { + rule := rules[0] + for _, mergeRule := range rules[1:] { + rule.Groups = append(rule.Groups, mergeRule.Groups...) } + key := rule.key() + newRuleMap[key] = rule + } + return newRuleMap +} + +// deduplicateURLs merges rules with same key except URLs +func deduplicateURLs(ruleMap map[ruleKey]*Rule) map[ruleKey]*Rule { + ruleMapWithoutURLs := make(map[string][]*Rule) + for _, rule := range ruleMap { + key := rule.keyWitGroupResourcesResourceNamesVerbs() + ruleMapWithoutURLs[key] = append(ruleMapWithoutURLs[key], rule) + } - // deduplicate groups - // 1. create map based on key without group - ruleMapWithoutGroup := make(map[string][]*Rule) - for _, rule := range ruleMap { - // get key without Group - key := rule.keyWithResourcesResourceNamesURLsVerbs() - ruleMapWithoutGroup[key] = append(ruleMapWithoutGroup[key], rule) + newRuleMap := make(map[ruleKey]*Rule) + for _, rules := range ruleMapWithoutURLs { + rule := rules[0] + for _, mergeRule := range rules[1:] { + rule.URLs = append(rule.URLs, mergeRule.URLs...) } - // 2. merge to ruleMap - ruleMap = make(map[ruleKey]*Rule) - for _, rules := range ruleMapWithoutGroup { - rule := rules[0] - for _, mergeRule := range rules[1:] { - rule.Groups = append(rule.Groups, mergeRule.Groups...) + key := rule.key() + newRuleMap[key] = rule + } + return newRuleMap +} + +// generateSortedPolicyRules sorts rules and normalizes verbs +func generateSortedPolicyRules(ruleMap map[ruleKey]*Rule) []rbacv1.PolicyRule { + keys := make([]ruleKey, 0, len(ruleMap)) + for key := range ruleMap { + keys = append(keys, key) + } + sort.Sort(ruleKeys(keys)) + + // Normalize rule verbs to "*" if any verb in the rule is an asterisk + for _, rule := range ruleMap { + for _, verb := range rule.Verbs { + if verb == "*" { + rule.Verbs = []string{"*"} + break } - key := rule.key() - ruleMap[key] = rule } + } - // deduplicate URLs - // 1. create map based on key without URLs - ruleMapWithoutURLs := make(map[string][]*Rule) - for _, rule := range ruleMap { - // get key without Group - key := rule.keyWitGroupResourcesResourceNamesVerbs() - ruleMapWithoutURLs[key] = append(ruleMapWithoutURLs[key], rule) + var policyRules []rbacv1.PolicyRule + for _, key := range keys { + policyRules = append(policyRules, ruleMap[key].ToRule()) + } + return policyRules +} + +// processRulesFromMarkers processes RBAC markers and groups them by namespace +func processRulesFromMarkers(ctx *genall.GenerationContext, evaluator *featuregate.FeatureGateEvaluator) (map[string][]*Rule, error) { + rulesByNSResource := make(map[string][]*Rule) + + for _, root := range ctx.Roots { + markerSet, err := markers.PackageMarkers(ctx.Collector, root) + if err != nil { + root.AddError(err) } - // 2. merge to ruleMap - ruleMap = make(map[ruleKey]*Rule) - for _, rules := range ruleMapWithoutURLs { - rule := rules[0] - for _, mergeRule := range rules[1:] { - rule.URLs = append(rule.URLs, mergeRule.URLs...) - } - key := rule.key() - ruleMap[key] = rule + + if err := processMarkersForRoot(markerSet, evaluator, rulesByNSResource); err != nil { + return nil, err + } + } + + return rulesByNSResource, nil +} + +// processMarkersForRoot processes markers for a single root +func processMarkersForRoot(markerSet markers.MarkerValues, evaluator *featuregate.FeatureGateEvaluator, rulesByNSResource map[string][]*Rule) error { + for _, markerValue := range markerSet[RuleDefinition.Name] { + rule := markerValue.(Rule) + + if err := featuregate.ValidateFeatureGateExpression(rule.FeatureGate, nil, false); err != nil { + return fmt.Errorf("invalid feature gate expression in RBAC rule: %w", err) } - // sort the Rules in rules according to their ruleKeys - keys := make([]ruleKey, 0, len(ruleMap)) - for key := range ruleMap { - keys = append(keys, key) + if !evaluator.EvaluateExpression(rule.FeatureGate) { + continue } - sort.Sort(ruleKeys(keys)) - - // Normalize rule verbs to "*" if any verb in the rule is an asterisk - for _, rule := range ruleMap { - for _, verb := range rule.Verbs { - if verb == "*" { - rule.Verbs = []string{"*"} - break - } - } + + addRuleToMap(rule, rulesByNSResource) + } + return nil +} + +// addRuleToMap adds a rule to the namespace-indexed rule map +func addRuleToMap(rule Rule, rulesByNSResource map[string][]*Rule) { + if len(rule.Resources) == 0 { + r := Rule{ + Groups: rule.Groups, + Resources: []string{}, + ResourceNames: rule.ResourceNames, + URLs: rule.URLs, + Namespace: rule.Namespace, + Verbs: rule.Verbs, + FeatureGate: rule.FeatureGate, } - var policyRules []rbacv1.PolicyRule - for _, key := range keys { - policyRules = append(policyRules, ruleMap[key].ToRule()) + rulesByNSResource[r.Namespace] = append(rulesByNSResource[r.Namespace], &r) + return + } + + for _, resource := range rule.Resources { + r := Rule{ + Groups: rule.Groups, + Resources: []string{resource}, + ResourceNames: rule.ResourceNames, + URLs: rule.URLs, + Namespace: rule.Namespace, + Verbs: rule.Verbs, + FeatureGate: rule.FeatureGate, } - return policyRules + rulesByNSResource[r.Namespace] = append(rulesByNSResource[r.Namespace], &r) } +} - // collect all the namespaces and sort them +// createRoleObjects creates Role and ClusterRole objects from rules +func createRoleObjects(rulesByNSResource map[string][]*Rule, roleName string) []interface{} { var namespaces []string for ns := range rulesByNSResource { namespaces = append(namespaces, ns) } sort.Strings(namespaces) - // process the items in rulesByNS by the order specified in `namespaces` to make sure that the Role order is stable var objs []interface{} for _, ns := range namespaces { rules := rulesByNSResource[ns] - policyRules := NormalizeRules(rules) + policyRules := normalizeRules(rules) if len(policyRules) == 0 { continue } + if ns == "" { - objs = append(objs, rbacv1.ClusterRole{ - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterRole", - APIVersion: rbacv1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: roleName, - }, - Rules: policyRules, - }) + objs = append(objs, createClusterRole(roleName, policyRules)) } else { - objs = append(objs, rbacv1.Role{ - TypeMeta: metav1.TypeMeta{ - Kind: "Role", - APIVersion: rbacv1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: roleName, - Namespace: ns, - }, - Rules: policyRules, - }) + objs = append(objs, createRole(roleName, ns, policyRules)) } } + return objs +} + +// createClusterRole creates a ClusterRole object +func createClusterRole(roleName string, policyRules []rbacv1.PolicyRule) rbacv1.ClusterRole { + return rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRole", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: roleName, + }, + Rules: policyRules, + } +} + +// createRole creates a Role object +func createRole(roleName, namespace string, policyRules []rbacv1.PolicyRule) rbacv1.Role { + return rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + Kind: "Role", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: roleName, + Namespace: namespace, + }, + Rules: policyRules, + } +} + +// GenerateRoles generate a slice of objs representing either a ClusterRole or a Role object +// The order of the objs in the returned slice is stable and determined by their namespaces. +func GenerateRoles(ctx *genall.GenerationContext, roleName string, featureGates string) ([]interface{}, error) { + enabledGates, err := featuregate.ParseFeatureGates(featureGates, false) + if err != nil { + return nil, fmt.Errorf("failed to parse feature gates: %w", err) + } + evaluator := featuregate.NewFeatureGateEvaluator(enabledGates) + + rulesByNSResource, err := processRulesFromMarkers(ctx, evaluator) + if err != nil { + return nil, err + } - return objs, nil + return createRoleObjects(rulesByNSResource, roleName), nil } func (g Generator) Generate(ctx *genall.GenerationContext) error { - objs, err := GenerateRoles(ctx, g.RoleName) + objs, err := GenerateRoles(ctx, g.RoleName, g.FeatureGates) if err != nil { return err } diff --git a/pkg/rbac/parser_integration_test.go b/pkg/rbac/parser_integration_test.go index 6d877d966..63ec2235b 100644 --- a/pkg/rbac/parser_integration_test.go +++ b/pkg/rbac/parser_integration_test.go @@ -42,7 +42,7 @@ var _ = Describe("ClusterRole generated by the RBAC Generator", func() { } By("generating a ClusterRole") - objs, err := rbac.GenerateRoles(ctx, "manager-role") + objs, err := rbac.GenerateRoles(ctx, "manager-role", "") Expect(err).NotTo(HaveOccurred()) By("loading the desired YAML") diff --git a/pkg/rbac/testdata/advanced_feature_gates/controller.go b/pkg/rbac/testdata/advanced_feature_gates/controller.go new file mode 100644 index 000000000..bf4db2579 --- /dev/null +++ b/pkg/rbac/testdata/advanced_feature_gates/controller.go @@ -0,0 +1,23 @@ +package testdata + +// Always included RBAC rule +// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch + +// Another always included rule +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list + +// OR logic: alpha OR beta feature gate RBAC rule +// +kubebuilder:rbac:featureGate=alpha|beta,groups="",resources=secrets,verbs=get;list;create + +// AND logic: alpha AND beta feature gate RBAC rule +// +kubebuilder:rbac:featureGate=alpha&beta,groups="",resources=services,verbs=get;list;create;update;delete + +// Complex precedence: (alpha AND beta) OR gamma +// +kubebuilder:rbac:featureGate=(alpha&beta)|gamma,groups=batch,resources=jobs,verbs=get;list;create + +// Complex precedence: (alpha OR beta) AND gamma +// +kubebuilder:rbac:featureGate=(alpha|beta)&gamma,groups=apps,resources=replicasets,verbs=get;list;watch + +func main() { + // Test file for advanced RBAC feature gates with complex expressions +} diff --git a/pkg/rbac/testdata/feature_gates/controller.go b/pkg/rbac/testdata/feature_gates/controller.go new file mode 100644 index 000000000..4a831b383 --- /dev/null +++ b/pkg/rbac/testdata/feature_gates/controller.go @@ -0,0 +1,17 @@ +package testdata + +// Always included RBAC rule +// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch + +// Another always included rule +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list + +// Alpha feature gate RBAC rule +// +kubebuilder:rbac:featureGate=alpha,groups=apps,resources=deployments,verbs=get;list;create;update;delete + +// Beta feature gate RBAC rule +// +kubebuilder:rbac:featureGate=beta,groups=extensions,resources=ingresses,verbs=get;list;create;update;delete + +func main() { + // Test file for RBAC feature gates +} diff --git a/pkg/rbac/zz_generated.markerhelp.go b/pkg/rbac/zz_generated.markerhelp.go index 085898ab5..558273b37 100644 --- a/pkg/rbac/zz_generated.markerhelp.go +++ b/pkg/rbac/zz_generated.markerhelp.go @@ -48,6 +48,10 @@ func (Generator) Help() *markers.DefinitionHelp { Summary: "specifies the year to substitute for \" YEAR\" in the header file.", Details: "", }, + "FeatureGates": { + Summary: "is a comma-separated list of feature gates to enable (e.g., \"alpha=true,beta=false\").", + Details: "Only RBAC rules with matching feature gates will be included in the generated output.\nFeature gates not explicitly listed are treated as disabled.\nUsage: controller-gen 'rbac:roleName=manager,featureGates=\"alpha=true,beta=false\"' paths=./...", + }, }, } } @@ -84,6 +88,10 @@ func (Rule) Help() *markers.DefinitionHelp { Summary: "specifies the scope of the Rule.", Details: "If not set, the Rule belongs to the generated ClusterRole.\nIf set, the Rule belongs to a Role, whose namespace is specified by this field.", }, + "FeatureGate": { + Summary: "specifies the feature gate(s) that control this RBAC rule.", + Details: "If not set, the rule is always included.\nIf set to a single gate (e.g., \"alpha\"), the rule is included when that gate is enabled.\nIf set to multiple gates separated by \"|\" (e.g., \"alpha|beta\"), the rule is included when ANY of the gates are enabled (OR logic).\nIf set to multiple gates separated by \"&\" (e.g., \"alpha&beta\"), the rule is included when ALL of the gates are enabled (AND logic).", + }, }, } } diff --git a/pkg/schemapatcher/gen.go b/pkg/schemapatcher/gen.go index 063a7cfa2..3849d0b38 100644 --- a/pkg/schemapatcher/gen.go +++ b/pkg/schemapatcher/gen.go @@ -112,7 +112,7 @@ func (g Generator) Generate(ctx *genall.GenerationContext) (result error) { } // generate schemata for the types we care about, and save them to be written later. - for _, groupKind := range crdgen.FindKubeKinds(parser, metav1Pkg) { + for _, groupKind := range crdgen.FindKubeKinds(parser, metav1Pkg, nil) { existingSet, wanted := partialCRDSets[groupKind] if !wanted { continue diff --git a/pkg/webhook/parser.go b/pkg/webhook/parser.go index 832edebb0..d355f91dc 100644 --- a/pkg/webhook/parser.go +++ b/pkg/webhook/parser.go @@ -31,6 +31,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/controller-tools/pkg/featuregate" "sigs.k8s.io/controller-tools/pkg/genall" "sigs.k8s.io/controller-tools/pkg/markers" ) @@ -159,6 +160,13 @@ type Config struct { // The URL configuration should be between quotes. // `url` cannot be specified when `path` is specified. URL string `marker:"url,optional"` + + // FeatureGate specifies the feature gate(s) that control this webhook. + // If not set, the webhook is always included. + // If set to a single gate (e.g., "alpha"), the webhook is included when that gate is enabled. + // If set to multiple gates separated by "|" (e.g., "alpha|beta"), the webhook is included when ANY of the gates are enabled (OR logic). + // If set to multiple gates separated by "&" (e.g., "alpha&beta"), the webhook is included when ALL of the gates are enabled (AND logic). + FeatureGate string `marker:"featureGate,optional"` } // verbToAPIVariant converts a marker's verb to the proper value for the API. @@ -426,6 +434,12 @@ type Generator struct { // Year specifies the year to substitute for " YEAR" in the header file. Year string `marker:",optional"` + + // FeatureGates is a comma-separated list of feature gates to enable (e.g., "alpha=true,beta=false"). + // Only webhook configurations with matching feature gates will be included in the generated output. + // Feature gates not explicitly listed are treated as disabled. + // Usage: controller-gen 'webhook:featureGates="alpha=true,beta=false"' paths=./... + FeatureGates string `marker:",optional"` } func (Generator) RegisterMarkers(into *markers.Registry) error { @@ -448,6 +462,13 @@ func (g Generator) Generate(ctx *genall.GenerationContext) error { var mutatingWebhookCfgs admissionregv1.MutatingWebhookConfiguration var validatingWebhookCfgs admissionregv1.ValidatingWebhookConfiguration + // Parse feature gates from the CLI parameter using centralized package + enabledGates, err := featuregate.ParseFeatureGates(g.FeatureGates, false) + if err != nil { + return err + } + evaluator := featuregate.NewFeatureGateEvaluator(enabledGates) + for _, root := range ctx.Roots { markerSet, err := markers.PackageMarkers(ctx.Collector, root) if err != nil { @@ -489,6 +510,17 @@ func (g Generator) Generate(ctx *genall.GenerationContext) error { for _, cfg := range cfgs { cfg := cfg.(Config) + + // Validate feature gate syntax if specified using centralized package + if err := featuregate.ValidateFeatureGateExpression(cfg.FeatureGate, nil, false); err != nil { + return fmt.Errorf("invalid feature gate for webhook %s: %w", cfg.Name, err) + } + + // Check if this webhook should be included based on feature gates using centralized evaluator + if !evaluator.EvaluateExpression(cfg.FeatureGate) { + continue + } + webhookVersions, err := cfg.webhookVersions() if err != nil { return err diff --git a/pkg/webhook/parser_featuregate_test.go b/pkg/webhook/parser_featuregate_test.go new file mode 100644 index 000000000..25b665026 --- /dev/null +++ b/pkg/webhook/parser_featuregate_test.go @@ -0,0 +1,158 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +package webhook_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-tools/pkg/featuregate" +) + +var _ = Describe("FeatureGates", func() { + Describe("ParseFeatureGates", func() { + It("should parse empty string", func() { + result, err := featuregate.ParseFeatureGates("", false) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(featuregate.FeatureGateMap{})) + }) + + It("should parse single gate enabled", func() { + result, err := featuregate.ParseFeatureGates("alpha=true", false) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(featuregate.FeatureGateMap{"alpha": true})) + }) + + It("should parse single gate disabled", func() { + result, err := featuregate.ParseFeatureGates("alpha=false", false) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(featuregate.FeatureGateMap{"alpha": false})) + }) + + It("should parse multiple gates", func() { + result, err := featuregate.ParseFeatureGates("alpha=true,beta=false,gamma=true", false) + Expect(err).ToNot(HaveOccurred()) + expected := featuregate.FeatureGateMap{ + "alpha": true, + "beta": false, + "gamma": true, + } + Expect(result).To(Equal(expected)) + }) + + It("should parse gates with spaces", func() { + result, err := featuregate.ParseFeatureGates(" alpha = true , beta = false ", false) + Expect(err).ToNot(HaveOccurred()) + expected := featuregate.FeatureGateMap{ + "alpha": true, + "beta": false, + } + Expect(result).To(Equal(expected)) + }) + + It("should ignore invalid format", func() { + result, err := featuregate.ParseFeatureGates("alpha=true,invalid,beta=false", false) + Expect(err).ToNot(HaveOccurred()) + expected := featuregate.FeatureGateMap{ + "alpha": true, + "beta": false, + } + Expect(result).To(Equal(expected)) + }) + }) + + Describe("ValidateFeatureGateExpression", func() { + It("should reject empty expression", func() { + err := featuregate.ValidateFeatureGateExpression("", nil, false) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should accept simple gate name", func() { + err := featuregate.ValidateFeatureGateExpression("alpha", nil, false) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should reject mixed operators", func() { + err := featuregate.ValidateFeatureGateExpression("alpha&beta|gamma", nil, false) + Expect(err).To(HaveOccurred()) + }) + + It("should accept OR expression", func() { + err := featuregate.ValidateFeatureGateExpression("alpha|beta", nil, false) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should accept AND expression", func() { + err := featuregate.ValidateFeatureGateExpression("alpha&beta", nil, false) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should reject invalid characters", func() { + err := featuregate.ValidateFeatureGateExpression("alpha@beta", nil, false) + Expect(err).To(HaveOccurred()) + }) + + It("should reject spaces", func() { + err := featuregate.ValidateFeatureGateExpression("alpha beta", nil, false) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("shouldIncludeWebhook (internal testing)", func() { + It("should include webhook without feature gates", func() { + // Test basic featuregate evaluator functionality + evaluator := featuregate.NewFeatureGateEvaluator(map[string]bool{}) + result := evaluator.EvaluateExpression("") + Expect(result).To(BeTrue()) + }) + + It("should include webhook with matching feature gate enabled", func() { + evaluator := featuregate.NewFeatureGateEvaluator(map[string]bool{"alpha": true}) + result := evaluator.EvaluateExpression("alpha") + Expect(result).To(BeTrue()) + }) + + It("should exclude webhook with matching feature gate disabled", func() { + evaluator := featuregate.NewFeatureGateEvaluator(map[string]bool{"alpha": false}) + result := evaluator.EvaluateExpression("alpha") + Expect(result).To(BeFalse()) + }) + + It("should exclude webhook with missing feature gate", func() { + evaluator := featuregate.NewFeatureGateEvaluator(map[string]bool{"beta": true}) + result := evaluator.EvaluateExpression("alpha") + Expect(result).To(BeFalse()) + }) + + It("should handle OR expression correctly", func() { + evaluator := featuregate.NewFeatureGateEvaluator(map[string]bool{"alpha": false, "beta": true}) + result := evaluator.EvaluateExpression("alpha|beta") + Expect(result).To(BeTrue()) + }) + + It("should handle AND expression correctly", func() { + evaluator := featuregate.NewFeatureGateEvaluator(map[string]bool{"alpha": true, "beta": true}) + result := evaluator.EvaluateExpression("alpha&beta") + Expect(result).To(BeTrue()) + }) + + It("should handle complex AND expression", func() { + evaluator := featuregate.NewFeatureGateEvaluator(map[string]bool{"alpha": true, "beta": true, "gamma": false}) + result := evaluator.EvaluateExpression("alpha&beta&gamma") + Expect(result).To(BeFalse()) + }) + }) +}) diff --git a/pkg/webhook/zz_generated.markerhelp.go b/pkg/webhook/zz_generated.markerhelp.go index 53ce42f59..b851a18f0 100644 --- a/pkg/webhook/zz_generated.markerhelp.go +++ b/pkg/webhook/zz_generated.markerhelp.go @@ -104,6 +104,10 @@ func (Config) Help() *markers.DefinitionHelp { Summary: "allows mutating webhooks configuration to specify an external URL when generating", Details: "the manifests, instead of using the internal service communication. Should be in format of\nhttps://address:port/path\nWhen this option is specified, the serviceConfig.Service is removed from webhook the manifest.\nThe URL configuration should be between quotes.\n`url` cannot be specified when `path` is specified.", }, + "FeatureGate": { + Summary: "specifies the feature gate(s) that control this webhook.", + Details: "If not set, the webhook is always included.\nIf set to a single gate (e.g., \"alpha\"), the webhook is included when that gate is enabled.\nIf set to multiple gates separated by \"|\" (e.g., \"alpha|beta\"), the webhook is included when ANY of the gates are enabled (OR logic).\nIf set to multiple gates separated by \"&\" (e.g., \"alpha&beta\"), the webhook is included when ALL of the gates are enabled (AND logic).", + }, }, } } @@ -124,6 +128,10 @@ func (Generator) Help() *markers.DefinitionHelp { Summary: "specifies the year to substitute for \" YEAR\" in the header file.", Details: "", }, + "FeatureGates": { + Summary: "is a comma-separated list of feature gates to enable (e.g., \"alpha=true,beta=false\").", + Details: "Only webhook configurations with matching feature gates will be included in the generated output.\nFeature gates not explicitly listed are treated as disabled.\nUsage: controller-gen 'webhook:featureGates=\"alpha=true,beta=false\"' paths=./...", + }, }, } }