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..f5087fcd42 100644 --- a/common/pkg/hooks/exec/runtimeconfigfilter_test.go +++ b/common/pkg/hooks/exec/runtimeconfigfilter_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "os" + "path/filepath" + "strings" "testing" "time" @@ -263,3 +265,170 @@ func TestRuntimeConfigFilter(t *testing.T) { }) } } + +func TestRuntimeConfigFilterOutputRedirection(t *testing.T) { + ctx := context.Background() + + t.Run("stdout annotation redirects output and preserves round-trip", func(t *testing.T) { + dir := t.TempDir() + stdoutPath := filepath.Join(dir, "stdout.log") + input := &spec.Spec{ + Version: "1.0.0", + Root: &spec.Root{Path: "rootfs"}, + Annotations: map[string]string{AnnotationHookStdout: stdoutPath}, + } + expectedJSON, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + + hooks := []spec.Hook{{Path: path, Args: []string{"sh", "-c", "cat"}}} + hookErr, err := RuntimeConfigFilterWithOptions(ctx, RuntimeConfigFilterOptions{Hooks: hooks, Config: input, PostKillTimeout: DefaultPostKillTimeout}) + if err != nil { + t.Fatal(err) + } + if hookErr != nil { + t.Fatal(hookErr) + } + + contents, err := os.ReadFile(stdoutPath) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, string(expectedJSON), string(contents)) + }) + + t.Run("created stdout file uses 0700 permissions, matching crun", func(t *testing.T) { + dir := t.TempDir() + stdoutPath := filepath.Join(dir, "stdout.log") + input := &spec.Spec{ + Version: "1.0.0", + Root: &spec.Root{Path: "rootfs"}, + Annotations: map[string]string{AnnotationHookStdout: stdoutPath}, + } + + hooks := []spec.Hook{{Path: path, Args: []string{"sh", "-c", "cat"}}} + hookErr, err := RuntimeConfigFilterWithOptions(ctx, RuntimeConfigFilterOptions{Hooks: hooks, Config: input, PostKillTimeout: DefaultPostKillTimeout}) + if err != nil { + t.Fatal(err) + } + if hookErr != nil { + t.Fatal(hookErr) + } + + info, err := os.Stat(stdoutPath) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, os.FileMode(0o700), info.Mode().Perm()) + }) + + t.Run("stderr annotation redirects stderr only", func(t *testing.T) { + dir := t.TempDir() + stderrPath := filepath.Join(dir, "stderr.log") + input := &spec.Spec{ + Version: "1.0.0", + Root: &spec.Root{Path: "rootfs"}, + Annotations: map[string]string{AnnotationHookStderr: stderrPath}, + } + + hooks := []spec.Hook{{Path: path, Args: []string{"sh", "-c", "echo -n stderr-content 1>&2; cat"}}} + hookErr, err := RuntimeConfigFilterWithOptions(ctx, RuntimeConfigFilterOptions{Hooks: hooks, Config: input, PostKillTimeout: DefaultPostKillTimeout}) + if err != nil { + t.Fatal(err) + } + if hookErr != nil { + t.Fatal(hookErr) + } + + contents, err := os.ReadFile(stderrPath) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, "stderr-content", string(contents)) + }) + + t.Run("both annotations set redirect independently", func(t *testing.T) { + dir := t.TempDir() + stdoutPath := filepath.Join(dir, "stdout.log") + stderrPath := filepath.Join(dir, "stderr.log") + input := &spec.Spec{ + Version: "1.0.0", + Root: &spec.Root{Path: "rootfs"}, + Annotations: map[string]string{ + AnnotationHookStdout: stdoutPath, + AnnotationHookStderr: stderrPath, + }, + } + expectedJSON, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + + hooks := []spec.Hook{{Path: path, Args: []string{"sh", "-c", "echo -n stderr-content 1>&2; cat"}}} + hookErr, err := RuntimeConfigFilterWithOptions(ctx, RuntimeConfigFilterOptions{Hooks: hooks, Config: input, PostKillTimeout: DefaultPostKillTimeout}) + if err != nil { + t.Fatal(err) + } + if hookErr != nil { + t.Fatal(hookErr) + } + + stdoutContents, err := os.ReadFile(stdoutPath) + if err != nil { + t.Fatal(err) + } + stderrContents, err := os.ReadFile(stderrPath) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, string(expectedJSON), string(stdoutContents)) + assert.Equal(t, "stderr-content", string(stderrContents)) + }) + + t.Run("existing file content is preserved in append mode", func(t *testing.T) { + dir := t.TempDir() + stdoutPath := filepath.Join(dir, "stdout.log") + sentinel := "existing-log-line\n" + if err := os.WriteFile(stdoutPath, []byte(sentinel), 0o644); err != nil { + t.Fatal(err) + } + + input := &spec.Spec{ + Version: "1.0.0", + Root: &spec.Root{Path: "rootfs"}, + Annotations: map[string]string{AnnotationHookStdout: stdoutPath}, + } + expectedJSON, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + + hooks := []spec.Hook{{Path: path, Args: []string{"sh", "-c", "cat"}}} + hookErr, err := RuntimeConfigFilterWithOptions(ctx, RuntimeConfigFilterOptions{Hooks: hooks, Config: input, PostKillTimeout: DefaultPostKillTimeout}) + if err != nil { + t.Fatal(err) + } + if hookErr != nil { + t.Fatal(hookErr) + } + + contents, err := os.ReadFile(stdoutPath) + if err != nil { + t.Fatal(err) + } + assert.True(t, strings.HasPrefix(string(contents), sentinel), "expected pre-existing content to be preserved") + assert.Equal(t, sentinel+string(expectedJSON), string(contents)) + }) + + t.Run("invalid stdout path returns an error", func(t *testing.T) { + input := &spec.Spec{ + Version: "1.0.0", + Root: &spec.Root{Path: "rootfs"}, + Annotations: map[string]string{AnnotationHookStdout: "/no/such/directory/stdout.log"}, + } + hooks := []spec.Hook{{Path: path, Args: []string{"sh", "-c", "cat"}}} + _, err := RuntimeConfigFilterWithOptions(ctx, RuntimeConfigFilterOptions{Hooks: hooks, Config: input, PostKillTimeout: DefaultPostKillTimeout}) + assert.ErrorContains(t, err, "opening stdout file") + }) +}