diff --git a/cmd/crowdsec-cli/main.go b/cmd/crowdsec-cli/main.go index fa687098d..e5d3bf8ef 100644 --- a/cmd/crowdsec-cli/main.go +++ b/cmd/crowdsec-cli/main.go @@ -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", diff --git a/cmd/crowdsec-cli/support.go b/cmd/crowdsec-cli/support.go index 1e8f6d9ea..4d3a4f9e9 100644 --- a/cmd/crowdsec-cli/support.go +++ b/cmd/crowdsec-cli/support.go @@ -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) }, } diff --git a/cmd/crowdsec/main.go b/cmd/crowdsec/main.go index 63506a6c3..66a13f85e 100644 --- a/cmd/crowdsec/main.go +++ b/cmd/crowdsec/main.go @@ -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 := "" + 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") diff --git a/go.mod b/go.mod index 291c6f899..fa083aaee 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 0065ffb21..0dcb50ef9 100644 --- a/go.sum +++ b/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/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= diff --git a/pkg/cstest/utils.go b/pkg/cstest/utils.go index fb8300094..068c2e284 100644 --- a/pkg/cstest/utils.go +++ b/pkg/cstest/utils.go @@ -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) { diff --git a/pkg/fflag/crowdsec.go b/pkg/fflag/crowdsec.go new file mode 100644 index 000000000..7e0b0e8f1 --- /dev/null +++ b/pkg/fflag/crowdsec.go @@ -0,0 +1,5 @@ +package fflag + +var CrowdsecFeatures = FeatureMap{ + "cscli_setup": {}, +} diff --git a/pkg/fflag/features.go b/pkg/fflag/features.go new file mode 100644 index 000000000..ddcc47716 --- /dev/null +++ b/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 +} diff --git a/pkg/fflag/features_test.go b/pkg/fflag/features_test.go new file mode 100644 index 000000000..1dfae38a5 --- /dev/null +++ b/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()) +} diff --git a/tests/bats/01_base.bats b/tests/bats/01_base.bats index bdcbaa3a7..ca19b06b2 100644 --- a/tests/bats/01_base.bats +++ b/tests/bats/01_base.bats @@ -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 +}