Skip to content

Commit ec261d7

Browse files
committed
fix tests for feature gates
1 parent 76f14a0 commit ec261d7

File tree

13 files changed

+751
-196
lines changed

13 files changed

+751
-196
lines changed

docs/book/src/reference/markers/feature-gates.md

Lines changed: 259 additions & 73 deletions
Large diffs are not rendered by default.

hack/docs/internal/cronjob-tutorial/generate_cronjob.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,12 +226,28 @@ type CronJob struct {`+`
226226
*/`)
227227
hackutils.CheckError("fixing schema for cronjob_types.go", err)
228228

229-
// fix lint
229+
// fix lint - handle the pattern with or without feature-gated fields
230230
err = pluginutil.ReplaceInFile(
231231
filepath.Join(sp.ctx.Dir, "api/v1/cronjob_types.go"),
232232
`/
233233
234+
235+
// Example of a feature-gated field:
236+
// Bar is an experimental field that requires the "experimental-bar" feature gate to be enabled
237+
// TODO: When controller-tools supports feature gates (issue #1238), use:
238+
// +kubebuilder:feature-gate=experimental-bar
239+
// +feature-gate experimental-bar
240+
// +optional
241+
Bar *string `+"`"+`json:"bar,omitempty"`+"`"+`
242+
}`, "/")
243+
if err != nil {
244+
// Fallback to the original pattern if the feature-gated field is not present
245+
err = pluginutil.ReplaceInFile(
246+
filepath.Join(sp.ctx.Dir, "api/v1/cronjob_types.go"),
247+
`/
248+
234249
}`, "/")
250+
}
235251
hackutils.CheckError("fixing cronjob_types.go end of status", err)
236252
}
237253

hack/docs/internal/multiversion-tutorial/generate_multiversion.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -735,11 +735,25 @@ We'll leave our spec largely unchanged, except to change the schedule field to a
735735

736736
err = pluginutil.ReplaceInFile(
737737
filepath.Join(sp.ctx.Dir, path),
738-
`// foo is an example field of CronJob. Edit cronjob_types.go to remove/update
738+
`// Example of a feature-gated field:
739+
// Bar is an experimental field that requires the "experimental-bar" feature gate to be enabled
740+
// TODO: When controller-tools supports feature gates (issue #1238), use:
741+
// +kubebuilder:feature-gate=experimental-bar
742+
// +feature-gate experimental-bar
739743
// +optional
740-
Foo *string `+"`json:\"foo,omitempty\"`",
744+
Bar *string `+"`"+`json:"bar,omitempty"`+"`",
741745
cronjobSpecMore,
742746
)
747+
if err != nil {
748+
// Fallback to the original pattern if the feature-gated field is not present
749+
err = pluginutil.ReplaceInFile(
750+
filepath.Join(sp.ctx.Dir, path),
751+
`// foo is an example field of CronJob. Edit cronjob_types.go to remove/update
752+
// +optional
753+
Foo *string `+"`json:\"foo,omitempty\"`",
754+
cronjobSpecMore,
755+
)
756+
}
743757
hackutils.CheckError("replace Foo with cronjob spec fields", err)
744758

745759
err = pluginutil.ReplaceInFile(

pkg/machinery/featuregate.go

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@ package machinery
1818

1919
import (
2020
"fmt"
21+
"regexp"
2122
"strings"
2223
)
2324

25+
// featureGateNameRegex validates feature gate names
26+
// Names should follow Kubernetes conventions: start with lowercase letter, followed by lowercase alphanumeric with hyphens
27+
var featureGateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`)
28+
2429
// FeatureGate represents a feature gate with its name and enabled state
2530
type FeatureGate struct {
2631
Name string
@@ -30,9 +35,23 @@ type FeatureGate struct {
3035
// FeatureGates represents a collection of feature gates
3136
type FeatureGates map[string]bool
3237

38+
// validateFeatureGateName validates that a feature gate name follows conventions
39+
func validateFeatureGateName(name string) error {
40+
if name == "" {
41+
return fmt.Errorf("empty feature gate name")
42+
}
43+
44+
if !featureGateNameRegex.MatchString(name) {
45+
return fmt.Errorf("invalid feature gate name '%s': must be lowercase alphanumeric with hyphens (e.g., 'experimental-feature')", name)
46+
}
47+
48+
return nil
49+
}
50+
3351
// ParseFeatureGates parses a comma-separated string of feature gates
34-
// Format: "feature1=true,feature2=false,feature3"
35-
// If no value is specified, the feature gate defaults to enabled
52+
// Format: "feature1=true,feature2=false,feature3=true"
53+
// Feature gate names must be lowercase alphanumeric with hyphens
54+
// Values must be 'true' or 'false' (case insensitive)
3655
func ParseFeatureGates(featureGates string) (FeatureGates, error) {
3756
if featureGates == "" {
3857
return make(FeatureGates), nil
@@ -41,13 +60,10 @@ func ParseFeatureGates(featureGates string) (FeatureGates, error) {
4160
gates := make(FeatureGates)
4261
parts := strings.Split(featureGates, ",")
4362

44-
for i, part := range parts {
63+
for _, part := range parts {
4564
part = strings.TrimSpace(part)
4665
if part == "" {
47-
// If this is the first part and it's empty, it's an error
48-
if i == 0 {
49-
return nil, fmt.Errorf("empty feature gate name")
50-
}
66+
// Skip empty parts (e.g., trailing commas)
5167
continue
5268
}
5369

@@ -63,20 +79,26 @@ func ParseFeatureGates(featureGates string) (FeatureGates, error) {
6379
name = strings.TrimSpace(kv[0])
6480
value := strings.TrimSpace(kv[1])
6581

82+
if value == "" {
83+
return nil, fmt.Errorf("empty value for feature gate '%s'", name)
84+
}
85+
6686
switch strings.ToLower(value) {
67-
case "true", "1", "yes", "on":
87+
case "true":
6888
enabled = true
69-
case "false", "0", "no", "off":
89+
case "false":
7090
enabled = false
7191
default:
72-
return nil, fmt.Errorf("invalid feature gate value for %s: %s", name, value)
92+
return nil, fmt.Errorf("invalid feature gate value for %s: %s (must be 'true' or 'false')", name, value)
7393
}
7494
} else {
95+
// No '=' found, treat entire part as name with default value true
7596
name = part
7697
}
7798

78-
if name == "" {
79-
return nil, fmt.Errorf("empty feature gate name")
99+
// Validate the feature gate name
100+
if err := validateFeatureGateName(name); err != nil {
101+
return nil, err
80102
}
81103

82104
gates[name] = enabled

pkg/plugins/golang/v4/scaffolds/api.go

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"errors"
2121
"fmt"
2222
"log/slog"
23+
"os"
2324
"path/filepath"
2425

2526
"github.com/spf13/afero"
@@ -97,9 +98,18 @@ func (s *apiScaffolder) Scaffold() error {
9798
return fmt.Errorf("error updating resource: %w", err)
9899
}
99100

101+
// If using --force, discover existing feature gates before overwriting files
102+
var existingGates []string
103+
if s.force && doAPI {
104+
existingGates = s.discoverFeatureGates()
105+
}
106+
100107
if doAPI {
101108
if err := scaffold.Execute(
102-
&api.Types{Force: s.force},
109+
&api.Types{
110+
Force: s.force,
111+
IncludeFeatureGateExample: false, // Don't include by default - keep it simple
112+
},
103113
&api.Group{},
104114
); err != nil {
105115
return fmt.Errorf("error scaffolding APIs: %w", err)
@@ -116,13 +126,40 @@ func (s *apiScaffolder) Scaffold() error {
116126
}
117127
}
118128

119-
// Discover feature gates from API types
120-
availableGates := s.discoverFeatureGates()
129+
// Discover feature gates from newly scaffolded API types
130+
newGates := s.discoverFeatureGates()
131+
var availableGates []string
132+
133+
// Merge existing gates with newly discovered ones if we used --force
134+
if len(existingGates) > 0 {
135+
gateMap := make(map[string]bool)
136+
137+
// Add existing gates
138+
for _, gate := range existingGates {
139+
gateMap[gate] = true
140+
}
141+
142+
// Add newly discovered gates (from template)
143+
for _, gate := range newGates {
144+
gateMap[gate] = true
145+
}
146+
147+
// Create merged list
148+
var mergedGates []string
149+
for gate := range gateMap {
150+
mergedGates = append(mergedGates, gate)
151+
}
152+
153+
availableGates = mergedGates
154+
} else {
155+
availableGates = newGates
156+
}
121157

122158
// Generate feature gates file
123-
if err := scaffold.Execute(
124-
&cmd.FeatureGates{AvailableGates: availableGates},
125-
); err != nil {
159+
featureGatesTemplate := &cmd.FeatureGates{}
160+
featureGatesTemplate.AvailableGates = availableGates
161+
featureGatesTemplate.IfExistsAction = machinery.OverwriteFile
162+
if err := scaffold.Execute(featureGatesTemplate); err != nil {
126163
return fmt.Errorf("error scaffolding feature gates: %w", err)
127164
}
128165

@@ -137,29 +174,62 @@ func (s *apiScaffolder) Scaffold() error {
137174

138175
// discoverFeatureGates scans the API directory for feature gate markers
139176
func (s *apiScaffolder) discoverFeatureGates() []string {
140-
parser := machinery.NewFeatureGateMarkerParser()
177+
mgParser := machinery.NewFeatureGateMarkerParser()
141178

142179
// Try to parse the API directory
143180
apiDir := "api"
144181
if s.config.IsMultiGroup() && s.resource.Group != "" {
145182
apiDir = filepath.Join("api", s.resource.Group)
146183
}
147184

185+
// Debug: Get current working directory
186+
if wd, err := os.Getwd(); err == nil {
187+
slog.Debug("Feature gate discovery", "workingDir", wd, "apiDir", apiDir)
188+
}
189+
148190
// Check if the directory exists before trying to parse it
149-
if _, err := s.fs.FS.Stat(apiDir); err != nil {
150-
slog.Debug("API directory does not exist yet, skipping feature gate discovery", "apiDir", apiDir)
191+
if _, err := os.Stat(apiDir); err != nil {
192+
slog.Debug("API directory does not exist yet, skipping feature gate discovery", "apiDir", apiDir, "error", err)
151193
return []string{}
152194
}
153195

154-
markers, err := parser.ParseDirectory(apiDir)
196+
var allMarkers []machinery.FeatureGateMarker
197+
198+
// Walk through each version directory and parse for feature gates
199+
entries, err := os.ReadDir(apiDir)
155200
if err != nil {
156-
slog.Debug("Failed to parse feature gates from directory", "apiDir", apiDir, "error", err)
201+
slog.Debug("Error reading API directory", "error", err, "apiDir", apiDir)
157202
return []string{}
158203
}
159204

160-
featureGates := machinery.ExtractFeatureGates(markers)
205+
slog.Debug("API directory contents", "apiDir", apiDir, "fileCount", len(entries))
206+
207+
for _, entry := range entries {
208+
slog.Debug("API directory file", "name", entry.Name(), "isDir", entry.IsDir())
209+
if entry.IsDir() {
210+
versionDir := filepath.Join(apiDir, entry.Name())
211+
212+
// Use the existing parser to parse the version directory
213+
markers, err := mgParser.ParseDirectory(versionDir)
214+
if err != nil {
215+
slog.Debug("Error parsing version directory for feature gates", "error", err, "versionDir", versionDir)
216+
continue
217+
}
218+
219+
slog.Debug("Parsed markers from directory", "versionDir", versionDir, "markerCount", len(markers))
220+
221+
// Debug: Print all discovered markers
222+
for _, marker := range markers {
223+
slog.Debug("Found feature gate marker", "gateName", marker.GateName, "line", marker.Line, "file", marker.File)
224+
}
225+
226+
allMarkers = append(allMarkers, markers...)
227+
}
228+
}
229+
230+
featureGates := machinery.ExtractFeatureGates(allMarkers)
161231
if len(featureGates) > 0 {
162-
slog.Info("Discovered feature gates", "featureGates", featureGates)
232+
slog.Debug("Discovered feature gates", "featureGates", featureGates)
163233
} else {
164234
slog.Debug("No feature gates found in directory", "apiDir", apiDir)
165235
}

pkg/plugins/golang/v4/scaffolds/api_test.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,23 @@ func TestAPIScaffolder_discoverFeatureGates_Testdata(t *testing.T) {
185185
apiScaffolder.fs = machinery.Filesystem{FS: afero.NewOsFs()}
186186

187187
featureGates := apiScaffolder.discoverFeatureGates()
188-
if len(featureGates) > 0 {
189-
t.Errorf("Expected no feature gates from testdata, but found %d: %v", len(featureGates), featureGates)
188+
189+
// The testdata contains a feature gate marker for "experimental-bar"
190+
expectedGates := []string{"experimental-bar"}
191+
if len(featureGates) != len(expectedGates) {
192+
t.Errorf("Expected %d feature gates from testdata, but found %d: %v", len(expectedGates), len(featureGates), featureGates)
193+
}
194+
195+
for _, expectedGate := range expectedGates {
196+
found := false
197+
for _, gate := range featureGates {
198+
if gate == expectedGate {
199+
found = true
200+
break
201+
}
202+
}
203+
if !found {
204+
t.Errorf("Expected to find feature gate %s, but it was not discovered", expectedGate)
205+
}
190206
}
191207
}

pkg/plugins/golang/v4/scaffolds/internal/templates/api/types.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ type Types struct {
3535
machinery.ResourceMixin
3636

3737
Force bool
38+
// IncludeFeatureGateExample determines whether to include feature gate example in the scaffolded API
39+
IncludeFeatureGateExample bool
3840
}
3941

4042
// SetTemplateDefaults implements machinery.Template
@@ -83,14 +85,15 @@ type {{ .Resource.Kind }}Spec struct {
8385
// foo is an example field of {{ .Resource.Kind }}. Edit {{ lower .Resource.Kind }}_types.go to remove/update
8486
// +optional
8587
Foo *string ` + "`" + `json:"foo,omitempty"` + "`" + `
88+
{{- if .IncludeFeatureGateExample }}
8689
8790
// Example of a feature-gated field:
8891
// Bar is an experimental field that requires the "experimental-bar" feature gate to be enabled
89-
// TODO: When controller-tools supports feature gates (issue #1238), use:
9092
// +kubebuilder:feature-gate=experimental-bar
9193
// +feature-gate experimental-bar
9294
// +optional
93-
// Bar *string ` + "`" + `json:"bar,omitempty"` + "`" + `
95+
Bar *string ` + "`" + `json:"bar,omitempty"` + "`" + `
96+
{{- end }}
9497
}
9598
9699
// {{ .Resource.Kind }}Status defines the observed state of {{ .Resource.Kind }}.

pkg/plugins/golang/v4/scaffolds/internal/templates/cmd/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ func main() {
293293
flag.BoolVar(&enableHTTP2, "enable-http2", false,
294294
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
295295
flag.StringVar(&featureGates, "feature-gates", "", "A set of key=value pairs that describe feature gates for alpha/experimental features. " +
296+
"Example: --feature-gates \"gate1=true,gate2=false\". " +
296297
"Options are: "+featuregates.GetFeatureGatesHelpText())
297298
opts := zap.Options{
298299
Development: true,

pkg/plugins/golang/v4/scaffolds/internal/templates/makefile.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,6 @@ help: ## Display this help.
119119
.PHONY: manifests
120120
manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects.
121121
$(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
122-
# TODO: When controller-tools supports feature gates (issue #1238), add:
123-
# $(CONTROLLER_GEN) --feature-gates=$(FEATURE_GATES) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
124122
125123
.PHONY: generate
126124
generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.

0 commit comments

Comments
 (0)