Skip to content

Commit 73f7c9b

Browse files
committed
feat: Add comprehensive feature gate support for CRD, RBAC, and Webhook generators
- Add centralized featuregate package with evaluation and parsing logic - Implement CRD feature gate markers for conditional field inclusion - Add RBAC feature gate support with multiple gate evaluation - Extend Webhook generator with feature gate capabilities - Include comprehensive test coverage and golden output files
1 parent 1ad88b0 commit 73f7c9b

37 files changed

+3302
-150
lines changed
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
Copyright 2025.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package crd_test
18+
19+
import (
20+
"bytes"
21+
"io"
22+
"os"
23+
"path/filepath"
24+
25+
"github.com/google/go-cmp/cmp"
26+
. "github.com/onsi/ginkgo"
27+
. "github.com/onsi/gomega"
28+
"sigs.k8s.io/controller-tools/pkg/crd"
29+
crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers"
30+
"sigs.k8s.io/controller-tools/pkg/genall"
31+
"sigs.k8s.io/controller-tools/pkg/loader"
32+
"sigs.k8s.io/controller-tools/pkg/markers"
33+
)
34+
35+
var _ = Describe("CRD Feature Gate Generation", func() {
36+
var (
37+
ctx *genall.GenerationContext
38+
out *featureGateOutputRule
39+
featureGateDir string
40+
originalWorkingDir string
41+
)
42+
43+
BeforeEach(func() {
44+
var err error
45+
originalWorkingDir, err = os.Getwd()
46+
Expect(err).NotTo(HaveOccurred())
47+
48+
featureGateDir = filepath.Join(originalWorkingDir, "testdata", "featuregates")
49+
50+
By("switching into featuregates testdata")
51+
err = os.Chdir(featureGateDir)
52+
Expect(err).NotTo(HaveOccurred())
53+
54+
By("loading the roots")
55+
pkgs, err := loader.LoadRoots(".")
56+
Expect(err).NotTo(HaveOccurred())
57+
Expect(pkgs).To(HaveLen(1))
58+
59+
out = &featureGateOutputRule{buf: &bytes.Buffer{}}
60+
ctx = &genall.GenerationContext{
61+
Collector: &markers.Collector{Registry: &markers.Registry{}},
62+
Roots: pkgs,
63+
Checker: &loader.TypeChecker{},
64+
OutputRule: out,
65+
}
66+
Expect(crdmarkers.Register(ctx.Collector.Registry)).To(Succeed())
67+
})
68+
69+
AfterEach(func() {
70+
By("restoring original working directory")
71+
err := os.Chdir(originalWorkingDir)
72+
Expect(err).NotTo(HaveOccurred())
73+
})
74+
75+
It("should not include feature-gated fields when no gates are enabled", func() {
76+
By("calling the generator")
77+
gen := &crd.Generator{
78+
CRDVersions: []string{"v1"},
79+
// No FeatureGates specified
80+
}
81+
Expect(gen.Generate(ctx)).NotTo(HaveOccurred())
82+
83+
By("loading the expected YAML")
84+
expectedFile, err := os.ReadFile("output_none/_featuregatetests.yaml")
85+
Expect(err).NotTo(HaveOccurred())
86+
87+
By("comparing the two")
88+
Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile)))
89+
})
90+
91+
It("should include only alpha-gated fields when alpha gate is enabled", func() {
92+
By("calling the generator")
93+
gen := &crd.Generator{
94+
CRDVersions: []string{"v1"},
95+
FeatureGates: "alpha=true",
96+
}
97+
Expect(gen.Generate(ctx)).NotTo(HaveOccurred())
98+
99+
By("loading the expected YAML")
100+
expectedFile, err := os.ReadFile("output_alpha/_featuregatetests.yaml")
101+
Expect(err).NotTo(HaveOccurred())
102+
103+
By("comparing the two")
104+
Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile)))
105+
})
106+
107+
It("should include only beta-gated fields when beta gate is enabled", func() {
108+
By("calling the generator")
109+
gen := &crd.Generator{
110+
CRDVersions: []string{"v1"},
111+
FeatureGates: "beta=true",
112+
}
113+
Expect(gen.Generate(ctx)).NotTo(HaveOccurred())
114+
115+
By("loading the expected YAML")
116+
expectedFile, err := os.ReadFile("output_beta/_featuregatetests.yaml")
117+
Expect(err).NotTo(HaveOccurred())
118+
119+
By("comparing the two")
120+
Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile)))
121+
})
122+
123+
It("should include both feature-gated fields when both gates are enabled", func() {
124+
By("calling the generator")
125+
gen := &crd.Generator{
126+
CRDVersions: []string{"v1"},
127+
FeatureGates: "alpha=true,beta=true",
128+
}
129+
Expect(gen.Generate(ctx)).NotTo(HaveOccurred())
130+
131+
By("loading the expected YAML")
132+
expectedFile, err := os.ReadFile("output_both/_featuregatetests.yaml")
133+
Expect(err).NotTo(HaveOccurred())
134+
135+
By("comparing the two")
136+
Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile)))
137+
})
138+
139+
It("should handle complex precedence: (alpha&beta)|gamma", func() {
140+
By("calling the generator with only gamma enabled")
141+
gen := &crd.Generator{
142+
CRDVersions: []string{"v1"},
143+
FeatureGates: "gamma=true",
144+
}
145+
Expect(gen.Generate(ctx)).NotTo(HaveOccurred())
146+
147+
By("loading the expected YAML")
148+
expectedFile, err := os.ReadFile("output_gamma/_featuregatetests.yaml")
149+
Expect(err).NotTo(HaveOccurred())
150+
151+
By("comparing the two")
152+
Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile)))
153+
})
154+
155+
It("should include all fields when all gates are enabled", func() {
156+
By("calling the generator with all gates enabled")
157+
gen := &crd.Generator{
158+
CRDVersions: []string{"v1"},
159+
FeatureGates: "alpha=true,beta=true,gamma=true",
160+
}
161+
Expect(gen.Generate(ctx)).NotTo(HaveOccurred())
162+
163+
By("loading the expected YAML")
164+
expectedFile, err := os.ReadFile("output_all/_featuregatetests.yaml")
165+
Expect(err).NotTo(HaveOccurred())
166+
167+
By("comparing the two")
168+
Expect(out.buf.String()).To(Equal(string(expectedFile)), cmp.Diff(out.buf.String(), string(expectedFile)))
169+
})
170+
})
171+
172+
// Helper types for testing
173+
type featureGateOutputRule struct {
174+
buf *bytes.Buffer
175+
}
176+
177+
func (o *featureGateOutputRule) Open(_ *loader.Package, _ string) (io.WriteCloser, error) {
178+
return featureGateNopCloser{o.buf}, nil
179+
}
180+
181+
type featureGateNopCloser struct {
182+
io.Writer
183+
}
184+
185+
func (n featureGateNopCloser) Close() error {
186+
return nil
187+
}

pkg/crd/gen.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2727
"k8s.io/apimachinery/pkg/runtime/schema"
2828
crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers"
29+
"sigs.k8s.io/controller-tools/pkg/featuregate"
2930
"sigs.k8s.io/controller-tools/pkg/genall"
3031
"sigs.k8s.io/controller-tools/pkg/loader"
3132
"sigs.k8s.io/controller-tools/pkg/markers"
@@ -85,6 +86,16 @@ type Generator struct {
8586
// Year specifies the year to substitute for " YEAR" in the header file.
8687
Year string `marker:",optional"`
8788

89+
// FeatureGates specifies which feature gates are enabled for conditional field inclusion.
90+
//
91+
// Single gate format: "gatename=true"
92+
// Multiple gates format: "gate1=true,gate2=false" (must use quoted strings for comma-separated values)
93+
//
94+
// Examples:
95+
// controller-gen crd:featureGates="alpha=true" paths=./api/...
96+
// controller-gen 'crd:featureGates="alpha=true,beta=false"' paths=./api/...
97+
FeatureGates string `marker:",optional"`
98+
8899
// DeprecatedV1beta1CompatibilityPreserveUnknownFields indicates whether
89100
// or not we should turn off field pruning for this resource.
90101
//
@@ -124,6 +135,11 @@ func transformPreserveUnknownFields(value bool) func(map[string]interface{}) err
124135
}
125136

