diff --git a/common/pkg/hooks/exec/runtimeconfigfilter.go b/common/pkg/hooks/exec/runtimeconfigfilter.go index 9c2d7a375f..f4396649de 100644 --- a/common/pkg/hooks/exec/runtimeconfigfilter.go +++ b/common/pkg/hooks/exec/runtimeconfigfilter.go @@ -5,6 +5,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "os" "reflect" "time" @@ -21,6 +23,11 @@ var spewConfig = spew.ConfigState{ SortKeys: true, } +const ( + AnnotationHookStdout = "run.oci.hooks.stdout" + AnnotationHookStderr = "run.oci.hooks.stderr" +) + type RuntimeConfigFilterOptions struct { // The hooks to run Hooks []spec.Hook @@ -55,9 +62,41 @@ func RuntimeConfigFilterWithOptions(ctx context.Context, options RuntimeConfigFi if err != nil { return nil, err } + var stdoutFile, stderrFile *os.File + + if options.Config != nil && options.Config.Annotations != nil { + if stdoutPath, ok := options.Config.Annotations[AnnotationHookStdout]; ok { + f, openErr := os.OpenFile(stdoutPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o700) + if openErr != nil { + return nil, fmt.Errorf("opening stdout file for config-filter hook: %w", openErr) + } + stdoutFile = f + defer stdoutFile.Close() + } + + if stderrPath, ok := options.Config.Annotations[AnnotationHookStderr]; ok { + f, openErr := os.OpenFile(stderrPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o700) + if openErr != nil { + return nil, fmt.Errorf("opening stderr file for config-filter hook: %w", openErr) + } + stderrFile = f + defer stderrFile.Close() + } + } for i, hook := range options.Hooks { var stdout bytes.Buffer - hookErr, err = RunWithOptions(ctx, RunOptions{Hook: &hook, Dir: options.Dir, State: data, Stdout: &stdout, PostKillTimeout: options.PostKillTimeout}) + var runStdout io.Writer = &stdout + var runStderr io.Writer + + if stdoutFile != nil { + runStdout = io.MultiWriter(&stdout, stdoutFile) + } + + if stderrFile != nil { + runStderr = stderrFile + } + + hookErr, err = RunWithOptions(ctx, RunOptions{Hook: &hook, Dir: options.Dir, State: data, Stdout: runStdout, Stderr: runStderr, PostKillTimeout: options.PostKillTimeout}) if err != nil { return hookErr, err } diff --git a/common/pkg/hooks/exec/runtimeconfigfilter_test.go b/common/pkg/hooks/exec/runtimeconfigfilter_test.go index ba7daa248e..2ef583ada8 100644 --- a/common/pkg/hooks/exec/runtimeconfigfilter_test.go +++ b/common/pkg/hooks/exec/runtimeconfigfilter_test.go @@ -5,11 +5,13 @@ import ( "encoding/json" "errors" "os" + "path/filepath" "testing" "time" spec "github.com/opencontainers/runtime-spec/specs-go" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRuntimeConfigFilter(t *testing.T) { @@ -263,3 +265,120 @@ func TestRuntimeConfigFilter(t *testing.T) { }) } } + +func TestRuntimeConfigFilterOutputRedirection(t *testing.T) { + for _, tt := range []struct { + name string + hookScript string + useStdoutAnnotation bool + stdoutPathOverride string + useStderrAnnotation bool + preExistingStdout string + checkStdoutMode bool + expectedStderr string + expectedErr string + }{ + { + name: "no stderr annotation still allows hook to write to stderr", + hookScript: "cat; echo -n stderr-content 1>&2", + }, + { + name: "stdout annotation redirects output and preserves round-trip", + hookScript: "cat", + useStdoutAnnotation: true, + }, + { + name: "created stdout file uses 0700 permissions, matching crun", + hookScript: "cat", + useStdoutAnnotation: true, + checkStdoutMode: true, + }, + { + name: "stderr annotation redirects stderr only", + hookScript: "echo -n stderr-content 1>&2; cat", + useStderrAnnotation: true, + expectedStderr: "stderr-content", + }, + { + name: "both annotations set redirect independently", + hookScript: "echo -n stderr-content 1>&2; cat", + useStdoutAnnotation: true, + useStderrAnnotation: true, + expectedStderr: "stderr-content", + }, + { + name: "existing file content is preserved in append mode", + hookScript: "cat", + useStdoutAnnotation: true, + preExistingStdout: "existing-log-line\n", + }, + { + name: "invalid stdout path returns an error", + hookScript: "cat", + useStdoutAnnotation: true, + stdoutPathOverride: "/no/such/directory/stdout.log", + expectedErr: "opening stdout file", + }, + } { + test := tt + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + stdoutPath := filepath.Join(dir, "stdout.log") + if test.stdoutPathOverride != "" { + stdoutPath = test.stdoutPathOverride + } + stderrPath := filepath.Join(dir, "stderr.log") + + if test.preExistingStdout != "" { + require.NoError(t, os.WriteFile(stdoutPath, []byte(test.preExistingStdout), 0o644)) + } + + annotations := map[string]string{} + if test.useStdoutAnnotation { + annotations[AnnotationHookStdout] = stdoutPath + } + if test.useStderrAnnotation { + annotations[AnnotationHookStderr] = stderrPath + } + + input := &spec.Spec{ + Version: "1.0.0", + Root: &spec.Root{Path: "rootfs"}, + Annotations: annotations, + } + + hooks := []spec.Hook{{Path: path, Args: []string{"sh", "-c", test.hookScript}}} + + if test.expectedErr != "" { + _, err := RuntimeConfigFilterWithOptions(t.Context(), RuntimeConfigFilterOptions{Hooks: hooks, Config: input, PostKillTimeout: DefaultPostKillTimeout}) + assert.ErrorContains(t, err, test.expectedErr) + return + } + + expectedJSON, err := json.Marshal(input) + require.NoError(t, err) + + hookErr, err := RuntimeConfigFilterWithOptions(t.Context(), RuntimeConfigFilterOptions{Hooks: hooks, Config: input, PostKillTimeout: DefaultPostKillTimeout}) + require.NoError(t, err) + require.NoError(t, hookErr) + + if test.useStdoutAnnotation { + contents, err := os.ReadFile(stdoutPath) + require.NoError(t, err) + assert.Equal(t, test.preExistingStdout+string(expectedJSON), string(contents)) + + if test.checkStdoutMode { + info, err := os.Stat(stdoutPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), info.Mode().Perm()) + } + } + + if test.expectedStderr != "" { + contents, err := os.ReadFile(stderrPath) + require.NoError(t, err) + assert.Equal(t, test.expectedStderr, string(contents)) + } + }) + } +}