diff --git a/app/app.go b/app/app.go index 3fff0e58e..1a30bc0b4 100644 --- a/app/app.go +++ b/app/app.go @@ -37,6 +37,7 @@ import ( "github.com/obolnetwork/charon/app/errors" "github.com/obolnetwork/charon/app/eth2wrap" + "github.com/obolnetwork/charon/app/featureset" "github.com/obolnetwork/charon/app/lifecycle" "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/app/retry" @@ -64,6 +65,7 @@ import ( type Config struct { P2P p2p.Config Log log.Config + Feature featureset.Config ManifestFile string DataDir string MonitoringAddr string @@ -116,6 +118,10 @@ func Run(ctx context.Context, conf Config) (err error) { return err } + if err := featureset.Init(ctx, conf.Feature); err != nil { + return err + } + hash, timestamp := GitCommit() log.Info(ctx, "Charon starting", z.Str("version", version.Version), diff --git a/app/app_test.go b/app/app_test.go index c4e7ab723..a14b5f14e 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -34,6 +34,7 @@ import ( "github.com/obolnetwork/charon/app" "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/featureset" "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/cmd" "github.com/obolnetwork/charon/p2p" @@ -168,6 +169,7 @@ func pingCluster(t *testing.T, test pingTest) { for i := 0; i < n; i++ { conf := app.Config{ Log: log.DefaultConfig(), + Feature: featureset.DefaultConfig(), SimnetBMock: true, MonitoringAddr: testutil.AvailableAddr(t).String(), // Random monitoring address ValidatorAPIAddr: testutil.AvailableAddr(t).String(), // Random validatorapi address diff --git a/app/featureset/config.go b/app/featureset/config.go new file mode 100644 index 000000000..55099b40b --- /dev/null +++ b/app/featureset/config.go @@ -0,0 +1,117 @@ +// Copyright © 2022 Obol Labs Inc. +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package featureset + +import ( + "context" + "math" + "strings" + "testing" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" +) + +const ( + enable status = math.MaxInt + disable status = 0 +) + +// Config configures the feature set package. +type Config struct { + // MinStatus defines the minimum enabled status. + MinStatus string + // Enabled overrides min status and enables a list of features. + Enabled []string + // Disabled overrides min status and disables a list of features. + Disabled []string +} + +// DefaultConfig returns the default config enabling only stable features. +func DefaultConfig() Config { + return Config{ + MinStatus: statusStable.String(), + } +} + +// Init initialises the global feature set state. +func Init(ctx context.Context, config Config) error { + var ok bool + for s := statusAlpha; s < statusSentinel; s++ { + if strings.ToLower(config.MinStatus) == strings.ToLower(s.String()) { + minStatus = s + ok = true + + break + } + } + if !ok { + return errors.New("unknown min status", z.Str("min_status", config.MinStatus)) + } + + for _, f := range config.Enabled { + var ok bool + for feature := range state { + if strings.ToLower(string(feature)) == strings.ToLower(f) { + state[feature] = enable + ok = true + } + } + if !ok { + log.Warn(ctx, "Ignoring unknown enabled feature", z.Str("feature", f)) + } + } + + for _, f := range config.Disabled { + var ok bool + for feature := range state { + if strings.ToLower(string(feature)) == strings.ToLower(f) { + state[feature] = disable + ok = true + } + } + if !ok { + log.Warn(ctx, "Ignoring unknown disabled feature", z.Str("feature", f)) + } + } + + return nil +} + +// EnableForT enables a feature for testing. +func EnableForT(t *testing.T, feature Feature) { + t.Helper() + + cache := state[feature] + t.Cleanup(func() { + state[feature] = cache + }) + + state[feature] = enable +} + +// DisableForT disables a feature for testing. +func DisableForT(t *testing.T, feature Feature) { + t.Helper() + + cache := state[feature] + t.Cleanup(func() { + state[feature] = cache + }) + + state[feature] = disable +} diff --git a/app/featureset/config_test.go b/app/featureset/config_test.go new file mode 100644 index 000000000..92193601f --- /dev/null +++ b/app/featureset/config_test.go @@ -0,0 +1,70 @@ +// Copyright © 2022 Obol Labs Inc. +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package featureset_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/app/featureset" +) + +// setup initialises global variable per test. +func setup(t *testing.T) { + t.Helper() + + err := featureset.Init(context.Background(), featureset.DefaultConfig()) + require.NoError(t, err) +} + +func TestConfig(t *testing.T) { + setup(t) + + err := featureset.Init(context.Background(), featureset.DefaultConfig()) + require.NoError(t, err) + + err = featureset.Init(context.Background(), featureset.Config{ + MinStatus: "alpha", + Enabled: []string{"ignored"}, + }) + require.NoError(t, err) + + require.True(t, featureset.Enabled(featureset.QBFTConsensus)) +} + +func TestEnableForT(t *testing.T) { + setup(t) + + testFeature := featureset.Feature("test") + require.False(t, featureset.Enabled(testFeature)) + + featureset.EnableForT(t, testFeature) + require.True(t, featureset.Enabled(testFeature)) + + featureset.DisableForT(t, testFeature) + require.False(t, featureset.Enabled(testFeature)) +} + +func TestQBFT(t *testing.T) { + setup(t) + + require.False(t, featureset.Enabled(featureset.QBFTConsensus)) + + featureset.EnableForT(t, featureset.QBFTConsensus) + require.True(t, featureset.Enabled(featureset.QBFTConsensus)) +} diff --git a/app/featureset/featureset.go b/app/featureset/featureset.go new file mode 100644 index 000000000..f54a19c51 --- /dev/null +++ b/app/featureset/featureset.go @@ -0,0 +1,57 @@ +// Copyright © 2022 Obol Labs Inc. +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +// Package featureset defines a set of global features and their rollout status. +package featureset + +//go:generate stringer -type=status -trimprefix=status + +// status enumerates the rollout status of a feature. +type status int + +const ( + // statusAlpha is for internal devnet testing. + statusAlpha status = iota + 1 + // statusBeta is for internal and external testnet testing. + statusBeta + // statusStable is for stable feature ready for production. + statusStable + // statusSentinel is an internal tail-end placeholder. + statusSentinel // Must always be last +) + +// Feature is a feature being rolled out. +type Feature string + +const ( + // QBFTConsensus introduces qbft consensus, see https://github.com/ObolNetwork/charon/issues/445. + QBFTConsensus Feature = "qbft_consensus" +) + +var ( + // state defines the current rollout status of each feature. + state = map[Feature]status{ + QBFTConsensus: statusAlpha, + // Add all features and there status here. + } + + // minStatus defines the minimum enabled status. + minStatus = statusStable +) + +// Enabled returns true if the feature is enabled. +func Enabled(feature Feature) bool { + return state[feature] >= minStatus +} diff --git a/app/featureset/featureset_internal_test.go b/app/featureset/featureset_internal_test.go new file mode 100644 index 000000000..65d19b173 --- /dev/null +++ b/app/featureset/featureset_internal_test.go @@ -0,0 +1,35 @@ +// Copyright © 2022 Obol Labs Inc. +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package featureset + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAllFeatureStatus(t *testing.T) { + // Add all features to this test + features := []Feature{ + QBFTConsensus, + } + + for _, feature := range features { + status, ok := state[feature] + require.True(t, ok) + require.Greater(t, status, 0) + } +} diff --git a/app/featureset/status_string.go b/app/featureset/status_string.go new file mode 100644 index 000000000..42683360e --- /dev/null +++ b/app/featureset/status_string.go @@ -0,0 +1,41 @@ +// Copyright © 2022 Obol Labs Inc. +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +// Code generated by "stringer -type=status -trimprefix=status"; DO NOT EDIT. + +package featureset + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[statusAlpha-1] + _ = x[statusBeta-2] + _ = x[statusStable-3] +} + +const _status_name = "AlphaBetaStable" + +var _status_index = [...]uint8{0, 5, 9, 15} + +func (i status) String() string { + i -= 1 + if i < 0 || i >= status(len(_status_index)-1) { + return "status(" + strconv.FormatInt(int64(i+1), 10) + ")" + } + return _status_name[_status_index[i]:_status_index[i+1]] +} diff --git a/app/simnet_test.go b/app/simnet_test.go index d163b6bcc..65af8ec22 100644 --- a/app/simnet_test.go +++ b/app/simnet_test.go @@ -34,6 +34,7 @@ import ( "github.com/obolnetwork/charon/app" "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/featureset" "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/core" "github.com/obolnetwork/charon/core/leadercast" @@ -132,6 +133,7 @@ func testSimnet(t *testing.T, args simnetArgs, propose bool) { for i := 0; i < args.N; i++ { conf := app.Config{ Log: log.DefaultConfig(), + Feature: featureset.DefaultConfig(), SimnetBMock: true, SimnetVMock: args.VMocks[i], MonitoringAddr: testutil.AvailableAddr(t).String(), // Random monitoring address diff --git a/cmd/cmd_internal_test.go b/cmd/cmd_internal_test.go index 4d2c1bc2d..d3aefae31 100644 --- a/cmd/cmd_internal_test.go +++ b/cmd/cmd_internal_test.go @@ -27,6 +27,7 @@ import ( "github.com/stretchr/testify/require" "github.com/obolnetwork/charon/app" + "github.com/obolnetwork/charon/app/featureset" "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/p2p" ) @@ -73,6 +74,11 @@ func TestCmdFlags(t *testing.T) { Denylist: "", DBPath: "", }, + Feature: featureset.Config{ + MinStatus: "stable", + Enabled: nil, + Disabled: nil, + }, ManifestFile: "./charon/manifest.json", DataDir: "from_env", MonitoringAddr: "127.0.0.1:16001", diff --git a/cmd/run.go b/cmd/run.go index cf9050a9c..ce9e80aa9 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -24,6 +24,7 @@ import ( "github.com/spf13/pflag" "github.com/obolnetwork/charon/app" + "github.com/obolnetwork/charon/app/featureset" "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/p2p" ) @@ -47,6 +48,7 @@ func newRunCmd(runFunc func(context.Context, app.Config) error) *cobra.Command { bindDataDirFlag(cmd.Flags(), &conf.DataDir) bindP2PFlags(cmd.Flags(), &conf.P2P) bindLogFlags(cmd.Flags(), &conf.Log) + bindFeatureFlags(cmd.Flags(), &conf.Feature) return cmd } @@ -83,3 +85,9 @@ func bindP2PFlags(flags *pflag.FlagSet, config *p2p.Config) { flags.StringVar(&config.Allowlist, "p2p-allowlist", "", "Comma-separated list of CIDR subnets for allowing only certain peer connections. Example: 192.168.0.0/16 would permit connections to peers on your local network only. The default is to accept all connections.") flags.StringVar(&config.Denylist, "p2p-denylist", "", "Comma-separated list of CIDR subnets for disallowing certain peer connections. Example: 192.168.0.0/16 would disallow connections to peers on your local network. The default is to accept all connections.") } + +func bindFeatureFlags(flags *pflag.FlagSet, config *featureset.Config) { + flags.StringSliceVar(&config.Enabled, "feature-set-enable", nil, "Comma-separated list of features to enable, overriding the default minimum feature set.") + flags.StringSliceVar(&config.Disabled, "feature-set-disable", nil, "Comma-separated list of features to disable, overriding the default minimum feature set.") + flags.StringVar(&config.MinStatus, "feature-set", "stable", "Minimum feature set to enable by default: alpha, beta, or stable. Warning: modify at own risk.") +} diff --git a/docs/configuration.md b/docs/configuration.md index 57a8187ad..5afb51986 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -85,6 +85,9 @@ Usage: Flags: --beacon-node-endpoint string Beacon node endpoint URL (default "http://localhost/") --data-dir string The directory where charon will store all its internal data (default "./charon/data") + --feature-set string Minimum feature set to enable by default: alpha, beta, or stable. Warning: modify at own risk. (default "stable") + --feature-set-disable strings Comma-separated list of features to disable, overriding the default minimum feature set. + --feature-set-enable strings Comma-separated list of features to enable, overriding the default minimum feature set. -h, --help Help for run --jaeger-address string Listening address for jaeger tracing --jaeger-service string Service name used for jaeger tracing (default "charon")