diff --git a/pkg/settings/cresettings/README.md b/pkg/settings/cresettings/README.md index 1c93a4e79..6ab67974e 100644 --- a/pkg/settings/cresettings/README.md +++ b/pkg/settings/cresettings/README.md @@ -39,7 +39,11 @@ flowchart end %% WorkflowLimit - Deprecated %% TODO unused +%% FeatureFlags +%% PerOrg.FeatureFlags %% PerOrg.ZeroBalancePruningTimeout +%% PerOwner.FeatureFlags +%% PerWorkflow.FeatureFlags subgraph Store.FetchWorkflowArtifacts PerWorkflow.WASMConfigSizeLimit{{PerWorkflow.WASMConfigSizeLimit}}:::bound diff --git a/pkg/settings/cresettings/defaults.json b/pkg/settings/cresettings/defaults.json index 42ec6a81a..987a38250 100644 --- a/pkg/settings/cresettings/defaults.json +++ b/pkg/settings/cresettings/defaults.json @@ -9,14 +9,18 @@ "VaultIdentifierNamespaceSizeLimit": "64b", "VaultPluginBatchSizeLimit": "20", "VaultRequestBatchSizeLimit": "10", + "FeatureFlags": {}, "PerOrg": { + "FeatureFlags": {}, "ZeroBalancePruningTimeout": "24h0m0s" }, "PerOwner": { + "FeatureFlags": {}, "WorkflowExecutionConcurrencyLimit": "5", "VaultSecretsLimit": "100" }, "PerWorkflow": { + "FeatureFlags": {}, "TriggerRegistrationsTimeout": "10s", "TriggerSubscriptionTimeout": "15s", "TriggerSubscriptionLimit": "10", diff --git a/pkg/settings/cresettings/defaults.toml b/pkg/settings/cresettings/defaults.toml index dc6430708..06446dbe7 100644 --- a/pkg/settings/cresettings/defaults.toml +++ b/pkg/settings/cresettings/defaults.toml @@ -9,13 +9,22 @@ VaultIdentifierNamespaceSizeLimit = '64b' VaultPluginBatchSizeLimit = '20' VaultRequestBatchSizeLimit = '10' +[FeatureFlags] +Flags = [] + [PerOrg] ZeroBalancePruningTimeout = '24h0m0s' +[PerOrg.FeatureFlags] +Flags = [] + [PerOwner] WorkflowExecutionConcurrencyLimit = '5' VaultSecretsLimit = '100' +[PerOwner.FeatureFlags] +Flags = [] + [PerWorkflow] TriggerRegistrationsTimeout = '10s' TriggerSubscriptionTimeout = '15s' @@ -36,6 +45,9 @@ WASMSecretsSizeLimit = '1mb' LogLineLimit = '1kb' LogEventLimit = '1000' +[PerWorkflow.FeatureFlags] +Flags = [] + [PerWorkflow.ChainAllowed] Default = 'false' diff --git a/pkg/settings/cresettings/settings.go b/pkg/settings/cresettings/settings.go index 1a3d0b88e..6cd3daa97 100644 --- a/pkg/settings/cresettings/settings.go +++ b/pkg/settings/cresettings/settings.go @@ -175,20 +175,25 @@ type Schema struct { VaultPluginBatchSizeLimit Setting[int] `unit:"{request}"` VaultRequestBatchSizeLimit Setting[int] `unit:"{request}"` + FeatureFlags FeatureFlags + PerOrg Orgs `scope:"org"` PerOwner Owners `scope:"owner"` PerWorkflow Workflows `scope:"workflow"` } type Orgs struct { + FeatureFlags FeatureFlags ZeroBalancePruningTimeout Setting[time.Duration] } type Owners struct { + FeatureFlags FeatureFlags WorkflowExecutionConcurrencyLimit Setting[int] `unit:"{workflow}"` VaultSecretsLimit Setting[int] `unit:"{secret}"` } type Workflows struct { + FeatureFlags FeatureFlags TriggerRegistrationsTimeout Setting[time.Duration] TriggerSubscriptionTimeout Setting[time.Duration] TriggerSubscriptionLimit Setting[int] `unit:"{subscription}"` diff --git a/pkg/settings/feature_flags.go b/pkg/settings/feature_flags.go new file mode 100644 index 000000000..f8fd1f8e4 --- /dev/null +++ b/pkg/settings/feature_flags.go @@ -0,0 +1,126 @@ +package settings + +import ( + "context" + "fmt" + "strconv" +) + +// FeatureFlag represents a single feature flag with an activation threshold and optional metadata. +type FeatureFlag struct { + Name string `json:"Name"` + ActivateAt int64 `json:"ActivateAt"` + Metadata map[string]string `json:"Metadata,omitempty"` +} + +// FeatureFlags holds a collection of feature flags at a given scope. +// It integrates with InitConfig and follows the same scoped precedence as Setting[T]. +// +// Flags defined in the schema serve as defaults. The Getter provides scoped overrides +// using dot-separated keys: ..ActivateAt +// +// Use With(getter) to bind a Getter, so callers of IsActive/GetFlag/GetMetadata +// don't need to pass one explicitly. +type FeatureFlags struct { + Flags []FeatureFlag `json:"Flags,omitempty"` + + // key is the settings path prefix assigned by InitConfig (e.g. "PerWorkflow.FeatureFlags"). + // It positions this collection in the settings tree for scoped getter lookups. + key string + scope Scope + getter Getter +} + +var _ keySetter = &FeatureFlags{} + +func (f *FeatureFlags) initSetting(key string, scope Scope, _ *string) error { + f.key = key + f.scope = scope + return nil +} + +func (f *FeatureFlags) GetKey() string { return f.key } +func (f *FeatureFlags) GetScope() Scope { return f.scope } + +// With returns a copy of FeatureFlags bound to the given Getter. +// The returned value can be used without passing a Getter to each method call. +func (f FeatureFlags) With(g Getter) FeatureFlags { + f.getter = g + return f +} + +func (f *FeatureFlags) getDefault(name string) *FeatureFlag { + for i := range f.Flags { + if f.Flags[i].Name == name { + return &f.Flags[i] + } + } + return nil +} + +// GetFlag looks up a feature flag by name, checking scoped overrides via the bound Getter first, +// then falling back to the default flags defined in the schema. +// The returned FeatureFlag is a copy; mutating it has no effect on the stored flags. +func (f *FeatureFlags) GetFlag(ctx context.Context, name string) (*FeatureFlag, error) { + if f.getter != nil { + activateAtKey := f.key + "." + name + ".ActivateAt" + str, err := f.getter.GetScoped(ctx, f.scope, activateAtKey) + if err != nil { + return nil, fmt.Errorf("failed to get feature flag %s: %w", name, err) + } + if str != "" { + activateAt, err := strconv.ParseInt(str, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid ActivateAt for flag %s: %w", name, err) + } + flag := &FeatureFlag{ + Name: name, + ActivateAt: activateAt, + } + if def := f.getDefault(name); def != nil && def.Metadata != nil { + flag.Metadata = make(map[string]string, len(def.Metadata)) + for k, v := range def.Metadata { + flag.Metadata[k] = v + } + } + return flag, nil + } + } + if def := f.getDefault(name); def != nil { + cp := *def + return &cp, nil + } + return nil, nil +} + +// IsActive returns true if the named flag exists and executionTimestamp >= ActivateAt. +func (f *FeatureFlags) IsActive(ctx context.Context, name string, executionTimestamp int64) (bool, error) { + flag, err := f.GetFlag(ctx, name) + if err != nil { + return false, err + } + if flag == nil { + return false, nil + } + return executionTimestamp >= flag.ActivateAt, nil +} + +// GetMetadata returns a single metadata value for a flag, checking scoped overrides via +// the bound Getter first (at key ..Metadata.), +// then falling back to the default flag's metadata. +func (f *FeatureFlags) GetMetadata(ctx context.Context, flagName, metaKey string) (string, error) { + if f.getter != nil { + key := f.key + "." + flagName + ".Metadata." + metaKey + str, err := f.getter.GetScoped(ctx, f.scope, key) + if err != nil { + return "", err + } + if str != "" { + return str, nil + } + } + if def := f.getDefault(flagName); def != nil && def.Metadata != nil { + return def.Metadata[metaKey], nil + } + return "", nil +} diff --git a/pkg/settings/feature_flags_test.go b/pkg/settings/feature_flags_test.go new file mode 100644 index 000000000..0c762e775 --- /dev/null +++ b/pkg/settings/feature_flags_test.go @@ -0,0 +1,245 @@ +package settings + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/contexts" +) + +func TestFeatureFlags_InitSetting(t *testing.T) { + type testSchema struct { + FeatureFlags FeatureFlags + PerWorkflow struct { + FeatureFlags FeatureFlags + } `scope:"workflow"` + } + var s testSchema + require.NoError(t, InitConfig(&s)) + + assert.Equal(t, "FeatureFlags", s.FeatureFlags.GetKey()) + assert.Equal(t, ScopeGlobal, s.FeatureFlags.GetScope()) + + assert.Equal(t, "PerWorkflow.FeatureFlags", s.PerWorkflow.FeatureFlags.GetKey()) + assert.Equal(t, ScopeWorkflow, s.PerWorkflow.FeatureFlags.GetScope()) +} + +func TestFeatureFlags_GetFlag_DefaultsOnly(t *testing.T) { + ff := FeatureFlags{ + Flags: []FeatureFlag{ + {Name: "feat_a", ActivateAt: 1000, Metadata: map[string]string{"k": "v"}}, + {Name: "feat_b", ActivateAt: 2000}, + }, + } + _ = ff.initSetting("FeatureFlags", ScopeGlobal, nil) + + ctx := t.Context() + flag, err := ff.GetFlag(ctx, "feat_a") + require.NoError(t, err) + require.NotNil(t, flag) + assert.Equal(t, int64(1000), flag.ActivateAt) + assert.Equal(t, "v", flag.Metadata["k"]) + + flag, err = ff.GetFlag(ctx, "missing") + require.NoError(t, err) + assert.Nil(t, flag) +} + +func TestFeatureFlags_GetFlag_GetterOverride(t *testing.T) { + ff := FeatureFlags{ + Flags: []FeatureFlag{ + {Name: "feat_a", ActivateAt: 1000, Metadata: map[string]string{"k": "default"}}, + }, + } + _ = ff.initSetting("FeatureFlags", ScopeWorkflow, nil) + + configJSON := `{ + "workflow": { + "wf1": { + "FeatureFlags": { + "feat_a": { "ActivateAt": 5000 } + } + } + } + }` + g, err := NewJSONGetter([]byte(configJSON)) + require.NoError(t, err) + + ctx := contexts.WithCRE(t.Context(), contexts.CRE{ + Owner: "owner1", + Workflow: "wf1", + }) + ffWithGetter := ff.With(g) + flag, err := ffWithGetter.GetFlag(ctx, "feat_a") + require.NoError(t, err) + require.NotNil(t, flag) + assert.Equal(t, int64(5000), flag.ActivateAt) + assert.Equal(t, "default", flag.Metadata["k"]) +} + +func TestFeatureFlags_GetFlag_ScopePrecedence(t *testing.T) { + ff := FeatureFlags{ + Flags: []FeatureFlag{ + {Name: "feat_a", ActivateAt: 1000}, + }, + } + _ = ff.initSetting("FeatureFlags", ScopeWorkflow, nil) + + configJSON := `{ + "global": { + "FeatureFlags": { "feat_a": { "ActivateAt": 2000 } } + }, + "owner": { + "owner1": { + "FeatureFlags": { "feat_a": { "ActivateAt": 3000 } } + } + }, + "workflow": { + "wf1": { + "FeatureFlags": { "feat_a": { "ActivateAt": 4000 } } + } + } + }` + g, err := NewJSONGetter([]byte(configJSON)) + require.NoError(t, err) + + ctx := contexts.WithCRE(t.Context(), contexts.CRE{ + Owner: "owner1", + Workflow: "wf1", + }) + + ffWithGetter := ff.With(g) + flag, err := ffWithGetter.GetFlag(ctx, "feat_a") + require.NoError(t, err) + require.NotNil(t, flag) + assert.Equal(t, int64(4000), flag.ActivateAt, "workflow scope should win") + + // Remove workflow override, owner should win + configJSON2 := `{ + "global": { + "FeatureFlags": { "feat_a": { "ActivateAt": 2000 } } + }, + "owner": { + "owner1": { + "FeatureFlags": { "feat_a": { "ActivateAt": 3000 } } + } + } + }` + g2, err := NewJSONGetter([]byte(configJSON2)) + require.NoError(t, err) + + ffWithGetter2 := ff.With(g2) + flag, err = ffWithGetter2.GetFlag(ctx, "feat_a") + require.NoError(t, err) + require.NotNil(t, flag) + assert.Equal(t, int64(3000), flag.ActivateAt, "owner scope should win when no workflow override") +} + +func TestFeatureFlags_IsActive(t *testing.T) { + ff := FeatureFlags{ + Flags: []FeatureFlag{ + {Name: "feat_a", ActivateAt: 1000}, + }, + } + _ = ff.initSetting("FeatureFlags", ScopeGlobal, nil) + + ctx := t.Context() + + active, err := ff.IsActive(ctx, "feat_a", 999) + require.NoError(t, err) + assert.False(t, active) + + active, err = ff.IsActive(ctx, "feat_a", 1000) + require.NoError(t, err) + assert.True(t, active) + + active, err = ff.IsActive(ctx, "feat_a", 2000) + require.NoError(t, err) + assert.True(t, active) + + active, err = ff.IsActive(ctx, "missing", 9999) + require.NoError(t, err) + assert.False(t, active) +} + +func TestFeatureFlags_GetMetadata(t *testing.T) { + ff := FeatureFlags{ + Flags: []FeatureFlag{ + {Name: "feat_a", ActivateAt: 1000, Metadata: map[string]string{"k1": "default_v1"}}, + }, + } + _ = ff.initSetting("FeatureFlags", ScopeWorkflow, nil) + + configJSON := `{ + "workflow": { + "wf1": { + "FeatureFlags": { + "feat_a": { + "ActivateAt": 5000, + "Metadata": { "k1": "override_v1", "k2": "new_v2" } + } + } + } + } + }` + g, err := NewJSONGetter([]byte(configJSON)) + require.NoError(t, err) + + ctx := contexts.WithCRE(t.Context(), contexts.CRE{ + Owner: "owner1", + Workflow: "wf1", + }) + + ffWithGetter := ff.With(g) + + val, err := ffWithGetter.GetMetadata(ctx, "feat_a", "k1") + require.NoError(t, err) + assert.Equal(t, "override_v1", val, "getter should override default metadata") + + val, err = ffWithGetter.GetMetadata(ctx, "feat_a", "k2") + require.NoError(t, err) + assert.Equal(t, "new_v2", val, "getter should provide new metadata keys") + + // Without getter, fall back to defaults + val, err = ff.GetMetadata(ctx, "feat_a", "k1") + require.NoError(t, err) + assert.Equal(t, "default_v1", val) + + val, err = ff.GetMetadata(ctx, "feat_a", "k2") + require.NoError(t, err) + assert.Equal(t, "", val, "missing metadata key returns empty string") +} + +func TestFeatureFlags_GetFlag_ReturnsCopy(t *testing.T) { + ff := FeatureFlags{ + Flags: []FeatureFlag{ + {Name: "feat_a", ActivateAt: 1000, Metadata: map[string]string{"k": "v"}}, + }, + } + _ = ff.initSetting("FeatureFlags", ScopeGlobal, nil) + + flag, err := ff.GetFlag(t.Context(), "feat_a") + require.NoError(t, err) + flag.ActivateAt = 9999 + + original := ff.getDefault("feat_a") + assert.Equal(t, int64(1000), original.ActivateAt, "mutation should not affect stored flag") +} + +func TestFeatureFlags_With_DoesNotMutateOriginal(t *testing.T) { + ff := FeatureFlags{ + Flags: []FeatureFlag{ + {Name: "feat_a", ActivateAt: 1000}, + }, + } + _ = ff.initSetting("FeatureFlags", ScopeGlobal, nil) + + g, err := NewJSONGetter([]byte(`{}`)) + require.NoError(t, err) + + bound := ff.With(g) + assert.Nil(t, ff.getter, "original should not have getter set") + assert.NotNil(t, bound.getter, "bound copy should have getter set") +}