feature flags (#1933)

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.
This commit is contained in:
mmetc 2022-12-20 16:11:51 +01:00 committed by GitHub
parent f68bc113a7
commit a32aa96752
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 793 additions and 7 deletions

View file

@ -18,6 +18,7 @@ import (
"github.com/crowdsecurity/crowdsec/pkg/cwhub"
"github.com/crowdsecurity/crowdsec/pkg/cwversion"
"github.com/crowdsecurity/crowdsec/pkg/database"
"github.com/crowdsecurity/crowdsec/pkg/fflag"
)
var bincoverTesting = ""
@ -52,8 +53,6 @@ func initConfig() {
} else if err_lvl {
log.SetLevel(log.ErrorLevel)
}
logFormatter := &log.TextFormatter{TimestampFormat: "02-01-2006 15:04:05", FullTimestamp: true}
log.SetFormatter(logFormatter)
if !inSlice(os.Args[1], NoNeedConfig) {
csConfig, err = csconfig.NewConfig(ConfigFilePath, false, false)
@ -68,6 +67,11 @@ func initConfig() {
csConfig = csconfig.NewDefaultConfig()
}
featurePath := filepath.Join(csConfig.ConfigPaths.ConfigDir, "feature.yaml")
if err = fflag.CrowdsecFeatures.SetFromYamlFile(featurePath, log.StandardLogger()); err != nil {
log.Fatalf("File %s: %s", featurePath, err)
}
if csConfig.Cscli == nil {
log.Fatalf("missing 'cscli' configuration in '%s', exiting", ConfigFilePath)
}
@ -130,6 +134,13 @@ var (
)
func main() {
// set the formatter asap and worry about level later
logFormatter := &log.TextFormatter{TimestampFormat: "02-01-2006 15:04:05", FullTimestamp: true}
log.SetFormatter(logFormatter)
// some features can require configuration or command-line options,
// so we need to parse them asap. we'll load from feature.yaml later.
fflag.CrowdsecFeatures.SetFromEnv("CROWDSEC_FEATURE_", log.StandardLogger())
var rootCmd = &cobra.Command{
Use: "cscli",

View file

@ -22,6 +22,7 @@ import (
"github.com/crowdsecurity/crowdsec/pkg/cwhub"
"github.com/crowdsecurity/crowdsec/pkg/cwversion"
"github.com/crowdsecurity/crowdsec/pkg/database"
"github.com/crowdsecurity/crowdsec/pkg/fflag"
"github.com/crowdsecurity/crowdsec/pkg/models"
"github.com/crowdsecurity/crowdsec/pkg/types"
)
@ -30,6 +31,7 @@ const (
SUPPORT_METRICS_HUMAN_PATH = "metrics/metrics.human"
SUPPORT_METRICS_PROMETHEUS_PATH = "metrics/metrics.prometheus"
SUPPORT_VERSION_PATH = "version.txt"
SUPPORT_FEATURES_PATH = "features.txt"
SUPPORT_OS_INFO_PATH = "osinfo.txt"
SUPPORT_PARSERS_PATH = "hub/parsers.txt"
SUPPORT_SCENARIOS_PATH = "hub/scenarios.txt"
@ -89,6 +91,18 @@ func collectVersion() []byte {
return []byte(cwversion.ShowStr())
}
func collectFeatures() []byte {
log.Info("Collecting feature flags")
enabledFeatures := fflag.CrowdsecFeatures.GetEnabledFeatures()
w := bytes.NewBuffer(nil)
for _, k := range enabledFeatures {
fmt.Fprintf(w, "%s\n", k)
}
return w.Bytes()
}
func collectOSInfo() ([]byte, error) {
log.Info("Collecting OS info")
info, err := osinfo.GetOSInfo()
@ -264,6 +278,7 @@ cscli support dump -f /tmp/crowdsec-support.zip
var skipHub, skipDB, skipCAPI, skipLAPI, skipAgent bool
infos := map[string][]byte{
SUPPORT_VERSION_PATH: collectVersion(),
SUPPORT_FEATURES_PATH: collectFeatures(),
}
if outFile == "" {
@ -271,7 +286,6 @@ cscli support dump -f /tmp/crowdsec-support.zip
}
dbClient, err = database.NewClient(csConfig.DbConfig)
if err != nil {
log.Warnf("Could not connect to database: %s", err)
skipDB = true
@ -291,7 +305,6 @@ cscli support dump -f /tmp/crowdsec-support.zip
}
err = initHub()
if err != nil {
log.Warn("Could not init hub, running on LAPI ? Hub related information will not be collected")
skipHub = true
@ -309,7 +322,7 @@ cscli support dump -f /tmp/crowdsec-support.zip
skipLAPI = true
}
if csConfig.API.Server == nil || csConfig.API.Server.OnlineClient.Credentials == nil {
if csConfig.API.Server == nil || csConfig.API.Server.OnlineClient == nil || csConfig.API.Server.OnlineClient.Credentials == nil {
log.Warn("no CAPI credentials found, skipping CAPI connectivity check")
skipCAPI = true
}
@ -322,7 +335,6 @@ cscli support dump -f /tmp/crowdsec-support.zip
}
infos[SUPPORT_OS_INFO_PATH], err = collectOSInfo()
if err != nil {
log.Warnf("could not collect OS information: %s", err)
infos[SUPPORT_OS_INFO_PATH] = []byte(err.Error())
@ -389,14 +401,17 @@ cscli support dump -f /tmp/crowdsec-support.zip
}
fw.Write([]byte(types.StripAnsiString(string(data))))
}
err = zipWriter.Close()
if err != nil {
log.Fatalf("could not finalize zip file: %s", err)
}
err = os.WriteFile(outFile, w.Bytes(), 0600)
if err != nil {
log.Fatalf("could not write zip file to %s: %s", outFile, err)
}
log.Infof("Written zip file to %s", outFile)
},
}

View file

@ -5,6 +5,7 @@ import (
"fmt"
_ "net/http/pprof"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
@ -20,6 +21,7 @@ import (
"github.com/crowdsecurity/crowdsec/pkg/csplugin"
"github.com/crowdsecurity/crowdsec/pkg/cwhub"
"github.com/crowdsecurity/crowdsec/pkg/cwversion"
"github.com/crowdsecurity/crowdsec/pkg/fflag"
"github.com/crowdsecurity/crowdsec/pkg/leakybucket"
"github.com/crowdsecurity/crowdsec/pkg/parser"
"github.com/crowdsecurity/crowdsec/pkg/types"
@ -295,9 +297,39 @@ func LoadConfig(cConfig *csconfig.Config) error {
return err
}
err := LoadFeatureFlags(cConfig, log.StandardLogger())
if err != nil {
return err
}
return nil
}
// LoadFeatureFlags parses {ConfigDir}/feature.yaml to enable/disable features.
//
// Since CROWDSEC_FEATURE_ envvars are parsed before config.yaml,
// when the logger is not yet initialized, we also log here a recap
// of what has been enabled.
func LoadFeatureFlags(cConfig *csconfig.Config, logger *log.Logger) error {
featurePath := filepath.Join(cConfig.ConfigPaths.ConfigDir, "feature.yaml")
if err := fflag.CrowdsecFeatures.SetFromYamlFile(featurePath, logger); err != nil {
return fmt.Errorf("file %s: %s", featurePath, err)
}
enabledFeatures := fflag.CrowdsecFeatures.GetEnabledFeatures()
msg := "<none>"
if len(enabledFeatures) > 0 {
msg = strings.Join(enabledFeatures, ", ")
}
logger.Infof("Enabled features: %s", msg)
return nil
}
// exitWithCode must be called right before the program termination,
// to allow measuring functional test coverage in case of abnormal exit.
//
@ -322,6 +354,9 @@ func exitWithCode(exitCode int, err error) {
var crowdsecT0 time.Time
func main() {
// some features can require configuration or command-line options,
// so wwe need to parse them asap. we'll load from feature.yaml later.
fflag.CrowdsecFeatures.SetFromEnv("CROWDSEC_FEATURE_", log.StandardLogger())
crowdsecT0 = time.Now()
defer types.CatchPanic("crowdsec/main")

2
go.mod
View file

@ -69,6 +69,7 @@ require (
github.com/aquasecurity/table v1.8.0
github.com/beevik/etree v1.1.0
github.com/blackfireio/osinfo v1.0.3
github.com/goccy/go-yaml v1.9.7
github.com/google/winops v0.0.0-20211216095627-f0e86eb1453b
github.com/ivanpirog/coloredcobra v1.0.1
github.com/mattn/go-isatty v0.0.14
@ -169,6 +170,7 @@ require (
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect

7
go.sum
View file

@ -209,6 +209,7 @@ github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/evanphx/json-patch v4.11.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
@ -372,6 +373,8 @@ github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWe
github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ=
github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0=
github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
github.com/goccy/go-yaml v1.9.7 h1:D/Vx+JITklB1ugSkncB4BNR67M3X6AKs9+rqVeo3ddw=
github.com/goccy/go-yaml v1.9.7/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXKkTfoE=
github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
@ -651,6 +654,7 @@ github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcncea
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
@ -664,7 +668,6 @@ github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
@ -1173,6 +1176,7 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -1264,6 +1268,7 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=

View file

@ -7,6 +7,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
logtest "github.com/sirupsen/logrus/hooks/test"
)
func AssertErrorContains(t *testing.T, err error, expectedErr string) {
@ -20,6 +22,21 @@ func AssertErrorContains(t *testing.T, err error, expectedErr string) {
assert.NoError(t, err)
}
func AssertErrorMessage(t *testing.T, err error, expectedErr string) {
t.Helper()
if expectedErr != "" {
errmsg := ""
if err != nil {
errmsg = err.Error()
}
assert.Equal(t, expectedErr, errmsg)
return
}
require.NoError(t, err)
}
func RequireErrorContains(t *testing.T, err error, expectedErr string) {
t.Helper()
@ -31,6 +48,39 @@ func RequireErrorContains(t *testing.T, err error, expectedErr string) {
require.NoError(t, err)
}
func RequireErrorMessage(t *testing.T, err error, expectedErr string) {
t.Helper()
if expectedErr != "" {
errmsg := ""
if err != nil {
errmsg = err.Error()
}
require.Equal(t, expectedErr, errmsg)
return
}
require.NoError(t, err)
}
func RequireLogContains(t *testing.T, hook *logtest.Hook, expected string) {
t.Helper()
// look for a log entry that matches the expected message
for _, entry := range hook.AllEntries() {
if strings.Contains(entry.Message, expected) {
return
}
}
// show all hook entries, in case the test fails we'll need them
for _, entry := range hook.AllEntries() {
t.Logf("log entry: %s", entry.Message)
}
require.Fail(t, "no log entry found with message", expected)
}
// Interpolate fills a string template with the given values, can be map or struct.
// example: Interpolate("{{.Name}}", map[string]string{"Name": "JohnDoe"})
func Interpolate(s string, data interface{}) (string, error) {

5
pkg/fflag/crowdsec.go Normal file
View file

@ -0,0 +1,5 @@
package fflag
var CrowdsecFeatures = FeatureMap{
"cscli_setup": {},
}

276
pkg/fflag/features.go Normal file
View file

@ -0,0 +1,276 @@
// 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"
)
const (
ActiveState = iota
DeprecatedState
RetiredState
)
type FeatureFlag struct {
State int // active, deprecated, retired
DeprecationMsg string // Why was it deprecated? What happens next? What should the user do?
}
type feature struct {
name string
flag FeatureFlag
enabled bool
fm *FeatureMap
}
func (f *feature) IsEnabled() bool {
return f.enabled
}
func (f *feature) Set(value bool) error {
// retired feature flags are ignored
if f.flag.State == RetiredState {
return FeatureRetiredError(*f)
}
f.enabled = value
(*f.fm)[f.name] = *f
// deprecated feature flags are still accepted, but a warning is triggered.
// We return an error but set the feature anyway.
if f.flag.State == DeprecatedState {
return FeatureDeprecatedError(*f)
}
return nil
}
type FeatureMap map[string]feature
// These are returned by the constructor.
var (
ErrFeatureNameEmpty = errors.New("name is empty")
ErrFeatureNameCase = errors.New("name is not lowercase")
ErrFeatureNameInvalid = errors.New("invalid name (allowed a-z, 0-9, _, .)")
)
var ErrFeatureUnknown = errors.New("unknown feature")
var ErrFeatureDeprecated = errors.New("the flag is deprecated")
func FeatureDeprecatedError(feat feature) error {
if feat.flag.DeprecationMsg != "" {
return fmt.Errorf("%w: %s", ErrFeatureDeprecated, feat.flag.DeprecationMsg)
}
return ErrFeatureDeprecated
}
var ErrFeatureRetired = errors.New("the flag is retired")
func FeatureRetiredError(feat feature) error {
if feat.flag.DeprecationMsg != "" {
return fmt.Errorf("%w: %s", ErrFeatureRetired, feat.flag.DeprecationMsg)
}
return ErrFeatureRetired
}
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 NewFeatureMap(flags map[string]FeatureFlag) (FeatureMap, error) {
fm := FeatureMap{}
for k, v := range flags {
if err := validateFeatureName(k); err != nil {
return nil, fmt.Errorf("feature flag '%s': %w", k, err)
}
fm[k] = feature{name: k, flag: v, enabled: false, fm: &fm}
}
return fm, nil
}
func (fm *FeatureMap) GetFeature(featureName string) (feature, error) {
feat, ok := (*fm)[featureName]
if !ok {
return feat, ErrFeatureUnknown
}
return feat, nil
}
func (fm *FeatureMap) SetFromEnv(prefix string, logger *logrus.Logger) error {
for _, e := range os.Environ() {
// ignore non-feature variables
if !strings.HasPrefix(e, prefix) {
continue
}
// extract feature name and value
pair := strings.SplitN(e, "=", 2)
varName := pair[0]
featureName := strings.ToLower(varName[len(prefix):])
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 := fm.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", varName, err)
continue
case errors.Is(err, ErrFeatureDeprecated):
logger.Warningf("Envvar '%s': %s", varName, err)
case err != nil:
return err
}
logger.Infof("Feature flag: %s=%t (from envvar)", featureName, enable)
}
return nil
}
func (fm *FeatureMap) 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 := fm.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", k, err)
continue
case errors.Is(err, ErrFeatureDeprecated):
logger.Warningf("Feature '%s': %s", k, err)
case err != nil:
return err
}
logger.Infof("Feature flag: %s=true (from config file)", k)
}
return nil
}
func (fm *FeatureMap) 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 fm.SetFromYaml(f, logger)
}
// GetEnabledFeatures returns the list of features that have been enabled by the user
func (fm *FeatureMap) GetEnabledFeatures() []string {
ret := make([]string, 0)
for k := range *fm {
feat := (*fm)[k]
if feat.IsEnabled() {
ret = append(ret, k)
}
}
sort.Strings(ret)
return ret
}

382
pkg/fflag/features_test.go Normal file
View file

@ -0,0 +1,382 @@
package fflag_test
import (
"os"
"strings"
"testing"
"github.com/sirupsen/logrus"
logtest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/require"
"github.com/crowdsecurity/crowdsec/pkg/cstest"
"github.com/crowdsecurity/crowdsec/pkg/fflag"
)
// Test the constructor, which is not required but useful for validation.
func TestNewFeatureMap(t *testing.T) {
tests := []struct {
name string
flags map[string]fflag.FeatureFlag
expectedErr string
}{
{
name: "no feature at all",
flags: map[string]fflag.FeatureFlag{},
},
{
name: "a plain feature or two",
flags: map[string]fflag.FeatureFlag{
"plain": {},
"plain_version2": {},
},
},
{
name: "capitalized feature name",
flags: map[string]fflag.FeatureFlag{
"Plain": {},
},
expectedErr: "feature flag 'Plain': name is not lowercase",
},
{
name: "empty feature name",
flags: map[string]fflag.FeatureFlag{
"": {},
},
expectedErr: "feature flag '': name is empty",
},
{
name: "invalid feature name",
flags: map[string]fflag.FeatureFlag{
"meh!": {},
},
expectedErr: "feature flag 'meh!': invalid name (allowed a-z, 0-9, _, .)",
},
}
for _, tc := range tests {
tc := tc
t.Run("", func(t *testing.T) {
_, err := fflag.NewFeatureMap(tc.flags)
cstest.RequireErrorContains(t, err, tc.expectedErr)
})
}
}
func setUp(t *testing.T) fflag.FeatureMap {
t.Helper()
fm, err := fflag.NewFeatureMap(map[string]fflag.FeatureFlag{
"experimental1": {},
"new_standard": {
State: fflag.DeprecatedState,
DeprecationMsg: "in 2.0 we'll do that by default",
},
"was_adopted": {
State: fflag.RetiredState,
DeprecationMsg: "the trinket was implemented in 1.5",
},
})
require.NoError(t, err)
return fm
}
func TestGetFeature(t *testing.T) {
tests := []struct {
name string
feature string
expectedErr string
}{
{
name: "just a feature",
feature: "experimental1",
}, {
name: "feature that does not exist",
feature: "will_never_exist",
expectedErr: "unknown feature",
},
}
fm := setUp(t)
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
_, err := fm.GetFeature(tc.feature)
cstest.RequireErrorMessage(t, err, tc.expectedErr)
if tc.expectedErr != "" {
return
}
})
}
}
func TestIsEnabled(t *testing.T) {
tests := []struct {
name string
feature string
enable bool
expected bool
}{
{
name: "feature that was not enabled",
feature: "experimental1",
expected: false,
}, {
name: "feature that was enabled",
feature: "experimental1",
enable: true,
expected: true,
},
}
fm := setUp(t)
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
feat, err := fm.GetFeature(tc.feature)
require.NoError(t, err)
err = feat.Set(tc.enable)
require.NoError(t, err)
require.Equal(t, tc.expected, feat.IsEnabled())
})
}
}
func TestFeatureSet(t *testing.T) {
tests := []struct {
name string // test description
feature string // feature name
value bool // value for SetFeature
expected bool // expected value from IsEnabled
expectedSetErr string // error expected from SetFeature
expectedGetErr string // error expected from GetFeature
}{
{
name: "enable a feature to try something new",
feature: "experimental1",
value: true,
expected: true,
}, {
// not useful in practice, unlikely to happen
name: "disable the feature that was enabled",
feature: "experimental1",
value: false,
expected: false,
}, {
name: "enable a feature that will be retired in v2",
feature: "new_standard",
value: true,
expected: true,
expectedSetErr: "the flag is deprecated: in 2.0 we'll do that by default",
}, {
name: "enable a feature that was retired in v1.5",
feature: "was_adopted",
value: true,
expected: false,
expectedSetErr: "the flag is retired: the trinket was implemented in 1.5",
}, {
name: "enable a feature that does not exist",
feature: "will_never_exist",
value: true,
expectedSetErr: "unknown feature",
expectedGetErr: "unknown feature",
},
}
// the tests are not indepedent because we don't instantiate a feature
// map for each one, but it simplified the code
fm := setUp(t)
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
feat, err := fm.GetFeature(tc.feature)
cstest.RequireErrorMessage(t, err, tc.expectedGetErr)
if tc.expectedGetErr != "" {
return
}
err = feat.Set(tc.value)
cstest.RequireErrorMessage(t, err, tc.expectedSetErr)
require.Equal(t, tc.expected, feat.IsEnabled())
})
}
}
func TestSetFromEnv(t *testing.T) {
tests := []struct {
name string
envvar string
value string
// expected bool
expectedLog []string
expectedErr string
}{
{
name: "variable that does not start with FFLAG_TEST_",
envvar: "PATH",
value: "/bin:/usr/bin/:/usr/local/bin",
// silently ignored
}, {
name: "enable a feature flag",
envvar: "FFLAG_TEST_EXPERIMENTAL1",
value: "true",
expectedLog: []string{"Feature flag: experimental1=true (from envvar)"},
}, {
name: "invalid value (not true or false)",
envvar: "FFLAG_TEST_EXPERIMENTAL1",
value: "maybe",
expectedLog: []string{"Ignored envvar FFLAG_TEST_EXPERIMENTAL1=maybe: invalid value (must be 'true' or 'false')"},
}, {
name: "feature flag that is unknown",
envvar: "FFLAG_TEST_WILL_NEVER_EXIST",
value: "true",
expectedLog: []string{"Ignored envvar 'FFLAG_TEST_WILL_NEVER_EXIST': unknown feature"},
}, {
name: "enable a deprecated feature",
envvar: "FFLAG_TEST_NEW_STANDARD",
value: "true",
expectedLog: []string{
"Envvar 'FFLAG_TEST_NEW_STANDARD': the flag is deprecated: in 2.0 we'll do that by default",
"Feature flag: new_standard=true (from envvar)",
},
}, {
name: "enable a feature that was retired in v1.5",
envvar: "FFLAG_TEST_WAS_ADOPTED",
value: "true",
expectedLog: []string{
"Ignored envvar 'FFLAG_TEST_WAS_ADOPTED': the flag is retired: " +
"the trinket was implemented in 1.5",
},
}, {
// this could happen in theory, but only if environment variables
// are parsed after configuration files, which is not a good idea
// because they are more useful asap
name: "disable a feature flag already set",
envvar: "FFLAG_TEST_EXPERIMENTAL1",
value: "false",
},
}
fm := setUp(t)
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
logger, hook := logtest.NewNullLogger()
logger.SetLevel(logrus.InfoLevel)
t.Setenv(tc.envvar, tc.value)
err := fm.SetFromEnv("FFLAG_TEST_", logger)
cstest.RequireErrorMessage(t, err, tc.expectedErr)
for _, expectedMessage := range tc.expectedLog {
cstest.RequireLogContains(t, hook, expectedMessage)
}
})
}
}
func TestSetFromYaml(t *testing.T) {
tests := []struct {
name string
yml string
expectedLog []string
expectedErr string
}{
{
name: "empty file",
yml: "",
// no error
}, {
name: "invalid yaml",
yml: "bad! content, bad!",
expectedErr: "failed to parse feature flags: [1:1] string was used where sequence is expected\n > 1 | bad! content, bad!\n ^",
}, {
name: "invalid feature flag name",
yml: "- not_a_feature",
expectedLog: []string{"Ignored feature flag 'not_a_feature': unknown feature"},
}, {
name: "invalid value (must be a list)",
yml: "experimental1: true",
expectedErr: "failed to parse feature flags: [1:14] value was used where sequence is expected\n > 1 | experimental1: true\n ^",
}, {
name: "enable a feature flag",
yml: "- experimental1",
expectedLog: []string{"Feature flag: experimental1=true (from config file)"},
}, {
name: "enable a deprecated feature",
yml: "- new_standard",
expectedLog: []string{
"Feature 'new_standard': the flag is deprecated: in 2.0 we'll do that by default",
"Feature flag: new_standard=true (from config file)",
},
}, {
name: "enable a retired feature",
yml: "- was_adopted",
expectedLog: []string{
"Ignored feature flag 'was_adopted': the flag is retired: the trinket was implemented in 1.5",
},
},
}
fm := setUp(t)
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
logger, hook := logtest.NewNullLogger()
logger.SetLevel(logrus.InfoLevel)
err := fm.SetFromYaml(strings.NewReader(tc.yml), logger)
cstest.RequireErrorMessage(t, err, tc.expectedErr)
for _, expectedMessage := range tc.expectedLog {
cstest.RequireLogContains(t, hook, expectedMessage)
}
})
}
}
func TestSetFromYamlFile(t *testing.T) {
tmpfile, err := os.CreateTemp("", "test")
require.NoError(t, err)
defer os.Remove(tmpfile.Name())
// write the config file
_, err = tmpfile.Write([]byte("- experimental1"))
require.NoError(t, err)
require.NoError(t, tmpfile.Close())
fm := setUp(t)
logger, hook := logtest.NewNullLogger()
logger.SetLevel(logrus.InfoLevel)
err = fm.SetFromYamlFile(tmpfile.Name(), logger)
require.NoError(t, err)
cstest.RequireLogContains(t, hook, "Feature flag: experimental1=true (from config file)")
}
func TestGetEnabledFeatures(t *testing.T) {
fm := setUp(t)
feat1, err := fm.GetFeature("new_standard")
require.NoError(t, err)
feat1.Set(true)
feat2, err := fm.GetFeature("experimental1")
require.NoError(t, err)
feat2.Set(true)
expected := []string{
"experimental1",
"new_standard",
}
require.Equal(t, expected, fm.GetEnabledFeatures())
}

View file

@ -269,3 +269,8 @@ declare stderr
assert_line 'crowdsecurity/ssh-bf'
assert_line 'crowdsecurity/ssh-slow-bf'
}
@test "cscli support dump (smoke test)" {
run -0 cscli support dump -f "$BATS_TEST_TMPDIR"/dump.zip
assert_file_exist "$BATS_TEST_TMPDIR"/dump.zip
}