summaryrefslogtreecommitdiff
path: root/pkg/hooks/exec
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/hooks/exec')
-rw-r--r--pkg/hooks/exec/exec.go62
-rw-r--r--pkg/hooks/exec/exec_test.go220
2 files changed, 282 insertions, 0 deletions
diff --git a/pkg/hooks/exec/exec.go b/pkg/hooks/exec/exec.go
new file mode 100644
index 000000000..94469b1d2
--- /dev/null
+++ b/pkg/hooks/exec/exec.go
@@ -0,0 +1,62 @@
+// Package exec provides utilities for executing Open Container Initative runtime hooks.
+package exec
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ osexec "os/exec"
+ "time"
+
+ rspec "github.com/opencontainers/runtime-spec/specs-go"
+)
+
+// DefaultPostKillTimeout is the recommended default post-kill timeout.
+const DefaultPostKillTimeout = time.Duration(10) * time.Second
+
+// Run executes the hook and waits for it to complete or for the
+// context or hook-specified timeout to expire.
+func Run(ctx context.Context, hook *rspec.Hook, state []byte, stdout io.Writer, stderr io.Writer, postKillTimeout time.Duration) (hookErr, err error) {
+ cmd := osexec.Cmd{
+ Path: hook.Path,
+ Args: hook.Args,
+ Env: hook.Env,
+ Stdin: bytes.NewReader(state),
+ Stdout: stdout,
+ Stderr: stderr,
+ }
+ if cmd.Env == nil {
+ cmd.Env = []string{}
+ }
+
+ if hook.Timeout != nil {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, time.Duration(*hook.Timeout)*time.Second)
+ defer cancel()
+ }
+
+ err = cmd.Start()
+ if err != nil {
+ return err, err
+ }
+ exit := make(chan error, 1)
+ go func() {
+ exit <- cmd.Wait()
+ }()
+
+ select {
+ case err = <-exit:
+ return err, err
+ case <-ctx.Done():
+ cmd.Process.Kill()
+ timer := time.NewTimer(postKillTimeout)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ err = fmt.Errorf("failed to reap process within %s of the kill signal", postKillTimeout)
+ case err = <-exit:
+ }
+ return err, ctx.Err()
+ }
+}
diff --git a/pkg/hooks/exec/exec_test.go b/pkg/hooks/exec/exec_test.go
new file mode 100644
index 000000000..433f953df
--- /dev/null
+++ b/pkg/hooks/exec/exec_test.go
@@ -0,0 +1,220 @@
+package exec
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ rspec "github.com/opencontainers/runtime-spec/specs-go"
+ "github.com/stretchr/testify/assert"
+)
+
+// path is the path to an example hook executable.
+var path string
+
+// unavoidableEnvironmentKeys may be injected even if the hook
+// executable is executed with a requested empty environment.
+var unavoidableEnvironmentKeys []string
+
+func TestRun(t *testing.T) {
+ ctx := context.Background()
+ hook := &rspec.Hook{
+ Path: path,
+ Args: []string{"sh", "-c", "cat"},
+ }
+ var stderr, stdout bytes.Buffer
+ hookErr, err := Run(ctx, hook, []byte("{}"), &stdout, &stderr, DefaultPostKillTimeout)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hookErr != nil {
+ t.Fatal(hookErr)
+ }
+ assert.Equal(t, "{}", stdout.String())
+ assert.Equal(t, "", stderr.String())
+}
+
+func TestRunIgnoreOutput(t *testing.T) {
+ ctx := context.Background()
+ hook := &rspec.Hook{
+ Path: path,
+ Args: []string{"sh", "-c", "cat"},
+ }
+ hookErr, err := Run(ctx, hook, []byte("{}"), nil, nil, DefaultPostKillTimeout)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hookErr != nil {
+ t.Fatal(hookErr)
+ }
+}
+
+func TestRunFailedStart(t *testing.T) {
+ ctx := context.Background()
+ hook := &rspec.Hook{
+ Path: "/does/not/exist",
+ }
+ hookErr, err := Run(ctx, hook, []byte("{}"), nil, nil, DefaultPostKillTimeout)
+ if err == nil {
+ t.Fatal("unexpected success")
+ }
+ if !os.IsNotExist(err) {
+ t.Fatal(err)
+ }
+ assert.Equal(t, err, hookErr)
+}
+
+func parseEnvironment(input string) (env map[string]string, err error) {
+ env = map[string]string{}
+ lines := strings.Split(input, "\n")
+ for i, line := range lines {
+ if line == "" && i == len(lines)-1 {
+ continue // no content after the terminal newline
+ }
+ keyValue := strings.SplitN(line, "=", 2)
+ if len(keyValue) < 2 {
+ return env, fmt.Errorf("no = in environment line: %q", line)
+ }
+ env[keyValue[0]] = keyValue[1]
+ }
+ for _, key := range unavoidableEnvironmentKeys {
+ delete(env, key)
+ }
+ return env, nil
+}
+
+func TestRunEnvironment(t *testing.T) {
+ ctx := context.Background()
+ hook := &rspec.Hook{
+ Path: path,
+ Args: []string{"sh", "-c", "env"},
+ }
+ for _, test := range []struct {
+ name string
+ env []string
+ expected map[string]string
+ }{
+ {
+ name: "unset",
+ expected: map[string]string{},
+ },
+ {
+ name: "set empty",
+ env: []string{},
+ expected: map[string]string{},
+ },
+ {
+ name: "set",
+ env: []string{
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "TERM=xterm",
+ },
+ expected: map[string]string{
+ "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "TERM": "xterm",
+ },
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ var stderr, stdout bytes.Buffer
+ hook.Env = test.env
+ hookErr, err := Run(ctx, hook, []byte("{}"), &stdout, &stderr, DefaultPostKillTimeout)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hookErr != nil {
+ t.Fatal(hookErr)
+ }
+ assert.Equal(t, "", stderr.String())
+
+ env, err := parseEnvironment(stdout.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ assert.Equal(t, test.expected, env)
+ })
+ }
+}
+
+func TestRunCancel(t *testing.T) {
+ hook := &rspec.Hook{
+ Path: path,
+ Args: []string{"sh", "-c", "echo waiting; sleep 2; echo done"},
+ }
+ one := 1
+ for _, test := range []struct {
+ name string
+ contextTimeout time.Duration
+ hookTimeout *int
+ expectedHookError string
+ expectedRunError error
+ expectedStdout string
+ }{
+ {
+ name: "no timeouts",
+ expectedStdout: "waiting\ndone\n",
+ },
+ {
+ name: "context timeout",
+ contextTimeout: time.Duration(1) * time.Second,
+ expectedStdout: "waiting\n",
+ expectedHookError: "^signal: killed$",
+ expectedRunError: context.DeadlineExceeded,
+ },
+ {
+ name: "hook timeout",
+ hookTimeout: &one,
+ expectedStdout: "waiting\n",
+ expectedHookError: "^signal: killed$",
+ expectedRunError: context.DeadlineExceeded,
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ ctx := context.Background()
+ var stderr, stdout bytes.Buffer
+ if test.contextTimeout > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, test.contextTimeout)
+ defer cancel()
+ }
+ hook.Timeout = test.hookTimeout
+ hookErr, err := Run(ctx, hook, []byte("{}"), &stdout, &stderr, DefaultPostKillTimeout)
+ assert.Equal(t, test.expectedRunError, err)
+ if test.expectedHookError == "" {
+ if hookErr != nil {
+ t.Fatal(hookErr)
+ }
+ } else {
+ assert.Regexp(t, test.expectedHookError, hookErr.Error())
+ }
+ assert.Equal(t, "", stderr.String())
+ assert.Equal(t, test.expectedStdout, stdout.String())
+ })
+ }
+}
+
+func TestRunKillTimeout(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Duration(500)*time.Millisecond)
+ defer cancel()
+ hook := &rspec.Hook{
+ Path: path,
+ Args: []string{"sh", "-c", "sleep 1"},
+ }
+ hookErr, err := Run(ctx, hook, []byte("{}"), nil, nil, time.Duration(0))
+ assert.Equal(t, context.DeadlineExceeded, err)
+ assert.Regexp(t, "^failed to reap process within 0s of the kill signal$", hookErr)
+}
+
+func init() {
+ if runtime.GOOS != "windows" {
+ path = "/bin/sh"
+ unavoidableEnvironmentKeys = []string{"PWD", "SHLVL", "_"}
+ } else {
+ panic("we need a reliable executable path on Windows")
+ }
+}