From 4aa52c62e9d0f1d4a45876d33c327cabe006076e Mon Sep 17 00:00:00 2001 From: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:56:22 +0000 Subject: [PATCH] fix: include captured stderr in the failed command message exec.ExitError.Stderr is only filled in by Cmd.Output when Cmd.Stderr is unset. RunFunction sets Cmd.Stderr and calls Run, so the field was always nil and the fatal result read "failed with : exit status N". Use the stderr the function already captured instead. Fixes #143 Signed-off-by: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com> --- fn.go | 2 +- fn_test.go | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/fn.go b/fn.go index f87e2ed..9143f58 100644 --- a/fn.go +++ b/fn.go @@ -169,7 +169,7 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) if cmderr != nil { exiterr := &exec.ExitError{} if errors.As(cmderr, &exiterr) { - msg := fmt.Sprintf("shellCmd %q for %q failed with %s", shellCmd, oxr.Resource.GetKind(), exiterr.Stderr) + msg := fmt.Sprintf("shellCmd %q for %q failed with %s", shellCmd, oxr.Resource.GetKind(), serr) response.Fatal(rsp, errors.Wrap(cmderr, msg)) } } diff --git a/fn_test.go b/fn_test.go index 12c6e51..8d48d4b 100644 --- a/fn_test.go +++ b/fn_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/structpb" fnv1 "github.com/crossplane/function-sdk-go/proto/v1" "github.com/crossplane/function-sdk-go/resource" @@ -718,3 +719,55 @@ func TestRunFunction(t *testing.T) { }) } } + +func TestRunFunctionFailureMessage(t *testing.T) { + cases := map[string]struct { + reason string + shellCommand string + wantMessage string + }{ + "IncludesStderr": { + reason: "The fatal result should include the stderr the failed command wrote", + shellCommand: "echo 'success output'; echo 'error output' >&2; exit 1", + wantMessage: "shellCmd \"echo 'success output'; echo 'error output' >&2; exit 1\" for \"\" failed with error output: exit status 1", + }, + "NoStderr": { + reason: "The fatal result should still report the exit status when the command wrote no stderr", + shellCommand: "exit 3", + wantMessage: "shellCmd \"exit 3\" for \"\" failed with : exit status 3", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f := &Function{log: logging.NewNopLogger()} + req := &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: testTagHello}, + } + input, err := structpb.NewStruct(map[string]any{ + "apiVersion": "template.fn.crossplane.io/v1alpha1", + "kind": "Parameters", + "shellCommand": tc.shellCommand, + "stdoutField": "status.atFunction.shell.stdout", + "stderrField": "status.atFunction.shell.stderr", + }) + if err != nil { + t.Fatalf("structpb.NewStruct(...): %v", err) + } + req.Input = input + + rsp, err := f.RunFunction(context.Background(), req) + if err != nil { + t.Fatalf("%s\nf.RunFunction(...): unexpected error: %v", tc.reason, err) + } + + results := rsp.GetResults() + if len(results) != 1 { + t.Fatalf("%s\nf.RunFunction(...): want 1 result, got %d: %v", tc.reason, len(results), results) + } + if diff := cmp.Diff(tc.wantMessage, results[0].GetMessage()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want message, +got message:\n%s", tc.reason, diff) + } + }) + } +}