Skip to content
8 changes: 8 additions & 0 deletions cmd/crossplane/render/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
49 changes: 47 additions & 2 deletions cmd/crossplane/render/engine_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{}
Expand Down
219 changes: 219 additions & 0 deletions cmd/crossplane/render/engine_docker_test.go
Original file line number Diff line number Diff line change
@@ -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 }
24 changes: 21 additions & 3 deletions cmd/crossplane/render/engine_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package render
import (
"bytes"
"context"
"os"
"os/exec"

"google.golang.org/protobuf/proto"
Expand Down Expand Up @@ -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{}
Expand Down
Loading
Loading