Skip to content

Commit

Permalink
Add feature flag system to support gradual rollouts (#151)
Browse files Browse the repository at this point in the history
Introduce a lightweight feature flag implementation to enable safer and
more controlled feature releases. This system will allow us to:

- Reduce risk by gradually rolling out new features to subsets of users
- Quickly disable problematc features without requiring a new release
- Manage feature lifecycles more effectively across different environments

The implementation uses a simple map-based approach for for efficiently
introducing and graduating new features in ACK.

Usage example:
```go

func someLogic() {
    ...
    if cfg.FeatureGates.IsEnabled("FeatureName") {

    } else {

    }
}
```

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
  • Loading branch information
a-hilaly authored Jul 11, 2024
1 parent b6876b5 commit 1fe89e5
Show file tree
Hide file tree
Showing 4 changed files with 375 additions and 0 deletions.
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

0 comments on commit 1fe89e5

Please sign in to comment.