crowdsec/pkg/fflag/features.go
mmetc 51800132cd
improve feature flag logging (#1986)
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
2023-01-13 13:42:42 +01:00

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
}