126137
func (g Generator) Generate(ctx *genall.GenerationContext) error {
138+
featureGates, err := featuregate.ParseFeatureGates(g.FeatureGates, true)
139+
if err != nil {
140+
return fmt.Errorf("invalid feature gates: %w", err)
141+
}
142+
127143
parser := &Parser{
128144
Collector: ctx.Collector,
129145
Checker: ctx.Checker,
@@ -132,6 +148,7 @@ func (g Generator) Generate(ctx *genall.GenerationContext) error {
132148
AllowDangerousTypes: g.AllowDangerousTypes != nil && *g.AllowDangerousTypes,
133149
// Indicates the parser on whether to register the ObjectMeta type or not
134150
GenerateEmbeddedObjectMeta: g.GenerateEmbeddedObjectMeta != nil && *g.GenerateEmbeddedObjectMeta,
151+
FeatureGates: featureGates,
135152
}
136153

137154
AddKnownTypes(parser)

pkg/crd/markers/featuregate.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
Copyright 2025.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package markers
18+
19+
import (
20+
apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
21+
)
22+
23+
// +controllertools:marker:generateHelp:category="CRD feature gates"
24+
25+
// FeatureGate marks a field to be conditionally included based on feature gate enablement.
26+
// Fields marked with +kubebuilder:feature-gate will only be included in generated CRDs
27+
// when the specified feature gate is enabled via the crd:featureGates parameter.
28+
type FeatureGate string
29+
30+
// ApplyToSchema does nothing for feature gates - they are processed by the generator
31+
// to conditionally include/exclude fields.
32+
func (FeatureGate) ApplyToSchema(schema *apiext.JSONSchemaProps, field string) error {
33+
// Feature gates don't modify the schema directly.
34+
// They are processed by the generator to conditionally include/exclude fields.
35+
return nil
36+
}

pkg/crd/markers/validation.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ var ValidationIshMarkers = []*definitionWithHelp{
127127
WithHelp(XPreserveUnknownFields{}.Help()),
128128
must(markers.MakeDefinition("kubebuilder:pruning:PreserveUnknownFields", markers.DescribesType, XPreserveUnknownFields{})).
129129
WithHelp(XPreserveUnknownFields{}.Help()),
130+
131+
must(markers.MakeDefinition("kubebuilder:feature-gate", markers.DescribesField, FeatureGate(""))).
132+
WithHelp(FeatureGate("").Help()),
130133
}
131134

132135
func init() {

pkg/crd/markers/zz_generated.markerhelp.go

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/crd/parser.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121

2222
apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2323
"k8s.io/apimachinery/pkg/runtime/schema"
24+
"sigs.k8s.io/controller-tools/pkg/featuregate"
2425
"sigs.k8s.io/controller-tools/pkg/internal/crd"
2526
"sigs.k8s.io/controller-tools/pkg/loader"
2627
"sigs.k8s.io/controller-tools/pkg/markers"
@@ -92,6 +93,9 @@ type Parser struct {
9293

9394
// GenerateEmbeddedObjectMeta specifies if any embedded ObjectMeta should be generated
9495
GenerateEmbeddedObjectMeta bool
96+
97+
// FeatureGates specifies which feature gates are enabled for conditional field inclusion
98+
FeatureGates featuregate.FeatureGateMap
9599
}
96100

97101
func (p *Parser) init() {
@@ -172,7 +176,7 @@ func (p *Parser) NeedSchemaFor(typ TypeIdent) {
172176
// avoid tripping recursive schemata, like ManagedFields, by adding an empty WIP schema
173177
p.Schemata[typ] = apiext.JSONSchemaProps{}
174178

175-
schemaCtx := newSchemaContext(typ.Package, p, p.AllowDangerousTypes, p.IgnoreUnexportedFields)
179+
schemaCtx := newSchemaContext(typ.Package, p, p.AllowDangerousTypes, p.IgnoreUnexportedFields, p.FeatureGates)
176180
ctxForInfo := schemaCtx.ForInfo(info)
177181

178182
pkgMarkers, err := markers.PackageMarkers(p.Collector, typ.Package)

pkg/crd/schema.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2929
"k8s.io/apimachinery/pkg/util/sets"
3030
crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers"
31+
"sigs.k8s.io/controller-tools/pkg/featuregate"
3132
"sigs.k8s.io/controller-tools/pkg/loader"
3233
"sigs.k8s.io/controller-tools/pkg/markers"
3334
)
@@ -72,17 +73,19 @@ type schemaContext struct {
7273

7374
allowDangerousTypes bool
7475
ignoreUnexportedFields bool
76+
featureGates featuregate.FeatureGateMap
7577
}
7678

7779
// newSchemaContext constructs a new schemaContext for the given package and schema requester.
7880
// It must have type info added before use via ForInfo.
79-
func newSchemaContext(pkg *loader.Package, req schemaRequester, allowDangerousTypes, ignoreUnexportedFields bool) *schemaContext {
81+
func newSchemaContext(pkg *loader.Package, req schemaRequester, allowDangerousTypes, ignoreUnexportedFields bool, featureGates featuregate.FeatureGateMap) *schemaContext {
8082
pkg.NeedTypesInfo()
8183
return &schemaContext{
8284
pkg: pkg,
8385
schemaRequester: req,
8486
allowDangerousTypes: allowDangerousTypes,
8587
ignoreUnexportedFields: ignoreUnexportedFields,
88+
featureGates: featureGates,
8689
}
8790
}
8891

@@ -95,6 +98,7 @@ func (c *schemaContext) ForInfo(info *markers.TypeInfo) *schemaContext {
9598
schemaRequester: c.schemaRequester,
9699
allowDangerousTypes: c.allowDangerousTypes,
97100
ignoreUnexportedFields: c.ignoreUnexportedFields,
101+
featureGates: c.featureGates,
98102
}
99103
}
100104

@@ -428,6 +432,19 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON
428432
continue
429433
}
430434

435+
// Check feature gate markers - skip field if feature gate is not enabled
436+
if featureGateMarker := field.Markers.Get("kubebuilder:feature-gate"); featureGateMarker != nil {
437+
if featureGate, ok := featureGateMarker.(crdmarkers.FeatureGate); ok {
438+
gateName := string(featureGate)
439+
// Create evaluator to handle complex expressions (OR/AND logic)
440+
evaluator := featuregate.NewFeatureGateEvaluator(ctx.featureGates)
441+
if !evaluator.EvaluateExpression(gateName) {
442+
// Skip this field as its feature gate expression is not satisfied
443+
continue
444+
}
445+
}
446+
}
447+
431448
jsonTag, hasTag := field.Tag.Lookup("json")
432449
if !hasTag {
433450
// if the field doesn't have a JSON tag, it doesn't belong in output (and shouldn't exist in a serialized type)

pkg/crd/schema_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
pkgstest "golang.org/x/tools/go/packages/packagestest"
2727
apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2828
crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers"
29+
"sigs.k8s.io/controller-tools/pkg/featuregate"
2930
testloader "sigs.k8s.io/controller-tools/pkg/loader/testutils"
3031
"sigs.k8s.io/controller-tools/pkg/markers"
3132
)
@@ -64,7 +65,7 @@ func transform(t *testing.T, expr string) *apiext.JSONSchemaProps {
6465
pkg.NeedTypesInfo()
6566
failIfErrors(t, pkg.Errors)
6667

67-
schemaContext := newSchemaContext(pkg, nil, true, false).ForInfo(&markers.TypeInfo{})
68+
schemaContext := newSchemaContext(pkg, nil, true, false, featuregate.FeatureGateMap{}).ForInfo(&markers.TypeInfo{})
6869
// yick: grab the only type definition
6970
definedType := pkg.Syntax[0].Decls[0].(*ast.GenDecl).Specs[0].(*ast.TypeSpec).Type
7071
result := typeToSchema(schemaContext, definedType)

0 commit comments

Comments
 (0)