diff --git a/cmd/controller-gen/main.go b/cmd/controller-gen/main.go index b421028a9..4efbb24e0 100644 --- a/cmd/controller-gen/main.go +++ b/cmd/controller-gen/main.go @@ -65,10 +65,11 @@ var ( // - output::
(per-generator output) // - output: (default output) allOutputRules = map[string]genall.OutputRule{ - "dir": genall.OutputToDirectory(""), - "none": genall.OutputToNothing, - "stdout": genall.OutputToStdout, - "artifacts": genall.OutputArtifacts{}, + "dir": genall.OutputToDirectory(""), + "none": genall.OutputToNothing, + "stdout": genall.OutputToStdout, + "artifacts": genall.OutputArtifacts{}, + "featuregate-dir": genall.OutputToFeatureGateDirectories{}, } // optionsRegistry contains all the marker definitions used to process command line options @@ -209,6 +210,12 @@ func main() { return helpForLevels(c.OutOrStdout(), c.OutOrStderr(), helpLevel, optionsRegistry, help.SortByOption) }) + // Add workflow command for advanced multi-gate, multi-output generation patterns + if len(os.Args) > 1 && os.Args[1] == "workflow" { + workflowCmd := NewWorkflowCommand() + cmd.AddCommand(workflowCmd) + } + if err := cmd.Execute(); err != nil { var errNoUsage noUsageError if !errors.As(err, &errNoUsage) { diff --git a/cmd/controller-gen/workflow.go b/cmd/controller-gen/workflow.go new file mode 100644 index 000000000..d64f11f0b --- /dev/null +++ b/cmd/controller-gen/workflow.go @@ -0,0 +1,267 @@ +/* +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 main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "sigs.k8s.io/controller-tools/pkg/featuregate" + "sigs.k8s.io/controller-tools/pkg/genall" +) + +// NewWorkflowCommand provides advanced CLI workflows for multi-gate, multi-output generation +func NewWorkflowCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "workflow", + Short: "Advanced workflows for multi-gate, multi-output generation", + Long: `Advanced workflows for generating multiple CRD variants with different feature gate combinations. + +This enables developers to: +- Generate CRDs for multiple feature gate combinations in a single command +- Create progressive rollout scenarios +- Test feature gate matrix combinations + +Examples: + # Generate CRDs for all feature gate combinations + controller-gen workflow matrix --gates=alpha,beta --base-path=./output + + # Generate CRDs for progressive rollout + controller-gen workflow progressive --gates=alpha,beta,gamma --base-path=./output`, + } + + cmd.AddCommand(NewMatrixCommand()) + cmd.AddCommand(NewProgressiveCommand()) + + return cmd +} + +// createWorkflowCommand creates a workflow command with common flags and validation +func createWorkflowCommand(use, short, long string, runFunc func([]string, string, []string) error) *cobra.Command { + var gates []string + var basePath string + var paths []string + + cmd := &cobra.Command{ + Use: use, + Short: short, + Long: long, + RunE: func(cmd *cobra.Command, args []string) error { + if len(gates) == 0 { + return fmt.Errorf("at least one feature gate must be specified") + } + if basePath == "" { + return fmt.Errorf("base path must be specified") + } + if len(paths) == 0 { + paths = []string{"./..."} + } + + return runFunc(gates, basePath, paths) + }, + } + + cmd.Flags().StringSliceVar(&gates, "gates", nil, "Feature gates to generate combinations for") + cmd.Flags().StringVar(&basePath, "base-path", "", "Base output directory") + cmd.Flags().StringSliceVar(&paths, "paths", []string{"./..."}, "Go package paths to process") + + _ = cmd.MarkFlagRequired("gates") + _ = cmd.MarkFlagRequired("base-path") + + return cmd +} + +// NewMatrixCommand generates CRDs for all possible feature gate combinations +func NewMatrixCommand() *cobra.Command { + return createWorkflowCommand( + "matrix", + "Generate CRDs for all possible feature gate combinations", + `Generate CRDs for all possible combinations of the specified feature gates. + +This will create directories for each combination: +- no_gates/ (all gates disabled) +- alpha/ (only alpha enabled) +- beta/ (only beta enabled) +- alpha_beta/ (both alpha and beta enabled) +- etc. + +This is useful for testing and packaging different feature variants.`, + generateMatrix, + ) +} + +// NewProgressiveCommand generates CRDs for progressive feature rollout +func NewProgressiveCommand() *cobra.Command { + return createWorkflowCommand( + "progressive", + "Generate CRDs for progressive feature rollout", + `Generate CRDs for progressive feature rollout scenarios: + +- stage_0/ (stable features only) +- stage_1/ (stable + first gate) +- stage_2/ (stable + first two gates) +- stage_N/ (stable + all gates) + +This enables gradual feature introduction in production environments.`, + generateProgressive, + ) +} + +// generateMatrix generates all possible feature gate combinations +func generateMatrix(gates []string, basePath string, paths []string) error { + // Generate all 2^n combinations + n := len(gates) + totalCombinations := 1 << n + + fmt.Printf("Generating %d feature gate combinations for gates: %v\n", totalCombinations, gates) + + for i := 0; i < totalCombinations; i++ { + var enabledGates []string + var gateSettings []string + + for j, gate := range gates { + if (i>>j)&1 == 1 { + enabledGates = append(enabledGates, gate) + gateSettings = append(gateSettings, fmt.Sprintf("%s=true", gate)) + } else { + gateSettings = append(gateSettings, fmt.Sprintf("%s=false", gate)) + } + } + + combination := strings.Join(gateSettings, ",") + outputDir := getOutputDirectory(enabledGates) + + err := runGenerationForCombination(combination, filepath.Join(basePath, outputDir), paths) + if err != nil { + return fmt.Errorf("failed to generate for combination %s: %w", combination, err) + } + } + + fmt.Printf("Successfully generated all %d combinations in %s\n", totalCombinations, basePath) + return nil +} + +// generateProgressive generates progressive rollout configurations +func generateProgressive(gates []string, basePath string, paths []string) error { + stages := len(gates) + 1 + + fmt.Printf("Generating %d progressive stages for gates: %v\n", stages, gates) + + // Stage 0: all gates disabled + stage0Settings := make([]string, len(gates)) + for i, gate := range gates { + stage0Settings[i] = fmt.Sprintf("%s=false", gate) + } + + err := runGenerationForCombination( + strings.Join(stage0Settings, ","), + filepath.Join(basePath, "stage_0"), + paths, + ) + if err != nil { + return fmt.Errorf("failed to generate stage 0: %w", err) + } + + // Progressive stages: enable one more gate at each stage + for i := 1; i <= len(gates); i++ { + var stageSettings []string + for j, gate := range gates { + if j < i { + stageSettings = append(stageSettings, fmt.Sprintf("%s=true", gate)) + } else { + stageSettings = append(stageSettings, fmt.Sprintf("%s=false", gate)) + } + } + + err := runGenerationForCombination( + strings.Join(stageSettings, ","), + filepath.Join(basePath, fmt.Sprintf("stage_%d", i)), + paths, + ) + if err != nil { + return fmt.Errorf("failed to generate stage %d: %w", i, err) + } + } + + fmt.Printf("Successfully generated all %d progressive stages in %s\n", stages, basePath) + return nil +} + +// getOutputDirectory determines the output directory name based on enabled gates +func getOutputDirectory(enabledGates []string) string { + if len(enabledGates) == 0 { + return "no_gates" + } + + // Sort gates for consistent naming + for i := 0; i < len(enabledGates)-1; i++ { + for j := 0; j < len(enabledGates)-i-1; j++ { + if enabledGates[j] > enabledGates[j+1] { + enabledGates[j], enabledGates[j+1] = enabledGates[j+1], enabledGates[j] + } + } + } + + return strings.Join(enabledGates, "_") +} + +// runGenerationForCombination runs controller-gen for a specific feature gate combination +func runGenerationForCombination(featureGates string, outputPath string, paths []string) error { + // Create output directory + if err := os.MkdirAll(outputPath, 0755); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", outputPath, err) + } + + fmt.Printf(" Generating: %s -> %s\n", featureGates, outputPath) + + // Parse feature gates to validate them + _, err := featuregate.ParseFeatureGates(featureGates) + if err != nil { + return fmt.Errorf("invalid feature gates %s: %w", featureGates, err) + } + + // Create a runtime configuration for this combination + args := []string{ + "crd", + fmt.Sprintf("crd:featureGates=%s", featureGates), + fmt.Sprintf("output:crd:dir=%s", outputPath), + } + + // Add paths + for _, path := range paths { + args = append(args, fmt.Sprintf("paths=%s", path)) + } + + // Use the existing optionsRegistry and run generation + rt, err := genall.FromOptions(optionsRegistry, args) + if err != nil { + return fmt.Errorf("failed to create runtime for combination %s: %w", featureGates, err) + } + + if len(rt.Generators) == 0 { + return fmt.Errorf("no generators specified for combination %s", featureGates) + } + + if hadErrs := rt.Run(); hadErrs { + return fmt.Errorf("generation failed for combination %s", featureGates) + } + + return nil +} 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..0b6aafa79 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) + 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 featureGate, ok := featureGateMarker.(crdmarkers.FeatureGate); ok { + gateName := string(featureGate) + // 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/validation.go b/pkg/crd/markers/validation.go index c91f5e6a1..35a4228aa 100644 --- a/pkg/crd/markers/validation.go +++ b/pkg/crd/markers/validation.go @@ -138,6 +138,12 @@ var ValidationIshMarkers = []*definitionWithHelp{ WithHelp(Title{}.Help()), must(markers.MakeAnyTypeDefinition("kubebuilder:title", markers.DescribesType, Title{})). WithHelp(Title{}.Help()), + + // Feature gate markers for both fields and types (separate registrations) + must(markers.MakeDefinition("kubebuilder:featuregate", markers.DescribesField, FeatureGate(""))). + WithHelp(FeatureGate("").Help()), + must(markers.MakeDefinition("kubebuilder:featuregate", markers.DescribesType, FeatureGate(""))). + WithHelp(FeatureGate("").Help()), } func init() { @@ -393,6 +399,31 @@ type ExactlyOneOf []string // +controllertools:marker:generateHelp:category="CRD validation" type AtLeastOneOf []string +// FeatureGate marks a field or type to be conditionally included based on feature gate enablement. +// +// Fields or types marked with +kubebuilder:featuregate will only be included in generated CRDs +// when the specified feature gate expression evaluates to true via the crd:featureGates parameter. +// +// Supported formats: +// - Single gate: +kubebuilder:featuregate=alpha +// - OR expression: +kubebuilder:featuregate=alpha|beta (true if ANY gate is enabled) +// - AND expression: +kubebuilder:featuregate=alpha&beta (true if ALL gates are enabled) +// - Mixed with precedence: +kubebuilder:featuregate=alpha&beta|gamma (equivalent to (alpha&beta)|gamma) +// - Explicit precedence: +kubebuilder:featuregate=(alpha|beta)&gamma +// +// Operator precedence follows standard conventions: & (AND) has higher precedence than | (OR). +// Use parentheses for explicit grouping when needed. +// +controllertools:marker:generateHelp:category="CRD feature gates" +type FeatureGate string + +// ApplyToSchema does nothing for feature gates - they are processed by the generator +// to conditionally include/exclude fields and types. +func (FeatureGate) ApplyToSchema(schema *apiext.JSONSchemaProps) error { + // Feature gates don't modify the schema directly. + // They are processed by the generator to conditionally include/exclude fields. + return nil +} + func (m Maximum) ApplyToSchema(schema *apiext.JSONSchemaProps) error { if !hasNumericType(schema) { return fmt.Errorf("must apply maximum to a numeric value, found %s", schema.Type) @@ -748,3 +779,5 @@ func fieldsToOneOfCelRuleStr(fields []string) string { list.WriteString("].filter(x,x==true).size()") return list.String() } + +// +controllertools:marker:generateHelp:category="CRD validation feature gates" diff --git a/pkg/crd/markers/zz_generated.markerhelp.go b/pkg/crd/markers/zz_generated.markerhelp.go index bf650ab23..73fdcd82f 100644 --- a/pkg/crd/markers/zz_generated.markerhelp.go +++ b/pkg/crd/markers/zz_generated.markerhelp.go @@ -138,6 +138,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 or type to be conditionally included based on feature gate enablement.", + Details: "Fields or 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 (Format) Help() *markers.DefinitionHelp { return &markers.DefinitionHelp{ Category: "CRD validation", diff --git a/pkg/crd/parser.go b/pkg/crd/parser.go index e57589a7c..cfe8c80c4 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() { @@ -177,7 +181,7 @@ func (p *Parser) NeedSchemaFor(typ TypeIdent) { props := p.Schemata[typ] return &props - }, p.AllowDangerousTypes, p.IgnoreUnexportedFields) + }, 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 4a95a1ced..9583d5ff9 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" ) @@ -76,11 +77,12 @@ 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, fetcher schemaFetcher, allowDangerousTypes, ignoreUnexportedFields bool) *schemaContext { +func newSchemaContext(pkg *loader.Package, req schemaRequester, fetcher schemaFetcher, allowDangerousTypes, ignoreUnexportedFields bool, featureGates featuregate.FeatureGateMap) *schemaContext { pkg.NeedTypesInfo() return &schemaContext{ pkg: pkg, @@ -88,6 +90,7 @@ func newSchemaContext(pkg *loader.Package, req schemaRequester, fetcher schemaFe schemaFetcher: fetcher, allowDangerousTypes: allowDangerousTypes, ignoreUnexportedFields: ignoreUnexportedFields, + featureGates: featureGates, } } @@ -101,6 +104,7 @@ func (c *schemaContext) ForInfo(info *markers.TypeInfo) *schemaContext { schemaFetcher: c.schemaFetcher, allowDangerousTypes: c.allowDangerousTypes, ignoreUnexportedFields: c.ignoreUnexportedFields, + featureGates: c.featureGates, } } @@ -431,6 +435,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:featuregate"); 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 ab37eb966..a928edf06 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, nil, true, false).ForInfo(&markers.TypeInfo{}) + schemaContext := newSchemaContext(pkg, nil, 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..c5a3094e3 --- /dev/null +++ b/pkg/crd/testdata/featuregates/output_alpha/_featuregatetests.yaml @@ -0,0 +1,78 @@ +--- +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 + 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 + 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..dc86b9940 --- /dev/null +++ b/pkg/crd/testdata/featuregates/output_beta/_featuregatetests.yaml @@ -0,0 +1,77 @@ +--- +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: + betaFeature: + description: Beta-gated field - only included when beta gate 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: + betaStatus: + description: Beta-gated status field + 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..a1825cdc2 --- /dev/null +++ b/pkg/crd/testdata/featuregates/output_both/_featuregatetests.yaml @@ -0,0 +1,100 @@ +--- +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 + 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 + 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..172fa1c49 --- /dev/null +++ b/pkg/crd/testdata/featuregates/output_gamma/_featuregatetests.yaml @@ -0,0 +1,71 @@ +--- +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: + complexOrFeature: + description: Complex precedence field - included when (alpha AND beta) + OR gamma is enabled + type: string + name: + description: Standard field - always included + type: string + required: + - name + type: object + status: + description: FeatureGateTestStatus defines the observed state with feature-gated + fields + properties: + complexOrStatus: + description: Complex precedence status field - included when (alpha + AND beta) OR gamma 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..3f6f173a4 --- /dev/null +++ b/pkg/crd/testdata/featuregates/output_none/_featuregatetests.yaml @@ -0,0 +1,63 @@ +--- +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: + name: + description: Standard field - always included + type: string + required: + - name + type: object + status: + description: FeatureGateTestStatus defines the observed state with feature-gated + fields + properties: + 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..8ff98e9f0 --- /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") + 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..93f81247f --- /dev/null +++ b/pkg/featuregate/evaluator.go @@ -0,0 +1,142 @@ +/* +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 +} + +// evaluateSimpleExpression evaluates feature gate expressions with support for parentheses, +// OR operations (lower precedence), and AND operations (higher precedence). +func (fge *FeatureGateEvaluator) evaluateSimpleExpression(expr string) bool { + // Remove all spaces for easier parsing + expr = strings.ReplaceAll(expr, " ", "") + + // Handle parentheses by evaluating them first (highest precedence) + 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 + } + } + } + + // 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..7087493ca --- /dev/null +++ b/pkg/featuregate/evaluator_test.go @@ -0,0 +1,203 @@ +/* +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 mixed operators without parentheses using precedence (AND before OR)", func() { + // alpha&gamma|beta = (alpha&gamma)|beta = (true&true)|false = true|false = true + Expect(evaluator.EvaluateExpression("alpha&gamma|beta")).To(BeTrue()) + // alpha&beta|delta = (alpha&beta)|delta = (true&false)|false = false|false = false + Expect(evaluator.EvaluateExpression("alpha&beta|delta")).To(BeFalse()) + // beta&delta|alpha = (beta&delta)|alpha = (false&false)|true = false|true = true + Expect(evaluator.EvaluateExpression("beta&delta|alpha")).To(BeTrue()) + // More complex: alpha|beta&gamma = alpha|(beta&gamma) = true|(false&true) = true|false = true + Expect(evaluator.EvaluateExpression("alpha|beta&gamma")).To(BeTrue()) + // More complex: beta|alpha&delta = beta|(alpha&delta) = false|(true&false) = false|false = false + Expect(evaluator.EvaluateExpression("beta|alpha&delta")).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..ac1e0c0d6 --- /dev/null +++ b/pkg/featuregate/parser.go @@ -0,0 +1,59 @@ +/* +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" +// +// 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. +func ParseFeatureGates(featureGates string) (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 { + return nil, fmt.Errorf("invalid feature gate format: %s (expected format: gate1=true,gate2=false)", pair) + } + + gateName := strings.TrimSpace(parts[0]) + gateValue := strings.TrimSpace(parts[1]) + + switch gateValue { + case "true": + gates[gateName] = true + case "false": + gates[gateName] = false + default: + return nil, fmt.Errorf("invalid feature gate value for %s: %s (must be 'true' or 'false')", gateName, gateValue) + } + } + + return gates, nil +} diff --git a/pkg/featuregate/parser_test.go b/pkg/featuregate/parser_test.go new file mode 100644 index 000000000..f7fbb2e46 --- /dev/null +++ b/pkg/featuregate/parser_test.go @@ -0,0 +1,108 @@ +/* +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 + expected FeatureGateMap + expectError bool + errorContains string + }{ + { + name: "empty string", + input: "", + expected: FeatureGateMap{}, + }, + { + name: "single gate enabled", + input: "alpha=true", + expected: FeatureGateMap{ + "alpha": true, + }, + }, + { + name: "single gate disabled", + input: "alpha=false", + expected: FeatureGateMap{ + "alpha": false, + }, + }, + { + name: "multiple gates", + input: "alpha=true,beta=false,gamma=true", + expected: FeatureGateMap{ + "alpha": true, + "beta": false, + "gamma": true, + }, + }, + { + name: "gates with spaces", + input: " alpha = true , beta = false ", + expected: FeatureGateMap{ + "alpha": true, + "beta": false, + }, + }, + { + name: "invalid format", + input: "alpha=true,invalid,beta=false", + expectError: true, + errorContains: "invalid feature gate format", + }, + { + name: "invalid value", + input: "alpha=true,beta=maybe", + expectError: true, + errorContains: "invalid feature gate value", + }, + { + name: "complex gate names", + input: "v1beta1=true,my-feature=false,under_score=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) + + 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..411497cf0 --- /dev/null +++ b/pkg/featuregate/registry.go @@ -0,0 +1,90 @@ +/* +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) + 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) + 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) +} + +// 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..969cda42f --- /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_AddKnownGates_Single(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.AddKnownGates("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("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..4c6ebeacf --- /dev/null +++ b/pkg/featuregate/types.go @@ -0,0 +1,56 @@ +/* +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 + +// 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 + } + + // Use the unified expression evaluator for all cases + return fge.evaluateSimpleExpression(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..8f0a6eb1d --- /dev/null +++ b/pkg/featuregate/validator.go @@ -0,0 +1,118 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the " return nil +}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) + } + } + + // 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 +} + +// 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..694662f41 --- /dev/null +++ b/pkg/featuregate/validator_test.go @@ -0,0 +1,299 @@ +/* +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 - precedence: AND then OR", + expr: "alpha&beta|gamma", + knownGates: knownGates, + strict: true, + }, + { + 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/genall/featuregate_output.go b/pkg/genall/featuregate_output.go new file mode 100644 index 000000000..d9210b203 --- /dev/null +++ b/pkg/genall/featuregate_output.go @@ -0,0 +1,144 @@ +/* +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 genall + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "sigs.k8s.io/controller-tools/pkg/featuregate" + "sigs.k8s.io/controller-tools/pkg/loader" + "sigs.k8s.io/controller-tools/pkg/markers" +) + +// OutputToFeatureGateDirectories outputs each object to a directory based on enabled feature gates. +// Each combination of feature gates gets its own subdirectory under the base path. +// +// For example, with feature gates "alpha=true,beta=false,gamma=true", files would be output to: +// - basePath/alpha_gamma/ (for gates that are enabled) +// - basePath/no_gates/ (for objects without feature gate requirements) +// - basePath/alpha/ (for objects requiring only alpha) +// - basePath/alpha_beta/ (for objects requiring both alpha and beta when beta is disabled - these would be skipped) +// +// The directory name format is: enabled_gates_sorted_alphabetically +type OutputToFeatureGateDirectories struct { + // BasePath is the base directory path where feature gate directories will be created + BasePath string `marker:"basePath"` + + // FeatureGates is the current feature gate configuration string (e.g., "alpha=true,beta=false") + FeatureGates string `marker:"featureGates"` + + // IncludeDisabled controls whether to create directories for disabled feature gate combinations + IncludeDisabled bool `marker:"includeDisabled,optional"` +} + +// Open creates a writer for the given package and filename, placing it in the appropriate +// feature gate directory based on the current feature gate configuration. +func (o OutputToFeatureGateDirectories) Open(pkg *loader.Package, itemPath string) (io.WriteCloser, error) { + if o.BasePath == "" { + return nil, fmt.Errorf("base path is required for feature gate directory output") + } + + // Parse current feature gates + featureGateMap, err := featuregate.ParseFeatureGates(o.FeatureGates) + if err != nil { + return nil, fmt.Errorf("failed to parse feature gates: %w", err) + } + + // Determine the appropriate directory based on enabled feature gates + subDir := o.getFeatureGateDirectory(featureGateMap) + fullPath := filepath.Join(o.BasePath, subDir) + + // Ensure the directory exists + if err := os.MkdirAll(fullPath, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory %s: %w", fullPath, err) + } + + // Create the file + filePath := filepath.Join(fullPath, itemPath) + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return nil, fmt.Errorf("failed to create parent directories for %s: %w", filePath, err) + } + + file, err := os.Create(filePath) + if err != nil { + return nil, fmt.Errorf("failed to create file %s: %w", filePath, err) + } + + return file, nil +} + +// getFeatureGateDirectory determines the subdirectory name based on enabled feature gates +func (o OutputToFeatureGateDirectories) getFeatureGateDirectory(featureGates featuregate.FeatureGateMap) string { + var enabledGates []string + + // Collect enabled gates + for gate, enabled := range featureGates { + if enabled { + enabledGates = append(enabledGates, gate) + } + } + + if len(enabledGates) == 0 { + return "no_gates" + } + + // Sort gates for consistent directory names + // Simple bubble sort for small slices + for i := 0; i < len(enabledGates)-1; i++ { + for j := 0; j < len(enabledGates)-i-1; j++ { + if enabledGates[j] > enabledGates[j+1] { + enabledGates[j], enabledGates[j+1] = enabledGates[j+1], enabledGates[j] + } + } + } + + return strings.Join(enabledGates, "_") +} + +// Help returns help information for the feature gate directory output rule +func (OutputToFeatureGateDirectories) Help() *markers.DefinitionHelp { + return &markers.DefinitionHelp{ + Category: "output", + DetailedHelp: markers.DetailedHelp{ + Summary: "output to feature gate-based directories", + Details: `Outputs files to directories based on enabled feature gates. + +Each combination of enabled feature gates gets its own subdirectory: +- "alpha_beta" for files when both alpha and beta gates are enabled +- "alpha" for files when only alpha gate is enabled +- "no_gates" for files that don't depend on feature gates + +This enables generating multiple CRD variants for different +feature gate configurations in a single run.`, + }, + FieldHelp: map[string]markers.DetailedHelp{ + "basePath": { + Summary: "the base directory path where feature gate directories will be created", + }, + "featureGates": { + Summary: "the current feature gate configuration string (e.g., \"alpha=true,beta=false\")", + }, + "includeDisabled": { + Summary: "whether to create directories for disabled feature gate combinations", + }, + }, + } +} diff --git a/pkg/rbac/feature_gates_test.go b/pkg/rbac/feature_gates_test.go new file mode 100644 index 000000000..528aaf727 --- /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: false}, // AND has higher precedence than OR + {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..9f4ede225 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) + 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/test/advanced_types.go b/pkg/test/advanced_types.go new file mode 100644 index 000000000..de79db513 --- /dev/null +++ b/pkg/test/advanced_types.go @@ -0,0 +1,51 @@ +// +groupName=advanced.example.com +package test + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// AdvancedExample demonstrates advanced feature gate usage +// +kubebuilder:object:root=true +type AdvancedExample struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AdvancedExampleSpec `json:"spec,omitempty"` + Status AdvancedExampleStatus `json:"status,omitempty"` +} + +// AdvancedExampleSpec demonstrates OR and AND combinations +type AdvancedExampleSpec struct { + // AlphaOrBetaField is included when either alpha OR beta is enabled + // +kubebuilder:featuregate=alpha|beta + AlphaOrBetaField string `json:"alphaOrBetaField,omitempty"` + + // AlphaAndBetaField is included when both alpha AND beta are enabled + // +kubebuilder:featuregate=alpha&beta + AlphaAndBetaField string `json:"alphaAndBetaField,omitempty"` + + // ComplexExpressionField uses parentheses for precedence + // +kubebuilder:featuregate=(alpha&beta)|gamma + ComplexExpressionField string `json:"complexExpressionField,omitempty"` + + // GammaOnlyField is included only when gamma is enabled + // +kubebuilder:featuregate=gamma + GammaOnlyField string `json:"gammaOnlyField,omitempty"` + + // AlwaysIncludedField has no feature gate + AlwaysIncludedField string `json:"alwaysIncludedField,omitempty"` +} + +// AdvancedExampleStatus defines the observed state +type AdvancedExampleStatus struct { + Phase string `json:"phase,omitempty"` +} + +// AdvancedExampleList contains a list of AdvancedExample +// +kubebuilder:object:root=true +type AdvancedExampleList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AdvancedExample `json:"items"` +} diff --git a/pkg/test/types.go b/pkg/test/types.go new file mode 100644 index 000000000..59e74fa9b --- /dev/null +++ b/pkg/test/types.go @@ -0,0 +1,52 @@ +// +groupName=test.example.com +package test + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Example demonstrates basic feature gate usage +// +kubebuilder:object:root=true +type Example struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ExampleSpec `json:"spec,omitempty"` + Status ExampleStatus `json:"status,omitempty"` +} + +// ExampleSpec defines the desired state of Example +type ExampleSpec struct { + // AlphaField is only included when alpha feature gate is enabled + // +kubebuilder:featuregate=alpha + AlphaField string `json:"alphaField,omitempty"` + + // BetaField is only included when beta feature gate is enabled + // +kubebuilder:featuregate=beta + BetaField string `json:"betaField,omitempty"` + + // StableField is always included (no feature gate) + StableField string `json:"stableField,omitempty"` + + // ComplexFeatureField requires both alpha and beta gates + // +kubebuilder:featuregate=alpha&beta + ComplexFeatureField string `json:"complexFeatureField,omitempty"` + + // RegularValidationField has normal validation (for comparison) + // +kubebuilder:validation:Pattern="^[a-z]+$" + RegularValidationField string `json:"regularValidationField,omitempty"` +} + +// ExampleStatus defines the observed state of Example +type ExampleStatus struct { + // Ready indicates if the resource is ready + Ready bool `json:"ready,omitempty"` +} + +// ExampleList contains a list of Example +// +kubebuilder:object:root=true +type ExampleList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Example `json:"items"` +} diff --git a/pkg/webhook/parser.go b/pkg/webhook/parser.go index 2737562be..10008a35f 100644 --- a/pkg/webhook/parser.go +++ b/pkg/webhook/parser.go @@ -32,6 +32,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" ) @@ -160,6 +161,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. @@ -427,6 +435,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 { @@ -449,6 +463,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) + if err != nil { + return err + } + evaluator := featuregate.NewFeatureGateEvaluator(enabledGates) + for _, root := range ctx.Roots { markerSet, err := markers.PackageMarkers(ctx.Collector, root) if err != nil { @@ -490,6 +511,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..355d7dfd9 --- /dev/null +++ b/pkg/webhook/parser_featuregate_test.go @@ -0,0 +1,154 @@ +/* +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("") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(featuregate.FeatureGateMap{})) + }) + + It("should parse single gate enabled", func() { + result, err := featuregate.ParseFeatureGates("alpha=true") + 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") + 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") + 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 ") + Expect(err).ToNot(HaveOccurred()) + expected := featuregate.FeatureGateMap{ + "alpha": true, + "beta": false, + } + Expect(result).To(Equal(expected)) + }) + + It("should reject invalid format", func() { + _, err := featuregate.ParseFeatureGates("alpha=true,invalid,beta=false") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid feature gate format")) + }) + }) + + 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 accept mixed operators with precedence", func() { + err := featuregate.ValidateFeatureGateExpression("alpha&beta|gamma", nil, false) + Expect(err).ToNot(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=./...", + }, }, } }