diff --git a/pkg/config/config.go b/pkg/config/config.go index b97f5ae..ea82484 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -34,6 +34,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" + "github.com/aws-controllers-k8s/runtime/pkg/featuregate" acktags "github.com/aws-controllers-k8s/runtime/pkg/tags" ackutil "github.com/aws-controllers-k8s/runtime/pkg/util" ) @@ -59,6 +60,7 @@ const ( flagReconcileResourceResyncSeconds = "reconcile-resource-resync-seconds" flagReconcileDefaultMaxConcurrency = "reconcile-default-max-concurrent-syncs" flagReconcileResourceMaxConcurrency = "reconcile-resource-max-concurrent-syncs" + flagFeatureGates = "feature-gates" envVarAWSRegion = "AWS_REGION" ) @@ -98,6 +100,9 @@ type Config struct { ReconcileResourceResyncSeconds []string ReconcileDefaultMaxConcurrency int ReconcileResourceMaxConcurrency []string + // TODO(a-hilaly): migrate to k8s.io/component-base and implement a proper parser for feature gates. + FeatureGates featuregate.FeatureGates + featureGatesRaw string } // BindFlags defines CLI/runtime configuration options @@ -226,6 +231,13 @@ func (cfg *Config) BindFlags() { " configuration maps resource kinds to maximum number of concurrent reconciles. If provided, "+ " resource-specific max concurrency takes precedence over the default max concurrency.", ) + flag.StringVar( + &cfg.featureGatesRaw, flagFeatureGates, + "", + "Feature gates to enable. The format is a comma-separated list of key=value pairs. "+ + "Valid keys are feature names and valid values are 'true' or 'false'."+ + "Available features: "+strings.Join(featuregate.GetDefaultFeatureGates().GetFeatureNames(), ", "), + ) } // SetupLogger initializes the logger used in the service controller @@ -323,6 +335,13 @@ func (cfg *Config) Validate(options ...Option) error { if cfg.ReconcileDefaultMaxConcurrency < 1 { return fmt.Errorf("invalid value for flag '%s': max concurrency default must be greater than 0", flagReconcileDefaultMaxConcurrency) } + + featureGatesMap, err := parseFeatureGates(cfg.featureGatesRaw) + if err != nil { + return fmt.Errorf("invalid value for flag '%s': %v", flagFeatureGates, err) + } + cfg.FeatureGates = featuregate.GetFeatureGatesWithOverrides(featureGatesMap) + return nil } @@ -469,3 +488,43 @@ func parseWatchNamespaceString(namespace string) ([]string, error) { } return namespaces, nil } + +// parseFeatureGates converts a raw string of feature gate settings into a FeatureGates structure. +// +// The input string should be in the format "feature1=bool,feature2=bool,...". +// For example: "MyFeature=true,AnotherFeature=false" +// +// This function: +// - Parses the input string into individual feature gate settings +// - Validates the format of each setting +// - Converts the boolean values +// - Applies these settings as overrides to the default feature gates +func parseFeatureGates(featureGatesRaw string) (map[string]bool, error) { + featureGatesRaw = strings.TrimSpace(featureGatesRaw) + if featureGatesRaw == "" { + return nil, nil + } + + featureGatesMap := map[string]bool{} + for _, featureGate := range strings.Split(featureGatesRaw, ",") { + featureGateKV := strings.SplitN(featureGate, "=", 2) + if len(featureGateKV) != 2 { + return nil, fmt.Errorf("invalid feature gate format: %s", featureGate) + } + + featureName := strings.TrimSpace(featureGateKV[0]) + if featureName == "" { + return nil, fmt.Errorf("invalid feature gate name: %s", featureGate) + } + + featureValue := strings.TrimSpace(featureGateKV[1]) + featureEnabled, err := strconv.ParseBool(featureValue) + if err != nil { + return nil, fmt.Errorf("invalid feature gate value for %s: %s", featureName, featureValue) + } + + featureGatesMap[featureName] = featureEnabled + } + + return featureGatesMap, nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index c98a8f5..f559024 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -14,6 +14,7 @@ package config import ( + "reflect" "strings" "testing" ) @@ -107,3 +108,78 @@ func TestParseNamespace(t *testing.T) { } } } + +func TestParseFeatureGates(t *testing.T) { + tests := []struct { + name string + input string + want map[string]bool + wantErr bool + }{ + { + name: "Empty input", + input: "", + want: nil, + }, + { + name: "Single feature enabled", + input: "Feature1=true", + want: map[string]bool{"Feature1": true}, + }, + { + name: "Single feature disabled", + input: "Feature1=false", + want: map[string]bool{"Feature1": false}, + }, + { + name: "Multiple features", + input: "Feature1=true,Feature2=false,Feature3=true", + want: map[string]bool{ + "Feature1": true, + "Feature2": false, + "Feature3": true, + }, + }, + { + name: "Whitespace in input", + input: " Feature1 = true , Feature2 = false ", + want: map[string]bool{ + "Feature1": true, + "Feature2": false, + }, + }, + { + name: "Invalid format", + input: "Feature1:true", + wantErr: true, + }, + { + name: "Invalid boolean value", + input: "Feature1=yes", + wantErr: true, + }, + { + name: "Missing value", + input: "Feature1=", + wantErr: true, + }, + { + name: "Missing key", + input: "=true", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseFeatureGates(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseFeatureGates() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseFeatureGates() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/featuregate/features.go b/pkg/featuregate/features.go new file mode 100644 index 0000000..56275af --- /dev/null +++ b/pkg/featuregate/features.go @@ -0,0 +1,102 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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 simple mechanism for managing feature gates +// in ACK controllers. It allows for default gates to be defined and +// optionally overridden. +package featuregate + +// defaultACKFeatureGates is a map of feature names to Feature structs +// representing the default feature gates for ACK controllers. +var defaultACKFeatureGates = FeatureGates{ + // Set feature gates here + // "feature1": {Stage: Alpha, Enabled: false}, +} + +// FeatureStage represents the development stage of a feature. +type FeatureStage string + +const ( + // Alpha represents a feature in early testing, potentially unstable. + // Alpha features may be removed or changed at any time and are disabled + // by default. + Alpha FeatureStage = "alpha" + + // Beta represents a feature in advanced testing, more stable than alpha. + // Beta features are enabled by default. + Beta FeatureStage = "beta" + + // GA represents a feature that is generally available and stable. + GA FeatureStage = "ga" +) + +// Feature represents a single feature gate with its properties. +type Feature struct { + // Stage indicates the current development stage of the feature. + Stage FeatureStage + + // Enabled determines if the feature is enabled. + Enabled bool +} + +// FeatureGates is a map representing a set of feature gates. +type FeatureGates map[string]Feature + +// IsEnabled checks if a feature with the given name is enabled. +// It returns true if the feature exists and is enabled, false +// otherwise. +func (fg FeatureGates) IsEnabled(name string) bool { + feature, ok := fg[name] + return ok && feature.Enabled +} + +// GetFeature retrieves a feature by its name. +// It returns the Feature struct and a boolean indicating whether the +// feature was found. +func (fg FeatureGates) GetFeature(name string) (Feature, bool) { + feature, ok := fg[name] + return feature, ok +} + +// GetFeatureNames returns a slice of feature names in the FeatureGates +// instance. +func (fg FeatureGates) GetFeatureNames() []string { + names := make([]string, 0, len(fg)) + for name := range fg { + names = append(names, name) + } + return names +} + +// GetDefaultFeatureGates returns a new FeatureGates instance initialized with the default feature set. +// This function should be used when no overrides are needed. +func GetDefaultFeatureGates() FeatureGates { + gates := make(FeatureGates) + for name, feature := range defaultACKFeatureGates { + gates[name] = feature + } + return gates +} + +// GetFeatureGatesWithOverrides returns a new FeatureGates instance with the default features, +// but with the provided overrides applied. This allows for runtime configuration of feature gates. +func GetFeatureGatesWithOverrides(featureGateOverrides map[string]bool) FeatureGates { + gates := GetDefaultFeatureGates() + for name, enabled := range featureGateOverrides { + if feature, ok := gates[name]; ok { + feature.Enabled = enabled + gates[name] = feature + } + } + return gates +} diff --git a/pkg/featuregate/features_test.go b/pkg/featuregate/features_test.go new file mode 100644 index 0000000..818cc25 --- /dev/null +++ b/pkg/featuregate/features_test.go @@ -0,0 +1,138 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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" +) + +func TestIsEnabled(t *testing.T) { + gates := FeatureGates{ + "enabledFeature": {Stage: Alpha, Enabled: true}, + "disabledFeature": {Stage: Beta, Enabled: false}, + } + + tests := []struct { + name string + feature string + expected bool + }{ + {"Enabled feature", "enabledFeature", true}, + {"Disabled feature", "disabledFeature", false}, + {"Non-existent feature", "nonExistentFeature", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := gates.IsEnabled(tt.feature); got != tt.expected { + t.Errorf("IsEnabled(%q) = %v, want %v", tt.feature, got, tt.expected) + } + }) + } +} + +func TestGetFeature(t *testing.T) { + gates := FeatureGates{ + "existingFeature": {Stage: GA, Enabled: true}, + } + + tests := []struct { + name string + feature string + expectedFound bool + }{ + {"Existing feature", "existingFeature", true}, + {"Non-existent feature", "nonExistentFeature", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + feature, found := gates.GetFeature(tt.feature) + if found != tt.expectedFound { + t.Errorf("GetFeature(%q) found = %v, want %v", tt.feature, found, tt.expectedFound) + } + if found { + if feature.Stage != GA || !feature.Enabled { + t.Errorf("GetFeature(%q) = %+v, want {Stage: GA, Enabled: true}", tt.feature, feature) + } + } + }) + } +} + +func TestGetDefaultFeatureGates(t *testing.T) { + // Temporarily replace defaultFeatureGates for this test + oldDefaultFeatureGates := defaultACKFeatureGates + defaultACKFeatureGates = FeatureGates{ + "feature1": {Stage: Alpha, Enabled: false}, + "feature2": {Stage: Beta, Enabled: true}, + } + defer func() { defaultACKFeatureGates = oldDefaultFeatureGates }() + + gates := GetDefaultFeatureGates() + + if len(gates) != 2 { + t.Errorf("GetDefaultFeatureGates() returned %d feature gates, want 2", len(gates)) + } + + if !gates.IsEnabled("feature2") { + t.Errorf("feature2 should be enabled") + } + + if gates.IsEnabled("feature1") { + t.Errorf("feature1 should be disabled") + } +} + +func TestGetFeatureGatesWithOverrides(t *testing.T) { + // Temporarily replace defaultFeatureGates for this test + oldDefaultFeatureGates := defaultACKFeatureGates + defaultACKFeatureGates = FeatureGates{ + "feature1": {Stage: Alpha, Enabled: false}, + "feature2": {Stage: Beta, Enabled: true}, + "feature3": {Stage: GA, Enabled: false}, + } + defer func() { defaultACKFeatureGates = oldDefaultFeatureGates }() + + overrides := map[string]bool{ + "feature1": true, + "feature2": false, + "feature4": true, // This should be ignored as it's not in defaultFeatureGates + } + + gates := GetFeatureGatesWithOverrides(overrides) + + tests := []struct { + name string + feature string + expected bool + }{ + {"Overridden to true", "feature1", true}, + {"Overridden to false", "feature2", false}, + {"Not overridden", "feature3", false}, + {"Non-existent feature", "feature4", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := gates.IsEnabled(tt.feature); got != tt.expected { + t.Errorf("IsEnabled(%q) = %v, want %v", tt.feature, got, tt.expected) + } + }) + } + + if len(gates) != 3 { + t.Errorf("GetFeatureGatesWithOverrides() returned %d feature gates, want 3", len(gates)) + } +}