Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions pkg/crd/featuregate_integration_test.go
Original file line number Diff line number Diff line change
@@ -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
}
36 changes: 33 additions & 3 deletions pkg/crd/gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -124,6 +135,11 @@ func transformPreserveUnknownFields(value bool) func(map[string]interface{}) err
}

func (g Generator) Generate(ctx *genall.GenerationContext) error {
featureGates, err := featuregate.ParseFeatureGates(g.FeatureGates, true)
if err != nil {
return fmt.Errorf("invalid feature gates: %w", err)
}

parser := &Parser{
Collector: ctx.Collector,
Checker: ctx.Checker,
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -317,6 +334,19 @@ func FindKubeKinds(parser *Parser, metav1Pkg *loader.Package) []schema.GroupKind
continue
}

// Check type-level feature gate marker
if featureGateMarker := info.Markers.Get("kubebuilder:featuregate"); featureGateMarker != nil {
if typeFeatureGate, ok := featureGateMarker.(crdmarkers.TypeFeatureGate); ok {
gateName := string(typeFeatureGate)
// Create evaluator to handle complex expressions (OR/AND logic)
evaluator := featuregate.NewFeatureGateEvaluator(featureGates)
if !evaluator.EvaluateExpression(gateName) {
// Skip this type as its feature gate expression is not satisfied
continue
}
}
}

groupKind := schema.GroupKind{
Group: parser.GroupVersions[pkg].Group,
Kind: typeIdent.Name,
Expand Down
22 changes: 22 additions & 0 deletions pkg/crd/markers/crd.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ var CRDMarkers = []*definitionWithHelp{

must(markers.MakeDefinition("kubebuilder:selectablefield", markers.DescribesType, SelectableField{})).
WithHelp(SelectableField{}.Help()),

must(markers.MakeDefinition("kubebuilder:featuregate", markers.DescribesType, TypeFeatureGate(""))).
WithHelp(TypeFeatureGate("").Help()),
Comment on lines +61 to +62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you not register the same marker as both a Type and Field level marker without duplicating the marker as we currently have?

}

// TODO: categories and singular used to be annotations types
Expand Down Expand Up @@ -419,3 +422,22 @@ func (s SelectableField) ApplyToCRD(crd *apiext.CustomResourceDefinitionSpec, ve

return nil
}

// +controllertools:marker:generateHelp:category="CRD feature gates"

// TypeFeatureGate marks an entire CRD type to be conditionally generated based on feature gate enablement.
// Types marked with +kubebuilder:featuregate will only be included in generated CRDs
// when the specified feature gate is enabled via the crd:featureGates parameter.
//
// Single gate format: +kubebuilder:featuregate=alpha
// OR expression: +kubebuilder:featuregate=alpha|beta
// AND expression: +kubebuilder:featuregate=alpha&beta
// Complex expression: +kubebuilder:featuregate=(alpha&beta)|gamma
type TypeFeatureGate string

// ApplyToCRD does nothing for type feature gates - they are processed by the generator
// to conditionally include/exclude entire CRDs during generation.
func (TypeFeatureGate) ApplyToCRD(crd *apiext.CustomResourceDefinitionSpec, version string) error {
// Type feature gates are handled during CRD discovery/generation, not during CRD spec modification
return nil
}
36 changes: 36 additions & 0 deletions pkg/crd/markers/featuregate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
Copyright 2025.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package markers
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the motivation for this being in a separate file?


import (
apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
)

// +controllertools:marker:generateHelp:category="CRD feature gates"

// FeatureGate marks a field to be conditionally included based on feature gate enablement.
// Fields marked with +kubebuilder:featuregate will only be included in generated CRDs
// when the specified feature gate is enabled via the crd:featureGates parameter.
type FeatureGate string

// ApplyToSchema does nothing for feature gates - they are processed by the generator
// to conditionally include/exclude fields.
func (FeatureGate) ApplyToSchema(schema *apiext.JSONSchemaProps, field string) error {
// Feature gates don't modify the schema directly.
// They are processed by the generator to conditionally include/exclude fields.
return nil
}
3 changes: 3 additions & 0 deletions pkg/crd/markers/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ var ValidationIshMarkers = []*definitionWithHelp{
WithHelp(XPreserveUnknownFields{}.Help()),
must(markers.MakeDefinition("kubebuilder:pruning:PreserveUnknownFields", markers.DescribesType, XPreserveUnknownFields{})).
WithHelp(XPreserveUnknownFields{}.Help()),

must(markers.MakeDefinition("kubebuilder:featuregate", markers.DescribesField, FeatureGate(""))).
WithHelp(FeatureGate("").Help()),
}

func init() {
Expand Down
22 changes: 22 additions & 0 deletions pkg/crd/markers/zz_generated.markerhelp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading