Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add feature flag system to support gradual rollouts #151

Merged
merged 2 commits into from
Jul 11, 2024
Merged
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
59 changes: 59 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
76 changes: 76 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
package config

import (
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -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)
}
})
}
}
102 changes: 102 additions & 0 deletions pkg/featuregate/features.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading