diff --git a/cmd/crossplane/render/engine.go b/cmd/crossplane/render/engine.go index 22aeea6..1e08ce4 100644 --- a/cmd/crossplane/render/engine.go +++ b/cmd/crossplane/render/engine.go @@ -44,6 +44,14 @@ type Engine interface { Setup(ctx context.Context, fns []pkgv1.Function) (cleanup func(), err error) // Render executes the render request and returns the response. + // + // On a pipeline-fatal exit (ExitCodePipelineFatal — see + // crossplane/crossplane#7455), Render may return BOTH a non-nil partial + // response AND a non-nil error. Callers that need to recover + // output.RequiredResources (or any other partial output) must check the + // returned response even when err != nil. Standard "nil-rsp on err" + // callers can ignore this; the response will simply be nil for them on + // any other failure mode. Render(ctx context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) } diff --git a/cmd/crossplane/render/engine_docker.go b/cmd/crossplane/render/engine_docker.go index 0b4e3b1..b67cf54 100644 --- a/cmd/crossplane/render/engine_docker.go +++ b/cmd/crossplane/render/engine_docker.go @@ -34,6 +34,21 @@ import ( renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" ) +// containerRunner is the subset of internal/docker the engine depends on. +type containerRunner interface { + Run(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) +} + +// realContainerRunner adapts docker.RunContainer to the containerRunner +// interface so dockerRenderEngine can hold the seam by interface rather than +// function pointer. +type realContainerRunner struct{} + +// Run delegates to docker.RunContainer. +func (realContainerRunner) Run(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) { + return docker.RunContainer(ctx, img, opts...) +} + // dockerRenderEngine executes crossplane internal render in a Docker container. type dockerRenderEngine struct { // image is the Crossplane Docker image reference. @@ -43,6 +58,13 @@ type dockerRenderEngine struct { network string log logging.Logger + + // runner runs the render container. Production callers leave it nil and + // Render falls through to realContainerRunner{}. Tests substitute a + // mockContainerRunner to exercise the engine's failure-mode handling + // (exit-3 partial output, *docker.ContainerExitError vs non-exit errors) + // without a real Docker daemon. + runner containerRunner } func (e *dockerRenderEngine) CheckContextSupport() error { @@ -78,6 +100,14 @@ func (e *dockerRenderEngine) Setup(ctx context.Context, fns []pkgv1.Function) (f // Render marshals the request, runs it through a Docker container, and returns // the response. +// +// Stderr from the container is captured (via docker.RunContainer's stderr +// return) and surfaced in returned errors so callers can inspect fatal +// pipeline messages programmatically. When the container exits with +// ExitCodePipelineFatal (a pipeline step returned SEVERITY_FATAL) and stdout +// carries a partial RenderResponse, Render parses it and returns both the +// partial response AND a non-nil error containing stderr — letting callers +// recover the partial output (e.g. RequiredResources) and iterate. func (e *dockerRenderEngine) Render(ctx context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { // Update any localhost function addresses if needed. if cinput := req.GetComposite(); cinput != nil { @@ -116,9 +146,24 @@ func (e *dockerRenderEngine) Render(ctx context.Context, req *renderv1alpha1.Ren e.log.Debug("Running crossplane internal render in Docker", "image", e.image, "network", e.network) - stdout, _, err := docker.RunContainer(ctx, e.image, opts...) + runner := e.runner + if runner == nil { + runner = realContainerRunner{} + } + + stdout, stderr, err := runner.Run(ctx, e.image, opts...) if err != nil { - return nil, errors.Wrap(err, "cannot run crossplane internal render in Docker") + var exitErr *docker.ContainerExitError + if errors.As(err, &exitErr) && exitErr.ExitCode == ExitCodePipelineFatal && len(stdout) > 0 { + // Pipeline-fatal with partial output. Parse the partial response + // and return both it and the stderr-bearing error. + rsp := &renderv1alpha1.RenderResponse{} + if uerr := proto.Unmarshal(stdout, rsp); uerr != nil { + return nil, errors.Wrapf(uerr, "cannot unmarshal partial render response after pipeline fatal: %s", exitErr.Stderr) + } + return rsp, errors.Errorf("crossplane internal render in Docker: pipeline returned fatal: %s", exitErr.Stderr) + } + return nil, errors.Wrapf(err, "crossplane internal render in Docker returned error with output: %s", stderr) } rsp := &renderv1alpha1.RenderResponse{} diff --git a/cmd/crossplane/render/engine_docker_test.go b/cmd/crossplane/render/engine_docker_test.go new file mode 100644 index 0000000..d27bedc --- /dev/null +++ b/cmd/crossplane/render/engine_docker_test.go @@ -0,0 +1,219 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/docker" + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" +) + +type mockContainerRunner struct { + MockRun func(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) +} + +func (m *mockContainerRunner) Run(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) { + return m.MockRun(ctx, img, opts...) +} + +var _ containerRunner = &mockContainerRunner{} + +func TestDockerRenderEngineRender(t *testing.T) { + // A canned response with a distinguishing CompositeResource so a successful + // (or partial) round-trip through Render asserts the unmarshal path, not + // just that we got something non-nil back. + xrStruct, err := structpb.NewStruct(map[string]any{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": map[string]any{"name": "test-xr"}, + }) + if err != nil { + t.Fatalf("cannot construct canned XR struct: %v", err) + } + cannedRsp := &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: xrStruct, + }, + }, + } + cannedRspBytes, err := proto.Marshal(cannedRsp) + if err != nil { + t.Fatalf("cannot marshal canned response: %v", err) + } + + type args struct { + runFn func(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) + } + + type want struct { + rsp *renderv1alpha1.RenderResponse + wantErr bool + wantInErr []string + wantSingleOccurrence []string + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Success": { + reason: "Render returns the unmarshaled response and no error on a clean exit.", + args: args{ + runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { + return cannedRspBytes, nil, nil + }, + }, + want: want{rsp: cannedRsp}, + }, + "FatalWithPartialOutput": { + reason: "On exit-3 with non-empty stdout, Render parses the partial response and returns it alongside a stderr-bearing error.", + args: args{ + runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { + return cannedRspBytes, []byte("boom: pipeline step requested fatal"), &docker.ContainerExitError{ + ExitCode: ExitCodePipelineFatal, + Stderr: []byte("boom: pipeline step requested fatal"), + } + }, + }, + want: want{ + rsp: cannedRsp, + wantErr: true, + wantInErr: []string{ + "pipeline returned fatal", + "boom: pipeline step requested fatal", + }, + }, + }, + "FatalWithNoPartialOutput": { + reason: "On exit-3 with empty stdout, Render falls back to the hard-fail path and surfaces stderr exactly once.", + args: args{ + runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { + return nil, []byte("boom: no partial"), &docker.ContainerExitError{ + ExitCode: ExitCodePipelineFatal, + Stderr: []byte("boom: no partial"), + } + }, + }, + want: want{ + wantErr: true, + wantInErr: []string{ + "crossplane internal render in Docker returned error with output", + "boom: no partial", + "container exited with status 3", + }, + wantSingleOccurrence: []string{"boom: no partial"}, + }, + }, + "HardFailWithExitError": { + reason: "Non-fatal exit codes wrap the *ContainerExitError; stderr is included once via Wrapf, exit code via the wrapped Error().", + args: args{ + runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { + return nil, []byte("the container is sad"), &docker.ContainerExitError{ + ExitCode: 1, + Stderr: []byte("the container is sad"), + } + }, + }, + want: want{ + wantErr: true, + wantInErr: []string{ + "crossplane internal render in Docker returned error with output", + "the container is sad", + "container exited with status 1", + }, + wantSingleOccurrence: []string{"the container is sad"}, + }, + }, + "HardFailNonExitError": { + reason: "Non-exit errors (e.g. image-pull failures) get the captured stderr buffer appended so its content isn't lost.", + args: args{ + runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { + return nil, []byte("non-exit stderr"), &nonExitError{msg: "image pull failed"} + }, + }, + want: want{ + wantErr: true, + wantInErr: []string{ + "crossplane internal render in Docker returned error with output", + "image pull failed", + "non-exit stderr", + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + e := &dockerRenderEngine{ + image: "test-image", + log: logging.NewNopLogger(), + runner: &mockContainerRunner{MockRun: tc.args.runFn}, + } + + rsp, err := e.Render(context.Background(), &renderv1alpha1.RenderRequest{}) + + switch { + case tc.want.wantErr && err == nil: + t.Fatalf("\n%s\nRender(...): want error, got nil", tc.reason) + case !tc.want.wantErr && err != nil: + t.Fatalf("\n%s\nRender(...): unexpected error: %v", tc.reason, err) + } + + for _, s := range tc.want.wantInErr { + if err == nil { + t.Errorf("\n%s\nRender(...): error is nil but expected to contain %q", tc.reason, s) + continue + } + if !strings.Contains(err.Error(), s) { + t.Errorf("\n%s\nRender(...): error %q does not contain %q", tc.reason, err.Error(), s) + } + } + + for _, s := range tc.want.wantSingleOccurrence { + if err == nil { + t.Errorf("\n%s\nRender(...): error is nil but expected exactly one occurrence of %q", tc.reason, s) + continue + } + if got := strings.Count(err.Error(), s); got != 1 { + t.Errorf("\n%s\nRender(...): error %q contains %q %d times, want exactly 1 (double-formatting bug?)", tc.reason, err.Error(), s, got) + } + } + + if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { + t.Errorf("\n%s\nRender(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +// nonExitError is a stand-in for non-*ContainerExitError failures (e.g. image +// pull errors) returned by docker.RunContainer. +type nonExitError struct{ msg string } + +func (e *nonExitError) Error() string { return e.msg } diff --git a/cmd/crossplane/render/engine_local.go b/cmd/crossplane/render/engine_local.go index 514220a..59a8ed9 100644 --- a/cmd/crossplane/render/engine_local.go +++ b/cmd/crossplane/render/engine_local.go @@ -19,7 +19,6 @@ package render import ( "bytes" "context" - "os" "os/exec" "google.golang.org/protobuf/proto" @@ -49,19 +48,38 @@ func (e *localRenderEngine) Setup(_ context.Context, _ []pkgv1.Function) (func() // Render marshals the request, runs it through a local binary, and returns // the response. +// +// Stderr is captured into the returned error so callers can surface fatal +// pipeline messages programmatically. When the binary exits with +// ExitCodePipelineFatal (a pipeline step returned SEVERITY_FATAL) and stdout +// carries a partial RenderResponse, Render parses it and returns both the +// partial response AND a non-nil error containing stderr — letting callers +// recover the partial output (e.g. RequiredResources) and iterate. func (e *localRenderEngine) Render(ctx context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { data, err := proto.Marshal(req) if err != nil { return nil, errors.Wrap(err, "cannot marshal render request") } + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, e.BinaryPath, "internal", "render") //nolint:gosec // The binary path is user-supplied via CLI flag. cmd.Stdin = bytes.NewReader(data) - cmd.Stderr = os.Stderr + cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { - return nil, errors.Wrap(err, "cannot run crossplane internal render") + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == ExitCodePipelineFatal && len(out) > 0 { + // Pipeline-fatal with partial output. Parse the partial response + // and return both it and the stderr-bearing error. + rsp := &renderv1alpha1.RenderResponse{} + if uerr := proto.Unmarshal(out, rsp); uerr != nil { + return nil, errors.Wrapf(uerr, "cannot unmarshal partial render response after pipeline fatal: %s", stderr.String()) + } + return rsp, errors.Errorf("crossplane internal render: pipeline returned fatal: %s", stderr.String()) + } + return nil, errors.Wrapf(err, "crossplane internal render returned error with output: %s", stderr.String()) } rsp := &renderv1alpha1.RenderResponse{} diff --git a/cmd/crossplane/render/engine_local_test.go b/cmd/crossplane/render/engine_local_test.go new file mode 100644 index 0000000..d5ea0e5 --- /dev/null +++ b/cmd/crossplane/render/engine_local_test.go @@ -0,0 +1,234 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/structpb" + + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" +) + +// envHelperMode drives the "helper process" pattern used by these tests. +// When set in a process's environment, TestMain below dispatches to +// runRenderHelper (which exits) instead of running the test suite. +// +// Why this pattern. localRenderEngine.Render exec's a binary at a path the +// caller supplies, then reads its stdout, captures its stderr, and +// inspects its exit code. To exercise the four behavioural branches the +// engine cares about (success / pipeline-fatal with partial stdout / +// pipeline-fatal with empty stdout / hard-fail with stderr) we need a +// "binary" that can do each on demand. The options are: +// +// 1. Ship a shell script per case — cross-platform pain, plus shell +// script bytes are awkward when the binary needs to write a real +// marshaled RenderResponse to stdout. +// 2. Build a side helper binary in testdata/ — extra build orchestration. +// 3. Re-exec the test binary itself, switching its behaviour on an env +// var. This is the standard Go stdlib idiom (used in os/exec's own +// tests) and is what we do here. +// +// How it works. Tests do `t.Setenv(envHelperMode, "")` and pass +// os.Args[0] as the binary path. The engine exec's that binary; the child +// process inherits the env var, TestMain sees it set, and dispatches to +// runRenderHelper which writes the canned response and exits with the +// chosen code — never reaching m.Run(). The parent reads the result as if +// it had just exec'd a real `crossplane internal render`. When the env +// var is unset (the normal `go test` invocation) TestMain falls through +// to m.Run() and the tests behave like any other Go tests. +// +// The cost is a few extra lines of TestMain plumbing; the benefit is a +// fully self-contained, deterministic, cross-platform fake binary with +// access to the same proto types the tests assert on. +const envHelperMode = "GO_HELPER_LOCAL_RENDER_MODE" + +// TestMain re-uses os.Args[0] as a stand-in for the `crossplane` binary when +// the engine_local tests want to control its stdout/stderr/exit-code. When +// envHelperMode is set we play the helper role and exit; otherwise we run the +// normal test suite. See the envHelperMode doc above for why. +func TestMain(m *testing.M) { + if mode := os.Getenv(envHelperMode); mode != "" { + runRenderHelper(mode) + // runRenderHelper always exits. + } + os.Exit(m.Run()) +} + +// runRenderHelper emulates `crossplane internal render` for a single canned +// scenario. It reads (and discards) stdin, optionally writes a marshaled +// RenderResponse to stdout, optionally writes a message to stderr, and exits +// with the scenario's exit code. +func runRenderHelper(mode string) { + _, _ = io.Copy(io.Discard, os.Stdin) + + rsp := helperResponse() + rspBytes, err := proto.Marshal(rsp) + if err != nil { + fmt.Fprintf(os.Stderr, "helper: cannot marshal canned response: %v\n", err) + os.Exit(127) + } + + switch mode { + case "success": + _, _ = os.Stdout.Write(rspBytes) + os.Exit(0) + case "fatal-with-partial": + _, _ = os.Stdout.Write(rspBytes) + fmt.Fprint(os.Stderr, "boom: pipeline step requested fatal") + os.Exit(ExitCodePipelineFatal) + case "fatal-no-partial": + fmt.Fprint(os.Stderr, "boom: pipeline step requested fatal but produced no output") + os.Exit(ExitCodePipelineFatal) + case "hard-fail": + fmt.Fprint(os.Stderr, "the binary is sad") + os.Exit(1) + default: + fmt.Fprintf(os.Stderr, "helper: unknown mode %q\n", mode) + os.Exit(127) + } +} + +// helperResponse returns the canned RenderResponse the helper writes to +// stdout. Shared with the test so we can assert exact-content round-trip. +func helperResponse() *renderv1alpha1.RenderResponse { + xr, _ := structpb.NewStruct(map[string]any{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": map[string]any{"name": "test-xr"}, + }) + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: xr, + }, + }, + } +} + +func TestLocalRenderEngineRender(t *testing.T) { + type args struct { + mode string + } + + type want struct { + rsp *renderv1alpha1.RenderResponse + wantErr bool + wantInErr []string + wantSingleOccurrence []string + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Success": { + reason: "Render returns the unmarshaled response and no error on a clean exit.", + args: args{mode: "success"}, + want: want{rsp: helperResponse()}, + }, + "FatalWithPartialOutput": { + reason: "On exit-3 with non-empty stdout, Render parses the partial response and returns it alongside a stderr-bearing error.", + args: args{mode: "fatal-with-partial"}, + want: want{ + rsp: helperResponse(), + wantErr: true, + wantInErr: []string{ + "pipeline returned fatal", + "boom: pipeline step requested fatal", + }, + wantSingleOccurrence: []string{"boom: pipeline step requested fatal"}, + }, + }, + "FatalWithNoPartialOutput": { + reason: "On exit-3 with empty stdout, Render falls back to the hard-fail path with stderr in the error.", + args: args{mode: "fatal-no-partial"}, + want: want{ + wantErr: true, + wantInErr: []string{ + "crossplane internal render returned error with output", + "pipeline step requested fatal but produced no output", + "exit status 3", + }, + wantSingleOccurrence: []string{"pipeline step requested fatal but produced no output"}, + }, + }, + "HardFail": { + reason: "Non-fatal exit codes surface stderr in the returned error.", + args: args{mode: "hard-fail"}, + want: want{ + wantErr: true, + wantInErr: []string{ + "crossplane internal render returned error with output", + "the binary is sad", + "exit status 1", + }, + wantSingleOccurrence: []string{"the binary is sad"}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Setenv(envHelperMode, tc.args.mode) + + e := &localRenderEngine{BinaryPath: os.Args[0]} + + rsp, err := e.Render(context.Background(), &renderv1alpha1.RenderRequest{}) + + switch { + case tc.want.wantErr && err == nil: + t.Fatalf("\n%s\nRender(...): want error, got nil", tc.reason) + case !tc.want.wantErr && err != nil: + t.Fatalf("\n%s\nRender(...): unexpected error: %v", tc.reason, err) + } + + for _, s := range tc.want.wantInErr { + if err == nil { + t.Errorf("\n%s\nRender(...): error is nil but expected to contain %q", tc.reason, s) + continue + } + if !strings.Contains(err.Error(), s) { + t.Errorf("\n%s\nRender(...): error %q does not contain %q", tc.reason, err.Error(), s) + } + } + + for _, s := range tc.want.wantSingleOccurrence { + if err == nil { + t.Errorf("\n%s\nRender(...): error is nil but expected exactly one occurrence of %q", tc.reason, s) + continue + } + if got := strings.Count(err.Error(), s); got != 1 { + t.Errorf("\n%s\nRender(...): error %q contains %q %d times, want exactly 1 (double-formatting bug?)", tc.reason, err.Error(), s, got) + } + } + + if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { + t.Errorf("\n%s\nRender(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/render/render.go b/cmd/crossplane/render/render.go index 2e8a084..5cc373f 100644 --- a/cmd/crossplane/render/render.go +++ b/cmd/crossplane/render/render.go @@ -40,6 +40,13 @@ import ( renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" ) +// ExitCodePipelineFatal is the exit code that `crossplane internal render` +// returns when a pipeline step responds with SEVERITY_FATAL. The binary +// populates stdout with a partial RenderResponse on this code so callers can +// recover output.RequiredResources (and similar) and iterate. See +// crossplane/crossplane#7455 for the upstream contract. +const ExitCodePipelineFatal = 3 + // CompositionInputs contains all inputs to the render process. type CompositionInputs struct { CompositeResource *ucomposite.Unstructured diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 11d0efc..c9202e7 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -384,9 +384,31 @@ func RunWithBindMount(hostPath, containerPath string) RunContainerOption { } } +// ContainerExitError is returned by RunContainer when the container exits with +// a non-zero status. It carries the exit code so callers can branch on +// well-known codes (e.g. the partial-output-on-fatal exit from +// `crossplane internal render`) and the captured stderr so callers can surface +// the failure details to users. Error() returns only the exit-code summary; +// callers that want the stderr content in the error message should wrap with +// errors.Wrapf using the Stderr field directly. +type ContainerExitError struct { + ExitCode int + Stderr []byte +} + +// Error implements error. +func (e *ContainerExitError) Error() string { + return fmt.Sprintf("container exited with status %d", e.ExitCode) +} + // RunContainer creates a container, optionally pipes stdin, waits for it to // exit, and returns stdout and stderr. The container is always removed on // return. This is intended for short-lived "run to completion" containers. +// +// On non-zero exit RunContainer returns the captured stdout, stderr, and a +// *ContainerExitError carrying the exit code. Callers that need to recover a +// partial stdout (e.g. exit code 3 from `crossplane internal render` per +// crossplane/crossplane#7455) should errors.As against *ContainerExitError. func RunContainer(ctx context.Context, img string, opts ...RunContainerOption) ([]byte, []byte, error) { cfg := &runContainerConfig{ containerConfig: &container.Config{ @@ -467,7 +489,10 @@ func RunContainer(ctx context.Context, img string, opts ...RunContainerOption) ( select { case status := <-statusCh: if status.StatusCode != 0 { - return stdout.Bytes(), stderr.Bytes(), fmt.Errorf("container exited with status %d: %s", status.StatusCode, stderr.String()) + return stdout.Bytes(), stderr.Bytes(), &ContainerExitError{ + ExitCode: int(status.StatusCode), + Stderr: stderr.Bytes(), + } } case err := <-errCh: return nil, nil, errors.Wrap(err, "error waiting for container")