6efc2688b1
Now checking for a feature flag is a one liner, with no need to control errors. if fflag.Crowdsec.CscliSetup.IsEnabled() { ... }
264 lines
6.3 KiB
Go
264 lines
6.3 KiB
Go
// Package fflag provides a simple feature flag system.
|
|
//
|
|
// Feature names are lowercase and can only contain letters, numbers, undercores
|
|
// and dots.
|
|
//
|
|
// good: "foo", "foo_bar", "foo.bar"
|
|
// bad: "Foo", "foo-bar"
|
|
//
|
|
// A feature flag can be enabled by the user with an environment variable
|
|
// or by adding it to {ConfigDir}/feature.yaml
|
|
//
|
|
// I.e. CROWDSEC_FEATURE_FOO_BAR=true
|
|
// or in feature.yaml:
|
|
// ---
|
|
// - foo_bar
|
|
//
|
|
// If the variable is set to false, the feature can still be enabled
|
|
// in feature.yaml. Features cannot be disabled in the file.
|
|
//
|
|
// A feature flag can be deprecated or retired. A deprecated feature flag is
|
|
// still accepted but a warning is logged. A retired feature flag is ignored
|
|
// and an error is logged.
|
|
//
|
|
// A specific deprecation message is used to inform the user of the behavior
|
|
// that has been decided when the flag is/was finally retired.
|
|
|
|
package fflag
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
var (
|
|
ErrFeatureNameEmpty = errors.New("name is empty")
|
|
ErrFeatureNameCase = errors.New("name is not lowercase")
|
|
ErrFeatureNameInvalid = errors.New("invalid name (allowed a-z, 0-9, _, .)")
|
|
ErrFeatureUnknown = errors.New("unknown feature")
|
|
ErrFeatureDeprecated = errors.New("the flag is deprecated")
|
|
ErrFeatureRetired = errors.New("the flag is retired")
|
|
)
|
|
|
|
const (
|
|
ActiveState = iota // the feature can be enabled, and its description is logged (Info)
|
|
DeprecatedState // the feature can be enabled, and a deprecation message is logged (Warning)
|
|
RetiredState // the feature is ignored and a deprecation message is logged (Error)
|
|
)
|
|
|
|
type Feature struct {
|
|
Name string
|
|
State int // active, deprecated, retired
|
|
|
|
// Description should be a short sentence, explaining the feature.
|
|
Description string
|
|
|
|
// DeprecationMessage is used to inform the user of the behavior that has
|
|
// been decided when the flag is/was finally retired.
|
|
DeprecationMsg string
|
|
|
|
enabled bool
|
|
}
|
|
|
|
func (f *Feature) IsEnabled() bool {
|
|
return f.enabled
|
|
}
|
|
|
|
// Set enables or disables a feature flag
|
|
// It should not be called directly by the user, but by SetFromEnv or SetFromYaml
|
|
func (f *Feature) Set(value bool) error {
|
|
// retired feature flags are ignored
|
|
if f.State == RetiredState {
|
|
return ErrFeatureRetired
|
|
}
|
|
|
|
f.enabled = value
|
|
|
|
// deprecated feature flags are still accepted, but a warning is triggered.
|
|
// We return an error but set the feature anyway.
|
|
if f.State == DeprecatedState {
|
|
return ErrFeatureDeprecated
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// A register allows to enable features from the environment or a file
|
|
type FeatureRegister struct {
|
|
EnvPrefix string
|
|
features map[string]*Feature
|
|
}
|
|
|
|
var featureNameRexp = regexp.MustCompile(`^[a-z0-9_\.]+$`)
|
|
|
|
func validateFeatureName(featureName string) error {
|
|
if featureName == "" {
|
|
return ErrFeatureNameEmpty
|
|
}
|
|
|
|
if featureName != strings.ToLower(featureName) {
|
|
return ErrFeatureNameCase
|
|
}
|
|
|
|
if !featureNameRexp.MatchString(featureName) {
|
|
return ErrFeatureNameInvalid
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (fr *FeatureRegister) RegisterFeature(feat *Feature) error {
|
|
if err := validateFeatureName(feat.Name); err != nil {
|
|
return fmt.Errorf("feature flag '%s': %w", feat.Name, err)
|
|
}
|
|
|
|
if fr.features == nil {
|
|
fr.features = make(map[string]*Feature)
|
|
}
|
|
|
|
fr.features[feat.Name] = feat
|
|
|
|
return nil
|
|
}
|
|
|
|
func (fr *FeatureRegister) GetFeature(featureName string) (*Feature, error) {
|
|
feat, ok := fr.features[featureName]
|
|
if !ok {
|
|
return feat, ErrFeatureUnknown
|
|
}
|
|
|
|
return feat, nil
|
|
}
|
|
|
|
func (fr *FeatureRegister) SetFromEnv(logger *logrus.Logger) error {
|
|
for _, e := range os.Environ() {
|
|
// ignore non-feature variables
|
|
if !strings.HasPrefix(e, fr.EnvPrefix) {
|
|
continue
|
|
}
|
|
|
|
// extract feature name and value
|
|
pair := strings.SplitN(e, "=", 2)
|
|
varName := pair[0]
|
|
featureName := strings.ToLower(varName[len(fr.EnvPrefix):])
|
|
value := pair[1]
|
|
|
|
var enable bool
|
|
|
|
switch value {
|
|
case "true":
|
|
enable = true
|
|
case "false":
|
|
enable = false
|
|
default:
|
|
logger.Errorf("Ignored envvar %s=%s: invalid value (must be 'true' or 'false')", varName, value)
|
|
continue
|
|
}
|
|
|
|
feat, err := fr.GetFeature(featureName)
|
|
if err != nil {
|
|
logger.Errorf("Ignored envvar '%s': %s.", varName, err)
|
|
continue
|
|
}
|
|
|
|
err = feat.Set(enable)
|
|
|
|
switch {
|
|
case errors.Is(err, ErrFeatureRetired):
|
|
logger.Errorf("Ignored envvar '%s': %s. %s", varName, err, feat.DeprecationMsg)
|
|
continue
|
|
case errors.Is(err, ErrFeatureDeprecated):
|
|
logger.Warningf("Envvar '%s': %s. %s", varName, err, feat.DeprecationMsg)
|
|
case err != nil:
|
|
return err
|
|
}
|
|
|
|
logger.Infof("Feature flag: %s=%t (from envvar). %s", featureName, enable, feat.Description)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (fr *FeatureRegister) SetFromYaml(r io.Reader, logger *logrus.Logger) error {
|
|
var cfg []string
|
|
|
|
bys, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// parse config file
|
|
if err := yaml.Unmarshal(bys, &cfg); err != nil {
|
|
if !errors.Is(err, io.EOF) {
|
|
return fmt.Errorf("failed to parse feature flags: %w", err)
|
|
}
|
|
|
|
logger.Debug("No feature flags in config file")
|
|
}
|
|
|
|
// set features
|
|
for _, k := range cfg {
|
|
feat, err := fr.GetFeature(k)
|
|
if err != nil {
|
|
logger.Errorf("Ignored feature flag '%s': %s", k, err)
|
|
continue
|
|
}
|
|
|
|
err = feat.Set(true)
|
|
|
|
switch {
|
|
case errors.Is(err, ErrFeatureRetired):
|
|
logger.Errorf("Ignored feature flag '%s': %s. %s", k, err, feat.DeprecationMsg)
|
|
continue
|
|
case errors.Is(err, ErrFeatureDeprecated):
|
|
logger.Warningf("Feature '%s': %s. %s", k, err, feat.DeprecationMsg)
|
|
case err != nil:
|
|
return err
|
|
}
|
|
|
|
logger.Infof("Feature flag: %s=true (from config file). %s", k, feat.Description)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (fr *FeatureRegister) SetFromYamlFile(path string, logger *logrus.Logger) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
logger.Debugf("Feature flags config file '%s' does not exist", path)
|
|
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("failed to open feature flags file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
logger.Debugf("Reading feature flags from %s", path)
|
|
|
|
return fr.SetFromYaml(f, logger)
|
|
}
|
|
|
|
// GetEnabledFeatures returns the list of features that have been enabled by the user
|
|
func (fr *FeatureRegister) GetEnabledFeatures() []string {
|
|
ret := make([]string, 0)
|
|
|
|
for k, feat := range fr.features {
|
|
if feat.IsEnabled() {
|
|
ret = append(ret, k)
|
|
}
|
|
}
|
|
|
|
sort.Strings(ret)
|
|
|
|
return ret
|
|
}
|