From e1b248ed9736240b28755ac528d760f742339f4a Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 19 Mar 2026 15:13:18 -0600 Subject: [PATCH 01/17] feat: add CRE runner to environment object --- .mockery.yml | 9 ++ deployment/environment.go | 13 ++ engine/cld/config/env/config.go | 8 + engine/cld/environment/environment.go | 1 + engine/cld/environment/fork.go | 1 + engine/cld/environment/options.go | 30 +++- .../ccip/renderers/mermaid/renderer.go | 8 +- engine/test/environment/components.go | 2 + engine/test/environment/environment.go | 2 + engine/test/environment/options.go | 9 ++ engine/test/runtime/environment.go | 1 + pkg/cre/cli_runner.go | 62 ++++++++ pkg/cre/cli_runner_test.go | 138 ++++++++++++++++++ pkg/cre/exit.go | 16 ++ pkg/cre/exit_test.go | 17 +++ pkg/cre/mocks/mock_runner.go | 116 +++++++++++++++ pkg/cre/runner.go | 15 ++ 17 files changed, 442 insertions(+), 6 deletions(-) create mode 100644 pkg/cre/cli_runner.go create mode 100644 pkg/cre/cli_runner_test.go create mode 100644 pkg/cre/exit.go create mode 100644 pkg/cre/exit_test.go create mode 100644 pkg/cre/mocks/mock_runner.go create mode 100644 pkg/cre/runner.go diff --git a/.mockery.yml b/.mockery.yml index 7b70107d9..e7bc3e6e9 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -149,3 +149,12 @@ packages: filename: "{{.InterfaceName | snakecase}}.go" interfaces: APIClientWrapped: + github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre: + config: + all: false + pkgname: "cremocks" + dir: "pkg/cre/mocks" + filename: "mock_{{.InterfaceName | snakecase}}.go" + structname: "{{.Mock}}{{.InterfaceName}}" + interfaces: + Runner: diff --git a/deployment/environment.go b/deployment/environment.go index 8b05e82c1..5fdf6a25a 100644 --- a/deployment/environment.go +++ b/deployment/environment.go @@ -14,6 +14,7 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/offchain" "github.com/smartcontractkit/chainlink-deployments-framework/offchain/ocr" "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) @@ -60,6 +61,10 @@ type Environment struct { OperationsBundle operations.Bundle // BlockChains is the container of all chains in the environment. BlockChains chain.BlockChains + // CRERunner invokes the CRE CLI from changesets. Optional in environment loading, so it won't + // fail to load environment if cre binary not available; failures (e.g. binary not found) only occur + // when CRERunner.Call() is used inside changesets. May be nil in test environments that did not set it. + CRERunner cre.Runner } // EnvironmentOption is a functional option for configuring an Environment @@ -100,6 +105,13 @@ func NewEnvironment( return env } +// WithCRERunner sets the CRE CLI runner +func WithCRERunner(r cre.Runner) EnvironmentOption { + return func(e *Environment) { + e.CRERunner = r + } +} + // Clone creates a copy of the environment with a new reference to the address book. func (e Environment) Clone() Environment { ab := NewMemoryAddressBook() @@ -125,6 +137,7 @@ func (e Environment) Clone() Environment { OCRSecrets: e.OCRSecrets, OperationsBundle: e.OperationsBundle, BlockChains: e.BlockChains, + CRERunner: e.CRERunner, } } diff --git a/engine/cld/config/env/config.go b/engine/cld/config/env/config.go index fb5d66b00..bb2915e9a 100644 --- a/engine/cld/config/env/config.go +++ b/engine/cld/config/env/config.go @@ -259,6 +259,14 @@ var ( "catalog.grpc": {"CATALOG_GRPC"}, "catalog.auth.kms_key_id": {"CATALOG_AUTH_KMS_KEY_ID"}, "catalog.auth.kms_key_region": {"CATALOG_AUTH_KMS_KEY_REGION"}, + "cre.auth.hmac_key_id": {"CRE_DEPLOY_HMAC_KEY_ID"}, + "cre.auth.hmac_key_secret": {"CRE_DEPLOY_HMAC_SECRET"}, + "cre.auth.tenant_id": {"CRE_TENANT_ID"}, + "cre.auth.org_id": {"CRE_ORG_ID"}, + "cre.tls": {"CRE_TLS"}, + "cre.timeout": {"CRE_TIMEOUT"}, + "cre.storage_address": {"CRE_STORAGE_ADDR"}, + "cre.don_family": {"CRE_DON_FAMILY"}, } ) diff --git a/engine/cld/environment/environment.go b/engine/cld/environment/environment.go index a0f8ae2da..6c57b7137 100644 --- a/engine/cld/environment/environment.go +++ b/engine/cld/environment/environment.go @@ -144,6 +144,7 @@ func Load( OCRSecrets: sharedSecrets, OperationsBundle: operations.NewBundle(getCtx, lggr, loadcfg.reporter, operations.WithOperationRegistry(loadcfg.operationRegistry)), BlockChains: blockChains, + CRERunner: resolveCRERunner(loadcfg.creRunner, loadcfg.creBinaryPath), }, nil } diff --git a/engine/cld/environment/fork.go b/engine/cld/environment/fork.go index fb86f4e10..1c876071f 100644 --- a/engine/cld/environment/fork.go +++ b/engine/cld/environment/fork.go @@ -149,6 +149,7 @@ func LoadFork( func() context.Context { return ctx }, focr.XXXGenerateTestOCRSecrets(), fchain.NewBlockChains(blockChains), + fdeployment.WithCRERunner(resolveCRERunner(loadcfg.creRunner, loadcfg.creBinaryPath)), ) return ForkedEnvironment{ diff --git a/engine/cld/environment/options.go b/engine/cld/environment/options.go index 55b26d3b0..ccef17b3f 100644 --- a/engine/cld/environment/options.go +++ b/engine/cld/environment/options.go @@ -1,10 +1,10 @@ package environment import ( - "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" - cfgdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/domain" "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) // LoadConfig contains configuration parameters for loading an environment. @@ -44,6 +44,9 @@ type LoadConfig struct { // datastoreType when set, overrides the datastore type from domain config (e.g. from --datastore flag). datastoreType *cfgdomain.DatastoreType + + creRunner cre.Runner + creBinaryPath string } // Configure applies a slice of LoadEnvironmentOption functions to the LoadConfig. @@ -189,3 +192,26 @@ func WithDatastoreType(t cfgdomain.DatastoreType) LoadEnvironmentOption { o.datastoreType = &t } } + +// WithCRERunner replaces the default CRE CLIRunner (useful in tests). +func WithCRERunner(r cre.Runner) LoadEnvironmentOption { + return func(o *LoadConfig) { + o.creRunner = r + } +} + +// WithCREBinaryPath sets the CRE CLI executable path. Empty uses "cre" on PATH. +func WithCREBinaryPath(path string) LoadEnvironmentOption { + return func(o *LoadConfig) { + o.creBinaryPath = path + } +} + +// resolveCRERunner returns override if set, otherwise a CLIRunner for the given path. +func resolveCRERunner(override cre.Runner, binaryPath string) cre.Runner { + if override != nil { + return override + } + + return &cre.CLIRunner{BinaryPath: binaryPath} +} diff --git a/engine/cld/mcms/proposalanalysis/examples/ccip/renderers/mermaid/renderer.go b/engine/cld/mcms/proposalanalysis/examples/ccip/renderers/mermaid/renderer.go index b780ce3df..6cf5855fb 100644 --- a/engine/cld/mcms/proposalanalysis/examples/ccip/renderers/mermaid/renderer.go +++ b/engine/cld/mcms/proposalanalysis/examples/ccip/renderers/mermaid/renderer.go @@ -43,14 +43,14 @@ func (r *MermaidRenderer) RenderTo(w io.Writer, _ renderer.RenderRequest, propos name := format.ResolveChainName(sel) id := sanitizeID(name) - b.WriteString(fmt.Sprintf(" subgraph %s [\"%s\"]\n", id, escapeQuotes(name))) + fmt.Fprintf(&b, " subgraph %s [\"%s\"]\n", id, escapeQuotes(name)) for _, n := range nodes { if n.chainSelector != sel { continue } - b.WriteString(fmt.Sprintf(" %s[\"%s\"]:::%s\n", n.id, escapeQuotes(n.label), contractStyle(n.contractType))) + fmt.Fprintf(&b, " %s[\"%s\"]:::%s\n", n.id, escapeQuotes(n.label), contractStyle(n.contractType)) } b.WriteString(" end\n") @@ -68,7 +68,7 @@ func (r *MermaidRenderer) RenderTo(w io.Writer, _ renderer.RenderRequest, propos from = prevID } - b.WriteString(fmt.Sprintf(" %s -->|\"%d. %s\"| %s\n", from, step, escapeQuotes(call.Name()), curID)) + fmt.Fprintf(&b, " %s -->|\"%d. %s\"| %s\n", from, step, escapeQuotes(call.Name()), curID) prevID = curID } } @@ -86,7 +86,7 @@ func (r *MermaidRenderer) RenderTo(w io.Writer, _ renderer.RenderRequest, propos for j := range nodes { if nodes[j].chainSelector == remoteSel && nodes[j].id != src.id { - b.WriteString(fmt.Sprintf(" %s -->|\"%s\"| %s\n", src.id, "chain update", nodes[j].id)) + fmt.Fprintf(&b, " %s -->|\"%s\"| %s\n", src.id, "chain update", nodes[j].id) break } diff --git a/engine/test/environment/components.go b/engine/test/environment/components.go index a63d52e5b..4d9a65dcf 100644 --- a/engine/test/environment/components.go +++ b/engine/test/environment/components.go @@ -9,6 +9,7 @@ import ( fdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" foffchain "github.com/smartcontractkit/chainlink-deployments-framework/offchain" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" ) // components is a struct that contains the components of the environment. @@ -21,6 +22,7 @@ type components struct { OffchainClient foffchain.Client NodeIDs []string Logger logger.Logger + CRERunner cre.Runner } // newComponents creates a new components instance. diff --git a/engine/test/environment/environment.go b/engine/test/environment/environment.go index 4eea237af..d7dddc27f 100644 --- a/engine/test/environment/environment.go +++ b/engine/test/environment/environment.go @@ -67,6 +67,7 @@ func (l *Loader) Load(ctx context.Context, opts ...LoadOpt) (*fdeployment.Enviro oc = foffchain.NewMemoryJobDistributor() } + // CRERunner may be nil; tests that need CRE use WithCRERunner(cremocks.NewMockRunner(t)). return &fdeployment.Environment{ Name: environmentName, Logger: cmps.Logger, @@ -78,6 +79,7 @@ func (l *Loader) Load(ctx context.Context, opts ...LoadOpt) (*fdeployment.Enviro GetContext: getCtx, OCRSecrets: focr.XXXGenerateTestOCRSecrets(), OperationsBundle: foperations.NewBundle(getCtx, cmps.Logger, foperations.NewMemoryReporter()), + CRERunner: cmps.CRERunner, }, nil } diff --git a/engine/test/environment/options.go b/engine/test/environment/options.go index 0957cf430..0012bf3ac 100644 --- a/engine/test/environment/options.go +++ b/engine/test/environment/options.go @@ -10,6 +10,7 @@ import ( fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/onchain" "github.com/smartcontractkit/chainlink-deployments-framework/offchain" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" ) // Assign the chain container loader constructors to local variables to allow for stubbing in tests. @@ -264,3 +265,11 @@ func withChainLoaderN(t *testing.T, loader *onchain.ChainLoader, n int) LoadOpt return nil } } + +// WithCRERunner sets the CRE CLI runner for the test environment. +func WithCRERunner(r cre.Runner) LoadOpt { + return func(cmps *components) error { + cmps.CRERunner = r + return nil + } +} diff --git a/engine/test/runtime/environment.go b/engine/test/runtime/environment.go index 7c04bee71..abf81fa87 100644 --- a/engine/test/runtime/environment.go +++ b/engine/test/runtime/environment.go @@ -17,6 +17,7 @@ func newEnvFromState(fromEnv fdeployment.Environment, state *State) fdeployment. Offchain: fromEnv.Offchain, BlockChains: fromEnv.BlockChains, NodeIDs: fromEnv.NodeIDs, + CRERunner: fromEnv.CRERunner, // These fields are updated by changesets and are pulled from state ExistingAddresses: state.AddressBook, diff --git a/pkg/cre/cli_runner.go b/pkg/cre/cli_runner.go new file mode 100644 index 000000000..69ae946a5 --- /dev/null +++ b/pkg/cre/cli_runner.go @@ -0,0 +1,62 @@ +package cre + +import ( + "bytes" + "context" + "errors" + "os/exec" +) + +const defaultBinary = "cre" + +// CLIRunner runs the CRE CLI via os/exec. Call executes the binary and captures stdout/stderr. +type CLIRunner struct { + // BinaryPath is the executable to run. Empty means "cre" (resolved via PATH). + BinaryPath string +} + +func (r *CLIRunner) binary() string { + if r.BinaryPath != "" { + return r.BinaryPath + } + + return defaultBinary +} + +// Call runs the binary and captures stdout and stderr. Exit code 0 returns (res, nil); +// exit code != 0 returns (res, *ExitError) so callers get both result and error. +// Runner-related failures (binary not found, context canceled) return (nil, err). +func (r *CLIRunner) Call(ctx context.Context, args ...string) (*CallResult, error) { + //nolint:gosec // G204: This is intentional - we're running a CLI tool with user-provided arguments. + // The binary path is controlled via configuration, and args are expected to be user-provided CLI arguments. + cmd := exec.CommandContext(ctx, r.binary(), args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + res := &CallResult{ + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), + ExitCode: 0, + } + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + res.ExitCode = exitErr.ExitCode() + return res, &ExitError{ + ExitCode: res.ExitCode, + Stdout: res.Stdout, + Stderr: res.Stderr, + } + } + + return nil, err + } + + return res, nil +} diff --git a/pkg/cre/cli_runner_test.go b/pkg/cre/cli_runner_test.go new file mode 100644 index 000000000..39bc66aa2 --- /dev/null +++ b/pkg/cre/cli_runner_test.go @@ -0,0 +1,138 @@ +package cre + +import ( + "context" + "errors" + "io/fs" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCLIRunner_binary(t *testing.T) { + t.Parallel() + tests := []struct { + name string + binaryPath string + want string + }{ + {"default_empty", "", defaultBinary}, + {"custom_path", "/opt/cre", "/opt/cre"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &CLIRunner{BinaryPath: tt.binaryPath} + require.Equal(t, tt.want, r.binary()) + }) + } +} + +func TestCLIRunner_Call(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + skip string + setupCtx func(*testing.T) context.Context + runner *CLIRunner + args []string + wantErr bool + wantResNil bool + wantExitCode int + wantErrIs error + checkExitError bool + }{ + { + name: "binary_not_found", + runner: &CLIRunner{BinaryPath: filepath.Join(t.TempDir(), "nonexistent-cre-xyz")}, + args: []string{"build"}, + wantErr: true, wantResNil: true, + }, + { + name: "context_already_canceled", + runner: &CLIRunner{BinaryPath: "cre"}, + setupCtx: func(t *testing.T) context.Context { + t.Helper() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + return ctx + }, + args: []string{"build"}, + wantErr: true, + wantResNil: true, + wantErrIs: context.Canceled, + }, + { + name: "nonzero_exit_returns_error", + skip: "windows", + runner: &CLIRunner{BinaryPath: "/bin/sh"}, + args: []string{"-c", "exit 41"}, + wantErr: true, + wantResNil: false, + wantExitCode: 41, + checkExitError: true, + }, + { + name: "success", + skip: "windows", + runner: &CLIRunner{BinaryPath: "true"}, + args: nil, + wantErr: false, + wantResNil: false, + wantExitCode: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.skip == "windows" && runtime.GOOS == "windows" { + t.Skip("skipped on windows") + } + if tt.name == "success" { + if _, err := exec.LookPath("true"); err != nil { + t.Skip("true not in PATH") + } + } + t.Parallel() + + ctx := t.Context() + if tt.setupCtx != nil { + ctx = tt.setupCtx(t) + } + res, err := tt.runner.Call(ctx, tt.args...) + if tt.wantErr { + require.Error(t, err) + if tt.wantResNil { + require.Nil(t, res) + } else { + require.NotNil(t, res) + require.Equal(t, tt.wantExitCode, res.ExitCode) + } + if tt.wantErrIs != nil { + require.ErrorIs(t, err, tt.wantErrIs) + } + if tt.checkExitError { + var exitErr *ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, tt.wantExitCode, exitErr.ExitCode) + } + if tt.name == "binary_not_found" { + require.True(t, + errors.Is(err, fs.ErrNotExist) || errors.Is(err, exec.ErrNotFound) || errors.Is(err, exec.ErrDot), + "expected not found style error, got %v", err) + } + + return + } + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, tt.wantExitCode, res.ExitCode) + }) + } +} diff --git a/pkg/cre/exit.go b/pkg/cre/exit.go new file mode 100644 index 000000000..a09fb2fba --- /dev/null +++ b/pkg/cre/exit.go @@ -0,0 +1,16 @@ +package cre + +import "fmt" + +// ExitError is returned when the CRE process ran and exited with a non-zero code. +// Use errors.As to inspect ExitCode, Stdout, and Stderr. Result is still returned +// from Call so callers can log or inspect output. +type ExitError struct { + ExitCode int + Stdout []byte + Stderr []byte +} + +func (e *ExitError) Error() string { + return fmt.Sprintf("cre: exited with code %d", e.ExitCode) +} diff --git a/pkg/cre/exit_test.go b/pkg/cre/exit_test.go new file mode 100644 index 000000000..1442a8c43 --- /dev/null +++ b/pkg/cre/exit_test.go @@ -0,0 +1,17 @@ +package cre + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExitError(t *testing.T) { + t.Parallel() + e := &ExitError{ExitCode: 3, Stderr: []byte("failed")} + require.Contains(t, e.Error(), "code 3") + var out *ExitError + require.ErrorAs(t, e, &out) + require.Equal(t, 3, out.ExitCode) + require.Equal(t, "failed", string(out.Stderr)) +} diff --git a/pkg/cre/mocks/mock_runner.go b/pkg/cre/mocks/mock_runner.go new file mode 100644 index 000000000..3405f1444 --- /dev/null +++ b/pkg/cre/mocks/mock_runner.go @@ -0,0 +1,116 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package cremocks + +import ( + "context" + + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" + mock "github.com/stretchr/testify/mock" +) + +// NewMockRunner creates a new instance of MockRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockRunner(t interface { + mock.TestingT + Cleanup(func()) +}) *MockRunner { + mock := &MockRunner{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockRunner is an autogenerated mock type for the Runner type +type MockRunner struct { + mock.Mock +} + +type MockRunner_Expecter struct { + mock *mock.Mock +} + +func (_m *MockRunner) EXPECT() *MockRunner_Expecter { + return &MockRunner_Expecter{mock: &_m.Mock} +} + +// Call provides a mock function for the type MockRunner +func (_mock *MockRunner) Call(ctx context.Context, args ...string) (*cre.CallResult, error) { + var tmpRet mock.Arguments + if len(args) > 0 { + tmpRet = _mock.Called(ctx, args) + } else { + tmpRet = _mock.Called(ctx) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for Call") + } + + var r0 *cre.CallResult + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ...string) (*cre.CallResult, error)); ok { + return returnFunc(ctx, args...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ...string) *cre.CallResult); ok { + r0 = returnFunc(ctx, args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*cre.CallResult) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ...string) error); ok { + r1 = returnFunc(ctx, args...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRunner_Call_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Call' +type MockRunner_Call_Call struct { + *mock.Call +} + +// Call is a helper method to define mock.On call +// - ctx context.Context +// - args ...string +func (_e *MockRunner_Expecter) Call(ctx interface{}, args ...interface{}) *MockRunner_Call_Call { + return &MockRunner_Call_Call{Call: _e.mock.On("Call", + append([]interface{}{ctx}, args...)...)} +} + +func (_c *MockRunner_Call_Call) Run(run func(ctx context.Context, args ...string)) *MockRunner_Call_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []string + var variadicArgs []string + if len(args) > 1 { + variadicArgs = args[1].([]string) + } + arg1 = variadicArgs + run( + arg0, + arg1..., + ) + }) + return _c +} + +func (_c *MockRunner_Call_Call) Return(callResult *cre.CallResult, err error) *MockRunner_Call_Call { + _c.Call.Return(callResult, err) + return _c +} + +func (_c *MockRunner_Call_Call) RunAndReturn(run func(ctx context.Context, args ...string) (*cre.CallResult, error)) *MockRunner_Call_Call { + _c.Call.Return(run) + return _c +} diff --git a/pkg/cre/runner.go b/pkg/cre/runner.go new file mode 100644 index 000000000..ae09f535f --- /dev/null +++ b/pkg/cre/runner.go @@ -0,0 +1,15 @@ +package cre + +import "context" + +// CallResult holds stdout, stderr, and exit code from a completed CRE call. +type CallResult struct { + Stdout []byte + Stderr []byte + ExitCode int +} + +// Runner runner for the CRE CLI. +type Runner interface { + Call(ctx context.Context, args ...string) (*CallResult, error) +} From 6dec23e2b049b65360c368084d8e1ff066a134b5 Mon Sep 17 00:00:00 2001 From: Pablo Estrada <139084212+ecPablo@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:01:54 -0600 Subject: [PATCH 02/17] Update pkg/cre/runner.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/cre/runner.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cre/runner.go b/pkg/cre/runner.go index ae09f535f..f98049367 100644 --- a/pkg/cre/runner.go +++ b/pkg/cre/runner.go @@ -9,7 +9,7 @@ type CallResult struct { ExitCode int } -// Runner runner for the CRE CLI. +// Runner is used to invoke the CRE CLI. type Runner interface { Call(ctx context.Context, args ...string) (*CallResult, error) } From ef9824840754669b1fdee7f0e2edd103119db5b1 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 19 Mar 2026 17:03:33 -0600 Subject: [PATCH 03/17] feat: increase test coverage --- engine/cld/environment/options_test.go | 80 +++++++++++++++++++++++++ engine/test/environment/options_test.go | 1 + 2 files changed, 81 insertions(+) diff --git a/engine/cld/environment/options_test.go b/engine/cld/environment/options_test.go index 9582d8c59..d1405f439 100644 --- a/engine/cld/environment/options_test.go +++ b/engine/cld/environment/options_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" foperations "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) @@ -110,3 +111,82 @@ func Test_WithDryRunJobDistributor(t *testing.T) { assert.True(t, opts.useDryRunJobDistributor) } + +func Test_WithCRERunner(t *testing.T) { + t.Parallel() + + opts := &LoadConfig{} + assert.Nil(t, opts.creRunner) + + runner := &cre.CLIRunner{BinaryPath: "/path/to/cre"} + option := WithCRERunner(runner) + option(opts) + + assert.Equal(t, runner, opts.creRunner) +} + +func Test_WithCREBinaryPath(t *testing.T) { + t.Parallel() + + opts := &LoadConfig{} + assert.Empty(t, opts.creBinaryPath) + + binaryPath := "/custom/path/to/cre" + option := WithCREBinaryPath(binaryPath) + option(opts) + + assert.Equal(t, binaryPath, opts.creBinaryPath) +} + +func Test_resolveCRERunner(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + override cre.Runner + binaryPath string + wantType string + wantPath string + }{ + { + name: "override takes precedence", + override: &cre.CLIRunner{BinaryPath: "/override/cre"}, + binaryPath: "/default/cre", + wantType: "*cre.CLIRunner", + wantPath: "/override/cre", + }, + { + name: "no override uses binary path", + override: nil, + binaryPath: "/custom/cre", + wantType: "*cre.CLIRunner", + wantPath: "/custom/cre", + }, + { + name: "empty binary path uses default", + override: nil, + binaryPath: "", + wantType: "*cre.CLIRunner", + wantPath: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := resolveCRERunner(tt.override, tt.binaryPath) + + if tt.override != nil { + // Override should return the exact same instance + assert.Equal(t, tt.override, got) + } else { + // No override should return a CLIRunner + require.NotNil(t, got) + cliRunner, ok := got.(*cre.CLIRunner) + require.True(t, ok, "expected *cre.CLIRunner, got %T", got) + assert.Equal(t, tt.binaryPath, cliRunner.BinaryPath) + } + }) + } +} diff --git a/engine/test/environment/options_test.go b/engine/test/environment/options_test.go index 6db5a3b07..5845a3476 100644 --- a/engine/test/environment/options_test.go +++ b/engine/test/environment/options_test.go @@ -9,6 +9,7 @@ import ( fchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/internal/testutils" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/onchain" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" ) func Test_withChainLoader(t *testing.T) { From d506054035cc263d71b6625ca70479a5d673b08c Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 19 Mar 2026 17:53:14 -0600 Subject: [PATCH 04/17] feat: increase test coverage --- engine/test/environment/options_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/engine/test/environment/options_test.go b/engine/test/environment/options_test.go index 5845a3476..07d21b90c 100644 --- a/engine/test/environment/options_test.go +++ b/engine/test/environment/options_test.go @@ -126,3 +126,17 @@ func Test_withChainLoaderN(t *testing.T) { }) } } + +func Test_WithCRERunner(t *testing.T) { + t.Parallel() + + cmps := newComponents() + require.Nil(t, cmps.CRERunner) + + runner := &cre.CLIRunner{BinaryPath: "/path/to/cre"} + option := WithCRERunner(runner) + err := option(cmps) + + require.NoError(t, err) + require.Equal(t, runner, cmps.CRERunner) +} From f615b0e85f31b60e5b9a6e9025da7c9c5fe9943e Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 20 Mar 2026 07:42:23 -0600 Subject: [PATCH 05/17] fix: undo changes from analyzer --- .../examples/ccip/renderers/mermaid/renderer.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/engine/cld/mcms/proposalanalysis/examples/ccip/renderers/mermaid/renderer.go b/engine/cld/mcms/proposalanalysis/examples/ccip/renderers/mermaid/renderer.go index 6cf5855fb..b780ce3df 100644 --- a/engine/cld/mcms/proposalanalysis/examples/ccip/renderers/mermaid/renderer.go +++ b/engine/cld/mcms/proposalanalysis/examples/ccip/renderers/mermaid/renderer.go @@ -43,14 +43,14 @@ func (r *MermaidRenderer) RenderTo(w io.Writer, _ renderer.RenderRequest, propos name := format.ResolveChainName(sel) id := sanitizeID(name) - fmt.Fprintf(&b, " subgraph %s [\"%s\"]\n", id, escapeQuotes(name)) + b.WriteString(fmt.Sprintf(" subgraph %s [\"%s\"]\n", id, escapeQuotes(name))) for _, n := range nodes { if n.chainSelector != sel { continue } - fmt.Fprintf(&b, " %s[\"%s\"]:::%s\n", n.id, escapeQuotes(n.label), contractStyle(n.contractType)) + b.WriteString(fmt.Sprintf(" %s[\"%s\"]:::%s\n", n.id, escapeQuotes(n.label), contractStyle(n.contractType))) } b.WriteString(" end\n") @@ -68,7 +68,7 @@ func (r *MermaidRenderer) RenderTo(w io.Writer, _ renderer.RenderRequest, propos from = prevID } - fmt.Fprintf(&b, " %s -->|\"%d. %s\"| %s\n", from, step, escapeQuotes(call.Name()), curID) + b.WriteString(fmt.Sprintf(" %s -->|\"%d. %s\"| %s\n", from, step, escapeQuotes(call.Name()), curID)) prevID = curID } } @@ -86,7 +86,7 @@ func (r *MermaidRenderer) RenderTo(w io.Writer, _ renderer.RenderRequest, propos for j := range nodes { if nodes[j].chainSelector == remoteSel && nodes[j].id != src.id { - fmt.Fprintf(&b, " %s -->|\"%s\"| %s\n", src.id, "chain update", nodes[j].id) + b.WriteString(fmt.Sprintf(" %s -->|\"%s\"| %s\n", src.id, "chain update", nodes[j].id)) break } From 969b23f6e6dda4334b773e677c4c470177a322df Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 20 Mar 2026 08:03:44 -0600 Subject: [PATCH 06/17] fix: move from pkg/cre to cre --- .mockery.yml | 4 ++-- {pkg/cre => cre}/cli_runner.go | 0 {pkg/cre => cre}/cli_runner_test.go | 0 {pkg/cre => cre}/exit.go | 0 {pkg/cre => cre}/exit_test.go | 0 {pkg/cre => cre}/mocks/mock_runner.go | 2 +- {pkg/cre => cre}/runner.go | 0 deployment/environment.go | 2 +- engine/cld/environment/options.go | 2 +- engine/cld/environment/options_test.go | 2 +- engine/test/environment/components.go | 2 +- engine/test/environment/options.go | 2 +- engine/test/environment/options_test.go | 2 +- 13 files changed, 9 insertions(+), 9 deletions(-) rename {pkg/cre => cre}/cli_runner.go (100%) rename {pkg/cre => cre}/cli_runner_test.go (100%) rename {pkg/cre => cre}/exit.go (100%) rename {pkg/cre => cre}/exit_test.go (100%) rename {pkg/cre => cre}/mocks/mock_runner.go (97%) rename {pkg/cre => cre}/runner.go (100%) diff --git a/.mockery.yml b/.mockery.yml index e7bc3e6e9..cd5ba3f96 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -149,11 +149,11 @@ packages: filename: "{{.InterfaceName | snakecase}}.go" interfaces: APIClientWrapped: - github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre: + github.com/smartcontractkit/chainlink-deployments-framework/cre: config: all: false pkgname: "cremocks" - dir: "pkg/cre/mocks" + dir: "cre/mocks" filename: "mock_{{.InterfaceName | snakecase}}.go" structname: "{{.Mock}}{{.InterfaceName}}" interfaces: diff --git a/pkg/cre/cli_runner.go b/cre/cli_runner.go similarity index 100% rename from pkg/cre/cli_runner.go rename to cre/cli_runner.go diff --git a/pkg/cre/cli_runner_test.go b/cre/cli_runner_test.go similarity index 100% rename from pkg/cre/cli_runner_test.go rename to cre/cli_runner_test.go diff --git a/pkg/cre/exit.go b/cre/exit.go similarity index 100% rename from pkg/cre/exit.go rename to cre/exit.go diff --git a/pkg/cre/exit_test.go b/cre/exit_test.go similarity index 100% rename from pkg/cre/exit_test.go rename to cre/exit_test.go diff --git a/pkg/cre/mocks/mock_runner.go b/cre/mocks/mock_runner.go similarity index 97% rename from pkg/cre/mocks/mock_runner.go rename to cre/mocks/mock_runner.go index 3405f1444..b5f0170c3 100644 --- a/pkg/cre/mocks/mock_runner.go +++ b/cre/mocks/mock_runner.go @@ -7,7 +7,7 @@ package cremocks import ( "context" - "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" + "github.com/smartcontractkit/chainlink-deployments-framework/cre" mock "github.com/stretchr/testify/mock" ) diff --git a/pkg/cre/runner.go b/cre/runner.go similarity index 100% rename from pkg/cre/runner.go rename to cre/runner.go diff --git a/deployment/environment.go b/deployment/environment.go index 5fdf6a25a..91a29c5f8 100644 --- a/deployment/environment.go +++ b/deployment/environment.go @@ -10,11 +10,11 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/chain" "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/cre" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/offchain" "github.com/smartcontractkit/chainlink-deployments-framework/offchain/ocr" "github.com/smartcontractkit/chainlink-deployments-framework/operations" - "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) diff --git a/engine/cld/environment/options.go b/engine/cld/environment/options.go index ccef17b3f..419721cac 100644 --- a/engine/cld/environment/options.go +++ b/engine/cld/environment/options.go @@ -1,9 +1,9 @@ package environment import ( + "github.com/smartcontractkit/chainlink-deployments-framework/cre" cfgdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/domain" "github.com/smartcontractkit/chainlink-deployments-framework/operations" - "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) diff --git a/engine/cld/environment/options_test.go b/engine/cld/environment/options_test.go index d1405f439..808b4d5d6 100644 --- a/engine/cld/environment/options_test.go +++ b/engine/cld/environment/options_test.go @@ -6,8 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/smartcontractkit/chainlink-deployments-framework/cre" foperations "github.com/smartcontractkit/chainlink-deployments-framework/operations" - "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) diff --git a/engine/test/environment/components.go b/engine/test/environment/components.go index 4d9a65dcf..f94e345f5 100644 --- a/engine/test/environment/components.go +++ b/engine/test/environment/components.go @@ -6,10 +6,10 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" fchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/cre" fdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" foffchain "github.com/smartcontractkit/chainlink-deployments-framework/offchain" - "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" ) // components is a struct that contains the components of the environment. diff --git a/engine/test/environment/options.go b/engine/test/environment/options.go index 0012bf3ac..573df7fac 100644 --- a/engine/test/environment/options.go +++ b/engine/test/environment/options.go @@ -6,11 +6,11 @@ import ( fchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + "github.com/smartcontractkit/chainlink-deployments-framework/cre" fdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/onchain" "github.com/smartcontractkit/chainlink-deployments-framework/offchain" - "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" ) // Assign the chain container loader constructors to local variables to allow for stubbing in tests. diff --git a/engine/test/environment/options_test.go b/engine/test/environment/options_test.go index 07d21b90c..54f151798 100644 --- a/engine/test/environment/options_test.go +++ b/engine/test/environment/options_test.go @@ -7,9 +7,9 @@ import ( "github.com/stretchr/testify/require" fchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/cre" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/internal/testutils" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/onchain" - "github.com/smartcontractkit/chainlink-deployments-framework/pkg/cre" ) func Test_withChainLoader(t *testing.T) { From da7d0173a4cff6fe61b1db06274dc9d85a25c4d4 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 20 Mar 2026 10:50:36 -0600 Subject: [PATCH 07/17] fix: add CRE config objects --- engine/cld/config/env/config.go | 20 ++++++++++++++++++++ engine/cld/config/env/config_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/engine/cld/config/env/config.go b/engine/cld/config/env/config.go index bb2915e9a..9032bb0ed 100644 --- a/engine/cld/config/env/config.go +++ b/engine/cld/config/env/config.go @@ -140,6 +140,23 @@ type CatalogConfig struct { Auth *CatalogAuthConfig `mapstructure:"auth" yaml:"auth,omitempty"` // The authentication configuration for the Catalog. } +// CREAuthConfig holds authentication settings for CRE (Chainlink Runtime Environment) deploy operations. +type CREAuthConfig struct { + HMACKeyID string `mapstructure:"hmac_key_id" yaml:"hmac_key_id"` // Secret: HMAC key ID + HMACKeySecret string `mapstructure:"hmac_key_secret" yaml:"hmac_key_secret"` // Secret: HMAC key secret + TenantID string `mapstructure:"tenant_id" yaml:"tenant_id"` + OrgID string `mapstructure:"org_id" yaml:"org_id"` +} + +// CREConfig is the configuration for CRE deploy and related CLI usage (credentials, endpoints, timeouts). +type CREConfig struct { + Auth CREAuthConfig `mapstructure:"auth" yaml:"auth"` + TLS string `mapstructure:"tls" yaml:"tls"` + Timeout string `mapstructure:"timeout" yaml:"timeout"` + StorageAddress string `mapstructure:"storage_address" yaml:"storage_address"` + DonFamily string `mapstructure:"don_family" yaml:"don_family"` +} + // OnchainConfig wraps the configuration for the onchain components. type OnchainConfig struct { KMS KMSConfig `mapstructure:"kms" yaml:"kms"` @@ -164,6 +181,9 @@ type Config struct { Onchain OnchainConfig `mapstructure:"onchain" yaml:"onchain"` Offchain OffchainConfig `mapstructure:"offchain" yaml:"offchain"` Catalog CatalogConfig `mapstructure:"catalog" yaml:"catalog"` + // CRE is optional. If the cre block is absent from YAML and CRE_* env vars are unset, + // unmarshaling leaves CRE zero-valued; Load/LoadEnv still succeed (same as other optional sections). + CRE CREConfig `mapstructure:"cre" yaml:"cre,omitempty"` } // Load loads the config from the file path, falling back to env vars if the file does not exist. diff --git a/engine/cld/config/env/config_test.go b/engine/cld/config/env/config_test.go index 0d382cdc3..0850f0016 100644 --- a/engine/cld/config/env/config_test.go +++ b/engine/cld/config/env/config_test.go @@ -73,6 +73,7 @@ var ( KMSKeyRegion: "us-east-1", }, }, + CRE: CREConfig{}, } // envVars is the environment variables that used to set the config. @@ -194,6 +195,7 @@ var ( KMSKeyRegion: "us-east-1", }, }, + CRE: CREConfig{}, } ) @@ -235,6 +237,7 @@ func Test_Load(t *testing.T) { //nolint:paralleltest // see comment in setupTest OCR: OCRConfig{}, }, Catalog: CatalogConfig{}, + CRE: CREConfig{}, }, }, { @@ -333,6 +336,29 @@ func Test_LoadEnv_Legacy(t *testing.T) { //nolint:paralleltest // see comment in assert.Equal(t, envCfg, got) } +func Test_LoadEnv_BindsCREFromEnv(t *testing.T) { //nolint:paralleltest // see comment in setupEnvVars + t.Setenv("CRE_DEPLOY_HMAC_KEY_ID", "kid-1") + t.Setenv("CRE_DEPLOY_HMAC_SECRET", "secret-1") + t.Setenv("CRE_TENANT_ID", "tenant-1") + t.Setenv("CRE_ORG_ID", "org-1") + t.Setenv("CRE_TLS", "true") + t.Setenv("CRE_TIMEOUT", "30s") + t.Setenv("CRE_STORAGE_ADDR", "addr-1") + t.Setenv("CRE_DON_FAMILY", "family-1") + + got, err := LoadEnv() + require.NoError(t, err) + + require.Equal(t, "kid-1", got.CRE.Auth.HMACKeyID) + require.Equal(t, "secret-1", got.CRE.Auth.HMACKeySecret) + require.Equal(t, "tenant-1", got.CRE.Auth.TenantID) + require.Equal(t, "org-1", got.CRE.Auth.OrgID) + require.Equal(t, "true", got.CRE.TLS) + require.Equal(t, "30s", got.CRE.Timeout) + require.Equal(t, "addr-1", got.CRE.StorageAddress) + require.Equal(t, "family-1", got.CRE.DonFamily) +} + func Test_YAML_Marshal_Unmarshal(t *testing.T) { t.Parallel() From bbf182384391658b8e9cacfbc38be1d29312d11d Mon Sep 17 00:00:00 2001 From: Pablo Estrada <139084212+ecPablo@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:05:48 -0600 Subject: [PATCH 08/17] Update engine/cld/environment/options_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- engine/cld/environment/options_test.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/engine/cld/environment/options_test.go b/engine/cld/environment/options_test.go index 808b4d5d6..cdc592382 100644 --- a/engine/cld/environment/options_test.go +++ b/engine/cld/environment/options_test.go @@ -145,29 +145,21 @@ func Test_resolveCRERunner(t *testing.T) { name string override cre.Runner binaryPath string - wantType string - wantPath string }{ { name: "override takes precedence", override: &cre.CLIRunner{BinaryPath: "/override/cre"}, binaryPath: "/default/cre", - wantType: "*cre.CLIRunner", - wantPath: "/override/cre", }, { name: "no override uses binary path", override: nil, binaryPath: "/custom/cre", - wantType: "*cre.CLIRunner", - wantPath: "/custom/cre", }, { name: "empty binary path uses default", override: nil, binaryPath: "", - wantType: "*cre.CLIRunner", - wantPath: "", }, } From 6aa743368e2f1a8bf2cea2f8273e8929df6206fb Mon Sep 17 00:00:00 2001 From: Pablo Estrada <139084212+ecPablo@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:07:06 -0600 Subject: [PATCH 09/17] Update engine/cld/environment/options_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- engine/cld/environment/options_test.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/engine/cld/environment/options_test.go b/engine/cld/environment/options_test.go index cdc592382..b137b7245 100644 --- a/engine/cld/environment/options_test.go +++ b/engine/cld/environment/options_test.go @@ -160,14 +160,6 @@ func Test_resolveCRERunner(t *testing.T) { name: "empty binary path uses default", override: nil, binaryPath: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := resolveCRERunner(tt.override, tt.binaryPath) if tt.override != nil { // Override should return the exact same instance From d85660e6293da1e26ec6c59632b521aba2482ca9 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 20 Mar 2026 13:18:01 -0600 Subject: [PATCH 10/17] fix: unit tests --- engine/cld/environment/options_test.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/engine/cld/environment/options_test.go b/engine/cld/environment/options_test.go index b137b7245..75d728d44 100644 --- a/engine/cld/environment/options_test.go +++ b/engine/cld/environment/options_test.go @@ -157,20 +157,27 @@ func Test_resolveCRERunner(t *testing.T) { binaryPath: "/custom/cre", }, { - name: "empty binary path uses default", + name: "empty_binary_path_uses_cli_default", override: nil, binaryPath: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := resolveCRERunner(tt.override, tt.binaryPath) if tt.override != nil { - // Override should return the exact same instance assert.Equal(t, tt.override, got) - } else { - // No override should return a CLIRunner - require.NotNil(t, got) - cliRunner, ok := got.(*cre.CLIRunner) - require.True(t, ok, "expected *cre.CLIRunner, got %T", got) - assert.Equal(t, tt.binaryPath, cliRunner.BinaryPath) + return } + + require.NotNil(t, got) + cliRunner, ok := got.(*cre.CLIRunner) + require.True(t, ok, "expected *cre.CLIRunner, got %T", got) + assert.Equal(t, tt.binaryPath, cliRunner.BinaryPath) }) } } From dd512269a05624c4facacb78e0e8ef444942e9a8 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 23 Mar 2026 07:59:18 -0600 Subject: [PATCH 11/17] fix: address review comments --- cre/cli_runner.go | 27 +++- cre/cli_runner_test.go | 168 ++++++++++++++++--------- cre/mocks/mock_runner.go | 8 +- cre/runner.go | 2 +- deployment/environment.go | 2 +- engine/cld/config/env/config.go | 3 +- engine/cld/environment/environment.go | 2 +- engine/cld/environment/fork.go | 2 +- engine/cld/environment/options.go | 25 +--- engine/cld/environment/options_test.go | 59 ++------- 10 files changed, 152 insertions(+), 146 deletions(-) diff --git a/cre/cli_runner.go b/cre/cli_runner.go index 69ae946a5..5b2cf8875 100644 --- a/cre/cli_runner.go +++ b/cre/cli_runner.go @@ -4,15 +4,27 @@ import ( "bytes" "context" "errors" + "io" "os/exec" ) const defaultBinary = "cre" -// CLIRunner runs the CRE CLI via os/exec. Call executes the binary and captures stdout/stderr. +// CLIRunner runs the CRE CLI via os/exec. Run executes the binary and captures stdout/stderr. +// +// Set [CLIRunner.Stdout] and/or [CLIRunner.Stderr] to stream output in real time (e.g. os.Stdout, +// a zap WriteSyncer, or any [io.Writer]). The captured bytes in [CallResult] are always available +// regardless of whether streaming writers are set. type CLIRunner struct { // BinaryPath is the executable to run. Empty means "cre" (resolved via PATH). BinaryPath string + Stdout io.Writer + Stderr io.Writer +} + +// NewCLIRunner returns a CLIRunner that resolves "cre" from PATH. +func NewCLIRunner() *CLIRunner { + return &CLIRunner{} } func (r *CLIRunner) binary() string { @@ -26,14 +38,14 @@ func (r *CLIRunner) binary() string { // Call runs the binary and captures stdout and stderr. Exit code 0 returns (res, nil); // exit code != 0 returns (res, *ExitError) so callers get both result and error. // Runner-related failures (binary not found, context canceled) return (nil, err). -func (r *CLIRunner) Call(ctx context.Context, args ...string) (*CallResult, error) { +func (r *CLIRunner) Run(ctx context.Context, args ...string) (*CallResult, error) { //nolint:gosec // G204: This is intentional - we're running a CLI tool with user-provided arguments. // The binary path is controlled via configuration, and args are expected to be user-provided CLI arguments. cmd := exec.CommandContext(ctx, r.binary(), args...) var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr + cmd.Stdout = wrapWriter(&stdout, r.Stdout) + cmd.Stderr = wrapWriter(&stderr, r.Stderr) err := cmd.Run() res := &CallResult{ @@ -60,3 +72,10 @@ func (r *CLIRunner) Call(ctx context.Context, args ...string) (*CallResult, erro return res, nil } + +func wrapWriter(buf *bytes.Buffer, stream io.Writer) io.Writer { + if stream == nil { + return buf + } + return io.MultiWriter(buf, stream) +} diff --git a/cre/cli_runner_test.go b/cre/cli_runner_test.go index 39bc66aa2..a509b0096 100644 --- a/cre/cli_runner_test.go +++ b/cre/cli_runner_test.go @@ -1,12 +1,9 @@ package cre import ( + "bytes" "context" - "errors" - "io/fs" - "os/exec" "path/filepath" - "runtime" "testing" "github.com/stretchr/testify/require" @@ -31,108 +28,157 @@ func TestCLIRunner_binary(t *testing.T) { } } -func TestCLIRunner_Call(t *testing.T) { +func TestCLIRunner_Run(t *testing.T) { t.Parallel() tests := []struct { - name string - skip string - setupCtx func(*testing.T) context.Context - runner *CLIRunner - args []string - wantErr bool - wantResNil bool - wantExitCode int - wantErrIs error - checkExitError bool + name string + setupCtx func(*testing.T) context.Context + runner *CLIRunner + args []string + wantErr bool + wantResNil bool + wantExitCode int + wantStdout string + wantStderr string + wantErrIs error + wantExitErr bool }{ { - name: "binary_not_found", - runner: &CLIRunner{BinaryPath: filepath.Join(t.TempDir(), "nonexistent-cre-xyz")}, - args: []string{"build"}, - wantErr: true, wantResNil: true, + name: "binary_not_found", + runner: &CLIRunner{BinaryPath: filepath.Join(t.TempDir(), "nonexistent-cre-xyz")}, + args: []string{"build"}, + wantErr: true, + wantResNil: true, }, { name: "context_already_canceled", - runner: &CLIRunner{BinaryPath: "cre"}, + runner: &CLIRunner{BinaryPath: "/bin/sh"}, setupCtx: func(t *testing.T) context.Context { t.Helper() - ctx, cancel := context.WithCancel(t.Context()) cancel() - return ctx }, - args: []string{"build"}, + args: []string{"-c", "echo unreachable"}, wantErr: true, wantResNil: true, wantErrIs: context.Canceled, }, { - name: "nonzero_exit_returns_error", - skip: "windows", - runner: &CLIRunner{BinaryPath: "/bin/sh"}, - args: []string{"-c", "exit 41"}, - wantErr: true, - wantResNil: false, - wantExitCode: 41, - checkExitError: true, + name: "nonzero_exit_captures_output", + runner: &CLIRunner{BinaryPath: "/bin/sh"}, + args: []string{"-c", `echo "fail out"; echo "fail err" >&2; exit 41`}, + wantErr: true, + wantExitCode: 41, + wantStdout: "fail out\n", + wantStderr: "fail err\n", + wantExitErr: true, }, { - name: "success", - skip: "windows", - runner: &CLIRunner{BinaryPath: "true"}, - args: nil, - wantErr: false, - wantResNil: false, + name: "success_with_output", + runner: &CLIRunner{BinaryPath: "/bin/sh"}, + args: []string{"-c", `echo "hello stdout"; echo "hello stderr" >&2`}, + wantStdout: "hello stdout\n", + wantStderr: "hello stderr\n", wantExitCode: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.skip == "windows" && runtime.GOOS == "windows" { - t.Skip("skipped on windows") - } - if tt.name == "success" { - if _, err := exec.LookPath("true"); err != nil { - t.Skip("true not in PATH") - } - } t.Parallel() ctx := t.Context() if tt.setupCtx != nil { ctx = tt.setupCtx(t) } - res, err := tt.runner.Call(ctx, tt.args...) + + res, err := tt.runner.Run(ctx, tt.args...) + + if tt.wantResNil { + require.Nil(t, res) + } if tt.wantErr { require.Error(t, err) - if tt.wantResNil { - require.Nil(t, res) - } else { - require.NotNil(t, res) - require.Equal(t, tt.wantExitCode, res.ExitCode) - } if tt.wantErrIs != nil { require.ErrorIs(t, err, tt.wantErrIs) } - if tt.checkExitError { + if tt.wantExitErr { var exitErr *ExitError require.ErrorAs(t, err, &exitErr) require.Equal(t, tt.wantExitCode, exitErr.ExitCode) } - if tt.name == "binary_not_found" { - require.True(t, - errors.Is(err, fs.ErrNotExist) || errors.Is(err, exec.ErrNotFound) || errors.Is(err, exec.ErrDot), - "expected not found style error, got %v", err) - } + } else { + require.NoError(t, err) + } + + if res != nil { + require.Equal(t, tt.wantExitCode, res.ExitCode) + require.Equal(t, tt.wantStdout, string(res.Stdout)) + require.Equal(t, tt.wantStderr, string(res.Stderr)) + } + }) + } +} + +func TestCLIRunner_StreamingWriters(t *testing.T) { + t.Parallel() - return + tests := []struct { + name string + args []string + wantStdout string + wantStderr string + }{ + { + name: "stdout_streamed", + args: []string{"-c", `echo "hello from stdout"`}, + wantStdout: "hello from stdout\n", + wantStderr: "", + }, + { + name: "stderr_streamed", + args: []string{"-c", `echo "hello from stderr" >&2`}, + wantStdout: "", + wantStderr: "hello from stderr\n", + }, + { + name: "both_streamed", + args: []string{"-c", `echo "out"; echo "err" >&2`}, + wantStdout: "out\n", + wantStderr: "err\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var streamOut, streamErr bytes.Buffer + r := &CLIRunner{ + BinaryPath: "/bin/sh", + Stdout: &streamOut, + Stderr: &streamErr, } + + res, err := r.Run(t.Context(), tt.args...) require.NoError(t, err) - require.NotNil(t, res) - require.Equal(t, tt.wantExitCode, res.ExitCode) + + require.Equal(t, tt.wantStdout, streamOut.String(), "streamed stdout") + require.Equal(t, tt.wantStderr, streamErr.String(), "streamed stderr") + + require.Equal(t, tt.wantStdout, string(res.Stdout), "captured stdout") + require.Equal(t, tt.wantStderr, string(res.Stderr), "captured stderr") }) } } + +func TestCLIRunner_NilWriters_DefaultBehavior(t *testing.T) { + t.Parallel() + + r := &CLIRunner{BinaryPath: "/bin/sh"} + res, err := r.Run(t.Context(), "-c", `echo "works"`) + require.NoError(t, err) + require.Equal(t, "works\n", string(res.Stdout)) +} diff --git a/cre/mocks/mock_runner.go b/cre/mocks/mock_runner.go index b5f0170c3..f5d034b9d 100644 --- a/cre/mocks/mock_runner.go +++ b/cre/mocks/mock_runner.go @@ -39,7 +39,7 @@ func (_m *MockRunner) EXPECT() *MockRunner_Expecter { } // Call provides a mock function for the type MockRunner -func (_mock *MockRunner) Call(ctx context.Context, args ...string) (*cre.CallResult, error) { +func (_mock *MockRunner) Run(ctx context.Context, args ...string) (*cre.CallResult, error) { var tmpRet mock.Arguments if len(args) > 0 { tmpRet = _mock.Called(ctx, args) @@ -49,7 +49,7 @@ func (_mock *MockRunner) Call(ctx context.Context, args ...string) (*cre.CallRes ret := tmpRet if len(ret) == 0 { - panic("no return value specified for Call") + panic("no return value specified for Run") } var r0 *cre.CallResult @@ -72,7 +72,7 @@ func (_mock *MockRunner) Call(ctx context.Context, args ...string) (*cre.CallRes return r0, r1 } -// MockRunner_Call_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Call' +// MockRunner_Call_Call is a *mock.Run that shadows Run/Return methods with type explicit version for method 'Run' type MockRunner_Call_Call struct { *mock.Call } @@ -81,7 +81,7 @@ type MockRunner_Call_Call struct { // - ctx context.Context // - args ...string func (_e *MockRunner_Expecter) Call(ctx interface{}, args ...interface{}) *MockRunner_Call_Call { - return &MockRunner_Call_Call{Call: _e.mock.On("Call", + return &MockRunner_Call_Call{Call: _e.mock.On("Run", append([]interface{}{ctx}, args...)...)} } diff --git a/cre/runner.go b/cre/runner.go index f98049367..5bc293987 100644 --- a/cre/runner.go +++ b/cre/runner.go @@ -11,5 +11,5 @@ type CallResult struct { // Runner is used to invoke the CRE CLI. type Runner interface { - Call(ctx context.Context, args ...string) (*CallResult, error) + Run(ctx context.Context, args ...string) (*CallResult, error) } diff --git a/deployment/environment.go b/deployment/environment.go index 91a29c5f8..ab7b30d99 100644 --- a/deployment/environment.go +++ b/deployment/environment.go @@ -63,7 +63,7 @@ type Environment struct { BlockChains chain.BlockChains // CRERunner invokes the CRE CLI from changesets. Optional in environment loading, so it won't // fail to load environment if cre binary not available; failures (e.g. binary not found) only occur - // when CRERunner.Call() is used inside changesets. May be nil in test environments that did not set it. + // when CRERunner.Run() is used inside changesets. May be nil in test environments that did not set it. CRERunner cre.Runner } diff --git a/engine/cld/config/env/config.go b/engine/cld/config/env/config.go index 9032bb0ed..5ad44aac6 100644 --- a/engine/cld/config/env/config.go +++ b/engine/cld/config/env/config.go @@ -141,6 +141,8 @@ type CatalogConfig struct { } // CREAuthConfig holds authentication settings for CRE (Chainlink Runtime Environment) deploy operations. +// WARNING: This data type contains sensitive fields and should not be logged or set in file +// configuration. type CREAuthConfig struct { HMACKeyID string `mapstructure:"hmac_key_id" yaml:"hmac_key_id"` // Secret: HMAC key ID HMACKeySecret string `mapstructure:"hmac_key_secret" yaml:"hmac_key_secret"` // Secret: HMAC key secret @@ -181,7 +183,6 @@ type Config struct { Onchain OnchainConfig `mapstructure:"onchain" yaml:"onchain"` Offchain OffchainConfig `mapstructure:"offchain" yaml:"offchain"` Catalog CatalogConfig `mapstructure:"catalog" yaml:"catalog"` - // CRE is optional. If the cre block is absent from YAML and CRE_* env vars are unset, // unmarshaling leaves CRE zero-valued; Load/LoadEnv still succeed (same as other optional sections). CRE CREConfig `mapstructure:"cre" yaml:"cre,omitempty"` } diff --git a/engine/cld/environment/environment.go b/engine/cld/environment/environment.go index 6c57b7137..c778de446 100644 --- a/engine/cld/environment/environment.go +++ b/engine/cld/environment/environment.go @@ -144,7 +144,7 @@ func Load( OCRSecrets: sharedSecrets, OperationsBundle: operations.NewBundle(getCtx, lggr, loadcfg.reporter, operations.WithOperationRegistry(loadcfg.operationRegistry)), BlockChains: blockChains, - CRERunner: resolveCRERunner(loadcfg.creRunner, loadcfg.creBinaryPath), + CRERunner: loadcfg.creRunner, }, nil } diff --git a/engine/cld/environment/fork.go b/engine/cld/environment/fork.go index 1c876071f..62eebd178 100644 --- a/engine/cld/environment/fork.go +++ b/engine/cld/environment/fork.go @@ -149,7 +149,7 @@ func LoadFork( func() context.Context { return ctx }, focr.XXXGenerateTestOCRSecrets(), fchain.NewBlockChains(blockChains), - fdeployment.WithCRERunner(resolveCRERunner(loadcfg.creRunner, loadcfg.creBinaryPath)), + fdeployment.WithCRERunner(loadcfg.creRunner), ) return ForkedEnvironment{ diff --git a/engine/cld/environment/options.go b/engine/cld/environment/options.go index 419721cac..2f161a00e 100644 --- a/engine/cld/environment/options.go +++ b/engine/cld/environment/options.go @@ -45,8 +45,7 @@ type LoadConfig struct { // datastoreType when set, overrides the datastore type from domain config (e.g. from --datastore flag). datastoreType *cfgdomain.DatastoreType - creRunner cre.Runner - creBinaryPath string + creRunner cre.Runner } // Configure applies a slice of LoadEnvironmentOption functions to the LoadConfig. @@ -66,11 +65,11 @@ func newLoadConfig() (*LoadConfig, error) { return nil, err } - // Default options return &LoadConfig{ reporter: operations.NewMemoryReporter(), operationRegistry: operations.NewOperationRegistry(), lggr: lggr, + creRunner: cre.NewCLIRunner(), }, nil } @@ -193,25 +192,11 @@ func WithDatastoreType(t cfgdomain.DatastoreType) LoadEnvironmentOption { } } -// WithCRERunner replaces the default CRE CLIRunner (useful in tests). +// WithCRERunner overrides the default CRE CLI runner. The default is a [cre.CLIRunner] that +// resolves "cre" from PATH. Use this to supply a custom binary path +// (e.g. WithCRERunner(&cre.CLIRunner{BinaryPath: "/opt/cre"})) or a mock in tests. func WithCRERunner(r cre.Runner) LoadEnvironmentOption { return func(o *LoadConfig) { o.creRunner = r } } - -// WithCREBinaryPath sets the CRE CLI executable path. Empty uses "cre" on PATH. -func WithCREBinaryPath(path string) LoadEnvironmentOption { - return func(o *LoadConfig) { - o.creBinaryPath = path - } -} - -// resolveCRERunner returns override if set, otherwise a CLIRunner for the given path. -func resolveCRERunner(override cre.Runner, binaryPath string) cre.Runner { - if override != nil { - return override - } - - return &cre.CLIRunner{BinaryPath: binaryPath} -} diff --git a/engine/cld/environment/options_test.go b/engine/cld/environment/options_test.go index 75d728d44..1d10b88d5 100644 --- a/engine/cld/environment/options_test.go +++ b/engine/cld/environment/options_test.go @@ -125,59 +125,14 @@ func Test_WithCRERunner(t *testing.T) { assert.Equal(t, runner, opts.creRunner) } -func Test_WithCREBinaryPath(t *testing.T) { +func Test_newLoadConfig_defaultCRERunner(t *testing.T) { t.Parallel() - opts := &LoadConfig{} - assert.Empty(t, opts.creBinaryPath) - - binaryPath := "/custom/path/to/cre" - option := WithCREBinaryPath(binaryPath) - option(opts) - - assert.Equal(t, binaryPath, opts.creBinaryPath) -} - -func Test_resolveCRERunner(t *testing.T) { - t.Parallel() + cfg, err := newLoadConfig() + require.NoError(t, err) - tests := []struct { - name string - override cre.Runner - binaryPath string - }{ - { - name: "override takes precedence", - override: &cre.CLIRunner{BinaryPath: "/override/cre"}, - binaryPath: "/default/cre", - }, - { - name: "no override uses binary path", - override: nil, - binaryPath: "/custom/cre", - }, - { - name: "empty_binary_path_uses_cli_default", - override: nil, - binaryPath: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := resolveCRERunner(tt.override, tt.binaryPath) - - if tt.override != nil { - assert.Equal(t, tt.override, got) - return - } - - require.NotNil(t, got) - cliRunner, ok := got.(*cre.CLIRunner) - require.True(t, ok, "expected *cre.CLIRunner, got %T", got) - assert.Equal(t, tt.binaryPath, cliRunner.BinaryPath) - }) - } + require.NotNil(t, cfg.creRunner) + cliRunner, ok := cfg.creRunner.(*cre.CLIRunner) + require.True(t, ok, "expected *cre.CLIRunner, got %T", cfg.creRunner) + assert.Empty(t, cliRunner.BinaryPath) } From a4995315b04ff19560d60c17ff6f2aa0dbdd4122 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 23 Mar 2026 08:02:17 -0600 Subject: [PATCH 12/17] fix: remove unnecessary comments --- cre/cli_runner.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cre/cli_runner.go b/cre/cli_runner.go index 5b2cf8875..9e258a941 100644 --- a/cre/cli_runner.go +++ b/cre/cli_runner.go @@ -11,10 +11,6 @@ import ( const defaultBinary = "cre" // CLIRunner runs the CRE CLI via os/exec. Run executes the binary and captures stdout/stderr. -// -// Set [CLIRunner.Stdout] and/or [CLIRunner.Stderr] to stream output in real time (e.g. os.Stdout, -// a zap WriteSyncer, or any [io.Writer]). The captured bytes in [CallResult] are always available -// regardless of whether streaming writers are set. type CLIRunner struct { // BinaryPath is the executable to run. Empty means "cre" (resolved via PATH). BinaryPath string From 320eaad317eeb9ab663b9ee35462c7c7e38227e4 Mon Sep 17 00:00:00 2001 From: Pablo Estrada <139084212+ecPablo@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:06:22 -0600 Subject: [PATCH 13/17] Update cre/cli_runner.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cre/cli_runner.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cre/cli_runner.go b/cre/cli_runner.go index 9e258a941..c3d923fbc 100644 --- a/cre/cli_runner.go +++ b/cre/cli_runner.go @@ -31,7 +31,7 @@ func (r *CLIRunner) binary() string { return defaultBinary } -// Call runs the binary and captures stdout and stderr. Exit code 0 returns (res, nil); +// Run runs the binary and captures stdout and stderr. Exit code 0 returns (res, nil); // exit code != 0 returns (res, *ExitError) so callers get both result and error. // Runner-related failures (binary not found, context canceled) return (nil, err). func (r *CLIRunner) Run(ctx context.Context, args ...string) (*CallResult, error) { From 2127d2839d2b4b465de9b2602f491c9c0b1193e6 Mon Sep 17 00:00:00 2001 From: Pablo Estrada <139084212+ecPablo@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:06:32 -0600 Subject: [PATCH 14/17] Update cre/exit.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cre/exit.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cre/exit.go b/cre/exit.go index a09fb2fba..0edf5b62f 100644 --- a/cre/exit.go +++ b/cre/exit.go @@ -4,7 +4,7 @@ import "fmt" // ExitError is returned when the CRE process ran and exited with a non-zero code. // Use errors.As to inspect ExitCode, Stdout, and Stderr. Result is still returned -// from Call so callers can log or inspect output. +// from Run (for example Runner.Run or CLIRunner.Run) so callers can log or inspect output. type ExitError struct { ExitCode int Stdout []byte From 22ac93bf8761b6ccc124bbd1600e2e93426cc70b Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 23 Mar 2026 08:26:22 -0600 Subject: [PATCH 15/17] fix: lint errors --- cre/cli_runner.go | 1 + cre/cli_runner_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/cre/cli_runner.go b/cre/cli_runner.go index c3d923fbc..eef19a962 100644 --- a/cre/cli_runner.go +++ b/cre/cli_runner.go @@ -73,5 +73,6 @@ func wrapWriter(buf *bytes.Buffer, stream io.Writer) io.Writer { if stream == nil { return buf } + return io.MultiWriter(buf, stream) } diff --git a/cre/cli_runner_test.go b/cre/cli_runner_test.go index a509b0096..602f26cc6 100644 --- a/cre/cli_runner_test.go +++ b/cre/cli_runner_test.go @@ -58,6 +58,7 @@ func TestCLIRunner_Run(t *testing.T) { t.Helper() ctx, cancel := context.WithCancel(t.Context()) cancel() + return ctx }, args: []string{"-c", "echo unreachable"}, From 3774eed9c55f0d8a29ec0fda06913102417757c6 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 23 Mar 2026 08:57:17 -0600 Subject: [PATCH 16/17] fix: make binary private, remove comments and use ErrorContains in tests --- cre/cli_runner.go | 29 +++++++++++-------------- cre/cli_runner_test.go | 26 ++++++++++------------ cre/exit_test.go | 2 +- engine/cld/config/env/config.go | 3 +-- engine/cld/environment/options.go | 4 ++-- engine/cld/environment/options_test.go | 5 ++--- engine/test/environment/options_test.go | 2 +- 7 files changed, 32 insertions(+), 39 deletions(-) diff --git a/cre/cli_runner.go b/cre/cli_runner.go index eef19a962..9d3425b48 100644 --- a/cre/cli_runner.go +++ b/cre/cli_runner.go @@ -12,32 +12,30 @@ const defaultBinary = "cre" // CLIRunner runs the CRE CLI via os/exec. Run executes the binary and captures stdout/stderr. type CLIRunner struct { - // BinaryPath is the executable to run. Empty means "cre" (resolved via PATH). - BinaryPath string - Stdout io.Writer - Stderr io.Writer + binaryPath string + // Stdout, if set, receives a real-time copy of the process stdout while it runs. + Stdout io.Writer + // Stderr, if set, receives a real-time copy of the process stderr while it runs. + Stderr io.Writer } -// NewCLIRunner returns a CLIRunner that resolves "cre" from PATH. -func NewCLIRunner() *CLIRunner { - return &CLIRunner{} -} - -func (r *CLIRunner) binary() string { - if r.BinaryPath != "" { - return r.BinaryPath +// NewCLIRunner returns a CLIRunner for the given binary path. An empty path defaults to "cre" +// (resolved via PATH). +func NewCLIRunner(binaryPath string) *CLIRunner { + if binaryPath == "" { + binaryPath = defaultBinary } - return defaultBinary + return &CLIRunner{binaryPath: binaryPath} } -// Run runs the binary and captures stdout and stderr. Exit code 0 returns (res, nil); +// Run executes the binary and captures stdout and stderr. Exit code 0 returns (res, nil); // exit code != 0 returns (res, *ExitError) so callers get both result and error. // Runner-related failures (binary not found, context canceled) return (nil, err). func (r *CLIRunner) Run(ctx context.Context, args ...string) (*CallResult, error) { //nolint:gosec // G204: This is intentional - we're running a CLI tool with user-provided arguments. // The binary path is controlled via configuration, and args are expected to be user-provided CLI arguments. - cmd := exec.CommandContext(ctx, r.binary(), args...) + cmd := exec.CommandContext(ctx, r.binaryPath, args...) var stdout, stderr bytes.Buffer cmd.Stdout = wrapWriter(&stdout, r.Stdout) @@ -73,6 +71,5 @@ func wrapWriter(buf *bytes.Buffer, stream io.Writer) io.Writer { if stream == nil { return buf } - return io.MultiWriter(buf, stream) } diff --git a/cre/cli_runner_test.go b/cre/cli_runner_test.go index 602f26cc6..c66df573a 100644 --- a/cre/cli_runner_test.go +++ b/cre/cli_runner_test.go @@ -9,21 +9,21 @@ import ( "github.com/stretchr/testify/require" ) -func TestCLIRunner_binary(t *testing.T) { +func TestNewCLIRunner(t *testing.T) { t.Parallel() tests := []struct { name string binaryPath string want string }{ - {"default_empty", "", defaultBinary}, + {"empty_defaults_to_cre", "", defaultBinary}, {"custom_path", "/opt/cre", "/opt/cre"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - r := &CLIRunner{BinaryPath: tt.binaryPath} - require.Equal(t, tt.want, r.binary()) + r := NewCLIRunner(tt.binaryPath) + require.Equal(t, tt.want, r.binaryPath) }) } } @@ -46,14 +46,14 @@ func TestCLIRunner_Run(t *testing.T) { }{ { name: "binary_not_found", - runner: &CLIRunner{BinaryPath: filepath.Join(t.TempDir(), "nonexistent-cre-xyz")}, + runner: NewCLIRunner(filepath.Join(t.TempDir(), "nonexistent-cre-xyz")), args: []string{"build"}, wantErr: true, wantResNil: true, }, { name: "context_already_canceled", - runner: &CLIRunner{BinaryPath: "/bin/sh"}, + runner: NewCLIRunner("/bin/sh"), setupCtx: func(t *testing.T) context.Context { t.Helper() ctx, cancel := context.WithCancel(t.Context()) @@ -68,7 +68,7 @@ func TestCLIRunner_Run(t *testing.T) { }, { name: "nonzero_exit_captures_output", - runner: &CLIRunner{BinaryPath: "/bin/sh"}, + runner: NewCLIRunner("/bin/sh"), args: []string{"-c", `echo "fail out"; echo "fail err" >&2; exit 41`}, wantErr: true, wantExitCode: 41, @@ -78,7 +78,7 @@ func TestCLIRunner_Run(t *testing.T) { }, { name: "success_with_output", - runner: &CLIRunner{BinaryPath: "/bin/sh"}, + runner: NewCLIRunner("/bin/sh"), args: []string{"-c", `echo "hello stdout"; echo "hello stderr" >&2`}, wantStdout: "hello stdout\n", wantStderr: "hello stderr\n", @@ -157,11 +157,9 @@ func TestCLIRunner_StreamingWriters(t *testing.T) { t.Parallel() var streamOut, streamErr bytes.Buffer - r := &CLIRunner{ - BinaryPath: "/bin/sh", - Stdout: &streamOut, - Stderr: &streamErr, - } + r := NewCLIRunner("/bin/sh") + r.Stdout = &streamOut + r.Stderr = &streamErr res, err := r.Run(t.Context(), tt.args...) require.NoError(t, err) @@ -178,7 +176,7 @@ func TestCLIRunner_StreamingWriters(t *testing.T) { func TestCLIRunner_NilWriters_DefaultBehavior(t *testing.T) { t.Parallel() - r := &CLIRunner{BinaryPath: "/bin/sh"} + r := NewCLIRunner("/bin/sh") res, err := r.Run(t.Context(), "-c", `echo "works"`) require.NoError(t, err) require.Equal(t, "works\n", string(res.Stdout)) diff --git a/cre/exit_test.go b/cre/exit_test.go index 1442a8c43..c599032d7 100644 --- a/cre/exit_test.go +++ b/cre/exit_test.go @@ -9,7 +9,7 @@ import ( func TestExitError(t *testing.T) { t.Parallel() e := &ExitError{ExitCode: 3, Stderr: []byte("failed")} - require.Contains(t, e.Error(), "code 3") + require.ErrorContains(t, e, "code 3") var out *ExitError require.ErrorAs(t, e, &out) require.Equal(t, 3, out.ExitCode) diff --git a/engine/cld/config/env/config.go b/engine/cld/config/env/config.go index 5ad44aac6..411cf1960 100644 --- a/engine/cld/config/env/config.go +++ b/engine/cld/config/env/config.go @@ -183,8 +183,7 @@ type Config struct { Onchain OnchainConfig `mapstructure:"onchain" yaml:"onchain"` Offchain OffchainConfig `mapstructure:"offchain" yaml:"offchain"` Catalog CatalogConfig `mapstructure:"catalog" yaml:"catalog"` - // unmarshaling leaves CRE zero-valued; Load/LoadEnv still succeed (same as other optional sections). - CRE CREConfig `mapstructure:"cre" yaml:"cre,omitempty"` + CRE CREConfig `mapstructure:"cre" yaml:"cre,omitempty"` } // Load loads the config from the file path, falling back to env vars if the file does not exist. diff --git a/engine/cld/environment/options.go b/engine/cld/environment/options.go index 2f161a00e..7f1fdfd95 100644 --- a/engine/cld/environment/options.go +++ b/engine/cld/environment/options.go @@ -69,7 +69,7 @@ func newLoadConfig() (*LoadConfig, error) { reporter: operations.NewMemoryReporter(), operationRegistry: operations.NewOperationRegistry(), lggr: lggr, - creRunner: cre.NewCLIRunner(), + creRunner: cre.NewCLIRunner(""), }, nil } @@ -194,7 +194,7 @@ func WithDatastoreType(t cfgdomain.DatastoreType) LoadEnvironmentOption { // WithCRERunner overrides the default CRE CLI runner. The default is a [cre.CLIRunner] that // resolves "cre" from PATH. Use this to supply a custom binary path -// (e.g. WithCRERunner(&cre.CLIRunner{BinaryPath: "/opt/cre"})) or a mock in tests. +// (e.g. WithCRERunner(cre.NewCLIRunner("/opt/cre"))) or a mock in tests. func WithCRERunner(r cre.Runner) LoadEnvironmentOption { return func(o *LoadConfig) { o.creRunner = r diff --git a/engine/cld/environment/options_test.go b/engine/cld/environment/options_test.go index 1d10b88d5..b7d9582ea 100644 --- a/engine/cld/environment/options_test.go +++ b/engine/cld/environment/options_test.go @@ -118,7 +118,7 @@ func Test_WithCRERunner(t *testing.T) { opts := &LoadConfig{} assert.Nil(t, opts.creRunner) - runner := &cre.CLIRunner{BinaryPath: "/path/to/cre"} + runner := cre.NewCLIRunner("/path/to/cre") option := WithCRERunner(runner) option(opts) @@ -132,7 +132,6 @@ func Test_newLoadConfig_defaultCRERunner(t *testing.T) { require.NoError(t, err) require.NotNil(t, cfg.creRunner) - cliRunner, ok := cfg.creRunner.(*cre.CLIRunner) + _, ok := cfg.creRunner.(*cre.CLIRunner) require.True(t, ok, "expected *cre.CLIRunner, got %T", cfg.creRunner) - assert.Empty(t, cliRunner.BinaryPath) } diff --git a/engine/test/environment/options_test.go b/engine/test/environment/options_test.go index 54f151798..3cafe126f 100644 --- a/engine/test/environment/options_test.go +++ b/engine/test/environment/options_test.go @@ -133,7 +133,7 @@ func Test_WithCRERunner(t *testing.T) { cmps := newComponents() require.Nil(t, cmps.CRERunner) - runner := &cre.CLIRunner{BinaryPath: "/path/to/cre"} + runner := cre.NewCLIRunner("/path/to/cre") option := WithCRERunner(runner) err := option(cmps) From 55275dc1a4f55cc652a77f548fe534dad491f4bc Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 23 Mar 2026 10:25:00 -0600 Subject: [PATCH 17/17] chore: linting and add changeset --- .changeset/flat-baboons-lose.md | 5 +++++ cre/cli_runner.go | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/flat-baboons-lose.md diff --git a/.changeset/flat-baboons-lose.md b/.changeset/flat-baboons-lose.md new file mode 100644 index 000000000..113866515 --- /dev/null +++ b/.changeset/flat-baboons-lose.md @@ -0,0 +1,5 @@ +--- +"chainlink-deployments-framework": minor +--- + +Add CRE CLI runner object. diff --git a/cre/cli_runner.go b/cre/cli_runner.go index 9d3425b48..67a47c031 100644 --- a/cre/cli_runner.go +++ b/cre/cli_runner.go @@ -71,5 +71,6 @@ func wrapWriter(buf *bytes.Buffer, stream io.Writer) io.Writer { if stream == nil { return buf } + return io.MultiWriter(buf, stream) }