Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funny-carpets-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink-deployments-framework": patch
---

feat: add post-proposal-execution hooks
8 changes: 8 additions & 0 deletions chain/blockchain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
52 changes: 33 additions & 19 deletions engine/cld/changeset/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
},
Expand Down Expand Up @@ -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)
},
Expand Down Expand Up @@ -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)
Expand All @@ -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)
},
Expand All @@ -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() {}
Expand Down Expand Up @@ -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),
}
}
31 changes: 31 additions & 0 deletions engine/cld/changeset/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
94 changes: 94 additions & 0 deletions engine/cld/changeset/mcms.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading