From 4378815de8d0a12feb7cbafb672e5768fbe2a829 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 8 Jun 2026 10:21:45 -0400 Subject: [PATCH 1/6] feat(render): capture stderr and handle pipeline-fatal exit code in both engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both render engines previously dropped information that downstream tools need: - localRenderEngine piped stderr straight to os.Stderr, so callers couldn't programmatically inspect FATAL pipeline messages. - dockerRenderEngine discarded stderr at the engine layer (the RunContainer return value was assigned to `_`); only on container error did stderr surface, folded into a fmt.Errorf string. - Neither engine implemented the partial-output-on-fatal contract from crossplane/crossplane#7455: when `crossplane internal render` exits with code 3 it populates stdout with a partial RenderResponse so callers can recover output.RequiredResources and iterate, but both engines wrapped the *exec.ExitError / *ContainerExitError and dropped stdout. This change: - Adds ExitCodePipelineFatal (= 3) in cmd/crossplane/render/render.go, documenting the contract. - Adds *ContainerExitError to internal/docker (carries ExitCode + captured stderr) so callers can errors.As against it. RunContainer returns it on non-zero exit instead of an opaque fmt.Errorf. - Captures stderr in localRenderEngine.Render via bytes.Buffer instead of os.Stderr, and surfaces it in the returned error. - Uses RunContainer's stderr return in dockerRenderEngine.Render and surfaces it in the returned error. - On exit-3 with non-empty stdout, both engines parse the partial *RenderResponse and return (rsp, err) — both non-nil — so callers can recover the partial output. - Adds engine_local_test.go (helper-process pattern via TestMain re-exec) and engine_docker_test.go (injectable runContainer fn) covering success, fatal-with-partial, fatal-no-partial, and hard-fail paths. Motivating downstream use case: crossplane-contrib/crossplane-diff currently wraps localRenderEngine to add this behaviour. See https://github.com/crossplane-contrib/crossplane-diff/pull/326 — once this lands the wrapper can be deleted and crossplane-diff can drop its local-binary fallback entirely. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Jonathan Ogilvie (cherry picked from commit 1843d3d99686ec85d37bbfb6538e7a2c5829cf1e) --- cmd/crossplane/render/engine_docker.go | 38 ++++- cmd/crossplane/render/engine_docker_test.go | 152 +++++++++++++++++++ cmd/crossplane/render/engine_local.go | 24 ++- cmd/crossplane/render/engine_local_test.go | 160 ++++++++++++++++++++ cmd/crossplane/render/render.go | 7 + internal/docker/docker.go | 25 ++- 6 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 cmd/crossplane/render/engine_docker_test.go create mode 100644 cmd/crossplane/render/engine_local_test.go diff --git a/cmd/crossplane/render/engine_docker.go b/cmd/crossplane/render/engine_docker.go index 0b4e3b1..872ed4d 100644 --- a/cmd/crossplane/render/engine_docker.go +++ b/cmd/crossplane/render/engine_docker.go @@ -34,6 +34,11 @@ import ( renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" ) +// runContainerFn matches docker.RunContainer's signature. It's a seam for +// testing — production callers leave it nil and the engine uses the real +// docker.RunContainer. +type runContainerFn func(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) + // dockerRenderEngine executes crossplane internal render in a Docker container. type dockerRenderEngine struct { // image is the Crossplane Docker image reference. @@ -43,6 +48,9 @@ type dockerRenderEngine struct { network string log logging.Logger + + // runContainer is overrideable for tests; defaults to docker.RunContainer. + runContainer runContainerFn } func (e *dockerRenderEngine) CheckContextSupport() error { @@ -78,6 +86,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 +132,27 @@ 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...) + run := e.runContainer + if run == nil { + run = docker.RunContainer + } + + stdout, stderr, err := 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.Errorf("cannot unmarshal partial render response after pipeline fatal: %s: %s", uerr.Error(), exitErr.Stderr) + } + return rsp, errors.Errorf("crossplane internal render in Docker: pipeline returned fatal: %s", exitErr.Stderr) + } + // stderr may have been folded into err already (ContainerExitError + // stringifies it); fall back to the captured stderr buffer otherwise + // so messages from non-exit failures (e.g. image pull) aren't lost. + return nil, errors.Errorf("cannot run crossplane internal render in Docker: %s: %s", err.Error(), 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..b490b00 --- /dev/null +++ b/cmd/crossplane/render/engine_docker_test.go @@ -0,0 +1,152 @@ +/* +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" + + "google.golang.org/protobuf/proto" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/docker" + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" +) + +func TestDockerRenderEngine_Render(t *testing.T) { + rsp := &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{}, + }, + } + rspBytes, err := proto.Marshal(rsp) + if err != nil { + t.Fatalf("cannot marshal canned response: %v", err) + } + + cases := map[string]struct { + runFn runContainerFn + wantRsp bool + wantErr bool + wantInErr []string + }{ + "Success": { + runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { + return rspBytes, nil, nil + }, + wantRsp: true, + }, + "FatalWithPartialOutput": { + runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { + return rspBytes, []byte("boom: pipeline step requested fatal"), &docker.ContainerExitError{ + ExitCode: ExitCodePipelineFatal, + Stderr: []byte("boom: pipeline step requested fatal"), + } + }, + wantRsp: true, + wantErr: true, + wantInErr: []string{ + "pipeline returned fatal", + "boom: pipeline step requested fatal", + }, + }, + "FatalWithNoPartialOutput": { + 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"), + } + }, + wantRsp: false, + wantErr: true, + wantInErr: []string{ + "cannot run crossplane internal render in Docker", + }, + }, + "HardFailWithExitError": { + 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"), + } + }, + wantRsp: false, + wantErr: true, + wantInErr: []string{ + "cannot run crossplane internal render in Docker", + "the container is sad", + }, + }, + "HardFailNonExitError": { + runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { + // e.g. image-pull failure: not a *ContainerExitError. + return nil, []byte("non-exit stderr"), &nonExitError{msg: "image pull failed"} + }, + wantRsp: false, + wantErr: true, + wantInErr: []string{ + "cannot run crossplane internal render in Docker", + "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(), + runContainer: tc.runFn, + } + + rsp, err := e.Render(context.Background(), &renderv1alpha1.RenderRequest{}) + + switch { + case tc.wantErr && err == nil: + t.Fatalf("Render(): want error, got nil") + case !tc.wantErr && err != nil: + t.Fatalf("Render(): unexpected error: %v", err) + } + + for _, want := range tc.wantInErr { + if err == nil { + t.Errorf("Render(): error is nil but expected to contain %q", want) + continue + } + if !strings.Contains(err.Error(), want) { + t.Errorf("Render(): error %q does not contain %q", err.Error(), want) + } + } + + switch { + case tc.wantRsp && rsp == nil: + t.Errorf("Render(): want non-nil response, got nil") + case !tc.wantRsp && rsp != nil: + t.Errorf("Render(): want nil response, got %+v", rsp) + } + }) + } +} + +// 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..29bd2d1 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.Errorf("cannot unmarshal partial render response after pipeline fatal: %s: %s", uerr.Error(), stderr.String()) + } + return rsp, errors.Errorf("crossplane internal render: pipeline returned fatal: %s", stderr.String()) + } + return nil, errors.Errorf("cannot run crossplane internal render: %s: %s", err.Error(), 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..3d3139f --- /dev/null +++ b/cmd/crossplane/render/engine_local_test.go @@ -0,0 +1,160 @@ +/* +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" + + "google.golang.org/protobuf/proto" + + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" +) + +// envHelperMode is the env var that, when set on a re-exec of the test binary, +// causes TestMain to dispatch to runRenderHelper instead of running tests. +// The value selects which canned response the helper emits. +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. +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 := &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{}, + }, + } + 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) + } +} + +func TestLocalRenderEngine_Render(t *testing.T) { + cases := map[string]struct { + mode string + wantRsp bool + wantErr bool + wantInErr []string + }{ + "Success": { + mode: "success", + wantRsp: true, + }, + "FatalWithPartialOutput": { + mode: "fatal-with-partial", + wantRsp: true, + wantErr: true, + wantInErr: []string{ + "pipeline returned fatal", + "boom: pipeline step requested fatal", + }, + }, + "FatalWithNoPartialOutput": { + mode: "fatal-no-partial", + wantRsp: false, + wantErr: true, + wantInErr: []string{ + "cannot run crossplane internal render", + "pipeline step requested fatal but produced no output", + }, + }, + "HardFail": { + mode: "hard-fail", + wantRsp: false, + wantErr: true, + wantInErr: []string{ + "cannot run crossplane internal render", + "the binary is sad", + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Setenv(envHelperMode, tc.mode) + + e := &localRenderEngine{BinaryPath: os.Args[0]} + + rsp, err := e.Render(context.Background(), &renderv1alpha1.RenderRequest{}) + + switch { + case tc.wantErr && err == nil: + t.Fatalf("Render(): want error, got nil") + case !tc.wantErr && err != nil: + t.Fatalf("Render(): unexpected error: %v", err) + } + + for _, want := range tc.wantInErr { + if err == nil { + t.Errorf("Render(): error is nil but expected to contain %q", want) + continue + } + if !strings.Contains(err.Error(), want) { + t.Errorf("Render(): error %q does not contain %q", err.Error(), want) + } + } + + switch { + case tc.wantRsp && rsp == nil: + t.Errorf("Render(): want non-nil response, got nil") + case !tc.wantRsp && rsp != nil: + t.Errorf("Render(): want nil response, got %+v", rsp) + } + }) + } +} diff --git a/cmd/crossplane/render/render.go b/cmd/crossplane/render/render.go index 2090300..94a57c2 100644 --- a/cmd/crossplane/render/render.go +++ b/cmd/crossplane/render/render.go @@ -42,6 +42,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 + // Annotations added to composed resources. const ( AnnotationKeyCompositionResourceName = "crossplane.io/composition-resource-name" diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 11d0efc..0d433df 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -384,9 +384,29 @@ 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. +type ContainerExitError struct { + ExitCode int + Stderr []byte +} + +// Error implements error. +func (e *ContainerExitError) Error() string { + return fmt.Sprintf("container exited with status %d: %s", e.ExitCode, e.Stderr) +} + // 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 +487,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") From 69fbec70f5d96969d5e84dd8254d13a9b5dc1a6a Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 8 Jun 2026 16:19:36 -0400 Subject: [PATCH 2/6] chore(render): expand comments on test seam + helper-process pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runContainer field on dockerRenderEngine and the env-var-driven TestMain dispatch in engine_local_test.go are both standard Go test patterns, but they're not obvious at a glance — and reviewer questions on the downstream PR (crossplane-contrib/crossplane-diff#326) showed both warrant inline justification. - Expand the doc comment on dockerRenderEngine.runContainer to explain why it exists (hermetic failure-mode coverage without a real Docker daemon) and why a per-instance unexported field is preferable to a package-level var (the latter would race in parallel tests). - Expand the doc comment on engine_local_test.go's envHelperMode + TestMain to explain the helper-process / re-exec idiom: why we re-use the test binary as a stand-in for the `crossplane` binary (no shell scripts, no testdata helper binary, deterministic protobuf payloads), how dispatch works (parent t.Setenv -> child inherits -> TestMain sees the var and exits before m.Run), and what the trade-off is (extra TestMain plumbing in exchange for a self-contained, cross-platform fake binary). No functional changes. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Jonathan Ogilvie (cherry picked from commit b196c89f42b9423f0f8364bbcf417b75dd49cf99) --- cmd/crossplane/render/engine_docker.go | 15 +++++++-- cmd/crossplane/render/engine_local_test.go | 36 +++++++++++++++++++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/cmd/crossplane/render/engine_docker.go b/cmd/crossplane/render/engine_docker.go index 872ed4d..4077259 100644 --- a/cmd/crossplane/render/engine_docker.go +++ b/cmd/crossplane/render/engine_docker.go @@ -35,8 +35,8 @@ import ( ) // runContainerFn matches docker.RunContainer's signature. It's a seam for -// testing — production callers leave it nil and the engine uses the real -// docker.RunContainer. +// testing the engine's failure-mode handling without a real Docker daemon — +// see runContainer below. type runContainerFn func(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) // dockerRenderEngine executes crossplane internal render in a Docker container. @@ -49,7 +49,16 @@ type dockerRenderEngine struct { log logging.Logger - // runContainer is overrideable for tests; defaults to docker.RunContainer. + // runContainer is the function used to actually run the render container. + // Production callers leave it nil and Render falls through to + // docker.RunContainer. Tests inject a fake so we can exercise the engine's + // behaviour around the docker package's return contract — particularly + // the exit-code-3 partial-output recovery path and the difference between + // *docker.ContainerExitError and other failure types like image-pull + // errors. Spinning up a real container that exits with a chosen code on + // demand is awkward; replacing the call at the package level via a global + // var would race in parallel tests. A per-instance unexported field gives + // us hermetic, sub-second tests with no shared mutable state. runContainer runContainerFn } diff --git a/cmd/crossplane/render/engine_local_test.go b/cmd/crossplane/render/engine_local_test.go index 3d3139f..19364fa 100644 --- a/cmd/crossplane/render/engine_local_test.go +++ b/cmd/crossplane/render/engine_local_test.go @@ -29,15 +29,43 @@ import ( renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" ) -// envHelperMode is the env var that, when set on a re-exec of the test binary, -// causes TestMain to dispatch to runRenderHelper instead of running tests. -// The value selects which canned response the helper emits. +// 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. +// normal test suite. See the envHelperMode doc above for why. func TestMain(m *testing.M) { if mode := os.Getenv(envHelperMode); mode != "" { runRenderHelper(mode) From 9667f93106b1fdba877eeba0fe82fe9fe2a18816 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 8 Jun 2026 16:47:19 -0400 Subject: [PATCH 3/6] refactor(render): swap docker test seam to one-method-interface idiom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dockerRenderEngine test seam was a function-typed field (runContainerFn). Replace it with a one-method interface (containerRunner) plus a realContainerRunner adapter, mirroring the existing pullClient/mockPullClient pattern used in runtime_docker.go in the same package. Tests now use mockContainerRunner with the same struct-of-Mock-funcs shape as mockPullClient. Same behaviour, same coverage, same hermetic execution — just expressed in the package's established idiom so reviewers can pattern-match against the existing convention rather than evaluating a new one. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Jonathan Ogilvie (cherry picked from commit 40354bd88b92095fdc88f341ebd0b282646afd62) --- cmd/crossplane/render/engine_docker.go | 46 ++++++++++++--------- cmd/crossplane/render/engine_docker_test.go | 18 ++++++-- 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/cmd/crossplane/render/engine_docker.go b/cmd/crossplane/render/engine_docker.go index 4077259..f7b464a 100644 --- a/cmd/crossplane/render/engine_docker.go +++ b/cmd/crossplane/render/engine_docker.go @@ -34,10 +34,23 @@ import ( renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" ) -// runContainerFn matches docker.RunContainer's signature. It's a seam for -// testing the engine's failure-mode handling without a real Docker daemon — -// see runContainer below. -type runContainerFn func(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) +// containerRunner is the subset of internal/docker the engine depends on. It +// follows the same one-method-interface mock pattern as pullClient in +// runtime_docker.go, letting tests substitute a mockContainerRunner without a +// real Docker daemon. +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 { @@ -49,17 +62,12 @@ type dockerRenderEngine struct { log logging.Logger - // runContainer is the function used to actually run the render container. - // Production callers leave it nil and Render falls through to - // docker.RunContainer. Tests inject a fake so we can exercise the engine's - // behaviour around the docker package's return contract — particularly - // the exit-code-3 partial-output recovery path and the difference between - // *docker.ContainerExitError and other failure types like image-pull - // errors. Spinning up a real container that exits with a chosen code on - // demand is awkward; replacing the call at the package level via a global - // var would race in parallel tests. A per-instance unexported field gives - // us hermetic, sub-second tests with no shared mutable state. - runContainer runContainerFn + // 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 { @@ -141,12 +149,12 @@ 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) - run := e.runContainer - if run == nil { - run = docker.RunContainer + runner := e.runner + if runner == nil { + runner = realContainerRunner{} } - stdout, stderr, err := run(ctx, e.image, opts...) + stdout, stderr, err := runner.Run(ctx, e.image, opts...) if err != nil { var exitErr *docker.ContainerExitError if errors.As(err, &exitErr) && exitErr.ExitCode == ExitCodePipelineFatal && len(stdout) > 0 { diff --git a/cmd/crossplane/render/engine_docker_test.go b/cmd/crossplane/render/engine_docker_test.go index b490b00..fe3f248 100644 --- a/cmd/crossplane/render/engine_docker_test.go +++ b/cmd/crossplane/render/engine_docker_test.go @@ -29,6 +29,16 @@ import ( 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 TestDockerRenderEngine_Render(t *testing.T) { rsp := &renderv1alpha1.RenderResponse{ Output: &renderv1alpha1.RenderResponse_Composite{ @@ -41,7 +51,7 @@ func TestDockerRenderEngine_Render(t *testing.T) { } cases := map[string]struct { - runFn runContainerFn + runFn func(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) wantRsp bool wantErr bool wantInErr []string @@ -111,9 +121,9 @@ func TestDockerRenderEngine_Render(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { e := &dockerRenderEngine{ - image: "test-image", - log: logging.NewNopLogger(), - runContainer: tc.runFn, + image: "test-image", + log: logging.NewNopLogger(), + runner: &mockContainerRunner{MockRun: tc.runFn}, } rsp, err := e.Render(context.Background(), &renderv1alpha1.RenderRequest{}) From f37c664fe0b054bdc918784799df96b9f105524c Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 8 Jun 2026 19:33:01 -0400 Subject: [PATCH 4/6] fix(render): address code review on PR #91 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine.go: document the two-non-nil (rsp, err) return contract on the Engine interface itself. Previously only the concrete implementations carried the doc — anything consuming the interface (or the mock in engine_mock.go) would assume the standard Go "nil-rsp on err" shape. - engine_docker.go: fix double-stderr in the hard-fail path. *docker.ContainerExitError.Error() already embeds stderr as "container exited with status N: "; the previous code unconditionally appended the captured stderr buffer on top of that, producing messages like "...status 1: the container is sad: the container is sad". Now we wrap on *ContainerExitError (whose Error already includes stderr) and only append stderr explicitly for non-exit errors (e.g. image pull failures) where err.Error() doesn't. - engine_local.go: trim trailing ": " when stderr is empty. Cosmetic but produced messages like "cannot run crossplane internal render: exit status 1: " on the no-stderr path. - engine_docker_test.go: add wantSingleOccurrence assertion to catch double-formatting bugs at test time. Both FatalWithNoPartialOutput and HardFailWithExitError now assert their stderr text appears exactly once in the returned error. Would have flagged the double-stderr bug above before review. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Jonathan Ogilvie (cherry picked from commit 32e497a8314de5c569c272c23bd279c622f75ef7) --- cmd/crossplane/render/engine.go | 8 ++++++++ cmd/crossplane/render/engine_docker.go | 17 ++++++++++++---- cmd/crossplane/render/engine_docker_test.go | 22 +++++++++++++++++---- cmd/crossplane/render/engine_local.go | 5 ++++- 4 files changed, 43 insertions(+), 9 deletions(-) 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 f7b464a..d7f0174 100644 --- a/cmd/crossplane/render/engine_docker.go +++ b/cmd/crossplane/render/engine_docker.go @@ -166,10 +166,19 @@ func (e *dockerRenderEngine) Render(ctx context.Context, req *renderv1alpha1.Ren } return rsp, errors.Errorf("crossplane internal render in Docker: pipeline returned fatal: %s", exitErr.Stderr) } - // stderr may have been folded into err already (ContainerExitError - // stringifies it); fall back to the captured stderr buffer otherwise - // so messages from non-exit failures (e.g. image pull) aren't lost. - return nil, errors.Errorf("cannot run crossplane internal render in Docker: %s: %s", err.Error(), stderr) + // On a *ContainerExitError, err.Error() already embeds stderr + // (ContainerExitError.Error stringifies it as "container exited with + // status N: "), so wrapping err is sufficient. For non-exit + // failures (e.g. image pull errors), append the captured stderr + // buffer so its content isn't lost when err.Error() doesn't include + // it. + if errors.As(err, &exitErr) { + return nil, errors.Wrap(err, "cannot run crossplane internal render in Docker") + } + if len(stderr) > 0 { + return nil, errors.Errorf("cannot run crossplane internal render in Docker: %s: %s", err.Error(), stderr) + } + return nil, errors.Wrap(err, "cannot run crossplane internal render in Docker") } rsp := &renderv1alpha1.RenderResponse{} diff --git a/cmd/crossplane/render/engine_docker_test.go b/cmd/crossplane/render/engine_docker_test.go index fe3f248..d3499bc 100644 --- a/cmd/crossplane/render/engine_docker_test.go +++ b/cmd/crossplane/render/engine_docker_test.go @@ -51,10 +51,11 @@ func TestDockerRenderEngine_Render(t *testing.T) { } cases := map[string]struct { - runFn func(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) - wantRsp bool - wantErr bool - wantInErr []string + runFn func(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) + wantRsp bool + wantErr bool + wantInErr []string + wantSingleOccurrence []string // strings that must appear exactly once (catches double-stderr bugs) }{ "Success": { runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { @@ -87,7 +88,9 @@ func TestDockerRenderEngine_Render(t *testing.T) { wantErr: true, wantInErr: []string{ "cannot run crossplane internal render in Docker", + "boom: no partial", }, + wantSingleOccurrence: []string{"boom: no partial"}, }, "HardFailWithExitError": { runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { @@ -102,6 +105,7 @@ func TestDockerRenderEngine_Render(t *testing.T) { "cannot run crossplane internal render in Docker", "the container is sad", }, + wantSingleOccurrence: []string{"the container is sad"}, }, "HardFailNonExitError": { runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { @@ -145,6 +149,16 @@ func TestDockerRenderEngine_Render(t *testing.T) { } } + for _, want := range tc.wantSingleOccurrence { + if err == nil { + t.Errorf("Render(): error is nil but expected exactly one occurrence of %q", want) + continue + } + if got := strings.Count(err.Error(), want); got != 1 { + t.Errorf("Render(): error %q contains %q %d times, want exactly 1 (double-formatting bug?)", err.Error(), want, got) + } + } + switch { case tc.wantRsp && rsp == nil: t.Errorf("Render(): want non-nil response, got nil") diff --git a/cmd/crossplane/render/engine_local.go b/cmd/crossplane/render/engine_local.go index 29bd2d1..58ed096 100644 --- a/cmd/crossplane/render/engine_local.go +++ b/cmd/crossplane/render/engine_local.go @@ -79,7 +79,10 @@ func (e *localRenderEngine) Render(ctx context.Context, req *renderv1alpha1.Rend } return rsp, errors.Errorf("crossplane internal render: pipeline returned fatal: %s", stderr.String()) } - return nil, errors.Errorf("cannot run crossplane internal render: %s: %s", err.Error(), stderr.String()) + if stderr.Len() > 0 { + return nil, errors.Errorf("cannot run crossplane internal render: %s: %s", err.Error(), stderr.String()) + } + return nil, errors.Wrap(err, "cannot run crossplane internal render") } rsp := &renderv1alpha1.RenderResponse{} From eea833503760bc4d4884b80d0d42b01d8eaca04b Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 8 Jun 2026 20:43:19 -0400 Subject: [PATCH 5/6] chore(render): align engine tests with package conventions per coderabbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both engine_docker_test.go and engine_local_test.go now follow the existing convention used by runtime_docker_test.go and the rest of cmd/crossplane/render: PascalCase test names without underscore (TestDockerRenderEngineRender, TestLocalRenderEngineRender), an args/want/reason struct shape, and cmp.Diff for response comparisons (via protocmp.Transform). While restructuring, also tighten the success / partial-output cases to compare the actual recovered *RenderResponse against the canned input rather than just asserting non-nil — a regression that returned an empty response would have slipped past the previous assertion. The canned response now carries a distinguishing CompositeResource struct so the round-trip exercises the unmarshal path. For symmetry, propagate wantSingleOccurrence to the local engine test. The local engine can't produce double-stderr today (its *exec.ExitError doesn't embed stderr the way *ContainerExitError does), but adding the assertion keeps the two test files structurally identical and guards against future drift. No production code changes. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Jonathan Ogilvie (cherry picked from commit f9700864e827713ca501ff2336a6440435a966e6) --- cmd/crossplane/render/engine_docker_test.go | 181 ++++++++++++-------- cmd/crossplane/render/engine_local_test.go | 132 +++++++++----- 2 files changed, 199 insertions(+), 114 deletions(-) diff --git a/cmd/crossplane/render/engine_docker_test.go b/cmd/crossplane/render/engine_docker_test.go index d3499bc..d2fd1d4 100644 --- a/cmd/crossplane/render/engine_docker_test.go +++ b/cmd/crossplane/render/engine_docker_test.go @@ -21,7 +21,10 @@ import ( "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" @@ -39,85 +42,126 @@ func (m *mockContainerRunner) Run(ctx context.Context, img string, opts ...docke var _ containerRunner = &mockContainerRunner{} -func TestDockerRenderEngine_Render(t *testing.T) { - rsp := &renderv1alpha1.RenderResponse{ +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{}, + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: xrStruct, + }, }, } - rspBytes, err := proto.Marshal(rsp) + cannedRspBytes, err := proto.Marshal(cannedRsp) if err != nil { t.Fatalf("cannot marshal canned response: %v", err) } - cases := map[string]struct { - runFn func(ctx context.Context, img string, opts ...docker.RunContainerOption) ([]byte, []byte, error) - wantRsp bool + 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 // strings that must appear exactly once (catches double-stderr bugs) + wantSingleOccurrence []string + } + + cases := map[string]struct { + reason string + args args + want want }{ "Success": { - runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { - return rspBytes, nil, nil + 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 + }, }, - wantRsp: true, + want: want{rsp: cannedRsp}, }, "FatalWithPartialOutput": { - runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { - return rspBytes, []byte("boom: pipeline step requested fatal"), &docker.ContainerExitError{ - ExitCode: ExitCodePipelineFatal, - Stderr: []byte("boom: pipeline step requested fatal"), - } + 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"), + } + }, }, - wantRsp: true, - wantErr: true, - wantInErr: []string{ - "pipeline returned fatal", - "boom: pipeline step requested fatal", + want: want{ + rsp: cannedRsp, + wantErr: true, + wantInErr: []string{ + "pipeline returned fatal", + "boom: pipeline step requested fatal", + }, }, }, "FatalWithNoPartialOutput": { - 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"), - } + 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"), + } + }, }, - wantRsp: false, - wantErr: true, - wantInErr: []string{ - "cannot run crossplane internal render in Docker", - "boom: no partial", + want: want{ + wantErr: true, + wantInErr: []string{ + "cannot run crossplane internal render in Docker", + "boom: no partial", + }, + wantSingleOccurrence: []string{"boom: no partial"}, }, - wantSingleOccurrence: []string{"boom: no partial"}, }, "HardFailWithExitError": { - 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"), - } + reason: "Non-fatal exit codes wrap the *ContainerExitError (whose Error already embeds stderr) without doubling stderr.", + 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"), + } + }, }, - wantRsp: false, - wantErr: true, - wantInErr: []string{ - "cannot run crossplane internal render in Docker", - "the container is sad", + want: want{ + wantErr: true, + wantInErr: []string{ + "cannot run crossplane internal render in Docker", + "the container is sad", + }, + wantSingleOccurrence: []string{"the container is sad"}, }, - wantSingleOccurrence: []string{"the container is sad"}, }, "HardFailNonExitError": { - runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { - // e.g. image-pull failure: not a *ContainerExitError. - return nil, []byte("non-exit stderr"), &nonExitError{msg: "image pull failed"} + 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"} + }, }, - wantRsp: false, - wantErr: true, - wantInErr: []string{ - "cannot run crossplane internal render in Docker", - "image pull failed", - "non-exit stderr", + want: want{ + wantErr: true, + wantInErr: []string{ + "cannot run crossplane internal render in Docker", + "image pull failed", + "non-exit stderr", + }, }, }, } @@ -127,43 +171,40 @@ func TestDockerRenderEngine_Render(t *testing.T) { e := &dockerRenderEngine{ image: "test-image", log: logging.NewNopLogger(), - runner: &mockContainerRunner{MockRun: tc.runFn}, + runner: &mockContainerRunner{MockRun: tc.args.runFn}, } rsp, err := e.Render(context.Background(), &renderv1alpha1.RenderRequest{}) switch { - case tc.wantErr && err == nil: - t.Fatalf("Render(): want error, got nil") - case !tc.wantErr && err != nil: - t.Fatalf("Render(): unexpected error: %v", err) + 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 _, want := range tc.wantInErr { + for _, s := range tc.want.wantInErr { if err == nil { - t.Errorf("Render(): error is nil but expected to contain %q", want) + t.Errorf("\n%s\nRender(...): error is nil but expected to contain %q", tc.reason, s) continue } - if !strings.Contains(err.Error(), want) { - t.Errorf("Render(): error %q does not contain %q", err.Error(), want) + if !strings.Contains(err.Error(), s) { + t.Errorf("\n%s\nRender(...): error %q does not contain %q", tc.reason, err.Error(), s) } } - for _, want := range tc.wantSingleOccurrence { + for _, s := range tc.want.wantSingleOccurrence { if err == nil { - t.Errorf("Render(): error is nil but expected exactly one occurrence of %q", want) + 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(), want); got != 1 { - t.Errorf("Render(): error %q contains %q %d times, want exactly 1 (double-formatting bug?)", err.Error(), want, got) + 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) } } - switch { - case tc.wantRsp && rsp == nil: - t.Errorf("Render(): want non-nil response, got nil") - case !tc.wantRsp && rsp != nil: - t.Errorf("Render(): want nil response, got %+v", rsp) + 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/engine_local_test.go b/cmd/crossplane/render/engine_local_test.go index 19364fa..ef2ee51 100644 --- a/cmd/crossplane/render/engine_local_test.go +++ b/cmd/crossplane/render/engine_local_test.go @@ -24,7 +24,10 @@ import ( "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" ) @@ -81,11 +84,7 @@ func TestMain(m *testing.M) { func runRenderHelper(mode string) { _, _ = io.Copy(io.Discard, os.Stdin) - rsp := &renderv1alpha1.RenderResponse{ - Output: &renderv1alpha1.RenderResponse_Composite{ - Composite: &renderv1alpha1.CompositeOutput{}, - }, - } + rsp := helperResponse() rspBytes, err := proto.Marshal(rsp) if err != nil { fmt.Fprintf(os.Stderr, "helper: cannot marshal canned response: %v\n", err) @@ -112,76 +111,121 @@ func runRenderHelper(mode string) { } } -func TestLocalRenderEngine_Render(t *testing.T) { +// 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 { - mode string - wantRsp bool - wantErr bool - wantInErr []string + reason string + args args + want want }{ "Success": { - mode: "success", - wantRsp: true, + reason: "Render returns the unmarshaled response and no error on a clean exit.", + args: args{mode: "success"}, + want: want{rsp: helperResponse()}, }, "FatalWithPartialOutput": { - mode: "fatal-with-partial", - wantRsp: true, - wantErr: true, - wantInErr: []string{ - "pipeline returned fatal", - "boom: pipeline step requested fatal", + 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": { - mode: "fatal-no-partial", - wantRsp: false, - wantErr: true, - wantInErr: []string{ - "cannot run crossplane internal render", - "pipeline step requested fatal but produced no output", + 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{ + "cannot run crossplane internal render", + "pipeline step requested fatal but produced no output", + }, + wantSingleOccurrence: []string{"pipeline step requested fatal but produced no output"}, }, }, "HardFail": { - mode: "hard-fail", - wantRsp: false, - wantErr: true, - wantInErr: []string{ - "cannot run crossplane internal render", - "the binary is sad", + reason: "Non-fatal exit codes surface stderr in the returned error.", + args: args{mode: "hard-fail"}, + want: want{ + wantErr: true, + wantInErr: []string{ + "cannot run crossplane internal render", + "the binary is sad", + }, + wantSingleOccurrence: []string{"the binary is sad"}, }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - t.Setenv(envHelperMode, tc.mode) + t.Setenv(envHelperMode, tc.args.mode) e := &localRenderEngine{BinaryPath: os.Args[0]} rsp, err := e.Render(context.Background(), &renderv1alpha1.RenderRequest{}) switch { - case tc.wantErr && err == nil: - t.Fatalf("Render(): want error, got nil") - case !tc.wantErr && err != nil: - t.Fatalf("Render(): unexpected error: %v", err) + 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 _, want := range tc.wantInErr { + for _, s := range tc.want.wantInErr { if err == nil { - t.Errorf("Render(): error is nil but expected to contain %q", want) + t.Errorf("\n%s\nRender(...): error is nil but expected to contain %q", tc.reason, s) continue } - if !strings.Contains(err.Error(), want) { - t.Errorf("Render(): error %q does not contain %q", err.Error(), want) + if !strings.Contains(err.Error(), s) { + t.Errorf("\n%s\nRender(...): error %q does not contain %q", tc.reason, err.Error(), s) } } - switch { - case tc.wantRsp && rsp == nil: - t.Errorf("Render(): want non-nil response, got nil") - case !tc.wantRsp && rsp != nil: - t.Errorf("Render(): want nil response, got %+v", rsp) + 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) } }) } From 709e252e04d5beebd6fbe8ea6257e8f4bf5a8695 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 9 Jun 2026 14:06:52 -0400 Subject: [PATCH 6/6] chore(render): address adamwg's review nits on PR #91 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine_docker.go: shorten containerRunner doc to a single sentence. The pattern-justification ("follows pullClient...") is rationale that can drift if pullClient ever changes, and isn't load-bearing for understanding the type. - internal/docker/docker.go: drop stderr from ContainerExitError.Error(). Error() now returns just "container exited with status N"; callers that want the stderr text wrap with errors.Wrapf using the .Stderr field directly. This eliminates the doubled-stderr risk that previously required defensive errors.As branching at every call site. - engine_docker.go + engine_local.go: collapse the post-Run hard-fail branches to a single errors.Wrapf("...returned error with output: %s", stderr) per adamwg's suggestion. Previously each engine had multiple branches (errors.As for *ContainerExitError, len(stderr)>0 conditional, plain Wrap fallback). With stderr no longer in ContainerExitError.Error(), the uniform Wrapf produces clean output in all four shapes: *ContainerExitError + stderr → "...with output: : container exited with status N" *ContainerExitError + empty → "...with output: : container exited with status N" *exec.ExitError + stderr → "...with output: : exit status N" non-exit + stderr → "...with output: : " - engine_*_test.go: update assertions for the new error format. The wantSingleOccurrence guards still work and confirm stderr appears exactly once across all four shapes. No production behaviour change beyond the error message wording. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Jonathan Ogilvie (cherry picked from commit ac63a4fa4654b3d471f7c86fd4ef8ff52034eb97) --- cmd/crossplane/render/engine_docker.go | 21 +++------------------ cmd/crossplane/render/engine_docker_test.go | 10 ++++++---- cmd/crossplane/render/engine_local.go | 7 ++----- cmd/crossplane/render/engine_local_test.go | 6 ++++-- internal/docker/docker.go | 6 ++++-- 5 files changed, 19 insertions(+), 31 deletions(-) diff --git a/cmd/crossplane/render/engine_docker.go b/cmd/crossplane/render/engine_docker.go index d7f0174..b67cf54 100644 --- a/cmd/crossplane/render/engine_docker.go +++ b/cmd/crossplane/render/engine_docker.go @@ -34,10 +34,7 @@ import ( renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" ) -// containerRunner is the subset of internal/docker the engine depends on. It -// follows the same one-method-interface mock pattern as pullClient in -// runtime_docker.go, letting tests substitute a mockContainerRunner without a -// real Docker daemon. +// 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) } @@ -162,23 +159,11 @@ func (e *dockerRenderEngine) Render(ctx context.Context, req *renderv1alpha1.Ren // and return both it and the stderr-bearing error. rsp := &renderv1alpha1.RenderResponse{} if uerr := proto.Unmarshal(stdout, rsp); uerr != nil { - return nil, errors.Errorf("cannot unmarshal partial render response after pipeline fatal: %s: %s", uerr.Error(), exitErr.Stderr) + 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) } - // On a *ContainerExitError, err.Error() already embeds stderr - // (ContainerExitError.Error stringifies it as "container exited with - // status N: "), so wrapping err is sufficient. For non-exit - // failures (e.g. image pull errors), append the captured stderr - // buffer so its content isn't lost when err.Error() doesn't include - // it. - if errors.As(err, &exitErr) { - return nil, errors.Wrap(err, "cannot run crossplane internal render in Docker") - } - if len(stderr) > 0 { - return nil, errors.Errorf("cannot run crossplane internal render in Docker: %s: %s", err.Error(), stderr) - } - return nil, errors.Wrap(err, "cannot run crossplane internal render in Docker") + 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 index d2fd1d4..d27bedc 100644 --- a/cmd/crossplane/render/engine_docker_test.go +++ b/cmd/crossplane/render/engine_docker_test.go @@ -123,14 +123,15 @@ func TestDockerRenderEngineRender(t *testing.T) { want: want{ wantErr: true, wantInErr: []string{ - "cannot run crossplane internal render in Docker", + "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 (whose Error already embeds stderr) without doubling stderr.", + 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{ @@ -142,8 +143,9 @@ func TestDockerRenderEngineRender(t *testing.T) { want: want{ wantErr: true, wantInErr: []string{ - "cannot run crossplane internal render in Docker", + "crossplane internal render in Docker returned error with output", "the container is sad", + "container exited with status 1", }, wantSingleOccurrence: []string{"the container is sad"}, }, @@ -158,7 +160,7 @@ func TestDockerRenderEngineRender(t *testing.T) { want: want{ wantErr: true, wantInErr: []string{ - "cannot run crossplane internal render in Docker", + "crossplane internal render in Docker returned error with output", "image pull failed", "non-exit stderr", }, diff --git a/cmd/crossplane/render/engine_local.go b/cmd/crossplane/render/engine_local.go index 58ed096..59a8ed9 100644 --- a/cmd/crossplane/render/engine_local.go +++ b/cmd/crossplane/render/engine_local.go @@ -75,14 +75,11 @@ func (e *localRenderEngine) Render(ctx context.Context, req *renderv1alpha1.Rend // and return both it and the stderr-bearing error. rsp := &renderv1alpha1.RenderResponse{} if uerr := proto.Unmarshal(out, rsp); uerr != nil { - return nil, errors.Errorf("cannot unmarshal partial render response after pipeline fatal: %s: %s", uerr.Error(), stderr.String()) + 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()) } - if stderr.Len() > 0 { - return nil, errors.Errorf("cannot run crossplane internal render: %s: %s", err.Error(), stderr.String()) - } - return nil, errors.Wrap(err, "cannot run crossplane internal render") + 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 index ef2ee51..d5ea0e5 100644 --- a/cmd/crossplane/render/engine_local_test.go +++ b/cmd/crossplane/render/engine_local_test.go @@ -169,8 +169,9 @@ func TestLocalRenderEngineRender(t *testing.T) { want: want{ wantErr: true, wantInErr: []string{ - "cannot run crossplane internal render", + "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"}, }, @@ -181,8 +182,9 @@ func TestLocalRenderEngineRender(t *testing.T) { want: want{ wantErr: true, wantInErr: []string{ - "cannot run crossplane internal render", + "crossplane internal render returned error with output", "the binary is sad", + "exit status 1", }, wantSingleOccurrence: []string{"the binary is sad"}, }, diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 0d433df..c9202e7 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -388,7 +388,9 @@ func RunWithBindMount(hostPath, containerPath string) RunContainerOption { // 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. +// 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 @@ -396,7 +398,7 @@ type ContainerExitError struct { // Error implements error. func (e *ContainerExitError) Error() string { - return fmt.Sprintf("container exited with status %d: %s", e.ExitCode, e.Stderr) + return fmt.Sprintf("container exited with status %d", e.ExitCode) } // RunContainer creates a container, optionally pipes stdin, waits for it to