diff --git a/deployment/cre/capabilities_registry/v2/changeset/add_capabilities.go b/deployment/cre/capabilities_registry/v2/changeset/add_capabilities.go index a761a412856..2f859f503d1 100644 --- a/deployment/cre/capabilities_registry/v2/changeset/add_capabilities.go +++ b/deployment/cre/capabilities_registry/v2/changeset/add_capabilities.go @@ -27,6 +27,11 @@ type AddCapabilitiesInput struct { DonNames []string `json:"donNames" yaml:"donNames"` // multiple DONs to update CapabilityConfigs []contracts.CapabilityConfig `json:"capabilityConfigs" yaml:"capabilityConfigs"` + // DonCapabilityConfigOverrides maps DON name to a list of config overrides. + // Each override's Config is deep-merged into the base CapabilityConfig.Config for the matching capability. + // If an override's CapabilityID is empty, it applies to all capabilities for that DON. + DonCapabilityConfigOverrides map[string][]sequences.CapabilityConfigOverride `json:"donCapabilityConfigOverrides,omitempty" yaml:"donCapabilityConfigOverrides,omitempty"` + // Force indicates whether to force the update even if we cannot validate that all forwarder contracts are ready to accept the new configure version. // This is very dangerous, and could break the whole platform if the forwarders are not ready. Be very careful with this option. Force bool `json:"force" yaml:"force"` @@ -48,6 +53,11 @@ func (u AddCapabilities) VerifyPreconditions(_ cldf.Environment, config AddCapab if len(config.CapabilityConfigs) == 0 { return errors.New("capabilityConfigs is required") } + for overrideDon := range config.DonCapabilityConfigOverrides { + if !slices.Contains(donNames, overrideDon) { + return fmt.Errorf("donCapabilityConfigOverrides contains DON name %q which is not in the DON names list", overrideDon) + } + } return nil } @@ -79,11 +89,12 @@ func (u AddCapabilities) Apply(e cldf.Environment, config AddCapabilitiesInput) sequences.AddCapabilities, sequences.AddCapabilitiesDeps{Env: &e, MCMSContracts: mcmsContracts}, sequences.AddCapabilitiesInput{ - RegistryRef: registryRef, - DonNames: u.donNames(config), - CapabilityConfigs: config.CapabilityConfigs, - Force: config.Force, - MCMSConfig: config.MCMSConfig, + RegistryRef: registryRef, + DonNames: u.donNames(config), + CapabilityConfigs: config.CapabilityConfigs, + DonCapabilityConfigOverrides: config.DonCapabilityConfigOverrides, + Force: config.Force, + MCMSConfig: config.MCMSConfig, }, ) if err != nil { diff --git a/deployment/cre/capabilities_registry/v2/changeset/add_capabilities_test.go b/deployment/cre/capabilities_registry/v2/changeset/add_capabilities_test.go index 14f5c419c3f..02423130e2f 100644 --- a/deployment/cre/capabilities_registry/v2/changeset/add_capabilities_test.go +++ b/deployment/cre/capabilities_registry/v2/changeset/add_capabilities_test.go @@ -16,6 +16,7 @@ import ( "github.com/smartcontractkit/chainlink/deployment/cre/capabilities_registry/v2/changeset" "github.com/smartcontractkit/chainlink/deployment/cre/capabilities_registry/v2/changeset/operations/contracts" "github.com/smartcontractkit/chainlink/deployment/cre/capabilities_registry/v2/changeset/pkg" + "github.com/smartcontractkit/chainlink/deployment/cre/capabilities_registry/v2/changeset/sequences" crecontracts "github.com/smartcontractkit/chainlink/deployment/cre/contracts" "github.com/smartcontractkit/chainlink/deployment/cre/test" ) @@ -141,6 +142,32 @@ func TestAddCapabilities_VerifyPreconditions(t *testing.T) { CapabilityConfigs: []contracts.CapabilityConfig{{Capability: contracts.Capability{CapabilityID: "cap@1.0.0"}, Config: map[string]any{"k": "v"}}}, }) require.NoError(t, err) + + // Override DON name not in donNames list + err = cs.VerifyPreconditions(*env.Env, changeset.AddCapabilitiesInput{ + RegistryChainSel: chainSelector, + RegistryQualifier: "qual", + DonNames: []string{"don-1"}, + CapabilityConfigs: []contracts.CapabilityConfig{{Capability: contracts.Capability{CapabilityID: "cap@1.0.0"}, Config: map[string]any{"k": "v"}}}, + DonCapabilityConfigOverrides: map[string][]sequences.CapabilityConfigOverride{ + "unknown-don": {{Config: map[string]any{"k": "v2"}}}, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown-don") + assert.Contains(t, err.Error(), "not in the DON names list") + + // Valid with overrides + err = cs.VerifyPreconditions(*env.Env, changeset.AddCapabilitiesInput{ + RegistryChainSel: chainSelector, + RegistryQualifier: "qual", + DonNames: []string{"don-1", "don-2"}, + CapabilityConfigs: []contracts.CapabilityConfig{{Capability: contracts.Capability{CapabilityID: "cap@1.0.0"}, Config: map[string]any{"k": "v"}}}, + DonCapabilityConfigOverrides: map[string][]sequences.CapabilityConfigOverride{ + "don-2": {{Config: map[string]any{"k": "v2"}}}, + }, + }) + require.NoError(t, err) } func addNewCapability(t *testing.T, fixture *test.EnvWrapperV2, capID string) { diff --git a/deployment/cre/capabilities_registry/v2/changeset/sequences/add_capabilities.go b/deployment/cre/capabilities_registry/v2/changeset/sequences/add_capabilities.go index 206b08a15ab..5f3f907c120 100644 --- a/deployment/cre/capabilities_registry/v2/changeset/sequences/add_capabilities.go +++ b/deployment/cre/capabilities_registry/v2/changeset/sequences/add_capabilities.go @@ -32,6 +32,10 @@ type AddCapabilitiesDeps struct { type AddCapabilitiesInput struct { CapabilityConfigs []contracts.CapabilityConfig // if Config subfield is nil, a default config is used + // DonCapabilityConfigOverrides maps DON name to per-DON config overrides that are + // deep-merged into the base CapabilityConfigs. See CapabilityConfigOverride for details. + DonCapabilityConfigOverrides map[string][]CapabilityConfigOverride + // DonNames are the DONs to update. At least one is required. DonNames []string @@ -148,7 +152,12 @@ var AddCapabilities = operations.NewSequence[AddCapabilitiesInput, AddCapabiliti p2pIDs = append(p2pIDs, node.P2pId) } - nodeUpdates, err := buildNodeUpdatesForDON(p2pIDs, input.CapabilityConfigs) + donCapConfigs, err := resolveCapabilityConfigsForDON(input.CapabilityConfigs, input.DonCapabilityConfigOverrides[donName]) + if err != nil { + return AddCapabilitiesOutput{}, fmt.Errorf("failed to resolve capability configs for DON %s: %w", donName, err) + } + + nodeUpdates, err := buildNodeUpdatesForDON(p2pIDs, donCapConfigs) if err != nil { return AddCapabilitiesOutput{}, fmt.Errorf("failed to build node updates for DON %s: %w", donName, err) } @@ -182,7 +191,7 @@ var AddCapabilities = operations.NewSequence[AddCapabilitiesInput, AddCapabiliti contracts.UpdateDONInput{ ChainSelector: chainSel, P2PIDs: p2pIDs, - CapabilityConfigs: input.CapabilityConfigs, + CapabilityConfigs: donCapConfigs, MergeCapabilityConfigsWithOnChain: true, DonName: donName, F: don.F, diff --git a/deployment/cre/capabilities_registry/v2/changeset/sequences/config_overrides.go b/deployment/cre/capabilities_registry/v2/changeset/sequences/config_overrides.go new file mode 100644 index 00000000000..efbb077b96c --- /dev/null +++ b/deployment/cre/capabilities_registry/v2/changeset/sequences/config_overrides.go @@ -0,0 +1,102 @@ +package sequences + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink/deployment/cre/capabilities_registry/v2/changeset/operations/contracts" +) + +// CapabilityConfigOverride specifies a per-DON override for capability configs. +// If CapabilityID is empty, the override is applied to all capabilities for that DON. +// Config is deep-merged into the base CapabilityConfig.Config. +type CapabilityConfigOverride struct { + CapabilityID string `json:"capabilityID" yaml:"capabilityID"` + Config map[string]any `json:"config" yaml:"config"` +} + +// resolveCapabilityConfigsForDON returns a copy of baseConfigs with per-DON overrides deep-merged in. +// If an override has an empty CapabilityID, it applies to all capabilities. +// If an override has a CapabilityID, it applies only to the matching capability. +func resolveCapabilityConfigsForDON(baseConfigs []contracts.CapabilityConfig, overrides []CapabilityConfigOverride) ([]contracts.CapabilityConfig, error) { + if len(overrides) == 0 { + return baseConfigs, nil + } + + result := make([]contracts.CapabilityConfig, len(baseConfigs)) + for i, base := range baseConfigs { + result[i] = contracts.CapabilityConfig{ + Capability: base.Capability, + Config: deepCopyMap(base.Config), + } + } + + for _, override := range overrides { + if override.Config == nil { + continue + } + applied := false + for i := range result { + if override.CapabilityID == "" || override.CapabilityID == result[i].Capability.CapabilityID { + result[i].Config = deepMergeMaps(result[i].Config, override.Config) + applied = true + } + } + if override.CapabilityID != "" && !applied { + return nil, fmt.Errorf("override references capability ID %q which does not exist in the base capability configs", override.CapabilityID) + } + } + + return result, nil +} + +// deepMergeMaps recursively merges override into base, returning a new map. +// For nested map[string]any values, it recurses. For all other types the override value wins. +// Neither input is mutated. +func deepMergeMaps(base, override map[string]any) map[string]any { + if base == nil && override == nil { + return nil + } + result := deepCopyMap(base) + if result == nil { + result = make(map[string]any) + } + for k, overrideVal := range override { + baseVal, exists := result[k] + if exists { + baseMap, baseIsMap := baseVal.(map[string]any) + overrideMap, overrideIsMap := overrideVal.(map[string]any) + if baseIsMap && overrideIsMap { + result[k] = deepMergeMaps(baseMap, overrideMap) + continue + } + } + result[k] = deepCopyValue(overrideVal) + } + return result +} + +func deepCopyMap(m map[string]any) map[string]any { + if m == nil { + return nil + } + result := make(map[string]any, len(m)) + for k, v := range m { + result[k] = deepCopyValue(v) + } + return result +} + +func deepCopyValue(v any) any { + switch val := v.(type) { + case map[string]any: + return deepCopyMap(val) + case []any: + cp := make([]any, len(val)) + for i, item := range val { + cp[i] = deepCopyValue(item) + } + return cp + default: + return v + } +} diff --git a/deployment/cre/capabilities_registry/v2/changeset/sequences/config_overrides_test.go b/deployment/cre/capabilities_registry/v2/changeset/sequences/config_overrides_test.go new file mode 100644 index 00000000000..50de1cb1552 --- /dev/null +++ b/deployment/cre/capabilities_registry/v2/changeset/sequences/config_overrides_test.go @@ -0,0 +1,269 @@ +package sequences + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/deployment/cre/capabilities_registry/v2/changeset/operations/contracts" +) + +func TestDeepMergeMaps(t *testing.T) { + t.Run("both nil", func(t *testing.T) { + result := deepMergeMaps(nil, nil) + assert.Nil(t, result) + }) + + t.Run("nil base", func(t *testing.T) { + override := map[string]any{"key": "value"} + result := deepMergeMaps(nil, override) + assert.Equal(t, map[string]any{"key": "value"}, result) + }) + + t.Run("nil override", func(t *testing.T) { + base := map[string]any{"key": "value"} + result := deepMergeMaps(base, nil) + assert.Equal(t, map[string]any{"key": "value"}, result) + }) + + t.Run("scalar override", func(t *testing.T) { + base := map[string]any{"a": 1, "b": 2} + override := map[string]any{"b": 3} + result := deepMergeMaps(base, override) + assert.Equal(t, map[string]any{"a": 1, "b": 3}, result) + }) + + t.Run("add new key", func(t *testing.T) { + base := map[string]any{"a": 1} + override := map[string]any{"b": 2} + result := deepMergeMaps(base, override) + assert.Equal(t, map[string]any{"a": 1, "b": 2}, result) + }) + + t.Run("nested map merge", func(t *testing.T) { + base := map[string]any{ + "outer": map[string]any{ + "keep": "yes", + "inner": map[string]any{ + "x": 1, + "y": 2, + }, + }, + } + override := map[string]any{ + "outer": map[string]any{ + "inner": map[string]any{ + "y": 99, + }, + }, + } + result := deepMergeMaps(base, override) + expected := map[string]any{ + "outer": map[string]any{ + "keep": "yes", + "inner": map[string]any{ + "x": 1, + "y": 99, + }, + }, + } + assert.Equal(t, expected, result) + }) + + t.Run("does not mutate inputs", func(t *testing.T) { + base := map[string]any{ + "nested": map[string]any{"a": 1}, + } + override := map[string]any{ + "nested": map[string]any{"b": 2}, + } + _ = deepMergeMaps(base, override) + + assert.Equal(t, map[string]any{"nested": map[string]any{"a": 1}}, base) + assert.Equal(t, map[string]any{"nested": map[string]any{"b": 2}}, override) + }) + + t.Run("realistic minResponsesToAggregate override", func(t *testing.T) { + base := map[string]any{ + "methodConfigs": map[string]any{ + "LogTrigger": map[string]any{ + "remoteTriggerConfig": map[string]any{ + "registrationRefresh": "20s", + "registrationExpiry": "60s", + "minResponsesToAggregate": 4, + "messageExpiry": "120s", + }, + }, + "BalanceAt": map[string]any{ + "remoteExecutableConfig": map[string]any{ + "requestTimeout": "30s", + }, + }, + }, + } + override := map[string]any{ + "methodConfigs": map[string]any{ + "LogTrigger": map[string]any{ + "remoteTriggerConfig": map[string]any{ + "minResponsesToAggregate": 2, + }, + }, + }, + } + result := deepMergeMaps(base, override) + + logTrigger := result["methodConfigs"].(map[string]any)["LogTrigger"].(map[string]any)["remoteTriggerConfig"].(map[string]any) + assert.Equal(t, 2, logTrigger["minResponsesToAggregate"]) + assert.Equal(t, "20s", logTrigger["registrationRefresh"]) + assert.Equal(t, "60s", logTrigger["registrationExpiry"]) + assert.Equal(t, "120s", logTrigger["messageExpiry"]) + + balanceAt := result["methodConfigs"].(map[string]any)["BalanceAt"].(map[string]any)["remoteExecutableConfig"].(map[string]any) + assert.Equal(t, "30s", balanceAt["requestTimeout"]) + }) +} + +func TestResolveCapabilityConfigsForDON(t *testing.T) { + baseConfigs := []contracts.CapabilityConfig{ + { + Capability: contracts.Capability{CapabilityID: "cap-a@1.0.0"}, + Config: map[string]any{ + "methodConfigs": map[string]any{ + "LogTrigger": map[string]any{ + "remoteTriggerConfig": map[string]any{ + "minResponsesToAggregate": 4, + "registrationRefresh": "20s", + }, + }, + }, + }, + }, + { + Capability: contracts.Capability{CapabilityID: "cap-b@1.0.0"}, + Config: map[string]any{ + "methodConfigs": map[string]any{ + "LogTrigger": map[string]any{ + "remoteTriggerConfig": map[string]any{ + "minResponsesToAggregate": 4, + "registrationRefresh": "20s", + }, + }, + }, + }, + }, + } + + t.Run("no overrides returns base", func(t *testing.T) { + result, err := resolveCapabilityConfigsForDON(baseConfigs, nil) + require.NoError(t, err) + assert.Equal(t, baseConfigs, result) + }) + + t.Run("empty overrides returns base", func(t *testing.T) { + result, err := resolveCapabilityConfigsForDON(baseConfigs, []CapabilityConfigOverride{}) + require.NoError(t, err) + assert.Equal(t, baseConfigs, result) + }) + + t.Run("override specific capability", func(t *testing.T) { + overrides := []CapabilityConfigOverride{ + { + CapabilityID: "cap-a@1.0.0", + Config: map[string]any{ + "methodConfigs": map[string]any{ + "LogTrigger": map[string]any{ + "remoteTriggerConfig": map[string]any{ + "minResponsesToAggregate": 2, + }, + }, + }, + }, + }, + } + + result, err := resolveCapabilityConfigsForDON(baseConfigs, overrides) + require.NoError(t, err) + require.Len(t, result, 2) + + capAConfig := result[0].Config["methodConfigs"].(map[string]any)["LogTrigger"].(map[string]any)["remoteTriggerConfig"].(map[string]any) + assert.Equal(t, 2, capAConfig["minResponsesToAggregate"]) + assert.Equal(t, "20s", capAConfig["registrationRefresh"]) + + capBConfig := result[1].Config["methodConfigs"].(map[string]any)["LogTrigger"].(map[string]any)["remoteTriggerConfig"].(map[string]any) + assert.Equal(t, 4, capBConfig["minResponsesToAggregate"]) + }) + + t.Run("override all capabilities with empty ID", func(t *testing.T) { + overrides := []CapabilityConfigOverride{ + { + CapabilityID: "", + Config: map[string]any{ + "methodConfigs": map[string]any{ + "LogTrigger": map[string]any{ + "remoteTriggerConfig": map[string]any{ + "minResponsesToAggregate": 2, + }, + }, + }, + }, + }, + } + + result, err := resolveCapabilityConfigsForDON(baseConfigs, overrides) + require.NoError(t, err) + require.Len(t, result, 2) + + for _, cfg := range result { + logTrigger := cfg.Config["methodConfigs"].(map[string]any)["LogTrigger"].(map[string]any)["remoteTriggerConfig"].(map[string]any) + assert.Equal(t, 2, logTrigger["minResponsesToAggregate"]) + assert.Equal(t, "20s", logTrigger["registrationRefresh"]) + } + }) + + t.Run("error on non-existent capability ID", func(t *testing.T) { + overrides := []CapabilityConfigOverride{ + { + CapabilityID: "non-existent@1.0.0", + Config: map[string]any{"foo": "bar"}, + }, + } + + _, err := resolveCapabilityConfigsForDON(baseConfigs, overrides) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-existent@1.0.0") + }) + + t.Run("nil config override is skipped", func(t *testing.T) { + overrides := []CapabilityConfigOverride{ + {CapabilityID: "", Config: nil}, + } + + result, err := resolveCapabilityConfigsForDON(baseConfigs, overrides) + require.NoError(t, err) + assert.Equal(t, 4, result[0].Config["methodConfigs"].(map[string]any)["LogTrigger"].(map[string]any)["remoteTriggerConfig"].(map[string]any)["minResponsesToAggregate"]) + }) + + t.Run("does not mutate base configs", func(t *testing.T) { + overrides := []CapabilityConfigOverride{ + { + CapabilityID: "", + Config: map[string]any{ + "methodConfigs": map[string]any{ + "LogTrigger": map[string]any{ + "remoteTriggerConfig": map[string]any{ + "minResponsesToAggregate": 99, + }, + }, + }, + }, + }, + } + + _, err := resolveCapabilityConfigsForDON(baseConfigs, overrides) + require.NoError(t, err) + + originalVal := baseConfigs[0].Config["methodConfigs"].(map[string]any)["LogTrigger"].(map[string]any)["remoteTriggerConfig"].(map[string]any)["minResponsesToAggregate"] + assert.Equal(t, 4, originalVal) + }) +}