diff --git a/internal/plugin/error.go b/internal/plugin/error.go index 212460cea..209ae04c8 100644 --- a/internal/plugin/error.go +++ b/internal/plugin/error.go @@ -15,15 +15,21 @@ limitations under the License. package plugin +import "strconv" + // InvokeExecError is returned when a plugin invocation returns a non-zero status/exit code // - subprocess plugin: child process exit code // - extism plugin: wasm function return code type InvokeExecError struct { - ExitCode int // Exit code from plugin code execution - Err error // Underlying error + ExitCode int // Exit code from plugin code execution + Err error // Underlying error + Stderr []byte // Captured stderr output } // Error implements the error interface func (e *InvokeExecError) Error() string { + if len(e.Stderr) > 0 { + return e.Err.Error() + ": " + strconv.Quote(string(e.Stderr)) + } return e.Err.Error() } diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go index cd1a0842c..053c33f09 100644 --- a/internal/plugin/runtime_subprocess.go +++ b/internal/plugin/runtime_subprocess.go @@ -169,17 +169,26 @@ func (r *SubprocessPluginRuntime) InvokeHook(event string) error { // right now we implement status and error return in 3 slightly different ways in this file // then replace the other three with a call to this func func executeCmd(prog *exec.Cmd, pluginName string) error { + var stderrBuf bytes.Buffer + if prog.Stderr != nil { + prog.Stderr = io.MultiWriter(prog.Stderr, &stderrBuf) + } else { + prog.Stderr = &stderrBuf + } if err := prog.Run(); err != nil { if eerr, ok := err.(*exec.ExitError); ok { + stderr := bytes.TrimSpace(stderrBuf.Bytes()) slog.Debug( "plugin execution failed", slog.String("pluginName", pluginName), slog.String("error", err.Error()), slog.Int("exitCode", eerr.ExitCode()), - slog.String("stderr", string(bytes.TrimSpace(eerr.Stderr)))) + slog.String("stderr", string(stderr)), + ) return &InvokeExecError{ Err: fmt.Errorf("plugin %q exited with error", pluginName), ExitCode: eerr.ExitCode(), + Stderr: stderr, } } diff --git a/internal/plugin/runtime_subprocess_test.go b/internal/plugin/runtime_subprocess_test.go index c651dd3f7..32cc587e2 100644 --- a/internal/plugin/runtime_subprocess_test.go +++ b/internal/plugin/runtime_subprocess_test.go @@ -16,9 +16,11 @@ limitations under the License. package plugin import ( + "bytes" "errors" "fmt" "os" + "os/exec" "path/filepath" "testing" @@ -84,3 +86,30 @@ func TestSubprocessPluginRuntime(t *testing.T) { assert.Nil(t, output) } + +func TestExecuteCmdCapturesStderr(t *testing.T) { + cmd := exec.Command("sh", "-c", `echo "plugin error" >&2; exit 1`) + err := executeCmd(cmd, "test-plugin") + + require.Error(t, err) + var ieerr *InvokeExecError + require.ErrorAs(t, err, &ieerr) + assert.Equal(t, 1, ieerr.ExitCode) + assert.Equal(t, []byte("plugin error"), ieerr.Stderr) + assert.Equal(t, `plugin "test-plugin" exited with error: "plugin error"`, ieerr.Error()) +} + +func TestExecuteCmdTeesStderr(t *testing.T) { + var existing bytes.Buffer + cmd := exec.Command("sh", "-c", `echo "plugin error" >&2; exit 1`) + cmd.Stderr = &existing + + err := executeCmd(cmd, "test-plugin") + + require.Error(t, err) + var ieerr *InvokeExecError + require.ErrorAs(t, err, &ieerr) + assert.Equal(t, 1, ieerr.ExitCode) + assert.Equal(t, []byte("plugin error"), ieerr.Stderr) + assert.Equal(t, "plugin error\n", existing.String()) +} diff --git a/pkg/cmd/load_plugins.go b/pkg/cmd/load_plugins.go index b6cc38ce6..6f3718502 100644 --- a/pkg/cmd/load_plugins.go +++ b/pkg/cmd/load_plugins.go @@ -124,7 +124,7 @@ func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) { execErr := &plugin.InvokeExecError{} if errors.As(err, &execErr) { return CommandError{ - error: execErr.Err, + error: execErr, ExitCode: execErr.ExitCode, } }