diff --git a/.changeset/funny-carpets-hang.md b/.changeset/funny-carpets-hang.md new file mode 100644 index 000000000..8ef586a7f --- /dev/null +++ b/.changeset/funny-carpets-hang.md @@ -0,0 +1,5 @@ +--- +"chainlink-deployments-framework": patch +--- + +feat: add post-proposal-execution hooks diff --git a/chain/blockchain.go b/chain/blockchain.go index 822dde0a2..8ed40216e 100644 --- a/chain/blockchain.go +++ b/chain/blockchain.go @@ -7,6 +7,7 @@ import ( "reflect" "slices" + "github.com/samber/lo" chainsel "github.com/smartcontractkit/chain-selectors" "github.com/smartcontractkit/chainlink-deployments-framework/chain/aptos" @@ -172,6 +173,13 @@ func (b BlockChains) StellarChains() map[uint64]stellar.Chain { return getChainsByType[stellar.Chain, *stellar.Chain](b) } +func (b BlockChains) ReadOnly() BlockChains { + return NewBlockChains(lo.MapValues(b.chains, func(c BlockChain, _ uint64) BlockChain { + // TODO: define ReadOnly() method for each chain, then replace with `c.ReadOnly()` + return c + })) +} + // ChainSelectorsOption defines a function type for configuring ChainSelectors type ChainSelectorsOption func(*chainSelectorsOptions) diff --git a/engine/cld/changeset/common.go b/engine/cld/changeset/common.go index befe31448..9991a6cba 100644 --- a/engine/cld/changeset/common.go +++ b/engine/cld/changeset/common.go @@ -43,6 +43,7 @@ type ConfiguredChangeSet interface { ThenWith(postProcessor PostProcessor) PostProcessingChangeSet WithPreHooks(hooks ...PreHook) ConfiguredChangeSet WithPostHooks(hooks ...PostHook) ConfiguredChangeSet + WithPostProposalHooks(hooks ...PostProposalHook) ConfiguredChangeSet } // WrappedChangeSet simply wraps a fdeployment.ChangeSetV2 to use it in the fluent interface, which hosts @@ -72,7 +73,7 @@ func (f WrappedChangeSet[C]) With(config C) ConfiguredChangeSet { return ChangeSetImpl[C]{changeset: f, configProvider: func() (C, error) { return config, nil }} } -// inputObject is a JSON object with a "payload" field that contains the actual input data for a Durable Pipeline. +// TypedJSON is a JSON object with a "payload" field that contains the actual input data for a Durable Pipeline. type TypedJSON struct { Payload json.RawMessage `json:"payload"` ChainOverrides []uint64 `json:"chainOverrides"` // Optional field for chain overrides @@ -121,15 +122,16 @@ func decodePayload[C any](payload json.RawMessage) (C, error) { // // Note: Prefer WithEnvInput for durable_pipelines.go func (f WrappedChangeSet[C]) WithJSON(_ C, inputStr string) ConfiguredChangeSet { - return ChangeSetImpl[C]{changeset: f, configProvider: func() (C, error) { - inputObject, err := parseTypedInput(inputStr) - if err != nil { - var zero C - return zero, err - } + return ChangeSetImpl[C]{ + changeset: f, configProvider: func() (C, error) { + inputObject, err := parseTypedInput(inputStr) + if err != nil { + var zero C + return zero, err + } - return decodePayload[C](inputObject.Payload) - }, + return decodePayload[C](inputObject.Payload) + }, inputChainOverrides: func() ([]uint64, error) { return loadInputChainOverrides(inputStr) }, @@ -188,7 +190,8 @@ func (f WrappedChangeSet[C]) WithEnvInput(opts ...EnvInputOption[C]) ConfiguredC return config, nil } - return ChangeSetImpl[C]{changeset: f, + return ChangeSetImpl[C]{ + changeset: f, configProvider: func() (C, error) { return providerFromInput(inputStr) }, @@ -231,6 +234,7 @@ func (f WrappedChangeSet[C]) WithConfigResolver(resolver fresolvers.ConfigResolv configProviderFromInput := func(rawInput string) (C, error) { var zero C + // Parse JSON input inputObject, err := parseTypedInput(rawInput) if err != nil { return zero, fmt.Errorf("failed to parse resolver input as JSON: %w", err) @@ -245,7 +249,8 @@ func (f WrappedChangeSet[C]) WithConfigResolver(resolver fresolvers.ConfigResolv return typedConfig, nil } - return ChangeSetImpl[C]{changeset: f, + return ChangeSetImpl[C]{ + changeset: f, configProvider: func() (C, error) { return configProviderFromInput(inputStr) }, @@ -269,8 +274,9 @@ type ChangeSetImpl[C any] struct { // Configure(...).WithConfigResolver(...) ConfigResolver fresolvers.ConfigResolver - preHooks []PreHook - postHooks []PostHook + preHooks []PreHook + postHooks []PostHook + postProposalHooks []PostProposalHook } func (ccs ChangeSetImpl[C]) noop() {} @@ -341,16 +347,24 @@ func (ccs ChangeSetImpl[C]) WithPostHooks(hooks ...PostHook) ConfiguredChangeSet return ccs } -func (ccs ChangeSetImpl[C]) getPreHooks() []PreHook { return ccs.preHooks } -func (ccs ChangeSetImpl[C]) getPostHooks() []PostHook { return ccs.postHooks } +// WithPostProposalHooks appends post-hooks to this changeset. Multiple calls are additive. +func (ccs ChangeSetImpl[C]) WithPostProposalHooks(hooks ...PostProposalHook) ConfiguredChangeSet { + ccs.postProposalHooks = append(slices.Clone(ccs.postProposalHooks), hooks...) + return ccs +} + +func (ccs ChangeSetImpl[C]) getPreHooks() []PreHook { return ccs.preHooks } +func (ccs ChangeSetImpl[C]) getPostHooks() []PostHook { return ccs.postHooks } +func (ccs ChangeSetImpl[C]) getPostProposalHooks() []PostProposalHook { return ccs.postProposalHooks } // ThenWith adds post-processing to a configured changeset. // Hooks registered before ThenWith are carried forward. func (ccs ChangeSetImpl[C]) ThenWith(postProcessor PostProcessor) PostProcessingChangeSet { return PostProcessingChangeSetImpl[C]{ - changeset: ccs, - postProcessor: postProcessor, - preHooks: slices.Clone(ccs.preHooks), - postHooks: slices.Clone(ccs.postHooks), + changeset: ccs, + postProcessor: postProcessor, + preHooks: slices.Clone(ccs.preHooks), + postHooks: slices.Clone(ccs.postHooks), + postProposalHooks: slices.Clone(ccs.postProposalHooks), } } diff --git a/engine/cld/changeset/hooks.go b/engine/cld/changeset/hooks.go index 9d0aa870d..dda4aece9 100644 --- a/engine/cld/changeset/hooks.go +++ b/engine/cld/changeset/hooks.go @@ -5,6 +5,9 @@ import ( "fmt" "time" + "github.com/smartcontractkit/mcms" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain" fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) @@ -41,6 +44,14 @@ type HookEnv struct { Logger logger.Logger } +// ProposalHookEnv is the restricted environment surface exposed to proposal hooks. +// Additional fields may be added in future versions as needs arise. +type ProposalHookEnv struct { + Name string + Logger logger.Logger + BlockChains chain.BlockChains +} + // PreHookParams is passed to pre-hooks. // All fields must be treated as read-only. type PreHookParams struct { @@ -59,6 +70,16 @@ type PostHookParams struct { Err error } +// PostProposalHookParams is passed to post-proposal-hooks. +// All fields must be treated as read-only. +type PostProposalHookParams struct { + Env ProposalHookEnv + ChangesetKey string + Proposal *mcms.TimelockProposal + Input any + Reports []MCMSTimelockExecuteReport +} + // PreHookFunc is the signature for functions that run before changeset Apply. // The context is derived from env.GetContext() with the hook's timeout applied. type PreHookFunc func(ctx context.Context, params PreHookParams) error @@ -67,6 +88,10 @@ type PreHookFunc func(ctx context.Context, params PreHookParams) error // The context is derived from env.GetContext() with the hook's timeout applied. type PostHookFunc func(ctx context.Context, params PostHookParams) error +// PostProposalHookFunc is the signature for functions that run after an MCMS proposal execution. +// The context is derived from env.GetContext() with the hook's timeout applied. +type PostProposalHookFunc func(ctx context.Context, params PostProposalHookParams) error + // HookDefinition holds the metadata common to all hooks. type HookDefinition struct { Name string @@ -86,6 +111,12 @@ type PostHook struct { Func PostHookFunc } +// PostProposalHook pairs a HookDefinition with a PostProposalHookFunc. +type PostProposalHook struct { + HookDefinition + Func PostProposalHookFunc +} + // ExecuteHook runs a hook function with the configured timeout and failure // policy. The parent context is derived from env.GetContext(); each hook // receives a child context with its timeout applied. diff --git a/engine/cld/changeset/mcms.go b/engine/cld/changeset/mcms.go new file mode 100644 index 000000000..0e2c903d1 --- /dev/null +++ b/engine/cld/changeset/mcms.go @@ -0,0 +1,94 @@ +package changeset + +import ( + "context" + "encoding/json" + "fmt" + "time" + + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/mcms" + mcmstypes "github.com/smartcontractkit/mcms/types" + + fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +// ----- mcms timelock execution report types ----- + +type mcmsReport[IN, OUT any] struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + Timestamp time.Time `json:"timestamp,omitzero"` + Input IN `json:"input,omitempty"` + Output OUT `json:"output,omitempty"` +} + +type MCMSTimelockExecuteReportInput struct { + Index int `json:"index"` + OperationID gethcommon.Hash `json:"operationID,omitzero"` + ChainSelector uint64 `json:"chainSelector"` + TimelockAddress string `json:"timelockAddress"` + MCMAddress string `json:"mcmAddress"` + AdditionalFields json.RawMessage `json:"additionalFields,omitempty,omitzero"` + Changeset MCMSReportChangeset `json:"changeset,omitzero"` +} + +type MCMSTimelockExecuteReportOutput struct { + TransactionResult mcmstypes.TransactionResult `json:"transactionResult"` +} + +type MCMSReportChangeset struct { + Index int `json:"index"` + Name string `json:"name,omitzero"` +} + +type MCMSTimelockExecuteReport mcmsReport[MCMSTimelockExecuteReportInput, MCMSTimelockExecuteReportOutput] + +const MCMSTimelockExecuteReportType = "timelock-execution" + +// RunProposalHooks executes all post-proposal hooks for the given proposal and reports. It returns +// an error if any of the hooks fail. +// Execution order is: +// 1. Per-changeset post-proposal-hooks +// 2. Global post-proposal-hooks +func (r *ChangesetsRegistry) RunProposalHooks( + key string, e fdeployment.Environment, proposal *mcms.TimelockProposal, input any, reports []MCMSTimelockExecuteReport, +) error { + applySnapshot, err := r.getApplySnapshot(key) + if err != nil { + return err + } + + params := PostProposalHookParams{ + Env: ProposalHookEnv{ + Name: e.Name, + Logger: e.Logger, + BlockChains: e.BlockChains.ReadOnly(), + }, + ChangesetKey: key, + Proposal: proposal, + Input: input, + Reports: reports, + } + + for _, h := range applySnapshot.registryEntry.postProposalHooks { + err := ExecuteHook(e, h.HookDefinition, func(ctx context.Context) error { + return h.Func(ctx, params) + }) + if err != nil { + return fmt.Errorf("changeset post-proposal-hook %q failed: %w", h.Name, err) + } + } + + for _, h := range applySnapshot.globalPostProposalHooks { + if err := ExecuteHook(e, h.HookDefinition, func(ctx context.Context) error { + return h.Func(ctx, params) + }); err != nil { + return fmt.Errorf("global post-proposal-hook %q failed: %w", h.Name, err) + } + } + + return nil +} diff --git a/engine/cld/changeset/mcms_test.go b/engine/cld/changeset/mcms_test.go new file mode 100644 index 000000000..aa8129358 --- /dev/null +++ b/engine/cld/changeset/mcms_test.go @@ -0,0 +1,197 @@ +package changeset + +import ( + "context" + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/smartcontractkit/mcms" + + "github.com/stretchr/testify/require" +) + +func Test_RunProposalHooks(t *testing.T) { + t.Parallel() + + proposalHook := func(id string, execLog *[]string) PostProposalHook { + return PostProposalHook{ + HookDefinition: HookDefinition{Name: "pp-hook", FailurePolicy: Abort}, + Func: func(_ context.Context, _ PostProposalHookParams) error { + *execLog = append(*execLog, id) + return nil + }, + } + } + + tests := []struct { + name string + key string + setup func(execLog *[]string) *ChangesetsRegistry + wantExecLogs []string + wantErr string + }{ + { + name: "unknown key returns error", + key: "nonexistent", + setup: func(execLog *[]string) *ChangesetsRegistry { + return NewChangesetsRegistry() + }, + wantErr: "changeset 'nonexistent' not found", + }, + { + name: "per-changeset hook is invoked", + key: "test-cs", + setup: func(execLog *[]string) *ChangesetsRegistry { + r := NewChangesetsRegistry() + r.entries["test-cs"] = registryEntry{ + changeset: noopChangeset{}, + postProposalHooks: []PostProposalHook{proposalHook("proposal-changeset-hook", execLog)}, + } + + return r + }, + wantExecLogs: []string{"proposal-changeset-hook"}, + }, + { + name: "global hook is invoked", + key: "test-cs", + setup: func(execLog *[]string) *ChangesetsRegistry { + r := NewChangesetsRegistry() + r.entries["test-cs"] = registryEntry{changeset: noopChangeset{}} + r.AddGlobalPostProposalHooks(proposalHook("proposal-global-hook", execLog)) + + return r + }, + wantExecLogs: []string{"proposal-global-hook"}, + }, + { + name: "changeset hook runs before global hook", + key: "test-cs", + setup: func(execLog *[]string) *ChangesetsRegistry { + r := NewChangesetsRegistry() + r.entries["test-cs"] = registryEntry{ + changeset: noopChangeset{}, + postProposalHooks: []PostProposalHook{proposalHook("proposal-changeset-hook", execLog)}, + } + r.AddGlobalPostProposalHooks(proposalHook("proposal-global-hook", execLog)) + + return r + }, + wantExecLogs: []string{"proposal-changeset-hook", "proposal-global-hook"}, + }, + { + name: "per-changeset hook Abort returns error", + key: "test-cs", + setup: func(execLog *[]string) *ChangesetsRegistry { + r := NewChangesetsRegistry() + r.entries["test-cs"] = registryEntry{ + changeset: noopChangeset{}, + postProposalHooks: []PostProposalHook{{ + HookDefinition: HookDefinition{Name: "failing-hook", FailurePolicy: Abort}, + Func: func(_ context.Context, _ PostProposalHookParams) error { + return errors.New("hook error") + }, + }}, + } + + return r + }, + wantErr: "post-proposal-hook \"failing-hook\" failed: hook error", + }, + { + name: "global hook Abort returns error", + key: "test-cs", + setup: func(execLog *[]string) *ChangesetsRegistry { + r := NewChangesetsRegistry() + r.entries["test-cs"] = registryEntry{changeset: noopChangeset{}} + r.AddGlobalPostProposalHooks(PostProposalHook{ + HookDefinition: HookDefinition{Name: "failing-global-hook", FailurePolicy: Abort}, + Func: func(_ context.Context, _ PostProposalHookParams) error { + return errors.New("global hook error") + }, + }) + + return r + }, + wantErr: "global post-proposal-hook \"failing-global-hook\" failed: global hook error", + }, + { + name: "Warn hook does not stop subsequent hooks", + key: "test-cs", + setup: func(execLog *[]string) *ChangesetsRegistry { + r := NewChangesetsRegistry() + r.entries["test-cs"] = registryEntry{ + changeset: noopChangeset{}, + postProposalHooks: []PostProposalHook{ + { + HookDefinition: HookDefinition{Name: "warn-hook", FailurePolicy: Warn}, + Func: func(_ context.Context, _ PostProposalHookParams) error { + return errors.New("non-critical failure") + }, + }, + proposalHook("second-hook", execLog), + }, + } + + return r + }, + wantExecLogs: []string{"second-hook"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + execLogs := []string{} + registry := tt.setup(&execLogs) + + err := registry.RunProposalHooks(tt.key, hookTestEnv(t), &mcms.TimelockProposal{}, nil, nil) + + if tt.wantErr == "" { + require.NoError(t, err) + require.Equal(t, tt.wantExecLogs, execLogs) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func Test_RunProposalHooks_HookReceivesCorrectParams(t *testing.T) { + t.Parallel() + + proposal := &mcms.TimelockProposal{} + input := "test-input" + reports := []MCMSTimelockExecuteReport{{Type: MCMSTimelockExecuteReportType}} + + var receivedParams PostProposalHookParams + + r := NewChangesetsRegistry() + r.entries["test-cs"] = registryEntry{ + changeset: noopChangeset{}, + postProposalHooks: []PostProposalHook{{ + HookDefinition: HookDefinition{Name: "param-checker", FailurePolicy: Warn}, + Func: func(_ context.Context, params PostProposalHookParams) error { + receivedParams = params + return nil + }, + }}, + } + + err := r.RunProposalHooks("test-cs", hookTestEnv(t), proposal, input, reports) + require.NoError(t, err) + + expectedParams := PostProposalHookParams{ + Env: ProposalHookEnv{Name: "test-env"}, + ChangesetKey: "test-cs", + Proposal: proposal, + Input: input, + Reports: reports, + } + require.Empty(t, cmp.Diff(expectedParams, receivedParams, + cmpopts.IgnoreFields(mcms.BaseProposal{}, "useSimulatedBackend"), + cmpopts.IgnoreFields(ProposalHookEnv{}, "Logger", "BlockChains"))) +} diff --git a/engine/cld/changeset/postprocess.go b/engine/cld/changeset/postprocess.go index 08a214407..797d88845 100644 --- a/engine/cld/changeset/postprocess.go +++ b/engine/cld/changeset/postprocess.go @@ -17,10 +17,11 @@ type PostProcessingChangeSet interface { var _ PostProcessingChangeSet = PostProcessingChangeSetImpl[any]{} type PostProcessingChangeSetImpl[C any] struct { - changeset ChangeSetImpl[C] - postProcessor PostProcessor - preHooks []PreHook - postHooks []PostHook + changeset ChangeSetImpl[C] + postProcessor PostProcessor + preHooks []PreHook + postHooks []PostHook + postProposalHooks []PostProposalHook } func (ccs PostProcessingChangeSetImpl[C]) noop() {} @@ -63,5 +64,14 @@ func (ccs PostProcessingChangeSetImpl[C]) WithPostHooks(hooks ...PostHook) PostP return ccs } +// WithPostProposalHooks appends post-proposal-hooks to this changeset. Multiple calls are additive. +func (ccs PostProcessingChangeSetImpl[C]) WithPostProposalHooks(hooks ...PostProposalHook) PostProcessingChangeSet { + ccs.postProposalHooks = append(slices.Clone(ccs.postProposalHooks), hooks...) + return ccs +} + func (ccs PostProcessingChangeSetImpl[C]) getPreHooks() []PreHook { return ccs.preHooks } func (ccs PostProcessingChangeSetImpl[C]) getPostHooks() []PostHook { return ccs.postHooks } +func (ccs PostProcessingChangeSetImpl[C]) getPostProposalHooks() []PostProposalHook { + return ccs.postProposalHooks +} diff --git a/engine/cld/changeset/registry.go b/engine/cld/changeset/registry.go index 97ca13ed7..a5c502966 100644 --- a/engine/cld/changeset/registry.go +++ b/engine/cld/changeset/registry.go @@ -10,7 +10,6 @@ import ( "sync" fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" - foperations "github.com/smartcontractkit/chainlink-deployments-framework/operations" ) @@ -65,8 +64,9 @@ type registryEntry struct { // options contains the configuration options for this changeset options ChangesetConfig - preHooks []PreHook - postHooks []PostHook + preHooks []PreHook + postHooks []PostHook + postProposalHooks []PostProposalHook } // hookCarrier is implemented by changeset types that carry hooks through the @@ -74,6 +74,7 @@ type registryEntry struct { type hookCarrier interface { getPreHooks() []PreHook getPostHooks() []PostHook + getPostProposalHooks() []PostProposalHook } // newRegistryEntry creates a new registry entry for a changeset. @@ -83,6 +84,7 @@ func newRegistryEntry(c ChangeSet, opts ChangesetConfig) registryEntry { if hc, ok := c.(hookCarrier); ok { entry.preHooks = hc.getPreHooks() entry.postHooks = hc.getPostHooks() + entry.postProposalHooks = hc.getPostProposalHooks() } return entry @@ -115,6 +117,8 @@ type ChangesetsRegistry struct { globalPreHooks []PreHook // globalPostHooks run after every changeset in this registry. globalPostHooks []PostHook + // globalPostProposalHooks run after every changeset in this registry is executed in a mcms proposal. + globalPostProposalHooks []PostProposalHook } type applyConfig struct { @@ -172,6 +176,14 @@ func (r *ChangesetsRegistry) AddGlobalPostHooks(hooks ...PostHook) { r.globalPostHooks = append(r.globalPostHooks, hooks...) } +// AddGlobalPostProposalHooks appends post-hooks that run after every changeset in this registry. +func (r *ChangesetsRegistry) AddGlobalPostProposalHooks(hooks ...PostProposalHook) { + r.mu.Lock() + defer r.mu.Unlock() + + r.globalPostProposalHooks = append(r.globalPostProposalHooks, hooks...) +} + // Apply applies a changeset, running any registered hooks around it. // // Execution order: @@ -200,7 +212,7 @@ func (r *ChangesetsRegistry) Apply( return entry.changeset.applyWithInput(e, cfg.inputStr) } - entry, globalPre, globalPost, err := r.getApplySnapshot(key) + applySnapshot, err := r.getApplySnapshot(key) if err != nil { return fdeployment.ChangesetOutput{}, err } @@ -215,7 +227,7 @@ func (r *ChangesetsRegistry) Apply( ChangesetKey: key, } - for _, h := range globalPre { + for _, h := range applySnapshot.globalPreHooks { if err := ExecuteHook(e, h.HookDefinition, func(ctx context.Context) error { return h.Func(ctx, preParams) }); err != nil { @@ -223,7 +235,7 @@ func (r *ChangesetsRegistry) Apply( } } - for _, h := range entry.preHooks { + for _, h := range applySnapshot.registryEntry.preHooks { if err := ExecuteHook(e, h.HookDefinition, func(ctx context.Context) error { return h.Func(ctx, preParams) }); err != nil { @@ -231,9 +243,7 @@ func (r *ChangesetsRegistry) Apply( } } - var output fdeployment.ChangesetOutput - var applyErr error - output, applyErr = entry.changeset.applyWithInput(e, cfg.inputStr) + output, applyErr := applySnapshot.registryEntry.changeset.applyWithInput(e, cfg.inputStr) postParams := PostHookParams{ Env: hookEnv, @@ -242,7 +252,7 @@ func (r *ChangesetsRegistry) Apply( Err: applyErr, } - for _, h := range entry.postHooks { + for _, h := range applySnapshot.registryEntry.postHooks { if err := ExecuteHook(e, h.HookDefinition, func(ctx context.Context) error { return h.Func(ctx, postParams) }); err != nil { @@ -255,7 +265,7 @@ func (r *ChangesetsRegistry) Apply( } } - for _, h := range globalPost { + for _, h := range applySnapshot.globalPostHooks { if err := ExecuteHook(e, h.HookDefinition, func(ctx context.Context) error { return h.Func(ctx, postParams) }); err != nil { @@ -279,19 +289,31 @@ func (r *ChangesetsRegistry) getApplyEntry(key string) (registryEntry, error) { return r.getApplyEntryLocked(key) } +type applySnapshot struct { + registryEntry registryEntry + globalPreHooks []PreHook + globalPostHooks []PostHook + globalPostProposalHooks []PostProposalHook +} + // getApplySnapshot reads the registry entry and global hook slices under // the mutex, returning copies so Apply can release the lock before running // hooks and the changeset. -func (r *ChangesetsRegistry) getApplySnapshot(key string) (registryEntry, []PreHook, []PostHook, error) { +func (r *ChangesetsRegistry) getApplySnapshot(key string) (applySnapshot, error) { r.mu.Lock() defer r.mu.Unlock() entry, err := r.getApplyEntryLocked(key) if err != nil { - return registryEntry{}, nil, nil, err + return applySnapshot{}, err } - return entry, slices.Clone(r.globalPreHooks), slices.Clone(r.globalPostHooks), nil + return applySnapshot{ + registryEntry: entry, + globalPreHooks: slices.Clone(r.globalPreHooks), + globalPostHooks: slices.Clone(r.globalPostHooks), + globalPostProposalHooks: slices.Clone(r.globalPostProposalHooks), + }, nil } func (r *ChangesetsRegistry) getApplyEntryLocked(key string) (registryEntry, error) { diff --git a/engine/cld/changeset/registry_test.go b/engine/cld/changeset/registry_test.go index 2db8bd577..4af8b81bb 100644 --- a/engine/cld/changeset/registry_test.go +++ b/engine/cld/changeset/registry_test.go @@ -825,78 +825,125 @@ func Test_WithHooks_ThenWith_AdditiveAfterThenWith(t *testing.T) { func Test_FluentAPI_HooksExtractedByAdd(t *testing.T) { t.Parallel() - var order []string + thenWith := func(_ fdeployment.Environment, o fdeployment.ChangesetOutput) (fdeployment.ChangesetOutput, error) { + return o, nil + } + newRegistry := func(cs ChangeSet) *ChangesetsRegistry { + r := NewChangesetsRegistry() + r.SetValidate(false) + r.Add("test-cs", cs) - pre := PreHook{ - HookDefinition: HookDefinition{Name: "fluent-pre"}, - Func: func(_ context.Context, _ PreHookParams) error { - order = append(order, "fluent-pre") - return nil - }, + return r } - post := PostHook{ - HookDefinition: HookDefinition{Name: "fluent-post"}, - Func: func(_ context.Context, _ PostHookParams) error { - order = append(order, "fluent-post") - return nil - }, + preHook := func(order *[]string) PreHook { + return PreHook{ + HookDefinition: HookDefinition{Name: "pre"}, + Func: func(_ context.Context, _ PreHookParams) error { + *order = append(*order, "pre") + return nil + }, + } + } + postHook := func(order *[]string) PostHook { + return PostHook{ + HookDefinition: HookDefinition{Name: "post"}, + Func: func(_ context.Context, _ PostHookParams) error { + *order = append(*order, "post") + return nil + }, + } + } + proposalHook := func(order *[]string) PostProposalHook { + return PostProposalHook{ + HookDefinition: HookDefinition{Name: "proposal-hook"}, + Func: func(_ context.Context, _ PostProposalHookParams) error { + *order = append(*order, "proposal") + return nil + }, + } } - cs := Configure(MyChangeSet).With("cfg"). - WithPreHooks(pre). - WithPostHooks(post) + t.Run("pre and post hooks", func(t *testing.T) { + t.Parallel() - r := NewChangesetsRegistry() - r.SetValidate(false) - r.Add("test-cs", cs) + hookExecutions := []string{} - entry := r.entries["test-cs"] - require.Len(t, entry.preHooks, 1, "Add should extract pre-hooks via hookCarrier") - require.Len(t, entry.postHooks, 1, "Add should extract post-hooks via hookCarrier") + cs := Configure(MyChangeSet).With("cfg"). + WithPreHooks(preHook(&hookExecutions)). + WithPostHooks(postHook(&hookExecutions)) - _, err := r.Apply("test-cs", hookTestEnv(t)) - require.NoError(t, err) - assert.Equal(t, []string{"fluent-pre", "fluent-post"}, order) -} + r := newRegistry(cs) -func Test_FluentAPI_ThenWith_HooksExtractedByAdd(t *testing.T) { - t.Parallel() + entry := r.entries["test-cs"] + require.Len(t, entry.preHooks, 1) + require.Len(t, entry.postHooks, 1) - var order []string + _, err := r.Apply("test-cs", hookTestEnv(t)) + require.NoError(t, err) + require.Equal(t, []string{"pre", "post"}, hookExecutions) + }) - pre := PreHook{ - HookDefinition: HookDefinition{Name: "pp-pre"}, - Func: func(_ context.Context, _ PreHookParams) error { - order = append(order, "pp-pre") - return nil - }, - } - post := PostHook{ - HookDefinition: HookDefinition{Name: "pp-post"}, - Func: func(_ context.Context, _ PostHookParams) error { - order = append(order, "pp-post") - return nil - }, - } + t.Run("pre and post hooks through ThenWith", func(t *testing.T) { + t.Parallel() - cs := Configure(MyChangeSet).With("cfg"). - WithPreHooks(pre). - ThenWith(func(_ fdeployment.Environment, o fdeployment.ChangesetOutput) (fdeployment.ChangesetOutput, error) { - return o, nil - }). - WithPostHooks(post) + hookExecutions := []string{} - r := NewChangesetsRegistry() - r.SetValidate(false) - r.Add("test-cs", cs) + cs := Configure(MyChangeSet).With("cfg"). + WithPreHooks(preHook(&hookExecutions)). + ThenWith(thenWith). + WithPostHooks(postHook(&hookExecutions)) - entry := r.entries["test-cs"] - require.Len(t, entry.preHooks, 1) - require.Len(t, entry.postHooks, 1) + r := newRegistry(cs) - _, err := r.Apply("test-cs", hookTestEnv(t)) - require.NoError(t, err) - assert.Equal(t, []string{"pp-pre", "pp-post"}, order) + entry := r.entries["test-cs"] + require.Len(t, entry.preHooks, 1) + require.Len(t, entry.postHooks, 1) + + _, err := r.Apply("test-cs", hookTestEnv(t)) + require.NoError(t, err) + require.Equal(t, []string{"pre", "post"}, hookExecutions) + }) + + t.Run("post-proposal hooks", func(t *testing.T) { + t.Parallel() + + hookExecutions := []string{} + + cs := Configure(MyChangeSet). + With("cfg"). + WithPostProposalHooks(proposalHook(&hookExecutions)) + + r := newRegistry(cs) + + entry := r.entries["test-cs"] + require.Len(t, entry.postProposalHooks, 1, "Add should extract post-proposal-hooks via hookCarrier") + require.Equal(t, "proposal-hook", entry.postProposalHooks[0].Name) + + err := r.RunProposalHooks("test-cs", hookTestEnv(t), nil, "input", nil) + require.NoError(t, err) + require.Equal(t, []string{"proposal"}, hookExecutions) + }) + + t.Run("post-proposal hooks through ThenWith", func(t *testing.T) { + t.Parallel() + + hookExecutions := []string{} + + cs := Configure(MyChangeSet). + With("cfg"). + WithPostProposalHooks(proposalHook(&hookExecutions)). + ThenWith(thenWith) + + r := newRegistry(cs) + + entry := r.entries["test-cs"] + require.Len(t, entry.postProposalHooks, 1) + require.Equal(t, "proposal-hook", entry.postProposalHooks[0].Name) + + err := r.RunProposalHooks("test-cs", hookTestEnv(t), nil, "input", nil) + require.NoError(t, err) + require.Equal(t, []string{"proposal"}, hookExecutions) + }) } func Test_WithHooks_SliceIsolation(t *testing.T) { @@ -1119,3 +1166,82 @@ func Test_Apply_HappyPath_WithHooks(t *testing.T) { assert.True(t, preHookRan, "pre-hook should have run") assert.True(t, postHookRan, "post-hook should have run") } + +func Test_WithPostProposalHooks(t *testing.T) { + t.Parallel() + + noop := func(_ context.Context, _ PostProposalHookParams) error { return nil } + h1 := PostProposalHook{HookDefinition: HookDefinition{Name: "h1"}, Func: noop} + h2 := PostProposalHook{HookDefinition: HookDefinition{Name: "h2"}, Func: noop} + thenWith := func(_ fdeployment.Environment, o fdeployment.ChangesetOutput) (fdeployment.ChangesetOutput, error) { + return o, nil + } + + t.Run("additive across multiple calls", func(t *testing.T) { + t.Parallel() + + cs := Configure(MyChangeSet).With("cfg"). + WithPostProposalHooks(h1). + WithPostProposalHooks(h2) + + hooks := cs.(hookCarrier).getPostProposalHooks() + require.Len(t, hooks, 2) + require.Equal(t, "h1", hooks[0].Name) + require.Equal(t, "h2", hooks[1].Name) + }) + + t.Run("slice isolation between branches", func(t *testing.T) { + t.Parallel() + + base := Configure(MyChangeSet).With("cfg").WithPostProposalHooks(h1) + branch := base.WithPostProposalHooks(h2) + + require.Len(t, base.(hookCarrier).getPostProposalHooks(), 1) + require.Len(t, branch.(hookCarrier).getPostProposalHooks(), 2) + }) + + t.Run("single hook carried forward through ThenWith", func(t *testing.T) { + t.Parallel() + + cs := Configure(MyChangeSet).With("cfg"). + WithPostProposalHooks(h1). + ThenWith(thenWith) + + hooks := cs.(hookCarrier).getPostProposalHooks() + require.Len(t, hooks, 1) + require.Equal(t, "h1", hooks[0].Name) + }) + + t.Run("multiple hooks carried forward through ThenWith", func(t *testing.T) { + t.Parallel() + + cs := Configure(MyChangeSet).With("cfg"). + WithPostProposalHooks(h1). + WithPostProposalHooks(h2). + ThenWith(thenWith) + + hooks := cs.(hookCarrier).getPostProposalHooks() + require.Len(t, hooks, 2) + require.Equal(t, "h1", hooks[0].Name) + require.Equal(t, "h2", hooks[1].Name) + }) +} + +func Test_Changesets_AddGlobalPostProposalHooks(t *testing.T) { + t.Parallel() + + r := NewChangesetsRegistry() + + h1 := PostProposalHook{HookDefinition: HookDefinition{Name: "h1"}} + h2 := PostProposalHook{HookDefinition: HookDefinition{Name: "h2"}} + h3 := PostProposalHook{HookDefinition: HookDefinition{Name: "h3"}} + + r.AddGlobalPostProposalHooks(h1, h2) + require.Len(t, r.globalPostProposalHooks, 2) + require.Equal(t, "h1", r.globalPostProposalHooks[0].Name) + require.Equal(t, "h2", r.globalPostProposalHooks[1].Name) + + r.AddGlobalPostProposalHooks(h3) + require.Len(t, r.globalPostProposalHooks, 3) + require.Equal(t, "h3", r.globalPostProposalHooks[2].Name) +} diff --git a/engine/cld/commands/commands.go b/engine/cld/commands/commands.go index 52615cf1b..3a5c5d23d 100644 --- a/engine/cld/commands/commands.go +++ b/engine/cld/commands/commands.go @@ -102,6 +102,9 @@ type MCMSConfig struct { // ProposalRenderers are custom renderers registered into analyze-proposal-v2. ProposalRenderers []proposalrenderer.Renderer + + // LoadChangesets are custom changeset loading functions. Optional + LoadChangesets func(envName string) (*cs.ChangesetsRegistry, error) } // MCMS creates the mcms command group for proposal analysis and conversion. @@ -112,6 +115,7 @@ func (c *Commands) MCMS(dom domain.Domain, cfg MCMSConfig) (*cobra.Command, erro ProposalContextProvider: cfg.ProposalContextProvider, ProposalAnalyzers: cfg.ProposalAnalyzers, ProposalRenderers: cfg.ProposalRenderers, + LoadChangesets: cfg.LoadChangesets, }) } diff --git a/engine/cld/commands/mcms/cmd_execute_fork.go b/engine/cld/commands/mcms/cmd_execute_fork.go index c332a683c..fce78cea7 100644 --- a/engine/cld/commands/mcms/cmd_execute_fork.go +++ b/engine/cld/commands/mcms/cmd_execute_fork.go @@ -64,10 +64,11 @@ type executeForkFlags struct { proposalKind string chainSelector uint64 testSigner bool + randomSalt bool } // newExecuteForkCmd creates the "execute-fork" subcommand. -func newExecuteForkCmd(cfg Config) *cobra.Command { +func newExecuteForkCmd(mcmsCfg Config) *cobra.Command { cmd := &cobra.Command{ Use: "execute-fork", Short: executeForkShort, @@ -80,9 +81,10 @@ func newExecuteForkCmd(cfg Config) *cobra.Command { proposalKind: flags.MustString(cmd.Flags().GetString("proposalKind")), chainSelector: flags.MustUint64(cmd.Flags().GetUint64("selector")), testSigner: flags.MustBool(cmd.Flags().GetBool("test-signer")), + randomSalt: flags.MustBool(cmd.Flags().GetBool("random-salt")), } - return runExecuteFork(cmd, cfg, f) + return runExecuteFork(cmd, mcmsCfg, f) }, } @@ -94,18 +96,24 @@ func newExecuteForkCmd(cfg Config) *cobra.Command { // Fork-specific flags cmd.Flags().Bool("test-signer", false, "Use a test signer key") + cmd.Flags().Bool("random-salt", false, "Override the proposal's salt with a random value. "+ + "Useful to run fork tests with proposals already executed onchain.") return cmd } // runExecuteFork executes the execute-fork command logic. -func runExecuteFork(cmd *cobra.Command, cfg Config, f executeForkFlags) error { +func runExecuteFork(cmd *cobra.Command, mcmsCfg Config, f executeForkFlags) error { ctx := cmd.Context() - deps := cfg.deps() + deps := mcmsCfg.deps() // --- Load all data first --- - proposalCfg, err := LoadProposalConfig(ctx, cfg.Logger, cfg.Domain, deps, cfg.ProposalContextProvider, + loadOptions := []any{acceptExpiredProposal} + if f.randomSalt { + loadOptions = append(loadOptions, randomSalt) + } + proposalCfg, err := LoadProposalConfig(ctx, mcmsCfg.Logger, mcmsCfg.Domain, deps, mcmsCfg.ProposalContextProvider, ProposalFlags{ ProposalPath: f.proposalPath, ProposalKind: f.proposalKind, @@ -113,7 +121,7 @@ func runExecuteFork(cmd *cobra.Command, cfg Config, f executeForkFlags) error { ChainSelector: f.chainSelector, Fork: true, }, - acceptExpiredProposal, + loadOptions..., ) if err != nil { return fmt.Errorf("error creating config: %w", err) @@ -140,7 +148,7 @@ func runExecuteFork(cmd *cobra.Command, cfg Config, f executeForkFlags) error { } // Execute the fork - return executeFork(ctx, cfg.Logger, forkCfg, f.testSigner) + return executeFork(ctx, mcmsCfg, forkCfg, f.testSigner) } // --- Fork execution logic (fork-specific) --- @@ -148,8 +156,10 @@ func runExecuteFork(cmd *cobra.Command, cfg Config, f executeForkFlags) error { // executeFork executes a proposal on a forked environment. // This is the main entry point for fork execution. func executeFork( - ctx context.Context, lggr logger.Logger, cfg *forkConfig, testSigner bool, + ctx context.Context, mcmsCfg Config, cfg *forkConfig, testSigner bool, ) error { + lggr := mcmsCfg.Logger + family, err := chainsel.GetSelectorFamily(cfg.chainSelector) if err != nil { return fmt.Errorf("failed to get selector family: %w", err) @@ -233,7 +243,7 @@ func executeFork( if err = anvilClient.EVMIncreaseTime(uint64(cfg.timelockProposal.Delay.Seconds())); err != nil { return fmt.Errorf("failed to increase time: %w", err) } - if err = anvilClient.AnvilMine([]interface{}{1}); err != nil { + if err = anvilClient.AnvilMine([]any{1}); err != nil { return fmt.Errorf("failed to mine block: %w", err) } @@ -244,7 +254,7 @@ func executeFork( } lggr.Info("Executing timelock chain command") - err = timelockExecuteChainCommand(ctx, lggr, cfg) + reports, err := timelockExecuteChainCommand(ctx, lggr, cfg) if err != nil { lggr.Warnw("Timelock.execute() - failure; starting calling individual ops for debugging", "err", err) if derr := diagnoseTimelockRevert(ctx, lggr, anvilClient.URL, cfg.chainSelector, cfg.timelockProposal.Operations, @@ -258,6 +268,17 @@ func executeFork( } lggr.Info("Timelock.execute() - success") + if mcmsCfg.LoadChangesets == nil { + lggr.Debug("LoadChangesets function not set in mcms config; skipping proposal hooks") + return nil + } + + cfg.env.Name = cfg.envStr // ensure hooks load the correct env config for the fork + err = runHooksInternal(mcmsCfg, cfg.env, cfg.timelockProposal, reports) + if err != nil { + lggr.Warnw("Failed to run post-execution hooks", "err", err) + } + return nil } @@ -332,11 +353,11 @@ func logTransactions(lggr logger.Logger, cfg *forkConfig) { return } - if _, alreadyWrapped := evmChain.Client.(*loggingRpcClient); alreadyWrapped { + if _, alreadyWrapped := evmChain.Client.(*loggingRPCClient); alreadyWrapped { return } - evmChain.Client = &loggingRpcClient{OnchainClient: evmChain.Client, txOpts: evmChain.DeployerKey, lggr: lggr} + evmChain.Client = &loggingRPCClient{OnchainClient: evmChain.Client, txOpts: evmChain.DeployerKey, lggr: lggr} chains[cfg.chainSelector] = evmChain cfg.blockchains = chain.NewBlockChains(chains) } @@ -445,14 +466,14 @@ func tryDecodeHexFromErrorString(errStr string, dec *ErrDecoder) string { return "" } -// loggingRpcClient wraps an OnchainClient to log transactions before sending. -type loggingRpcClient struct { +// loggingRPCClient wraps an OnchainClient to log transactions before sending. +type loggingRPCClient struct { cldf_evm.OnchainClient txOpts *bind.TransactOpts lggr logger.Logger } -func (c *loggingRpcClient) SendTransaction(ctx context.Context, tx *gethtypes.Transaction) error { +func (c *loggingRPCClient) SendTransaction(ctx context.Context, tx *gethtypes.Transaction) error { c.lggr.Infow("sending on-chain transaction", "from", c.txOpts.From, "to", tx.To(), "value", tx.Value(), "data", common.Bytes2Hex(tx.Data())) diff --git a/engine/cld/commands/mcms/cmd_run_hooks.go b/engine/cld/commands/mcms/cmd_run_hooks.go new file mode 100644 index 000000000..48ba888ad --- /dev/null +++ b/engine/cld/commands/mcms/cmd_run_hooks.go @@ -0,0 +1,179 @@ +package mcms + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "slices" + + "github.com/go-viper/mapstructure/v2" + "github.com/samber/lo" + "github.com/smartcontractkit/mcms" + mcmstypes "github.com/smartcontractkit/mcms/types" + "github.com/spf13/cobra" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldfchangeset "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/flags" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/text" +) + +var ( + runHooksShort = "Run post proposal execution hooks" + + runHooksLong = text.LongDesc(` + Run post proposal execution hooks + `) + + runHooksExample = text.Examples(` + # Run post proposal execution hooks + myapp mcms hooks \ + --environment staging \ + --report ./path/to/timelock-execution-report.json \ + --proposal ./path/to/proposal.json \ + --selector 12345678901234567890 + `) +) + +type runHooksFlags struct { + environment string + proposalPath string + proposalKind string + chainSelector uint64 + reports []cldfchangeset.MCMSTimelockExecuteReport +} + +type proposalMetadata struct { + Changesets []changesetMetadata `json:"changesets" mapstructure:"changesets"` + PostExecutionHooks []proposalHooksMetadata `json:"postExecutionHooks" mapstructure:"postExecutionHooks"` +} + +type proposalHooksMetadata struct { + Name string `json:"name" mapstructure:"name"` + Input any `json:"input" mapstructure:"input"` +} + +type changesetMetadata struct { + Name string `json:"name" mapstructure:"name"` + OperationIDs []string `json:"operationIDs" mapstructure:"operationIDs"` + Input any `json:"input" mapstructure:"input"` +} + +func newRunProposalHooksCmd(cfg Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "hooks", + Short: runHooksShort, + Long: runHooksLong, + Example: runHooksExample, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if cfg.LoadChangesets == nil { + return errors.New("load changesets function is required to run hooks") + } + + reports, err := loadReport(flags.MustString(cmd.Flags().GetString("report"))) + if err != nil { + return fmt.Errorf("failed to load report file: %w", err) + } + + f := runHooksFlags{ + environment: flags.MustString(cmd.Flags().GetString("environment")), + proposalPath: flags.MustString(cmd.Flags().GetString("proposal")), + proposalKind: flags.MustString(cmd.Flags().GetString("proposalKind")), + chainSelector: flags.MustUint64(cmd.Flags().GetUint64("selector")), + reports: reports, + } + + return runHooks(cmd.Context(), cfg, f) + }, + } + + flags.Environment(cmd) + flags.Proposal(cmd) + flags.ProposalKind(cmd, string(mcmstypes.KindTimelockProposal)) + flags.ChainSelector(cmd, true) + cmd.Flags().String("report", "", "File with timelock execution report.") + _ = cmd.MarkFlagRequired("report") + + return cmd +} + +func runHooks(ctx context.Context, cfg Config, hFlags runHooksFlags) error { + deps := cfg.deps() + + proposalCfg, err := LoadProposalConfig(ctx, cfg.Logger, cfg.Domain, deps, cfg.ProposalContextProvider, + ProposalFlags{ + ProposalPath: hFlags.proposalPath, + ProposalKind: hFlags.proposalKind, + Environment: hFlags.environment, + ChainSelector: hFlags.chainSelector, + }, + acceptExpiredProposal, + ) + if err != nil { + return fmt.Errorf("failed to create proposal config: %w", err) + } + + if proposalCfg.TimelockProposal == nil { + return errors.New("expected proposal to be a TimelockProposal") + } + + return runHooksInternal(cfg, proposalCfg.Env, proposalCfg.TimelockProposal, hFlags.reports) +} + +func runHooksInternal( + cfg Config, + env cldf.Environment, + timelockProposal *mcms.TimelockProposal, + reports []cldfchangeset.MCMSTimelockExecuteReport, +) error { + if cfg.LoadChangesets == nil { + return errors.New("LoadChangesets function is required for proposal hook execution") + } + + var metadata proposalMetadata + err := mapstructure.Decode(timelockProposal.Metadata, &metadata) + if err != nil { + return fmt.Errorf("failed to unmarshal hooks metadata: %w", err) + } + + changesetRegistry, err := cfg.LoadChangesets(env.Name) + if err != nil { + return fmt.Errorf("failed to load changesets: %w", err) + } + + for _, changeset := range metadata.Changesets { + changesetReports := lo.Filter(reports, func(r cldfchangeset.MCMSTimelockExecuteReport, _ int) bool { + return slices.Contains(changeset.OperationIDs, r.Input.OperationID.Hex()) + }) + + herr := changesetRegistry.RunProposalHooks(changeset.Name, env, timelockProposal, changeset.Input, changesetReports) + if herr != nil { + cfg.Logger.Errorw("proposal hook failed", "changeset", changeset.Name, "error", herr) + err = errors.Join(err, fmt.Errorf("proposal hook for changeset %q failed: %w", changeset.Name, herr)) + } + } + + return err +} + +func loadReport(path string) ([]cldfchangeset.MCMSTimelockExecuteReport, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open report file: %w", err) + } + defer file.Close() + + decoder := json.NewDecoder(file) + decoder.UseNumber() + + var report []cldfchangeset.MCMSTimelockExecuteReport + err = decoder.Decode(&report) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal report file: %w", err) + } + + return report, nil +} diff --git a/engine/cld/commands/mcms/cmd_run_hooks_test.go b/engine/cld/commands/mcms/cmd_run_hooks_test.go new file mode 100644 index 000000000..d233c6dbf --- /dev/null +++ b/engine/cld/commands/mcms/cmd_run_hooks_test.go @@ -0,0 +1,364 @@ +package mcms + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + zapcoreobserver "go.uber.org/zap/zaptest/observer" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset" + cldfdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + cldfenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +func Test_newRunProposalHooksCmd(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + args []string + cfg Config + logs *zapcoreobserver.ObservedLogs + testDir string + setup func(*testing.T, *testCase) + assert func(*testing.T, *testCase, error) + } + noopSetup := func(*testing.T, *testCase) {} + + tests := []testCase{ + { + name: "failure: required flags not set", + args: []string{}, + cfg: Config{}, + setup: noopSetup, + assert: func(t *testing.T, _ *testCase, err error) { + t.Helper() + require.ErrorContains(t, err, `required flag(s) "environment", "proposal", "report", "selector" not set`) + }, + }, + { + name: "failure: no LoadChangesetFunction provided", + args: []string{ + "--environment", "testnet", + "--proposal", "proposal.json", + "--report", "report.json", + "--selector", strconv.FormatUint(chainsel.GETH_TESTNET.Selector, 10), + }, + cfg: Config{}, + setup: noopSetup, + assert: func(t *testing.T, _ *testCase, err error) { + t.Helper() + require.ErrorContains(t, err, "load changesets function is required to run hooks") + }, + }, + { + name: "success: processes proposal without changesets without any side effects", + cfg: Config{ + LoadChangesets: loadChangesets, + Deps: Deps{ + EnvironmentLoader: func( + ctx context.Context, domain cldfdomain.Domain, envKey string, lggr logger.Logger, opts ...cldfenv.LoadEnvironmentOption, + ) (cldf.Environment, error) { + return cldf.Environment{ + Name: envKey, + Logger: lggr, + GetContext: func() context.Context { return ctx }, + }, nil + }, + }, + }, + setup: func(t *testing.T, testCtx *testCase) { + t.Helper() + testCtx.testDir = t.TempDir() + + envName := "testnet" + err := os.Mkdir(filepath.Join(testCtx.testDir, envName), 0o700) + require.NoError(t, err) + + proposalPath := filepath.Join(testCtx.testDir, envName, "proposal.json") + err = os.WriteFile(proposalPath, testProposalWithoutChangesetsJSON, 0o600) + require.NoError(t, err) + + reportPath := filepath.Join(testCtx.testDir, envName, "report.json") + err = os.WriteFile(reportPath, testReportJSON, 0o600) + require.NoError(t, err) + + testCtx.args = []string{ + "--environment", envName, + "--proposal", proposalPath, + "--report", reportPath, + "--selector", strconv.FormatUint(chainsel.GETH_TESTNET.Selector, 10), + } + + lggr, logs := logger.TestObserved(t, zapcore.DebugLevel) + testCtx.cfg.Logger = lggr + testCtx.logs = logs + }, + assert: func(t *testing.T, testCtx *testCase, err error) { + t.Helper() + require.NoError(t, err) + require.Equal(t, 0, testCtx.logs.FilterMessage("test-changeset-post-proposal-hook executed").Len()) + require.Equal(t, 0, testCtx.logs.FilterMessage("test-global-post-proposal-hook executed").Len()) + }, + }, + { + name: "success: processes proposal with two changesets, only one of which has a hook", + cfg: Config{ + LoadChangesets: loadChangesets, + Deps: Deps{ + EnvironmentLoader: func( + ctx context.Context, domain cldfdomain.Domain, envKey string, lggr logger.Logger, opts ...cldfenv.LoadEnvironmentOption, + ) (cldf.Environment, error) { + return cldf.Environment{ + Name: envKey, + Logger: lggr, + GetContext: func() context.Context { return ctx }, + }, nil + }, + }, + }, + setup: func(t *testing.T, testCtx *testCase) { + t.Helper() + testCtx.testDir = t.TempDir() + + envName := "testnet" + err := os.Mkdir(filepath.Join(testCtx.testDir, envName), 0o700) + require.NoError(t, err) + + proposalPath := filepath.Join(testCtx.testDir, envName, "proposal.json") + err = os.WriteFile(proposalPath, testProposalWithChangesetsJSON, 0o600) + require.NoError(t, err) + + reportPath := filepath.Join(testCtx.testDir, envName, "report.json") + err = os.WriteFile(reportPath, testReportJSON, 0o600) + require.NoError(t, err) + + testCtx.args = []string{ + "--environment", envName, + "--proposal", proposalPath, + "--report", reportPath, + "--selector", strconv.FormatUint(chainsel.GETH_TESTNET.Selector, 10), + } + + lggr, logs := logger.TestObserved(t, zapcore.DebugLevel) + testCtx.cfg.Logger = lggr + testCtx.logs = logs + }, + assert: func(t *testing.T, testCtx *testCase, err error) { + t.Helper() + require.NoError(t, err) + require.Equal(t, 1, testCtx.logs.FilterMessage("test-changeset-post-proposal-hook executed").Len()) + require.Equal(t, 2, testCtx.logs.FilterMessage("test-global-post-proposal-hook executed").Len()) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tt.setup(t, &tt) + + cmd := newRunProposalHooksCmd(tt.cfg) + cmd.SetArgs(tt.args) + err := cmd.Execute() + + tt.assert(t, &tt, err) + }) + } +} + +// ----- helpers ----- + +var testProposalWithoutChangesetsJSON = []byte(`{ + "version": "v1", + "kind": "TimelockProposal", + "validUntil": 2004259681, + "chainMetadata": { + "3379446385462418246": { + "mcmAddress": "0x0000000000000000000000000000000000000001", + "startingOpCount": 0, + "additionalFields": {} + } + }, + "description": "Test proposal", + "overridePreviousRoot": false, + "action": "schedule", + "delay": "1h0m0s", + "signatures": null, + "timelockAddresses": { + "3379446385462418246": "0x0000000000000000000000000000000000000002" + }, + "operations": [ + { + "operationID": "0x342ae55e5f86f04edeb7f9294370354a07ca69e8c9e95c92b71b7e28ca799195", + "chainSelector": 3379446385462418246, + "transactions": [ + { + "to": "0x0000000000000000000000000000000000000000", + "additionalFields": {"value": 0}, + "data": "ZGF0YQ==", + "contractType": "", + "tags": null + } + ] + } + ] +}`) + +var testProposalWithChangesetsJSON = []byte(`{ + "version": "v1", + "kind": "TimelockProposal", + "validUntil": 2004259681, + "chainMetadata": { + "3379446385462418246": { + "mcmAddress": "0x0000000000000000000000000000000000000001", + "startingOpCount": 0, + "additionalFields": {} + } + }, + "timelockAddresses": { + "3379446385462418246": "0x0000000000000000000000000000000000000002" + }, + "description": "Test proposal", + "overridePreviousRoot": false, + "action": "schedule", + "delay": "1h0m0s", + "signatures": null, + "metadata": { + "changesets": [ + { + "name": "001_test_changeset", + "input": {}, + "operationIDs": ["0x342ae55e5f86f04edeb7f9294370354a07ca69e8c9e95c92b71b7e28ca799195"] + }, + { + "name": "002_test_changeset", + "input": {}, + "operationIDs": ["0x7035f429cd9f1ee3455617b74a0b29b29b7af8c24aa48b9b1f0827f9d76571da"] + } + ] + }, + "operations": [ + { + "operationID": "0x342ae55e5f86f04edeb7f9294370354a07ca69e8c9e95c92b71b7e28ca799195", + "chainSelector": 3379446385462418246, + "transactions": [ + { + "to": "0x0000000000000000000000000000000000000003", + "additionalFields": {"value": 0}, + "data": "ZGF0YQ==" + } + ] + }, + { + "operationID": "0x7035f429cd9f1ee3455617b74a0b29b29b7af8c24aa48b9b1f0827f9d76571da", + "chainSelector": 3379446385462418246, + "transactions": [ + { + "to": "0x0000000000000000000000000000000000000004", + "additionalFields": {"value": 0}, + "data": "ZGF0YQ==" + } + ] + } + ] +}`) + +var testReportJSON = []byte(`[ + { + "id": "f4a81ef3-54f2-46f0-82a8-b3954c355b5f", + "status": "SUCCESS", + "timestamp": "2026-03-25T23:18:23.394643-03:00", + "input": { + "index": 0, + "operationID": "0x2a7a360a499fdbddead746aa11c1fbbf2b7ed1f2b86ee398fb4cad610e2ecb9d", + "chainSelector": 3379446385462418246, + "timelockAddress": "0xa316c2dEeaF7593F7E3Ce15D69D80eF60Aa1A919", + "mcmAddress": "0xB09e94838Bd0c7c0ba105705Ec09ED6a10953EDe", + "additionalFields": null + }, + "output": { + "transactionResult": { + "hash": "0xda30e0c13e66e2fdec526620e5e655faa4d5de3139d0c04f7515d0cb3b145aab", + "chainFamily": "evm", + "rawData": { + "type": "0x2", + "chainId": "0x14a34", + "nonce": "0x4a", + "to": "0x6a08ed6cba5398f061eac2b3f01e0047974851d0", + "gas": "0x1043c8", + "gasPrice": null, + "maxPriorityFeePerGas": "0xf4240", + "maxFeePerGas": "0xa7d8c0", + "value": "0x0", + "input": "0x6ceef48000000000", + "accessList": [], + "v": "0x1", + "r": "0x809f6092d7a9ac3c186be1b0faeba873f575561e8b7393bda57fc505f80b8932", + "s": "0x51dded11cf4e94a99084d8c0d081835b73b7f3a00b049c467bcfd9b640ae047", + "yParity": "0x1", + "hash": "0xda30e0c13e66e2fdec526620e5e655faa4d5de3139d0c04f7515d0cb3b145aab" + } + } + } + } +]`) + +func loadChangesets(envName string) (*changeset.ChangesetsRegistry, error) { + registry := changeset.NewChangesetsRegistry() + + registry.Add("001_test_changeset", + changeset.Configure(TestChangeset{}). + With(testChangesetConfig{}). + WithPostProposalHooks(changeset.PostProposalHook{ + HookDefinition: changeset.HookDefinition{ + Name: "test-changeset-post-proposal-hook", + Timeout: 30 * time.Second, + FailurePolicy: changeset.Abort, + }, + Func: func(ctx context.Context, params changeset.PostProposalHookParams) error { + params.Env.Logger.Info("test-changeset-post-proposal-hook executed") + return nil + }, + }), + ) + + registry.Add("002_test_changeset", + changeset.Configure(TestChangeset{}). + With(testChangesetConfig{})) + + registry.AddGlobalPostProposalHooks(changeset.PostProposalHook{ + HookDefinition: changeset.HookDefinition{ + Name: "test-global-post-proposal-hook", + Timeout: 30 * time.Second, + FailurePolicy: changeset.Abort, + }, + Func: func(ctx context.Context, params changeset.PostProposalHookParams) error { + params.Env.Logger.Info("test-global-post-proposal-hook executed") + return nil + }, + }) + + return registry, nil +} + +type testChangesetConfig struct{} + +type TestChangeset struct{} + +func (TestChangeset) Apply(env cldf.Environment, cfg testChangesetConfig) (cldf.ChangesetOutput, error) { + return cldf.ChangesetOutput{}, nil +} + +func (TestChangeset) VerifyPreconditions(env cldf.Environment, cfg testChangesetConfig) error { + return nil +} diff --git a/engine/cld/commands/mcms/command.go b/engine/cld/commands/mcms/command.go index d46bf021d..5b5cff47c 100644 --- a/engine/cld/commands/mcms/command.go +++ b/engine/cld/commands/mcms/command.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/text" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" proposalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/analyzer" @@ -43,6 +44,9 @@ type Config struct { // ProposalRenderers are custom renderers registered into the v2 proposal analysis engine. ProposalRenderers []proposalrenderer.Renderer + // LoadChangeset are custom changeset loading functions. Optional + LoadChangesets func(envName string) (*changeset.ChangesetsRegistry, error) + // Deps holds optional dependencies that can be overridden. // If fields are nil, production defaults are used. Deps Deps @@ -102,6 +106,7 @@ func NewCommand(cfg Config) (*cobra.Command, error) { cmd.AddCommand(newAnalyzeProposalV2Cmd(cfg)) cmd.AddCommand(newConvertUpfCmd(cfg)) cmd.AddCommand(newExecuteForkCmd(cfg)) + cmd.AddCommand(newRunProposalHooksCmd(cfg)) return cmd, nil } diff --git a/engine/cld/commands/mcms/deps.go b/engine/cld/commands/mcms/deps.go index 2600766c7..56a071aa9 100644 --- a/engine/cld/commands/mcms/deps.go +++ b/engine/cld/commands/mcms/deps.go @@ -2,6 +2,7 @@ package mcms import ( "context" + "math/big" "github.com/smartcontractkit/mcms" "github.com/smartcontractkit/mcms/types" @@ -21,6 +22,15 @@ type EnvironmentLoaderFunc func( opts ...cldfenvironment.LoadEnvironmentOption, ) (cldf.Environment, error) +// ForkEnvironmentLoaderFunc loads a fork environment. +type ForkEnvironmentLoaderFunc func( + ctx context.Context, + domain domain.Domain, + envKey string, + blockNumbers map[uint64]*big.Int, + opts ...cldfenvironment.LoadEnvironmentOption, +) (cldfenvironment.ForkedEnvironment, error) + // ProposalLoaderFunc loads a proposal from a file. type ProposalLoaderFunc func(kind types.ProposalKind, path string) (mcms.ProposalInterface, error) @@ -29,6 +39,9 @@ type Deps struct { // EnvironmentLoader loads a deployment environment. EnvironmentLoader EnvironmentLoaderFunc + // ForkEnvironmentLoader loads a deployment environment. + ForkEnvironmentLoader ForkEnvironmentLoaderFunc + // ProposalLoader loads a proposal from a file. ProposalLoader ProposalLoaderFunc } @@ -38,6 +51,9 @@ func (d *Deps) applyDefaults() { if d.EnvironmentLoader == nil { d.EnvironmentLoader = defaultEnvironmentLoader } + if d.ForkEnvironmentLoader == nil { + d.ForkEnvironmentLoader = cldfenvironment.LoadFork + } if d.ProposalLoader == nil { d.ProposalLoader = mcms.LoadProposal } diff --git a/engine/cld/commands/mcms/execute_fork_integration_test.go b/engine/cld/commands/mcms/execute_fork_integration_test.go index ee951cb58..decff960f 100644 --- a/engine/cld/commands/mcms/execute_fork_integration_test.go +++ b/engine/cld/commands/mcms/execute_fork_integration_test.go @@ -73,51 +73,57 @@ func Test_executeFork_Integration(t *testing.T) { //nolint:paralleltest DeployerTransactorGen: cldfchainprovider.TransactorFromRaw(domainConfig.Env.Onchain.EVM.DeployerKey), T: t, } - provider := cldfchainprovider.NewCTFAnvilChainProvider(chainsel.GETH_TESTNET.Selector, anvilConfig) + chainSelector := chainsel.GETH_TESTNET.Selector + provider := cldfchainprovider.NewCTFAnvilChainProvider(chainSelector, anvilConfig) evmChain, err := provider.Initialize(t.Context()) require.NoError(t, err) + t.Cleanup(func() { _ = provider.Container.Terminate(t.Context()) }) saveDomainNetworkConfigForIntegration(t, &domain, integrationEnvName, domainConfig, provider, anvilConfig.Port) - env, err := cldfenv.Load(t.Context(), domain, integrationEnvName) + env, err := cldfenv.Load(t.Context(), domain, integrationEnvName, cldfenv.WithLogger(lggr)) require.NoError(t, err) env.BlockChains = cldfchain.NewBlockChains(map[uint64]cldfchain.BlockChain{ chainsel.GETH_TESTNET.Selector: evmChain, }) - chain := slices.Collect(maps.Values(env.BlockChains.EVMChains()))[0] mcmAddress, timelockAddress, callProxyAddress, env := deployMCMSForIntegration(t, env) saveChangesetOutputsForIntegration(t, domain, env, "deploy-mcms") - timelockProposal, mcmProposal := testTimelockProposalForIntegration(t, chain, timelockAddress, mcmAddress) - forkedEnv, err := cldfenv.LoadFork(t.Context(), domain, env.Name, nil, - cldfenv.WithLogger(lggr), cldfenv.OnlyLoadChainsFor([]uint64{chain.Selector}), + cldfenv.WithLogger(lggr), cldfenv.OnlyLoadChainsFor([]uint64{chainSelector}), cldfenv.WithAnvilKeyAsDeployer(), cldfenv.WithoutJD()) require.NoError(t, err) + forkedChain := slices.Collect(maps.Values(forkedEnv.BlockChains.EVMChains()))[0] + + timelockProposal, mcmProposal := testTimelockProposal(t, forkedChain, timelockAddress, mcmAddress, 2082758399) proposalCtx, err := analyzer.NewDefaultProposalContext(env) require.NoError(t, err) + defaultForkCfg := &forkConfig{ + kind: mcmstypes.KindTimelockProposal, + proposal: mcmProposal, + timelockProposal: &timelockProposal, + chainSelector: chainSelector, + blockchains: forkedEnv.BlockChains, + envStr: env.Name, + env: env, + fork: true, + forkedEnv: forkedEnv, + proposalCtx: proposalCtx, + } + tests := []struct { - name string - cfg *forkConfig - assert func(err error) + name string + mcmsCfg Config + cfg func() *forkConfig + assert func(err error) }{ { - name: "success", - cfg: &forkConfig{ - kind: mcmstypes.KindTimelockProposal, - proposal: mcmProposal, - timelockProposal: &timelockProposal, - chainSelector: chain.Selector, - blockchains: forkedEnv.BlockChains, - envStr: env.Name, - env: env, - fork: true, - forkedEnv: forkedEnv, - proposalCtx: proposalCtx, - }, + name: "success: hooks disabled (LoadChangesets not in mcms config)", + mcmsCfg: Config{Logger: lggr}, + cfg: func() *forkConfig { return defaultForkCfg }, assert: func(err error) { require.NoError(t, err) require.Equal(t, 1, logs.FilterMessageSnippet("MCM.setRoot() - success").Len()) @@ -146,10 +152,45 @@ func Test_executeFork_Integration(t *testing.T) { //nolint:paralleltest }) }, }, + { + name: "success: hooks enabled (LoadChangesets in mcms config)", + mcmsCfg: Config{ + Logger: lggr, + Domain: domain, + LoadChangesets: loadChangesets, + }, + cfg: func() *forkConfig { + timelockProposal, mcmProposal := testTimelockProposal(t, forkedChain, timelockAddress, mcmAddress, 2082758400) + fcfg := *defaultForkCfg + fcfg.proposal = mcmProposal + fcfg.timelockProposal = &timelockProposal + fcfg.timelockProposal.Metadata = map[string]any{ + "changesets": []any{ + map[string]any{ + "name": "001_test_changeset", + "input": map[string]any{}, + "operationIDs": []any{"0x342ae55e5f86f04edeb7f9294370354a07ca69e8c9e95c92b71b7e28ca799195"}, + }, + }, + } + + return &fcfg + }, + assert: func(err error) { + require.NoError(t, err) + require.Equal(t, 1, logs.FilterMessageSnippet("MCM.setRoot() - success").Len()) + require.Equal(t, 1, logs.FilterMessageSnippet("MCM.execute() - success").Len()) + require.Equal(t, 1, logs.FilterMessageSnippet("Timelock.execute() - success").Len()) + require.Equal(t, 3, logs.FilterMessage("sending on-chain transaction").Len()) + require.Equal(t, 1, logs.FilterMessage("test-changeset-post-proposal-hook executed").Len()) + require.Equal(t, 1, logs.FilterMessage("test-global-post-proposal-hook executed").Len()) + }, + }, } for _, tt := range tests { //nolint:paralleltest t.Run(tt.name, func(t *testing.T) { - err := executeFork(t.Context(), lggr, tt.cfg, true) + lggr.Infof("EXECUTING FORK TEST (%s)", tt.name) + err := executeFork(t.Context(), tt.mcmsCfg, tt.cfg(), true) tt.assert(err) logs.TakeAll() // clear logs @@ -310,22 +351,30 @@ func deployTimelockAndCallProxyForIntegration( return timelockAddress.Hex(), callProxyAddress.Hex(), env } -func testTimelockProposalForIntegration( +func testTimelockProposal( t *testing.T, chain evmchain.Chain, timelockAddress string, mcmAddress string, + validUntil uint32, ) (mcms.TimelockProposal, mcms.Proposal) { t.Helper() + opCount, err := mcmsevmsdk.NewInspector(chain.Client).GetOpCount(t.Context(), mcmAddress) + require.NoError(t, err) + t.Logf("%v OPCOUNT: %d", mcmAddress, opCount) + timelockProposal, err := mcms.NewTimelockProposalBuilder(). SetVersion("v1"). - SetValidUntil(2082758399). + SetValidUntil(validUntil). SetDescription("test timelock proposal"). SetOverridePreviousRoot(true). SetAction(mcmstypes.TimelockActionSchedule). AddTimelockAddress(mcmstypes.ChainSelector(chain.Selector), timelockAddress). - AddChainMetadata(mcmstypes.ChainSelector(chain.Selector), mcmstypes.ChainMetadata{MCMAddress: mcmAddress}). + AddChainMetadata(mcmstypes.ChainSelector(chain.Selector), mcmstypes.ChainMetadata{ + StartingOpCount: opCount, + MCMAddress: mcmAddress, + }). AddOperation(mcmstypes.BatchOperation{ ChainSelector: mcmstypes.ChainSelector(chain.Selector), Transactions: []mcmstypes.Transaction{{ diff --git a/engine/cld/commands/mcms/operations.go b/engine/cld/commands/mcms/operations.go index a10f4db30..61e9ce17e 100644 --- a/engine/cld/commands/mcms/operations.go +++ b/engine/cld/commands/mcms/operations.go @@ -5,11 +5,16 @@ import ( "errors" "fmt" "math/big" + "slices" "strings" + "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/go-viper/mapstructure/v2" + "github.com/google/uuid" + "github.com/samber/lo" "github.com/smartcontractkit/mcms" "github.com/smartcontractkit/mcms/sdk" "github.com/smartcontractkit/mcms/sdk/evm/bindings" @@ -20,6 +25,7 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/datastore" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldfchangeset "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset" "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) @@ -141,22 +147,46 @@ func executeChainCommand(ctx context.Context, lggr logger.Logger, cfg *forkConfi return nil } +type timelockExecuteChainCommandOptions struct { + idFn func() string + clockFn func() time.Time +} + +type timelockExecuteChainCommandOption func(*timelockExecuteChainCommandOptions) + // timelockExecuteChainCommand executes timelock operations. -func timelockExecuteChainCommand(ctx context.Context, lggr logger.Logger, cfg *forkConfig) error { +func timelockExecuteChainCommand( + ctx context.Context, lggr logger.Logger, cfg *forkConfig, opts ...timelockExecuteChainCommandOption, +) ([]cldfchangeset.MCMSTimelockExecuteReport, error) { + options := timelockExecuteChainCommandOptions{idFn: uuid.NewString, clockFn: time.Now} + for _, opt := range opts { + opt(&options) + } + if cfg.timelockProposal == nil { - return errors.New("expected proposal to be have non-nil *TimelockProposal") + return nil, errors.New("expected proposal to have non-nil *TimelockProposal") + } + + timelockAddress, ok := cfg.timelockProposal.TimelockAddresses[types.ChainSelector(cfg.chainSelector)] + if !ok { + return nil, errors.New("failed to find timelock address for chain selector in proposal") + } + chainMetadata, ok := cfg.timelockProposal.ChainMetadata[types.ChainSelector(cfg.chainSelector)] + if !ok { + return nil, errors.New("failed to find chain metadata for chain selector in proposal") } executable, err := createTimelockExecutable(ctx, cfg) if err != nil { - return fmt.Errorf("failed to create TimelockExecutable: %w", err) + return nil, fmt.Errorf("failed to create TimelockExecutable: %w", err) } executeOptions, err := timelockExecuteOptions(ctx, lggr, cfg) if err != nil { - return fmt.Errorf("failed to get timelock execute options: %w", err) + return nil, fmt.Errorf("failed to get timelock execute options: %w", err) } + reports := []cldfchangeset.MCMSTimelockExecuteReport{} for i := range cfg.timelockProposal.Operations { if uint64(cfg.timelockProposal.Operations[i].ChainSelector) == cfg.chainSelector { // Check if operation is done, if so, skip it @@ -167,17 +197,39 @@ func timelockExecuteChainCommand(ctx context.Context, lggr logger.Logger, cfg *f } if err := executable.IsOperationReady(ctx, i); err != nil { - return fmt.Errorf("operation %d is not ready to be executed: %w", i, err) + return nil, fmt.Errorf("operation %d is not ready to be executed: %w", i, err) } + timestamp := options.clockFn() + result, err := executable.Execute(ctx, i, executeOptions...) if err != nil { - return fmt.Errorf("failed to execute operation %d: %w", i, err) + return nil, fmt.Errorf("failed to execute operation %d: %w", i, err) } + operationID := cfg.timelockProposal.Operations[i].OperationID + reports = append(reports, cldfchangeset.MCMSTimelockExecuteReport{ + ID: options.idFn(), + Type: cldfchangeset.MCMSTimelockExecuteReportType, + Status: "SUCCESS", + Timestamp: timestamp, + Input: cldfchangeset.MCMSTimelockExecuteReportInput{ + Index: i, + ChainSelector: cfg.chainSelector, + OperationID: operationID, + TimelockAddress: timelockAddress, + MCMAddress: chainMetadata.MCMAddress, + AdditionalFields: chainMetadata.AdditionalFields, + Changeset: findChangeset(operationID, cfg.timelockProposal.Metadata), + }, + Output: cldfchangeset.MCMSTimelockExecuteReportOutput{ + TransactionResult: result, + }, + }) + err = confirmTransaction(ctx, lggr, result, cfg) if err != nil { - return fmt.Errorf("failed to confirm execute transaction: %w", err) + return nil, fmt.Errorf("failed to confirm execute transaction: %w", err) } lggr.Infof("Operation %d executed successfully: %s\n", i, result) @@ -186,7 +238,7 @@ func timelockExecuteChainCommand(ctx context.Context, lggr logger.Logger, cfg *f lggr.Infof("All operations executed successfully") - return nil + return reports, nil } // confirmTransaction waits for a transaction to be confirmed. @@ -398,3 +450,34 @@ func addCallProxyOption( return fmt.Errorf("failed to find call proxy contract for timelock %v", timelockAddress) } + +// findChangeset finds the changeset name and index for the given operation ID from the proposal +// metadata. It assumes the metadata has a "changesets" field which is a list of changesets (as +// implemented in CLDF) +func findChangeset(operationID common.Hash, metadata map[string]any) cldfchangeset.MCMSReportChangeset { + if metadata == nil { + return cldfchangeset.MCMSReportChangeset{} + } + + type changesetType struct { + Name string `json:"name"` + OperationIDs []string `json:"operationIDs"` + } + var parsedMetadata struct { + Changesets []changesetType `json:"changesets"` + } + + err := mapstructure.Decode(metadata, &parsedMetadata) + if err != nil { + return cldfchangeset.MCMSReportChangeset{} + } + + changeset, index, found := lo.FindIndexOf(parsedMetadata.Changesets, func(c changesetType) bool { + return slices.Contains(c.OperationIDs, operationID.Hex()) + }) + if !found { + return cldfchangeset.MCMSReportChangeset{} + } + + return cldfchangeset.MCMSReportChangeset{Index: index, Name: changeset.Name} +} diff --git a/engine/cld/commands/mcms/proposal_config.go b/engine/cld/commands/mcms/proposal_config.go index 62c59804b..45e9ed031 100644 --- a/engine/cld/commands/mcms/proposal_config.go +++ b/engine/cld/commands/mcms/proposal_config.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "fmt" + "slices" "github.com/smartcontractkit/mcms" "github.com/smartcontractkit/mcms/chainwrappers" @@ -18,8 +19,10 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) -// acceptExpiredProposal is a sentinel option to accept expired proposals. -var acceptExpiredProposal = struct{}{} +const ( + acceptExpiredProposal = "accept-expired-proposal-option" // sentinel option to accept expired proposals. + randomSalt = "random-salt-option" // sentinel option to override the proposal's salt with a random value +) // ProposalConfig holds the loaded proposal configuration. type ProposalConfig struct { @@ -51,7 +54,7 @@ func LoadProposalConfig( deps *Deps, proposalCtxProvider analyzer.ProposalContextProvider, flags ProposalFlags, - opts ...interface{}, + opts ...any, ) (*ProposalConfig, error) { // Validate proposal kind proposalKind, exists := types.StringToProposalKind[flags.ProposalKind] @@ -62,7 +65,7 @@ func LoadProposalConfig( // Load proposal from file fileProposal, err := deps.ProposalLoader(proposalKind, flags.ProposalPath) if err != nil { - if !containsAcceptExpired(opts) || !isProposalExpiredError(err) { + if !slices.Contains(opts, acceptExpiredProposal) || !isProposalExpiredError(err) { return nil, fmt.Errorf("error loading proposal: %w", err) } } @@ -72,8 +75,12 @@ func LoadProposalConfig( if proposalKind == types.KindTimelockProposal { timelockCastedProposal = fileProposal.(*mcms.TimelockProposal) - if flags.Fork && timelockCastedProposal.Action == types.TimelockActionSchedule { + if flags.Fork && slices.Contains(opts, randomSalt) && timelockCastedProposal.Action == types.TimelockActionSchedule { timelockCastedProposal.SaltOverride = newRandomSalt() + _, serr := timelockCastedProposal.SetOperationIDs(ctx, true) + if serr != nil { + return nil, fmt.Errorf("failed to set operation IDs after resetting the timelock proposal salt: %w", serr) + } } converters, cerr := chainwrappers.BuildConverters(timelockCastedProposal.ChainMetadata) @@ -112,8 +119,7 @@ func LoadProposalConfig( // Load Environment if cfg.Fork { - // For forked environments, load via LoadFork - cfg.ForkedEnv, err = cldfenvironment.LoadFork(ctx, dom, cfg.EnvStr, nil, + cfg.ForkedEnv, err = deps.ForkEnvironmentLoader(ctx, dom, cfg.EnvStr, nil, cldfenvironment.OnlyLoadChainsFor(chainSelectors), cldfenvironment.WithoutJD(), cldfenvironment.WithLogger(lggr)) @@ -163,17 +169,6 @@ func isProposalExpiredError(err error) bool { return containsStr(errStr, "expired") || containsStr(errStr, "valid_until") } -// containsAcceptExpired checks if the opts contain acceptExpiredProposal. -func containsAcceptExpired(opts []interface{}) bool { - for _, opt := range opts { - if opt == acceptExpiredProposal { - return true - } - } - - return false -} - func containsStr(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { diff --git a/engine/cld/commands/mcms/proposal_config_test.go b/engine/cld/commands/mcms/proposal_config_test.go new file mode 100644 index 000000000..e8415f6c2 --- /dev/null +++ b/engine/cld/commands/mcms/proposal_config_test.go @@ -0,0 +1,238 @@ +package mcms + +import ( + "context" + "errors" + "math/big" + "os" + "path/filepath" + "testing" + + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/mcms" + "github.com/smartcontractkit/mcms/chainwrappers" + mcmstypes "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + cldfenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +func TestLoadProposalConfig(t *testing.T) { + t.Parallel() + + // Write a valid TimelockProposal to a shared temp file for tests that need real loading. + proposalFilePath := filepath.Join(t.TempDir(), "proposal.json") + require.NoError(t, os.WriteFile(proposalFilePath, testProposalWithoutChangesetsJSON, 0o600)) + proposal, err := mcms.LoadProposal(mcmstypes.KindTimelockProposal, proposalFilePath) + require.NoError(t, err) + timelockProposal := proposal.(*mcms.TimelockProposal) + converters, err := chainwrappers.BuildConverters(timelockProposal.ChainMetadata) + require.NoError(t, err) + mcmProposal, _, err := timelockProposal.Convert(t.Context(), converters) + require.NoError(t, err) + + tests := []struct { + name string + dom domain.Domain + deps *Deps + proposalCtxProvider analyzer.ProposalContextProvider + flags ProposalFlags + opts []any + assert func(t *testing.T, got *ProposalConfig, err error) + }{ + { + name: "failure: unknown proposal kind", + flags: ProposalFlags{ProposalKind: "UnknownKind"}, + assert: func(t *testing.T, got *ProposalConfig, err error) { + t.Helper() + require.ErrorContains(t, err, "unknown proposal kind 'UnknownKind'") + }, + }, + { + name: "failure: proposal loader returns error", + flags: ProposalFlags{ + ProposalKind: string(mcmstypes.KindTimelockProposal), + Environment: "testnet", + ChainSelector: chainsel.GETH_TESTNET.Selector, + }, + deps: &Deps{ + ProposalLoader: func(_ mcmstypes.ProposalKind, _ string) (mcms.ProposalInterface, error) { + return nil, errors.New("failed to read file") + }, + EnvironmentLoader: nopEnvLoader, + }, + assert: func(t *testing.T, got *ProposalConfig, err error) { + t.Helper() + require.ErrorContains(t, err, "error loading proposal: failed to read file") + }, + }, + { + name: "failure: environment loader returns error", + flags: ProposalFlags{ + ProposalKind: string(mcmstypes.KindProposal), + Environment: "testnet", + ChainSelector: chainsel.GETH_TESTNET.Selector, + }, + deps: &Deps{ + ProposalLoader: func(_ mcmstypes.ProposalKind, _ string) (mcms.ProposalInterface, error) { + return &mcms.Proposal{}, nil + }, + EnvironmentLoader: func( + _ context.Context, _ domain.Domain, _ string, _ logger.Logger, _ ...cldfenv.LoadEnvironmentOption, + ) (cldf.Environment, error) { + return cldf.Environment{}, errors.New("environment error") + }, + }, + assert: func(t *testing.T, got *ProposalConfig, err error) { + t.Helper() + require.ErrorContains(t, err, "error loading environment: environment error") + }, + }, + { + name: "failure: expired proposal without acceptExpiredProposal option", + flags: ProposalFlags{ + ProposalKind: string(mcmstypes.KindTimelockProposal), + Environment: "testnet", + ChainSelector: chainsel.GETH_TESTNET.Selector, + }, + deps: &Deps{ + ProposalLoader: func(_ mcmstypes.ProposalKind, _ string) (mcms.ProposalInterface, error) { + return nil, errors.New("proposal has expired: valid_until exceeded") + }, + EnvironmentLoader: nopEnvLoader, + }, + assert: func(t *testing.T, got *ProposalConfig, err error) { + t.Helper() + require.ErrorContains(t, err, "error loading proposal: proposal has expired: valid_until exceeded") + }, + }, + { + name: "failure: proposal context provider returns error", + flags: ProposalFlags{ + ProposalKind: string(mcmstypes.KindProposal), + Environment: "testnet", + ChainSelector: chainsel.GETH_TESTNET.Selector, + }, + deps: &Deps{ + ProposalLoader: func(_ mcmstypes.ProposalKind, _ string) (mcms.ProposalInterface, error) { + return &mcms.Proposal{}, nil + }, + EnvironmentLoader: nopEnvLoader, + }, + proposalCtxProvider: func(_ cldf.Environment) (analyzer.ProposalContext, error) { + return nil, errors.New("failed to build proposal context") + }, + assert: func(t *testing.T, got *ProposalConfig, err error) { + t.Helper() + require.ErrorContains(t, err, "error creating proposal context") + }, + }, + { + name: "success: loads proposal config", + flags: ProposalFlags{ + ProposalPath: proposalFilePath, + ProposalKind: string(mcmstypes.KindTimelockProposal), + Environment: "testnet", + ChainSelector: chainsel.GETH_TESTNET.Selector, + }, + deps: &Deps{ + ProposalLoader: func(_ mcmstypes.ProposalKind, _ string) (mcms.ProposalInterface, error) { + return proposal, nil + }, + EnvironmentLoader: nopEnvLoader, + }, + proposalCtxProvider: func(_ cldf.Environment) (analyzer.ProposalContext, error) { + return nil, nil //nolint:nilnil + }, + assert: func(t *testing.T, got *ProposalConfig, err error) { + t.Helper() + + want := &ProposalConfig{ + Kind: mcmstypes.KindTimelockProposal, + Proposal: mcmProposal, + TimelockProposal: timelockProposal, + ChainSelector: chainsel.GETH_TESTNET.Selector, + EnvStr: "testnet", + Env: cldf.Environment{Name: "testnet"}, + } + + require.NoError(t, err) + require.Equal(t, want, got) + }, + }, + { + name: "success: loads proposal with acceptExpiredProposal option", + flags: ProposalFlags{ + ProposalKind: string(mcmstypes.KindTimelockProposal), + ProposalPath: proposalFilePath, + Environment: "testnet", + ChainSelector: chainsel.GETH_TESTNET.Selector, + }, + deps: &Deps{ + // load the real proposal but inject an artificial expiry error. + ProposalLoader: func(kind mcmstypes.ProposalKind, _ string) (mcms.ProposalInterface, error) { + return proposal, errors.New("proposal has expired: valid_until exceeded") + }, + EnvironmentLoader: nopEnvLoader, + }, + opts: []any{acceptExpiredProposal}, + assert: func(t *testing.T, got *ProposalConfig, err error) { + t.Helper() + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, mcmstypes.KindTimelockProposal, got.Kind) + require.Equal(t, "testnet", got.EnvStr) + require.Equal(t, proposal, got.TimelockProposal) + }, + }, + { + name: "success: loads proposal with randomSalt option", + flags: ProposalFlags{ + ProposalKind: string(mcmstypes.KindTimelockProposal), + ProposalPath: proposalFilePath, + Environment: "testnet", + ChainSelector: chainsel.GETH_TESTNET.Selector, + Fork: true, + }, + deps: &Deps{ + ProposalLoader: mcms.LoadProposal, + EnvironmentLoader: nopEnvLoader, + ForkEnvironmentLoader: nopForkEnvLoader, + }, + opts: []any{randomSalt}, + assert: func(t *testing.T, got *ProposalConfig, err error) { + t.Helper() + require.NoError(t, err) + require.NotNil(t, got.TimelockProposal.SaltOverride) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := LoadProposalConfig(t.Context(), logger.Nop(), tt.dom, tt.deps, + tt.proposalCtxProvider, tt.flags, tt.opts...) + + tt.assert(t, got, err) + }) + } +} + +// ----- helpers ----- + +func nopEnvLoader( + _ context.Context, _ domain.Domain, envKey string, _ logger.Logger, _ ...cldfenv.LoadEnvironmentOption, +) (cldf.Environment, error) { + return cldf.Environment{Name: envKey}, nil +} + +func nopForkEnvLoader( + _ context.Context, _ domain.Domain, envKey string, _ map[uint64]*big.Int, _ ...cldfenv.LoadEnvironmentOption, +) (cldfenv.ForkedEnvironment, error) { + return cldfenv.ForkedEnvironment{Environment: cldf.Environment{Name: envKey}}, nil +} diff --git a/engine/cld/commands/pipeline/run.go b/engine/cld/commands/pipeline/run.go index 56b7a41b9..6891d7a5b 100644 --- a/engine/cld/commands/pipeline/run.go +++ b/engine/cld/commands/pipeline/run.go @@ -1,12 +1,19 @@ package pipeline import ( + "context" + "encoding/json" + "errors" "fmt" + "os" "strconv" "time" + "github.com/ethereum/go-ethereum/common" + "github.com/samber/lo" "github.com/spf13/cobra" + fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/flags" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/pipeline/input" @@ -167,6 +174,12 @@ func runRun(cmd *cobra.Command, cfg *Config, f runFlags) error { return saveErr } + err = saveChangesetProposalMetadata(cmd.Context(), actualChangesetName, out) + if err != nil { + return fmt.Errorf("failed to save changeset proposal metadata: %w", err) + } + + // TODO: proposal decoding is handled by the CLD GH workflows; this should be removed. if len(out.DescribedTimelockProposals) == 0 && cfg.DecodeProposalCtxProvider != nil { out.DescribedTimelockProposals = make([]string, len(out.MCMSTimelockProposals)) proposalContext, err := cfg.DecodeProposalCtxProvider(env) @@ -190,3 +203,38 @@ func runRun(cmd *cobra.Command, cfg *Config, f runFlags) error { return nil } + +func saveChangesetProposalMetadata(ctx context.Context, changesetName string, out fdeployment.ChangesetOutput) error { + if len(out.MCMSTimelockProposals) == 0 { + return nil + } + + changesetInputJSON := os.Getenv("DURABLE_PIPELINE_INPUT") + if len(changesetInputJSON) == 0 { + return errors.New("durable pipeline input is empty or not set") + } + + for i := range out.MCMSTimelockProposals { + proposal := &out.MCMSTimelockProposals[i] + if proposal.Metadata == nil { + proposal.Metadata = map[string]any{} + } + + operationIDs, _, err := proposal.OperationIDs(ctx) + if err != nil { + return fmt.Errorf("failed to get operation IDs from proposal: %w", err) + } + + proposal.Metadata["changesets"] = []struct { + Name string `json:"name"` + Input json.RawMessage `json:"input"` + OperationIDs []string `json:"operationIDs"` + }{{ + Name: changesetName, + Input: json.RawMessage(changesetInputJSON), + OperationIDs: lo.Map(operationIDs, func(o common.Hash, _ int) string { return o.Hex() }), + }} + } + + return nil +} diff --git a/engine/cld/commands/pipeline/run_test.go b/engine/cld/commands/pipeline/run_test.go index 266b03e06..d0047b360 100644 --- a/engine/cld/commands/pipeline/run_test.go +++ b/engine/cld/commands/pipeline/run_test.go @@ -2,21 +2,25 @@ package pipeline import ( "context" + "encoding/json" "errors" "os" "path/filepath" "testing" + "github.com/samber/lo" + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/mcms" + mcmstypes "github.com/smartcontractkit/mcms/types" "github.com/stretchr/testify/require" - "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" - fresolvers "github.com/smartcontractkit/chainlink-deployments-framework/changeset/resolvers" fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) // stubChangeset implements ChangeSetV2 for testing. @@ -36,6 +40,21 @@ func (s *stubChangeset) VerifyPreconditions(_ fdeployment.Environment, _ any) er var _ fdeployment.ChangeSetV2[any] = (*stubChangeset)(nil) +// stubProposalChangeset implements ChangeSetV2 that generates a proposal for testing. +type stubProposalChangeset struct { + TimelockProposal mcms.TimelockProposal +} + +func (s *stubProposalChangeset) Apply(_ fdeployment.Environment, _ any) (fdeployment.ChangesetOutput, error) { + return fdeployment.ChangesetOutput{MCMSTimelockProposals: []mcms.TimelockProposal{s.TimelockProposal}}, nil +} + +func (s *stubProposalChangeset) VerifyPreconditions(_ fdeployment.Environment, _ any) error { + return nil +} + +var _ fdeployment.ChangeSetV2[any] = (*stubProposalChangeset)(nil) + // registryProviderStub provides a changeset registry for tests. type registryProviderStub struct { *changeset.BaseRegistryProvider @@ -54,16 +73,33 @@ func (m *mockProposalContext) SetRenderer(analyzer.Renderer) {} func (m *mockProposalContext) GetRenderer() analyzer.Renderer { return analyzer.NewMarkdownRenderer() } + func (m *mockProposalContext) FieldsContext(uint64) *analyzer.FieldContext { return &analyzer.FieldContext{} } + func (m *mockProposalContext) GetSolanaDecoderRegistry() analyzer.SolanaDecoderRegistry { return nil } + func (m *mockProposalContext) GetEVMRegistry() analyzer.EVMABIRegistry { return nil } +// loadProposal is a helper function to load the generated proposal from disk for assertions. +func loadProposal(t *testing.T, proposalsDir string) (*mcms.TimelockProposal, string) { + t.Helper() + + files, err := filepath.Glob(filepath.Join(proposalsDir, "*.json")) + require.NoError(t, err) + require.Len(t, files, 1) + + proposal, err := mcms.LoadProposal(mcmstypes.KindTimelockProposal, files[0]) + require.NoError(t, err) + + return proposal.(*mcms.TimelockProposal), files[0] +} + //nolint:paralleltest func TestRunCmd_Success(t *testing.T) { env := "testnet" @@ -536,3 +572,114 @@ func TestRunCmd_RequiresInputFile(t *testing.T) { require.Error(t, err) require.Equal(t, `required flag(s) "input-file" not set`, err.Error()) } + +//nolint:paralleltest +func TestRunCmd_ReturnsProposal(t *testing.T) { + env := "testnet" + changesetName := "0001_test_changeset" + workspaceRoot := t.TempDir() + domainsRoot := filepath.Join(workspaceRoot, "domains") + testDomain := domain.NewDomain(domainsRoot, "test") + envRoot := filepath.Join(domainsRoot, testDomain.String(), env) + inputsDir := filepath.Join(envRoot, "durable_pipelines", "inputs") + require.NoError(t, os.MkdirAll(inputsDir, 0o755)) + proposalsDir := filepath.Join(envRoot, "proposals") + + yamlContent := `environment: testnet +domain: test +changesets: + - 0001_test_changeset: + payload: + chain: optimism_sepolia + value: 100` + yamlFileName := "test-input.yaml" + require.NoError(t, os.WriteFile(filepath.Join(inputsDir, yamlFileName), []byte(yamlContent), 0o600)) + + originalWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(workspaceRoot)) + t.Cleanup(func() { require.NoError(t, os.Chdir(originalWd)) }) + + changesetStub := &stubProposalChangeset{TimelockProposal: *testProposal} + loadChangesets := func(envName string) (*changeset.ChangesetsRegistry, error) { + rp := ®istryProviderStub{ + BaseRegistryProvider: changeset.NewBaseRegistryProvider(), + AddAction: func(reg *changeset.ChangesetsRegistry) { + reg.Add(changesetName, changeset.Configure(changesetStub).With(1)) + }, + } + if initErr := rp.Init(); initErr != nil { + return nil, initErr + } + + return rp.Registry(), nil + } + decodeProvider := func(fdeployment.Environment) (analyzer.ProposalContext, error) { + return &mockProposalContext{}, nil + } + + cfg := &Config{ + Logger: logger.Test(t), + Domain: testDomain, + LoadChangesets: loadChangesets, + DecodeProposalCtxProvider: decodeProvider, + ConfigResolverManager: fresolvers.NewConfigResolverManager(), + Deps: Deps{ + EnvironmentLoader: func(ctx context.Context, dom domain.Domain, envKey string, opts ...environment.LoadEnvironmentOption) (fdeployment.Environment, error) { + return fdeployment.Environment{}, nil + }, + }, + } + + cmd, err := NewCommand(cfg) + require.NoError(t, err) + + cmd.SetArgs([]string{ + "run", + "--environment", env, + "--changeset", changesetName, + "--input-file", yamlFileName, + "--dry-run", + }) + + err = cmd.Execute() + require.NoError(t, err) + + proposal, _ := loadProposal(t, proposalsDir) + require.Equal(t, "v1", proposal.Version) + require.NotNil(t, proposal.Metadata) + require.Equal(t, map[string]any{ + "changesets": []any{ + map[string]any{ + "name": changesetName, + "input": map[string]any{ + "payload": map[string]any{ + "chain": "optimism_sepolia", + "value": json.Number("100"), + }, + }, + "operationIDs": []any{"0x0206ff10261710c45650d4912ffc6d6808fd1248fb510e47d0e9482058d9c048"}, + }, + }, + }, proposal.Metadata) +} + +// ----- shared test data ----- + +var testProposal = lo.Must(mcms.NewTimelockProposalBuilder(). + SetVersion("v1"). + SetValidUntil(2082758399). + SetDescription("test timelock proposal"). + SetOverridePreviousRoot(true). + SetAction(mcmstypes.TimelockActionSchedule). + AddTimelockAddress(mcmstypes.ChainSelector(chainsel.GETH_TESTNET.Selector), "0xTimelockAddress"). + AddChainMetadata(mcmstypes.ChainSelector(chainsel.GETH_TESTNET.Selector), mcmstypes.ChainMetadata{MCMAddress: "0xMCMAddress"}). + AddOperation(mcmstypes.BatchOperation{ + ChainSelector: mcmstypes.ChainSelector(chainsel.GETH_TESTNET.Selector), + Transactions: []mcmstypes.Transaction{{ + To: "0xToAddress", + Data: []byte("0x"), + AdditionalFields: json.RawMessage(`{"value": 0}`), + }}, + }). + Build()) diff --git a/engine/cld/domain/artifacts_test.go b/engine/cld/domain/artifacts_test.go index 3359454fa..6d789a4f7 100644 --- a/engine/cld/domain/artifacts_test.go +++ b/engine/cld/domain/artifacts_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/Masterminds/semver/v3" + gethcommon "github.com/ethereum/go-ethereum/common" chainsel "github.com/smartcontractkit/chain-selectors" mcmsv2 "github.com/smartcontractkit/mcms" mcmstypes "github.com/smartcontractkit/mcms/types" @@ -423,7 +424,7 @@ func Test_Artifacts_ChangesetOperationsReportsFileExists(t *testing.T) { beforeFunc: func(t *testing.T, artsDir *ArtifactsDir) { t.Helper() - err := os.Mkdir(filepath.Join(artsDir.OperationsReportsDirPath(), "0001_initial-reports.json"), 0755) + err := os.Mkdir(filepath.Join(artsDir.OperationsReportsDirPath(), "0001_initial-reports.json"), 0o755) require.NoError(t, err) }, giveChangesetKey: "0001_initial", @@ -473,7 +474,7 @@ func Test_Artifacts_SaveChangesetOutput_LoadChangesetOutput(t *testing.T) { Spec: js2.MustMarshal(), } - validUntilUnixTime = uint32(time.Now().Add(time.Hour).Unix()) //nolint:gosec // This won't overflow until 7 Feb 2106, and would also cause MCMS to fail anyway + validUntilUnixTime = uint32(time.Date(2035, time.December, 31, 23, 59, 59, 999999999, time.UTC).Unix()) //nolint:gosec mcmsProposals = []mcmsv2.Proposal{ { @@ -543,6 +544,7 @@ func Test_Artifacts_SaveChangesetOutput_LoadChangesetOutput(t *testing.T) { }, Operations: []mcmstypes.BatchOperation{ { + OperationID: gethcommon.HexToHash("0xe8baef452df2576e6867edad359ff541e1c3d8bd80c5c2acd737e8173c5f2245"), ChainSelector: mcmstypes.ChainSelector(chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector), Transactions: []mcmstypes.Transaction{ { @@ -572,6 +574,7 @@ func Test_Artifacts_SaveChangesetOutput_LoadChangesetOutput(t *testing.T) { }, Operations: []mcmstypes.BatchOperation{ { + OperationID: gethcommon.HexToHash("0x445a3dd84afb93f57fd894e5f468d3167c0a015c21603c6c6ed97a0b40421b04"), ChainSelector: mcmstypes.ChainSelector(chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector), Transactions: []mcmstypes.Transaction{ { @@ -963,7 +966,8 @@ func Test_Artifacts_SaveAndLoadOperationsReport(t *testing.T) { name: "report does not exist - return empty slice", giveCsKey: "invalid", want: []operations.Report[any, any]{}, - }, { + }, + { name: "success - directory does not exist - should create it", giveCsKey: changesetKey, beforeFunc: func(t *testing.T, artsDir *ArtifactsDir) { diff --git a/engine/test/internal/mcmsutils/conversion_test.go b/engine/test/internal/mcmsutils/conversion_test.go index 03ec67858..fccfa3b0f 100644 --- a/engine/test/internal/mcmsutils/conversion_test.go +++ b/engine/test/internal/mcmsutils/conversion_test.go @@ -89,7 +89,7 @@ func TestConvertTimelock(t *testing.T) { }, } }, - wantErr: "failed to build converters: error getting chain family: chain family not found for selector 999999", + wantErr: "failed to build converters: failed to build converter for selector 999999", }, { name: "fails with invalid chain family", @@ -106,7 +106,7 @@ func TestConvertTimelock(t *testing.T) { }, } }, - wantErr: "failed to build converters: error getting chain family: unsupported chain family: tron", + wantErr: "failed to build converters: failed to build converter for selector 2052925811360307740", }, } diff --git a/engine/test/internal/mcmsutils/testdata/timelock_proposal.json b/engine/test/internal/mcmsutils/testdata/timelock_proposal.json index 9b7522061..43d319692 100644 --- a/engine/test/internal/mcmsutils/testdata/timelock_proposal.json +++ b/engine/test/internal/mcmsutils/testdata/timelock_proposal.json @@ -19,6 +19,7 @@ }, "operations": [ { + "operationID": "0x342ae55e5f86f04edeb7f9294370354a07ca69e8c9e95c92b71b7e28ca799195", "chainSelector": 16015286601757825753, "transactions": [ { diff --git a/go.mod b/go.mod index 423d48789..4273d3813 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20250815105909-75499abc4335 github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d - github.com/smartcontractkit/mcms v0.37.0 + github.com/smartcontractkit/mcms v0.39.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 @@ -183,7 +183,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 github.com/gofrs/flock v0.12.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect diff --git a/go.sum b/go.sum index 9ab48d6a1..7d907f1f8 100644 --- a/go.sum +++ b/go.sum @@ -787,8 +787,8 @@ github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12i github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7/go.mod h1:FX7/bVdoep147QQhsOPkYsPEXhGZjeYx6lBSaSXtZOA= github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d h1:LokA9PoCNb8mm8mDT52c3RECPMRsGz1eCQORq+J3n74= github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d/go.mod h1:Acy3BTBxou83ooMESLO90s8PKSu7RvLCzwSTbxxfOK0= -github.com/smartcontractkit/mcms v0.37.0 h1:h3tqQhVdLezyHOuPsGcknPFLlZlXpOHTchwlfO3D9s8= -github.com/smartcontractkit/mcms v0.37.0/go.mod h1:7YqJPR8w9GiO1L/JjjTrwlSwAZ7i3J7cgOcu88PqtvU= +github.com/smartcontractkit/mcms v0.39.0 h1:ORIpFZnNj24FQUZCktnZCNMUpNmd4kz15/Vddrk/LXc= +github.com/smartcontractkit/mcms v0.39.0/go.mod h1:7YqJPR8w9GiO1L/JjjTrwlSwAZ7i3J7cgOcu88PqtvU= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=