Skip to content

Commit 1fe89e5

Browse files
authored
Add feature flag system to support gradual rollouts (#151)
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.
1 parent b6876b5 commit 1fe89e5

File tree

4 files changed

+375
-0
lines changed

4 files changed

+375
-0
lines changed

pkg/config/config.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"sigs.k8s.io/controller-runtime/pkg/log/zap"
3535

3636
ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1"
37+
"github.com/aws-controllers-k8s/runtime/pkg/featuregate"
3738
acktags "github.com/aws-controllers-k8s/runtime/pkg/tags"
3839
ackutil "github.com/aws-controllers-k8s/runtime/pkg/util"
3940
)
@@ -59,6 +60,7 @@ const (
5960
flagReconcileResourceResyncSeconds = "reconcile-resource-resync-seconds"
6061
flagReconcileDefaultMaxConcurrency = "reconcile-default-max-concurrent-syncs"
6162
flagReconcileResourceMaxConcurrency = "reconcile-resource-max-concurrent-syncs"
63+
flagFeatureGates = "feature-gates"
6264
envVarAWSRegion = "AWS_REGION"
6365
)
6466

@@ -98,6 +100,9 @@ type Config struct {
98100
ReconcileResourceResyncSeconds []string
99101
ReconcileDefaultMaxConcurrency int
100102
ReconcileResourceMaxConcurrency []string
103+
// TODO(a-hilaly): migrate to k8s.io/component-base and implement a proper parser for feature gates.
104+
FeatureGates featuregate.FeatureGates
105+
featureGatesRaw string
101106
}
102107

103108
// BindFlags defines CLI/runtime configuration options
@@ -226,6 +231,13 @@ func (cfg *Config) BindFlags() {
226231
" configuration maps resource kinds to maximum number of concurrent reconciles. If provided, "+
227232
" resource-specific max concurrency takes precedence over the default max concurrency.",
228233
)
234+
flag.StringVar(
235+
&cfg.featureGatesRaw, flagFeatureGates,
236+
"",
237+
"Feature gates to enable. The format is a comma-separated list of key=value pairs. "+
238+
"Valid keys are feature names and valid values are 'true' or 'false'."+
239+
"Available features: "+strings.Join(featuregate.GetDefaultFeatureGates().GetFeatureNames(), ", "),
240+
)
229241
}
230242

231243
// SetupLogger initializes the logger used in the service controller
@@ -323,6 +335,13 @@ func (cfg *Config) Validate(options ...Option) error {
323335
if cfg.ReconcileDefaultMaxConcurrency < 1 {
324336
return fmt.Errorf("invalid value for flag '%s': max concurrency default must be greater than 0", flagReconcileDefaultMaxConcurrency)
325337
}
338+
339+
featureGatesMap, err := parseFeatureGates(cfg.featureGatesRaw)
340+
if err != nil {
341+
return fmt.Errorf("invalid value for flag '%s': %v", flagFeatureGates, err)
342+
}
343+
cfg.FeatureGates = featuregate.GetFeatureGatesWithOverrides(featureGatesMap)
344+
326345
return nil
327346
}
328347

@@ -469,3 +488,43 @@ func parseWatchNamespaceString(namespace string) ([]string, error) {
469488
}
470489
return namespaces, nil
471490
}
491+
492+
// parseFeatureGates converts a raw string of feature gate settings into a FeatureGates structure.
493+
//
494+
// The input string should be in the format "feature1=bool,feature2=bool,...".
495+
// For example: "MyFeature=true,AnotherFeature=false"
496+
//
497+
// This function:
498+
// - Parses the input string into individual feature gate settings
499+
// - Validates the format of each setting
500+
// - Converts the boolean values
501+
// - Applies these settings as overrides to the default feature gates
502+
func parseFeatureGates(featureGatesRaw string) (map[string]bool, error) {
503+
featureGatesRaw = strings.TrimSpace(featureGatesRaw)
504+
if featureGatesRaw == "" {
505+
return nil, nil
506+
}
507+
508+
featureGatesMap := map[string]bool{}
509+
for _, featureGate := range strings.Split(featureGatesRaw, ",") {
510+
featureGateKV := strings.SplitN(featureGate, "=", 2)
511+
if len(featureGateKV) != 2 {
512+
return nil, fmt.Errorf("invalid feature gate format: %s", featureGate)
513+
}
514+
515+
featureName := strings.TrimSpace(featureGateKV[0])
516+
if featureName == "" {
517+
return nil, fmt.Errorf("invalid feature gate name: %s", featureGate)
518+
}
519+
520+
featureValue := strings.TrimSpace(featureGateKV[1])
521+
featureEnabled, err := strconv.ParseBool(featureValue)
522+
if err != nil {
523+
return nil, fmt.Errorf("invalid feature gate value for %s: %s", featureName, featureValue)
524+
}
525+
526+
featureGatesMap[featureName] = featureEnabled
527+
}
528+
529+
return featureGatesMap, nil
530+
}

pkg/config/config_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
package config
1515

1616
import (
17+
"reflect"
1718
"strings"
1819
"testing"
1920
)
@@ -107,3 +108,78 @@ func TestParseNamespace(t *testing.T) {
107108
}
108109
}
109110
}
111+
112+
func TestParseFeatureGates(t *testing.T) {
113+
tests := []struct {
114+
name string
115+
input string
116+
want map[string]bool
117+
wantErr bool
118+
}{
119+
{
120+
name: "Empty input",
121+
input: "",
122+
want: nil,
123+
},
124+
{
125+
name: "Single feature enabled",
126+
input: "Feature1=true",
127+
want: map[string]bool{"Feature1": true},
128+
},
129+
{
130+
name: "Single feature disabled",
131+
input: "Feature1=false",
132+
want: map[string]bool{"Feature1": false},
133+
},
134+
{
135+
name: "Multiple features",
136+
input: "Feature1=true,Feature2=false,Feature3=true",
137+
want: map[string]bool{
138+
"Feature1": true,
139+
"Feature2": false,
140+
"Feature3": true,
141+
},
142+
},
143+
{
144+
name: "Whitespace in input",
145+
input: " Feature1 = true , Feature2 = false ",
146+
want: map[string]bool{
147+
"Feature1": true,
148+
"Feature2": false,
149+
},
150+
},
151+
{
152+
name: "Invalid format",
153+
input: "Feature1:true",
154+
wantErr: true,
155+
},
156+
{
157+
name: "Invalid boolean value",
158+
input: "Feature1=yes",
159+
wantErr: true,
160+
},
161+
{
162+
name: "Missing value",
163+
input: "Feature1=",
164+
wantErr: true,
165+
},
166+
{
167+
name: "Missing key",
168+
input: "=true",
169+
wantErr: true,
170+
},
171+
}
172+
173+
for _, tt := range tests {
174+
t.Run(tt.name, func(t *testing.T) {
175+
got, err := parseFeatureGates(tt.input)
176+
if (err != nil) != tt.wantErr {
177+
t.Errorf("parseFeatureGates() error = %v, wantErr %v", err, tt.wantErr)
178+
return
179+
}
180+
if !reflect.DeepEqual(got, tt.want) {
181+
t.Errorf("parseFeatureGates() = %v, want %v", got, tt.want)
182+
}
183+
})
184+
}
185+
}

pkg/featuregate/features.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"). You may
4+
// not use this file except in compliance with the License. A copy of the
5+
// License is located at
6+
//
7+
// http://aws.amazon.com/apache2.0/
8+
//
9+
// or in the "license" file accompanying this file. This file is distributed
10+
// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
// express or implied. See the License for the specific language governing
12+
// permissions and limitations under the License.
13+
14+
// Package featuregate provides a simple mechanism for managing feature gates
15+
// in ACK controllers. It allows for default gates to be defined and
16+
// optionally overridden.
17+
package featuregate
18+
19+
// defaultACKFeatureGates is a map of feature names to Feature structs
20+
// representing the default feature gates for ACK controllers.
21+
var defaultACKFeatureGates = FeatureGates{
22+
// Set feature gates here
23+
// "feature1": {Stage: Alpha, Enabled: false},
24+
}
25+
26+
// FeatureStage represents the development stage of a feature.
27+
type FeatureStage string
28+
29+
const (
30+
// Alpha represents a feature in early testing, potentially unstable.
31+
// Alpha features may be removed or changed at any time and are disabled
32+
// by default.
33+
Alpha FeatureStage = "alpha"
34+
35+
// Beta represents a feature in advanced testing, more stable than alpha.
36+
// Beta features are enabled by default.
37+
Beta FeatureStage = "beta"
38+
39+
// GA represents a feature that is generally available and stable.
40+
GA FeatureStage = "ga"
41+
)
42+
43+
// Feature represents a single feature gate with its properties.
44+
type Feature struct {
45+
// Stage indicates the current development stage of the feature.
46+
Stage FeatureStage
47+
48+
// Enabled determines if the feature is enabled.
49+
Enabled bool
50+
}
51+
52+
// FeatureGates is a map representing a set of feature gates.
53+
type FeatureGates map[string]Feature
54+
55+
// IsEnabled checks if a feature with the given name is enabled.
56+
// It returns true if the feature exists and is enabled, false
57+
// otherwise.
58+
func (fg FeatureGates) IsEnabled(name string) bool {
59+
feature, ok := fg[name]
60+
return ok && feature.Enabled
61+
}
62+
63+
// GetFeature retrieves a feature by its name.
64+
// It returns the Feature struct and a boolean indicating whether the
65+
// feature was found.
66+
func (fg FeatureGates) GetFeature(name string) (Feature, bool) {
67+
feature, ok := fg[name]
68+
return feature, ok
69+
}
70+
71+
// GetFeatureNames returns a slice of feature names in the FeatureGates
72+
// instance.
73+
func (fg FeatureGates) GetFeatureNames() []string {
74+
names := make([]string, 0, len(fg))
75+
for name := range fg {
76+
names = append(names, name)
77+
}
78+
return names
79+
}
80+
81+
// GetDefaultFeatureGates returns a new FeatureGates instance initialized with the default feature set.
82+
// This function should be used when no overrides are needed.
83+
func GetDefaultFeatureGates() FeatureGates {
84+
gates := make(FeatureGates)
85+
for name, feature := range defaultACKFeatureGates {
86+
gates[name] = feature
87+
}
88+
return gates
89+
}
90+
91+
// GetFeatureGatesWithOverrides returns a new FeatureGates instance with the default features,
92+
// but with the provided overrides applied. This allows for runtime configuration of feature gates.
93+
func GetFeatureGatesWithOverrides(featureGateOverrides map[string]bool) FeatureGates {
94+
gates := GetDefaultFeatureGates()
95+
for name, enabled := range featureGateOverrides {
96+
if feature, ok := gates[name]; ok {
97+
feature.Enabled = enabled
98+
gates[name] = feature
99+
}
100+
}
101+
return gates
102+
}

0 commit comments

Comments
 (0)