Browse Source

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.
mmetc 2 years ago
parent
commit
a32aa96752

+ 13 - 2
cmd/crowdsec-cli/main.go

@@ -18,6 +18,7 @@ import (
 	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
 	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
 	"github.com/crowdsecurity/crowdsec/pkg/database"
 	"github.com/crowdsecurity/crowdsec/pkg/database"
+	"github.com/crowdsecurity/crowdsec/pkg/fflag"
 )
 )
 
 
 var bincoverTesting = ""
 var bincoverTesting = ""
@@ -52,8 +53,6 @@ func initConfig() {
 	} else if err_lvl {
 	} else if err_lvl {
 		log.SetLevel(log.ErrorLevel)
 		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) {
 	if !inSlice(os.Args[1], NoNeedConfig) {
 		csConfig, err = csconfig.NewConfig(ConfigFilePath, false, false)
 		csConfig, err = csconfig.NewConfig(ConfigFilePath, false, false)
@@ -68,6 +67,11 @@ func initConfig() {
 		csConfig = csconfig.NewDefaultConfig()
 		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 {
 	if csConfig.Cscli == nil {
 		log.Fatalf("missing 'cscli' configuration in '%s', exiting", ConfigFilePath)
 		log.Fatalf("missing 'cscli' configuration in '%s', exiting", ConfigFilePath)
 	}
 	}
@@ -130,6 +134,13 @@ var (
 )
 )
 
 
 func main() {
 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{
 	var rootCmd = &cobra.Command{
 		Use:   "cscli",
 		Use:   "cscli",

+ 19 - 4
cmd/crowdsec-cli/support.go

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

+ 35 - 0
cmd/crowdsec/main.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"fmt"
 	_ "net/http/pprof"
 	_ "net/http/pprof"
 	"os"
 	"os"
+	"path/filepath"
 	"runtime"
 	"runtime"
 	"sort"
 	"sort"
 	"strings"
 	"strings"
@@ -20,6 +21,7 @@ import (
 	"github.com/crowdsecurity/crowdsec/pkg/csplugin"
 	"github.com/crowdsecurity/crowdsec/pkg/csplugin"
 	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
 	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
+	"github.com/crowdsecurity/crowdsec/pkg/fflag"
 	"github.com/crowdsecurity/crowdsec/pkg/leakybucket"
 	"github.com/crowdsecurity/crowdsec/pkg/leakybucket"
 	"github.com/crowdsecurity/crowdsec/pkg/parser"
 	"github.com/crowdsecurity/crowdsec/pkg/parser"
 	"github.com/crowdsecurity/crowdsec/pkg/types"
 	"github.com/crowdsecurity/crowdsec/pkg/types"
@@ -295,9 +297,39 @@ func LoadConfig(cConfig *csconfig.Config) error {
 		return err
 		return err
 	}
 	}
 
 
+	err := LoadFeatureFlags(cConfig, log.StandardLogger())
+	if err != nil {
+		return err
+	}
+
 	return nil
 	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,
 // exitWithCode must be called right before the program termination,
 // to allow measuring functional test coverage in case of abnormal exit.
 // 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
 var crowdsecT0 time.Time
 
 
 func main() {
 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()
 	crowdsecT0 = time.Now()
 
 
 	defer types.CatchPanic("crowdsec/main")
 	defer types.CatchPanic("crowdsec/main")

+ 2 - 0
go.mod

@@ -69,6 +69,7 @@ require (
 	github.com/aquasecurity/table v1.8.0
 	github.com/aquasecurity/table v1.8.0
 	github.com/beevik/etree v1.1.0
 	github.com/beevik/etree v1.1.0
 	github.com/blackfireio/osinfo v1.0.3
 	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/google/winops v0.0.0-20211216095627-f0e86eb1453b
 	github.com/ivanpirog/coloredcobra v1.0.1
 	github.com/ivanpirog/coloredcobra v1.0.1
 	github.com/mattn/go-isatty v0.0.14
 	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/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
 	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
 	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
 	golang.org/x/text v0.3.7 // 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/appengine v1.6.7 // indirect
 	google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21 // indirect
 	google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect

+ 6 - 1
go.sum

@@ -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/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/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.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 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
 github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 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.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/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/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 v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 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=
 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.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-colorable v0.1.4/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.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.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 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
 github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
 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-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.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.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 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
 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-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-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-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-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 h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 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-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-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-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=
 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.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
 google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
 google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=

+ 50 - 0
pkg/cstest/utils.go

@@ -7,6 +7,8 @@ import (
 
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
+
+	logtest "github.com/sirupsen/logrus/hooks/test"
 )
 )
 
 
 func AssertErrorContains(t *testing.T, err error, expectedErr string) {
 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)
 	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) {
 func RequireErrorContains(t *testing.T, err error, expectedErr string) {
 	t.Helper()
 	t.Helper()
 
 
@@ -31,6 +48,39 @@ func RequireErrorContains(t *testing.T, err error, expectedErr string) {
 	require.NoError(t, err)
 	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.
 // Interpolate fills a string template with the given values, can be map or struct.
 // example: Interpolate("{{.Name}}", map[string]string{"Name": "JohnDoe"})
 // example: Interpolate("{{.Name}}", map[string]string{"Name": "JohnDoe"})
 func Interpolate(s string, data interface{}) (string, error) {
 func Interpolate(s string, data interface{}) (string, error) {

+ 5 - 0
pkg/fflag/crowdsec.go

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

+ 276 - 0
pkg/fflag/features.go

@@ -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 - 0
pkg/fflag/features_test.go

@@ -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())
+}

+ 5 - 0
tests/bats/01_base.bats

@@ -269,3 +269,8 @@ declare stderr
     assert_line 'crowdsecurity/ssh-bf'
     assert_line 'crowdsecurity/ssh-bf'
     assert_line 'crowdsecurity/ssh-slow-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
+}