For cscli: it should provide a terse output, not nag users with configuration details. Although it's usually important that cscli and crowdsec have the same enabled features, having it list them every time the command is invoked can be too much. For crowdsec: when features are set from the environment, it's too early to log where we should. So we can use log.Debug at activation time, and list them again once logging is configured. - wrap some functions in csconfig for convenience and DRY - for each enabled feature, log.Debug - log all enabled features once as Info (crowdsec) or Debug (cscli) - file does not exist -> log.Trace
265 lines
6.3 KiB
Go
265 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.Debugf("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.Debugf("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.Tracef("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
|
|
}
|