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/flat-baboons-lose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink-deployments-framework": minor
---

Add CRE CLI runner object.
9 changes: 9 additions & 0 deletions .mockery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,12 @@ packages:
filename: "{{.InterfaceName | snakecase}}.go"
interfaces:
APIClientWrapped:
github.com/smartcontractkit/chainlink-deployments-framework/cre:
config:
all: false
pkgname: "cremocks"
dir: "cre/mocks"
filename: "mock_{{.InterfaceName | snakecase}}.go"
structname: "{{.Mock}}{{.InterfaceName}}"
interfaces:
Runner:
76 changes: 76 additions & 0 deletions cre/cli_runner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cre

import (
"bytes"
"context"
"errors"
"io"
"os/exec"
)

const defaultBinary = "cre"

// CLIRunner runs the CRE CLI via os/exec. Run executes the binary and captures stdout/stderr.
type CLIRunner struct {
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 for the given binary path. An empty path defaults to "cre"
// (resolved via PATH).
func NewCLIRunner(binaryPath string) *CLIRunner {
if binaryPath == "" {
binaryPath = defaultBinary
}

return &CLIRunner{binaryPath: binaryPath}
}

// 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.binaryPath, args...)

var stdout, stderr bytes.Buffer
cmd.Stdout = wrapWriter(&stdout, r.Stdout)
cmd.Stderr = wrapWriter(&stderr, r.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
}

func wrapWriter(buf *bytes.Buffer, stream io.Writer) io.Writer {
if stream == nil {
return buf
}

return io.MultiWriter(buf, stream)
}
183 changes: 183 additions & 0 deletions cre/cli_runner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package cre

import (
"bytes"
"context"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

func TestNewCLIRunner(t *testing.T) {
t.Parallel()
tests := []struct {
name string
binaryPath string
want string
}{
{"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 := NewCLIRunner(tt.binaryPath)
require.Equal(t, tt.want, r.binaryPath)
})
}
}

func TestCLIRunner_Run(t *testing.T) {
t.Parallel()

tests := []struct {
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: NewCLIRunner(filepath.Join(t.TempDir(), "nonexistent-cre-xyz")),
args: []string{"build"},
wantErr: true,
wantResNil: true,
},
{
name: "context_already_canceled",
runner: NewCLIRunner("/bin/sh"),
setupCtx: func(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithCancel(t.Context())
cancel()

return ctx
},
args: []string{"-c", "echo unreachable"},
wantErr: true,
wantResNil: true,
wantErrIs: context.Canceled,
},
{
name: "nonzero_exit_captures_output",
runner: NewCLIRunner("/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_with_output",
runner: NewCLIRunner("/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) {
t.Parallel()

ctx := t.Context()
if tt.setupCtx != nil {
ctx = tt.setupCtx(t)
}

res, err := tt.runner.Run(ctx, tt.args...)

if tt.wantResNil {
require.Nil(t, res)
}
if tt.wantErr {
require.Error(t, err)
if tt.wantErrIs != nil {
require.ErrorIs(t, err, tt.wantErrIs)
}
if tt.wantExitErr {
var exitErr *ExitError
require.ErrorAs(t, err, &exitErr)
require.Equal(t, tt.wantExitCode, exitErr.ExitCode)
}
} 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()

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 := NewCLIRunner("/bin/sh")
r.Stdout = &streamOut
r.Stderr = &streamErr

res, err := r.Run(t.Context(), tt.args...)
require.NoError(t, err)

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 := NewCLIRunner("/bin/sh")
res, err := r.Run(t.Context(), "-c", `echo "works"`)
require.NoError(t, err)
require.Equal(t, "works\n", string(res.Stdout))
}
16 changes: 16 additions & 0 deletions cre/exit.go
Original file line number Diff line number Diff line change
@@ -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 Run (for example Runner.Run or CLIRunner.Run) 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)
}
17 changes: 17 additions & 0 deletions cre/exit_test.go
Original file line number Diff line number Diff line change
@@ -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.ErrorContains(t, e, "code 3")
var out *ExitError
require.ErrorAs(t, e, &out)
require.Equal(t, 3, out.ExitCode)
require.Equal(t, "failed", string(out.Stderr))
}
Loading
Loading