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:
parent
f68bc113a7
commit
a32aa96752
10 changed files with 793 additions and 7 deletions
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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
2
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
|
||||
|
|
7
go.sum
7
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=
|
||||
|
|
|
@ -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
5
pkg/fflag/crowdsec.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package fflag
|
||||
|
||||
var CrowdsecFeatures = FeatureMap{
|
||||
"cscli_setup": {},
|
||||
}
|
276
pkg/fflag/features.go
Normal file
276
pkg/fflag/features.go
Normal 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
382
pkg/fflag/features_test.go
Normal 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())
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue