From d169a8e8d7c0f0e7d232530a6ccca176c7409d71 Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Wed, 10 Jun 2026 16:10:39 +1000 Subject: [PATCH] feat(utils): introduce merge sequence Introduced ExecuteOnChainSequenceAndMerge util which originated from ccip tooling api [here](https://github.com/smartcontractkit/chainlink-ccip/blob/298ed1c38d5cda61a688beb2494a96a5409daf3d/deployment/utils/sequences/sequences.go#L46) This utils allows user to execute multiple sequence and accumulate their results into an object call OnChainOutput JIRA: https://smartcontract-it.atlassian.net/browse/CLD-2464 --- .changeset/strong-spiders-end.md | 7 + changeset/sequenceutils/merge.go | 56 +++++++ changeset/sequenceutils/merge_test.go | 214 ++++++++++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 .changeset/strong-spiders-end.md create mode 100644 changeset/sequenceutils/merge.go create mode 100644 changeset/sequenceutils/merge_test.go diff --git a/.changeset/strong-spiders-end.md b/.changeset/strong-spiders-end.md new file mode 100644 index 000000000..8e19b98b3 --- /dev/null +++ b/.changeset/strong-spiders-end.md @@ -0,0 +1,7 @@ +--- +"chainlink-deployments-framework": minor +--- + +feat(utils): new ExecuteOnChainSequenceAndMerge util + +Execute sequence and merge output into an aggregate OnChainOutput. diff --git a/changeset/sequenceutils/merge.go b/changeset/sequenceutils/merge.go new file mode 100644 index 000000000..4b46066cf --- /dev/null +++ b/changeset/sequenceutils/merge.go @@ -0,0 +1,56 @@ +package sequenceutils + +import ( + "fmt" + "reflect" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// ExecuteOnChainSequenceAndMerge executes a sequence and merges the output into the given OnChainOutput. +// On error, the accumulated agg is returned unchanged. +// +// Env metadata is a single record per deployment. If both agg and the sequence output set env metadata +// with different values, merge fails with deployment.ErrInvalidConfig rather than silently overwriting. +// Re-merging the same env (shared pointer or equal Metadata value) is allowed. +func ExecuteOnChainSequenceAndMerge[IN any, DEP any]( + b operations.Bundle, + deps DEP, + seq *operations.Sequence[IN, OnChainOutput, DEP], + input IN, + agg OnChainOutput, +) (OnChainOutput, error) { + if seq == nil { + return agg, fmt.Errorf("%w: sequence is required", deployment.ErrInvalidConfig) + } + report, err := operations.ExecuteSequence(b, seq, deps, input) + if err != nil { + return agg, fmt.Errorf("failed to execute %s: %w", seq.ID(), err) + } + if envMetadataConflicts(agg.Metadata.Env, report.Output.Metadata.Env) { + return agg, fmt.Errorf("%w: conflicting env metadata from sequence %s", + deployment.ErrInvalidConfig, seq.ID()) + } + agg.BatchOps = append(agg.BatchOps, report.Output.BatchOps...) + agg.Metadata.Addresses = append(agg.Metadata.Addresses, report.Output.Metadata.Addresses...) + agg.Metadata.Contracts = append(agg.Metadata.Contracts, report.Output.Metadata.Contracts...) + agg.Metadata.Chains = append(agg.Metadata.Chains, report.Output.Metadata.Chains...) + if report.Output.Metadata.Env != nil { + agg.Metadata.Env = report.Output.Metadata.Env + } + + return agg, nil +} + +func envMetadataConflicts(existing, incoming *datastore.EnvMetadata) bool { + if existing == nil || incoming == nil { + return false + } + if existing == incoming { + return false + } + + return !reflect.DeepEqual(existing.Metadata, incoming.Metadata) +} diff --git a/changeset/sequenceutils/merge_test.go b/changeset/sequenceutils/merge_test.go new file mode 100644 index 000000000..7d2dd2a38 --- /dev/null +++ b/changeset/sequenceutils/merge_test.go @@ -0,0 +1,214 @@ +package sequenceutils + +import ( + "errors" + "testing" + + "github.com/Masterminds/semver/v3" + mcms_types "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +func TestExecuteOnChainSequenceAndMerge_success(t *testing.T) { + t.Parallel() + + env := testEnvironment(t) + seq := testSequence(t, func(_ operations.Bundle, _ struct{}, _ struct{}) (OnChainOutput, error) { + return OnChainOutput{ + BatchOps: []mcms_types.BatchOperation{sampleBatchOp()}, + Metadata: datastore.MetadataBundle{ + Addresses: []datastore.AddressRef{{ + Address: "0xabc", + ChainSelector: 1, + Type: "Timelock", + Version: semver.MustParse("1.0.0"), + }}, + Chains: []datastore.ChainMetadata{{ChainSelector: 1, Metadata: "chain-a"}}, + }, + }, nil + }) + + agg, err := ExecuteOnChainSequenceAndMerge(env.OperationsBundle, struct{}{}, seq, struct{}{}, OnChainOutput{}) + require.NoError(t, err) + require.Len(t, agg.BatchOps, 1) + require.Len(t, agg.Metadata.Addresses, 1) + require.Len(t, agg.Metadata.Chains, 1) +} + +func TestExecuteOnChainSequenceAndMerge_preservesAggOnExecuteFailure(t *testing.T) { + t.Parallel() + + env := testEnvironment(t) + seqErr := errors.New("sequence failed") + + okSeq := testSequence(t, func(_ operations.Bundle, _ struct{}, _ struct{}) (OnChainOutput, error) { + return OnChainOutput{ + Metadata: datastore.MetadataBundle{ + Addresses: []datastore.AddressRef{{ + Address: "0xabc", + ChainSelector: 1, + Type: "Timelock", + Version: semver.MustParse("1.0.0"), + }}, + }, + }, nil + }) + failSeq := operations.NewSequence( + "test-seq-fail", + semver.MustParse("1.0.0"), + "test sequence fail", + func(_ operations.Bundle, _ struct{}, _ struct{}) (OnChainOutput, error) { + return OnChainOutput{}, seqErr + }, + ) + + agg, err := ExecuteOnChainSequenceAndMerge(env.OperationsBundle, struct{}{}, okSeq, struct{}{}, OnChainOutput{}) + require.NoError(t, err) + require.Len(t, agg.Metadata.Addresses, 1) + + agg, err = ExecuteOnChainSequenceAndMerge(env.OperationsBundle, struct{}{}, failSeq, struct{}{}, agg) + require.Error(t, err) + require.ErrorContains(t, err, seqErr.Error()) + require.Len(t, agg.Metadata.Addresses, 1) +} + +func TestExecuteOnChainSequenceAndMerge_appendsChainsWithoutDeduping(t *testing.T) { + t.Parallel() + + env := testEnvironment(t) + seq := testSequence(t, func(_ operations.Bundle, _ struct{}, _ struct{}) (OnChainOutput, error) { + return OnChainOutput{ + Metadata: datastore.MetadataBundle{ + Chains: []datastore.ChainMetadata{{ChainSelector: 1, Metadata: "a"}}, + }, + }, nil + }) + + agg := OnChainOutput{} + agg, err := ExecuteOnChainSequenceAndMerge(env.OperationsBundle, struct{}{}, seq, struct{}{}, agg) + require.NoError(t, err) + agg, err = ExecuteOnChainSequenceAndMerge(env.OperationsBundle, struct{}{}, seq, struct{}{}, agg) + require.NoError(t, err) + require.Len(t, agg.Metadata.Chains, 2) + require.Equal(t, uint64(1), agg.Metadata.Chains[0].ChainSelector) + require.Equal(t, uint64(1), agg.Metadata.Chains[1].ChainSelector) +} + +func TestExecuteOnChainSequenceAndMerge_nilSequence(t *testing.T) { + t.Parallel() + + env := testEnvironment(t) + agg := OnChainOutput{Metadata: datastore.MetadataBundle{ + Addresses: []datastore.AddressRef{{Address: "0xabc", ChainSelector: 1, Type: "Timelock", Version: semver.MustParse("1.0.0")}}, + }} + + out, err := ExecuteOnChainSequenceAndMerge(env.OperationsBundle, struct{}{}, nil, struct{}{}, agg) + require.Error(t, err) + require.ErrorIs(t, err, deployment.ErrInvalidConfig) + require.ErrorContains(t, err, "sequence is required") + require.Equal(t, agg, out) +} + +func TestExecuteOnChainSequenceAndMerge_sameEnvPointer(t *testing.T) { + t.Parallel() + + env := testEnvironment(t) + envMeta := &datastore.EnvMetadata{Metadata: "shared"} + seq1 := operations.NewSequence( + "test-seq-env-shared-1", + semver.MustParse("1.0.0"), + "test sequence shared env 1", + func(_ operations.Bundle, _ struct{}, _ struct{}) (OnChainOutput, error) { + return OnChainOutput{Metadata: datastore.MetadataBundle{Env: envMeta}}, nil + }, + ) + seq2 := operations.NewSequence( + "test-seq-env-shared-2", + semver.MustParse("1.0.0"), + "test sequence shared env 2", + func(_ operations.Bundle, _ struct{}, _ struct{}) (OnChainOutput, error) { + return OnChainOutput{Metadata: datastore.MetadataBundle{Env: envMeta}}, nil + }, + ) + + agg, err := ExecuteOnChainSequenceAndMerge(env.OperationsBundle, struct{}{}, seq1, struct{}{}, OnChainOutput{}) + require.NoError(t, err) + require.Same(t, envMeta, agg.Metadata.Env) + + agg, err = ExecuteOnChainSequenceAndMerge(env.OperationsBundle, struct{}{}, seq2, struct{}{}, agg) + require.NoError(t, err) + require.Same(t, envMeta, agg.Metadata.Env) +} + +func TestExecuteOnChainSequenceAndMerge_equivalentEnvValues(t *testing.T) { + t.Parallel() + + env := testEnvironment(t) + seq1 := operations.NewSequence( + "test-seq-env-equiv-1", + semver.MustParse("1.0.0"), + "test sequence equivalent env 1", + func(_ operations.Bundle, _ struct{}, _ struct{}) (OnChainOutput, error) { + return OnChainOutput{Metadata: datastore.MetadataBundle{ + Env: &datastore.EnvMetadata{Metadata: "shared"}, + }}, nil + }, + ) + seq2 := operations.NewSequence( + "test-seq-env-equiv-2", + semver.MustParse("1.0.0"), + "test sequence equivalent env 2", + func(_ operations.Bundle, _ struct{}, _ struct{}) (OnChainOutput, error) { + return OnChainOutput{Metadata: datastore.MetadataBundle{ + Env: &datastore.EnvMetadata{Metadata: "shared"}, + }}, nil + }, + ) + + agg, err := ExecuteOnChainSequenceAndMerge(env.OperationsBundle, struct{}{}, seq1, struct{}{}, OnChainOutput{}) + require.NoError(t, err) + require.Equal(t, "shared", agg.Metadata.Env.Metadata) + + agg, err = ExecuteOnChainSequenceAndMerge(env.OperationsBundle, struct{}{}, seq2, struct{}{}, agg) + require.NoError(t, err) + require.Equal(t, "shared", agg.Metadata.Env.Metadata) +} + +func TestExecuteOnChainSequenceAndMerge_envConflict(t *testing.T) { + t.Parallel() + + env := testEnvironment(t) + envMeta := &datastore.EnvMetadata{Metadata: "staging"} + seq := operations.NewSequence( + "test-seq-env-conflict", + semver.MustParse("1.0.0"), + "test sequence env conflict", + func(_ operations.Bundle, _ struct{}, _ struct{}) (OnChainOutput, error) { + return OnChainOutput{ + BatchOps: []mcms_types.BatchOperation{sampleBatchOp()}, + Metadata: datastore.MetadataBundle{ + Env: envMeta, + Addresses: []datastore.AddressRef{{ + Address: "0xdef", + ChainSelector: 2, + Type: "Timelock", + Version: semver.MustParse("1.0.0"), + }}, + }, + }, nil + }, + ) + + agg := OnChainOutput{Metadata: datastore.MetadataBundle{Env: &datastore.EnvMetadata{Metadata: "prod"}}} + agg, err := ExecuteOnChainSequenceAndMerge(env.OperationsBundle, struct{}{}, seq, struct{}{}, agg) + require.Error(t, err) + require.ErrorIs(t, err, deployment.ErrInvalidConfig) + require.ErrorContains(t, err, "conflicting env metadata") + require.Equal(t, "prod", agg.Metadata.Env.Metadata) + require.Empty(t, agg.BatchOps) + require.Empty(t, agg.Metadata.Addresses) +}