diff options
Diffstat (limited to 'pkg')
-rw-r--r-- | pkg/hooks/0.1.0/hook.go | 93 | ||||
-rw-r--r-- | pkg/hooks/0.1.0/hook_test.go | 182 | ||||
-rw-r--r-- | pkg/hooks/1.0.0/hook.go | 78 | ||||
-rw-r--r-- | pkg/hooks/1.0.0/hook_test.go | 200 | ||||
-rw-r--r-- | pkg/hooks/1.0.0/when.go | 92 | ||||
-rw-r--r-- | pkg/hooks/1.0.0/when_test.go | 289 | ||||
-rw-r--r-- | pkg/hooks/README.md | 175 | ||||
-rw-r--r-- | pkg/hooks/hooks.go | 177 | ||||
-rw-r--r-- | pkg/hooks/hooks_test.go | 143 | ||||
-rw-r--r-- | pkg/hooks/monitor.go | 73 | ||||
-rw-r--r-- | pkg/hooks/monitor_test.go | 129 | ||||
-rw-r--r-- | pkg/hooks/read.go | 83 | ||||
-rw-r--r-- | pkg/hooks/read_test.go | 193 | ||||
-rw-r--r-- | pkg/hooks/version.go | 6 |
14 files changed, 1806 insertions, 107 deletions
diff --git a/pkg/hooks/0.1.0/hook.go b/pkg/hooks/0.1.0/hook.go new file mode 100644 index 000000000..e10c3d254 --- /dev/null +++ b/pkg/hooks/0.1.0/hook.go @@ -0,0 +1,93 @@ +// Package hook is the 0.1.0 hook configuration structure. +package hook + +import ( + "encoding/json" + "errors" + "strings" + + rspec "github.com/opencontainers/runtime-spec/specs-go" + hooks "github.com/projectatomic/libpod/pkg/hooks" + current "github.com/projectatomic/libpod/pkg/hooks/1.0.0" +) + +// Version is the hook configuration version defined in this package. +const Version = "0.1.0" + +// Hook is the hook configuration structure. +type Hook struct { + Hook *string `json:"hook"` + Arguments []string `json:"arguments,omitempty"` + + // https://github.com/kubernetes-incubator/cri-o/pull/1235 + Stages []string `json:"stages"` + Stage []string `json:"stage"` + + Cmds []string `json:"cmds,omitempty"` + Cmd []string `json:"cmd,omitempty"` + + Annotations []string `json:"annotations,omitempty"` + Annotation []string `json:"annotation,omitempty"` + + HasBindMounts *bool `json:"hasbindmounts,omitempty"` +} + +func read(content []byte) (hook *current.Hook, err error) { + var raw Hook + if err = json.Unmarshal(content, &raw); err != nil { + return nil, err + } + + if raw.Hook == nil { + return nil, errors.New("missing required property: hook") + } + + if raw.Stages == nil { + raw.Stages = raw.Stage + } else if raw.Stage != nil { + return nil, errors.New("cannot set both 'stage' and 'stages'") + } + if raw.Stages == nil { + return nil, errors.New("missing required property: stages") + } + + if raw.Cmds == nil { + raw.Cmds = raw.Cmd + } else if raw.Cmd != nil { + return nil, errors.New("cannot set both 'cmd' and 'cmds'") + } + + if raw.Annotations == nil { + raw.Annotations = raw.Annotation + } else if raw.Annotation != nil { + return nil, errors.New("cannot set both 'annotation' and 'annotations'") + } + + hook = ¤t.Hook{ + Version: current.Version, + Hook: rspec.Hook{ + Path: *raw.Hook, + }, + When: current.When{ + Commands: raw.Cmds, + HasBindMounts: raw.HasBindMounts, + Or: true, + }, + Stages: raw.Stages, + } + if raw.Arguments != nil { + hook.Hook.Args = append([]string{*raw.Hook}, raw.Arguments...) + } + if raw.Annotations != nil { + hook.When.Annotations = map[string]string{ + ".*": strings.Join(raw.Annotations, "|"), + } + } + + return hook, nil +} + +func init() { + hooks.Readers[""] = read + hooks.Readers[Version] = read +} diff --git a/pkg/hooks/0.1.0/hook_test.go b/pkg/hooks/0.1.0/hook_test.go new file mode 100644 index 000000000..44cb21e3a --- /dev/null +++ b/pkg/hooks/0.1.0/hook_test.go @@ -0,0 +1,182 @@ +package hook + +import ( + "testing" + + rspec "github.com/opencontainers/runtime-spec/specs-go" + current "github.com/projectatomic/libpod/pkg/hooks/1.0.0" + "github.com/stretchr/testify/assert" +) + +func TestGood(t *testing.T) { + hook, err := read([]byte("{\"hook\": \"/a/b/c\", \"stages\": [\"prestart\"], \"cmds\": [\"sh\"]}")) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, ¤t.Hook{ + Version: current.Version, + Hook: rspec.Hook{ + Path: "/a/b/c", + }, + When: current.When{ + Commands: []string{"sh"}, + Or: true, + }, + Stages: []string{"prestart"}, + }, hook) +} + +func TestInvalidJSON(t *testing.T) { + _, err := read([]byte("{")) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^unexpected end of JSON input$", err.Error()) +} + +func TestArguments(t *testing.T) { + hook, err := read([]byte("{\"hook\": \"/a/b/c\", \"arguments\": [\"d\", \"e\"], \"stages\": [\"prestart\"], \"cmds\": [\"sh\"]}")) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, ¤t.Hook{ + Version: current.Version, + Hook: rspec.Hook{ + Path: "/a/b/c", + Args: []string{"/a/b/c", "d", "e"}, + }, + When: current.When{ + Commands: []string{"sh"}, + Or: true, + }, + Stages: []string{"prestart"}, + }, hook) +} + +func TestEmptyObject(t *testing.T) { + _, err := read([]byte("{}")) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^missing required property: hook$", err.Error()) +} + +func TestNoStages(t *testing.T) { + _, err := read([]byte("{\"hook\": \"/a/b/c\"}")) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^missing required property: stages$", err.Error()) +} + +func TestStage(t *testing.T) { + hook, err := read([]byte("{\"hook\": \"/a/b/c\", \"stage\": [\"prestart\"]}")) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, ¤t.Hook{ + Version: current.Version, + Hook: rspec.Hook{ + Path: "/a/b/c", + }, + When: current.When{Or: true}, + Stages: []string{"prestart"}, + }, hook) +} + +func TestStagesAndStage(t *testing.T) { + _, err := read([]byte("{\"hook\": \"/a/b/c\", \"stages\": [\"prestart\"], \"stage\": [\"prestart\"]}")) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^cannot set both 'stage' and 'stages'$", err.Error()) +} + +func TestCmd(t *testing.T) { + hook, err := read([]byte("{\"hook\": \"/a/b/c\", \"stage\": [\"prestart\"], \"cmd\": [\"sh\"]}")) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, ¤t.Hook{ + Version: current.Version, + Hook: rspec.Hook{ + Path: "/a/b/c", + }, + When: current.When{ + Commands: []string{"sh"}, + Or: true, + }, + Stages: []string{"prestart"}, + }, hook) +} + +func TestCmdsAndCmd(t *testing.T) { + _, err := read([]byte("{\"hook\": \"/a/b/c\", \"stages\": [\"prestart\"], \"cmds\": [\"sh\"], \"cmd\": [\"true\"]}")) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^cannot set both 'cmd' and 'cmds'$", err.Error()) +} + +func TestAnnotations(t *testing.T) { + hook, err := read([]byte("{\"hook\": \"/a/b/c\", \"stage\": [\"prestart\"], \"annotations\": [\"a\", \"b\"]}")) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, ¤t.Hook{ + Version: current.Version, + Hook: rspec.Hook{ + Path: "/a/b/c", + }, + When: current.When{ + Annotations: map[string]string{".*": "a|b"}, + Or: true, + }, + Stages: []string{"prestart"}, + }, hook) +} + +func TestAnnotation(t *testing.T) { + hook, err := read([]byte("{\"hook\": \"/a/b/c\", \"stage\": [\"prestart\"], \"annotation\": [\"a\", \"b\"]}")) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, ¤t.Hook{ + Version: current.Version, + Hook: rspec.Hook{ + Path: "/a/b/c", + }, + When: current.When{ + Annotations: map[string]string{".*": "a|b"}, + Or: true, + }, + Stages: []string{"prestart"}, + }, hook) +} + +func TestAnnotationsAndAnnotation(t *testing.T) { + _, err := read([]byte("{\"hook\": \"/a/b/c\", \"stages\": [\"prestart\"], \"annotations\": [\"a\"], \"annotation\": [\"b\"]}")) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^cannot set both 'annotation' and 'annotations'$", err.Error()) +} + +func TestHasBindMounts(t *testing.T) { + hook, err := read([]byte("{\"hook\": \"/a/b/c\", \"stage\": [\"prestart\"], \"hasbindmounts\": true}")) + if err != nil { + t.Fatal(err) + } + hasBindMounts := true + assert.Equal(t, ¤t.Hook{ + Version: current.Version, + Hook: rspec.Hook{ + Path: "/a/b/c", + }, + When: current.When{ + HasBindMounts: &hasBindMounts, + Or: true, + }, + Stages: []string{"prestart"}, + }, hook) +} diff --git a/pkg/hooks/1.0.0/hook.go b/pkg/hooks/1.0.0/hook.go new file mode 100644 index 000000000..cf623ee42 --- /dev/null +++ b/pkg/hooks/1.0.0/hook.go @@ -0,0 +1,78 @@ +// Package hook is the 1.0.0 hook configuration structure. +package hook + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + + rspec "github.com/opencontainers/runtime-spec/specs-go" + "github.com/pkg/errors" +) + +// Version is the hook configuration version defined in this package. +const Version = "1.0.0" + +// Hook is the hook configuration structure. +type Hook struct { + Version string `json:"version"` + Hook rspec.Hook `json:"hook"` + When When `json:"when"` + Stages []string `json:"stages"` +} + +// Read reads hook JSON bytes, verifies them, and returns the hook configuration. +func Read(content []byte) (hook *Hook, err error) { + if err = json.Unmarshal(content, &hook); err != nil { + return nil, err + } + return hook, nil +} + +// Validate performs load-time hook validation. +func (hook *Hook) Validate() (err error) { + if hook == nil { + return errors.New("nil hook") + } + + if hook.Version != Version { + return fmt.Errorf("unexpected hook version %q (expecting %v)", hook.Version, Version) + } + + if hook.Hook.Path == "" { + return errors.New("missing required property: hook.path") + } + + if _, err := os.Stat(hook.Hook.Path); err != nil { + return err + } + + for key, value := range hook.When.Annotations { + if _, err = regexp.Compile(key); err != nil { + return errors.Wrapf(err, "invalid annotation key %q", key) + } + if _, err = regexp.Compile(value); err != nil { + return errors.Wrapf(err, "invalid annotation value %q", value) + } + } + + for _, command := range hook.When.Commands { + if _, err = regexp.Compile(command); err != nil { + return errors.Wrapf(err, "invalid command %q", command) + } + } + + if hook.Stages == nil { + return errors.New("missing required property: stages") + } + + validStages := map[string]bool{"prestart": true, "poststart": true, "poststop": true} + for _, stage := range hook.Stages { + if !validStages[stage] { + return fmt.Errorf("unknown stage %q", stage) + } + } + + return nil +} diff --git a/pkg/hooks/1.0.0/hook_test.go b/pkg/hooks/1.0.0/hook_test.go new file mode 100644 index 000000000..003be34bb --- /dev/null +++ b/pkg/hooks/1.0.0/hook_test.go @@ -0,0 +1,200 @@ +package hook + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + 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 + +func TestGoodRead(t *testing.T) { + hook, err := Read([]byte("{\"version\": \"1.0.0\", \"hook\": {\"path\": \"/a/b/c\"}, \"when\": {\"always\": true}, \"stages\": [\"prestart\"]}")) + if err != nil { + t.Fatal(err) + } + always := true + assert.Equal(t, &Hook{ + Version: Version, + Hook: rspec.Hook{ + Path: "/a/b/c", + }, + When: When{ + Always: &always, + }, + Stages: []string{"prestart"}, + }, hook) +} + +func TestInvalidJSON(t *testing.T) { + _, err := Read([]byte("{")) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^unexpected end of JSON input$", err.Error()) +} + +func TestGoodValidate(t *testing.T) { + always := true + hook := &Hook{ + Version: Version, + Hook: rspec.Hook{ + Path: path, + }, + When: When{ + Always: &always, + }, + Stages: []string{"prestart"}, + } + err := hook.Validate() + if err != nil { + t.Fatal(err) + } +} + +func TestNilValidation(t *testing.T) { + var hook *Hook + err := hook.Validate() + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^nil hook$", err.Error()) +} + +func TestWrongVersion(t *testing.T) { + hook := Hook{Version: "0.1.0"} + err := hook.Validate() + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^unexpected hook version \"0.1.0\" \\(expecting 1.0.0\\)$", err.Error()) +} + +func TestNoHookPath(t *testing.T) { + hook := Hook{ + Version: "1.0.0", + Hook: rspec.Hook{}, + } + err := hook.Validate() + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^missing required property: hook.path$", err.Error()) +} + +func TestUnknownHookPath(t *testing.T) { + hook := Hook{ + Version: "1.0.0", + Hook: rspec.Hook{ + Path: filepath.Join("does", "not", "exist"), + }, + } + err := hook.Validate() + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^stat does/not/exist: no such file or directory$", err.Error()) + if !os.IsNotExist(err) { + t.Fatal("opaque wrapping for not-exist errors") + } +} + +func TestNoStages(t *testing.T) { + hook := Hook{ + Version: "1.0.0", + Hook: rspec.Hook{ + Path: path, + }, + } + err := hook.Validate() + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^missing required property: stages$", err.Error()) +} + +func TestInvalidStage(t *testing.T) { + hook := Hook{ + Version: "1.0.0", + Hook: rspec.Hook{ + Path: path, + }, + Stages: []string{"does-not-exist"}, + } + err := hook.Validate() + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^unknown stage \"does-not-exist\"$", err.Error()) +} + +func TestInvalidAnnotationKey(t *testing.T) { + hook := Hook{ + Version: "1.0.0", + Hook: rspec.Hook{ + Path: path, + }, + When: When{ + Annotations: map[string]string{ + "[": "a", + }, + }, + Stages: []string{"prestart"}, + } + err := hook.Validate() + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^invalid annotation key \"\\[\": error parsing regexp: .*", err.Error()) +} + +func TestInvalidAnnotationValue(t *testing.T) { + hook := Hook{ + Version: "1.0.0", + Hook: rspec.Hook{ + Path: path, + }, + When: When{ + Annotations: map[string]string{ + "a": "[", + }, + }, + Stages: []string{"prestart"}, + } + err := hook.Validate() + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^invalid annotation value \"\\[\": error parsing regexp: .*", err.Error()) +} + +func TestInvalidCommand(t *testing.T) { + hook := Hook{ + Version: "1.0.0", + Hook: rspec.Hook{ + Path: path, + }, + When: When{ + Commands: []string{"["}, + }, + Stages: []string{"prestart"}, + } + err := hook.Validate() + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^invalid command \"\\[\": error parsing regexp: .*", err.Error()) +} + +func init() { + if runtime.GOOS != "windows" { + path = "/bin/sh" + } else { + panic("we need a reliable executable path on Windows") + } +} diff --git a/pkg/hooks/1.0.0/when.go b/pkg/hooks/1.0.0/when.go new file mode 100644 index 000000000..3d2a5fd72 --- /dev/null +++ b/pkg/hooks/1.0.0/when.go @@ -0,0 +1,92 @@ +package hook + +import ( + "regexp" + + rspec "github.com/opencontainers/runtime-spec/specs-go" + "github.com/pkg/errors" +) + +// When holds hook-injection conditions. +type When struct { + Always *bool `json:"always,omitempty"` + Annotations map[string]string `json:"annotation,omitempty"` + Commands []string `json:"commands,omitempty"` + HasBindMounts *bool `json:"hasBindMounts,omitempty"` + + // Or enables any-of matching. + // + // Deprecated: this property is for is backwards-compatibility with + // 0.1.0 hooks. It will be removed when we drop support for them. + Or bool `json:"-"` +} + +// Match returns true if the given conditions match the configuration. +func (when *When) Match(config *rspec.Spec, annotations map[string]string, hasBindMounts bool) (match bool, err error) { + matches := 0 + + if when.Always != nil { + if *when.Always { + if when.Or { + return true, nil + } + matches++ + } else if !when.Or { + return false, nil + } + } + + if when.HasBindMounts != nil { + if *when.HasBindMounts && hasBindMounts { + if when.Or { + return true, nil + } + matches++ + } else if !when.Or { + return false, nil + } + } + + for keyPattern, valuePattern := range when.Annotations { + match := false + for key, value := range annotations { + match, err = regexp.MatchString(keyPattern, key) + if err != nil { + return false, errors.Wrap(err, "annotation key") + } + if match { + match, err = regexp.MatchString(valuePattern, value) + if err != nil { + return false, errors.Wrap(err, "annotation value") + } + if match { + break + } + } + } + if match { + if when.Or { + return true, nil + } + matches++ + } else if !when.Or { + return false, nil + } + } + + if config.Process != nil { + command := config.Process.Args[0] + for _, cmdPattern := range when.Commands { + match, err := regexp.MatchString(cmdPattern, command) + if err != nil { + return false, errors.Wrap(err, "command") + } + if match { + return true, nil + } + } + return false, nil + } + + return matches > 0, nil +} diff --git a/pkg/hooks/1.0.0/when_test.go b/pkg/hooks/1.0.0/when_test.go new file mode 100644 index 000000000..9047f4c9f --- /dev/null +++ b/pkg/hooks/1.0.0/when_test.go @@ -0,0 +1,289 @@ +package hook + +import ( + "fmt" + "testing" + + rspec "github.com/opencontainers/runtime-spec/specs-go" + "github.com/stretchr/testify/assert" +) + +func TestNoMatch(t *testing.T) { + config := &rspec.Spec{} + for _, or := range []bool{true, false} { + t.Run(fmt.Sprintf("or %t", or), func(t *testing.T) { + when := When{Or: or} + match, err := when.Match(config, map[string]string{}, false) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, false, match) + }) + } +} + +func TestAlways(t *testing.T) { + config := &rspec.Spec{} + for _, always := range []bool{true, false} { + for _, or := range []bool{true, false} { + t.Run(fmt.Sprintf("always %t, or %t", always, or), func(t *testing.T) { + when := When{Always: &always, Or: or} + match, err := when.Match(config, map[string]string{}, false) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, always, match) + }) + } + } +} + +func TestHasBindMountsAnd(t *testing.T) { + hasBindMounts := true + when := When{HasBindMounts: &hasBindMounts} + config := &rspec.Spec{} + for _, containerHasBindMounts := range []bool{false, true} { + t.Run(fmt.Sprintf("%t", containerHasBindMounts), func(t *testing.T) { + match, err := when.Match(config, map[string]string{}, containerHasBindMounts) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, containerHasBindMounts, match) + }) + } +} + +func TestHasBindMountsOr(t *testing.T) { + hasBindMounts := true + when := When{HasBindMounts: &hasBindMounts, Or: true} + config := &rspec.Spec{} + for _, containerHasBindMounts := range []bool{false, true} { + t.Run(fmt.Sprintf("%t", containerHasBindMounts), func(t *testing.T) { + match, err := when.Match(config, map[string]string{}, containerHasBindMounts) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, containerHasBindMounts, match) + }) + } +} + +func TestAnnotations(t *testing.T) { + when := When{ + Annotations: map[string]string{ + "^a$": "^b$", + "^c$": "^d$", + }, + } + config := &rspec.Spec{} + for _, test := range []struct { + name string + annotations map[string]string + or bool + match bool + }{ + { + name: "matching both, and", + annotations: map[string]string{ + "a": "b", + "c": "d", + "e": "f", + }, + or: false, + match: true, + }, + { + name: "matching one, and", + annotations: map[string]string{ + "a": "b", + }, + or: false, + match: false, + }, + { + name: "matching one, or", + annotations: map[string]string{ + "a": "b", + }, + or: true, + match: true, + }, + { + name: "key-only, or", + annotations: map[string]string{ + "a": "bc", + }, + or: true, + match: false, + }, + { + name: "value-only, or", + annotations: map[string]string{ + "ac": "b", + }, + or: true, + match: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + when.Or = test.or + match, err := when.Match(config, test.annotations, false) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, test.match, match) + }) + } +} + +func TestCommands(t *testing.T) { + when := When{ + Commands: []string{ + "^/bin/sh$", + }, + } + config := &rspec.Spec{Process: &rspec.Process{}} + for _, test := range []struct { + name string + args []string + match bool + }{ + { + name: "good", + args: []string{"/bin/sh", "a", "b"}, + match: true, + }, + { + name: "extra characters", + args: []string{"/bin/shell", "a", "b"}, + match: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + config.Process.Args = test.args + match, err := when.Match(config, map[string]string{}, false) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, test.match, match) + }) + } +} + +func TestHasBindMountsAndCommands(t *testing.T) { + hasBindMounts := true + when := When{ + HasBindMounts: &hasBindMounts, + Commands: []string{ + "^/bin/sh$", + }, + } + config := &rspec.Spec{Process: &rspec.Process{}} + for _, test := range []struct { + name string + command string + hasBindMounts bool + or bool + match bool + }{ + { + name: "both, and", + command: "/bin/sh", + hasBindMounts: true, + or: false, + match: true, + }, + { + name: "both, and", + command: "/bin/sh", + hasBindMounts: true, + or: true, + match: true, + }, + { + name: "bind, and", + command: "/bin/shell", + hasBindMounts: true, + or: false, + match: false, + }, + { + name: "bind, or", + command: "/bin/shell", + hasBindMounts: true, + or: true, + match: true, + }, + { + name: "command, and", + command: "/bin/sh", + hasBindMounts: false, + or: false, + match: false, + }, + { + name: "command, or", + command: "/bin/sh", + hasBindMounts: false, + or: true, + match: true, + }, + { + name: "neither, and", + command: "/bin/shell", + hasBindMounts: false, + or: false, + match: false, + }, + { + name: "neither, or", + command: "/bin/shell", + hasBindMounts: false, + or: true, + match: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + config.Process.Args = []string{test.command} + when.Or = test.or + match, err := when.Match(config, map[string]string{}, test.hasBindMounts) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, test.match, match) + }) + } +} + +func TestInvalidRegexp(t *testing.T) { + config := &rspec.Spec{Process: &rspec.Process{Args: []string{"/bin/sh"}}} + for _, test := range []struct { + name string + when When + expected string + }{ + { + name: "invalid-annotation-key", + when: When{Annotations: map[string]string{"[": "a"}}, + expected: "^annotation key: error parsing regexp: .*", + }, + { + name: "invalid-annotation-value", + when: When{Annotations: map[string]string{"a": "["}}, + expected: "^annotation value: error parsing regexp: .*", + }, + { + name: "invalid-command", + when: When{Commands: []string{"["}}, + expected: "^command: error parsing regexp: .*", + }, + } { + t.Run(test.name, func(t *testing.T) { + _, err := test.when.Match(config, map[string]string{"a": "b"}, false) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, test.expected, err.Error()) + }) + } +} diff --git a/pkg/hooks/README.md b/pkg/hooks/README.md new file mode 100644 index 000000000..e47000a09 --- /dev/null +++ b/pkg/hooks/README.md @@ -0,0 +1,175 @@ +# OCI Hooks Configuration + +For POSIX platforms, the [OCI runtime configuration][runtime-spec] supports [hooks][spec-hooks] for configuring custom actions related to the life cycle of the container. +The way you enable the hooks above is by editing the OCI runtime configuration before running the OCI runtime (e.g. [`runc`][runc]). +CRI-O and `podman create` create the OCI configuration for you, and this documentation allows developers to configure them to set their intended hooks. + +One problem with hooks is that the runtime actually stalls execution of the container before running the hooks and stalls completion of the container, until all hooks complete. +This can cause some performance issues. +Also a lot of hooks just check if certain configuration is set and then exit early, without doing anything. +For example the [oci-systemd-hook][] only executes if the command is `init` or `systemd`, otherwise it just exits. +This means if we automatically enabled all hooks, every container would have to execute `oci-systemd-hook`, even if they don't run systemd inside of the container. +Performance would also suffer if we exectuted each hook at each stage ([pre-start][], [post-start][], and [post-stop][]). + +## Notational Conventions + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" are to be interpreted as described in [RFC 2119][rfc2119]. + +## JSON Definition + +This package reads all [JSON][] files (ending with a `.json` extention) from a series of hook directories. +For both `crio` and `podman`, hooks are read from `/usr/share/containers/oci/hooks.d/*.json`. + +For `crio`, hook JSON is also read from `/etc/containers/oci/hooks.d/*.json`. +If files of with the same name exist in both directories, the one in `/etc/containers/oci/hooks.d` takes precedence. + +Each JSON file should contain an object with the following properties: + +### 1.0.0 Hook Schema + +* **`version`** (REQUIRED, string) Sets the hook-definition version. + For this schema version, the value MUST be 1.0.0. +* **`hook`** (REQUIRED, object) The hook to inject, with the [hook-entry schema][spec-hooks] defined by the 1.0.1 OCI Runtime Specification. +* **`when`** (REQUIRED, object) Conditions under which the hook is injected. + The following properties can be specified: + + * **`always`** (OPTIONAL, boolean) If set `true`, this condition matches. + * **`annotations`** (OPTIONAL, object) If all `annotations` key/value pairs match a key/value pair from the [configured annotations][spec-annotations], this condition matches. + Both keys and values MUST be [POSIX extended regular expressions][POSIX-ERE]. + * **`commands`** (OPTIONAL, array of strings) If the configured [`process.args[0]`][spec-process] matches an entry, this condition matches. + Entries MUST be [POSIX extended regular expressions][POSIX-ERE]. + * **`hasBindMounts`** (OPTIONAL, boolean) If `hasBindMounts` is true and the caller requested host-to-container bind mounts (beyond those that CRI-O or libpod use by default), this condition matches. +* **`stages`** (REQUIRED, array of strings) Stages when the hook MUST be injected. + Entries MUST be chosen from the 1.0.1 OCI Runtime Specification [hook stages][spec-hooks]. + +If *all* of the conditions set in `when` match, then the `hook` MUST be injected for the stages set in `stages`. + +#### Example + +The following configuration injects [`oci-systemd-hook`][oci-systemd-hook] in the [pre-start][] and [post-stop][] stages if [`process.args[0]`][spec-process] ends with `/init` or `/systemd`: + +```console +$ cat /etc/containers/oci/hooks.d/oci-systemd-hook.json +{ + "version": "1.0.0", + "hook": { + "path": "/usr/libexec/oci/hooks.d/oci-systemd-hook" + } + "when": { + "args": [".*/init$" , ".*/systemd$"], + }, + "stages": ["prestart", "poststop"] +} +``` + +The following example injects [`oci-umount --debug`][oci-umount] in the [pre-start][] phase if the container is configured to bind-mount host directories into the container. + +```console +$ cat /etc/containers/oci/hooks.d/oci-umount.json +{ + "version": "1.0.0", + "hook": { + "path": "/usr/libexec/oci/hooks.d/oci-umount", + "args": ["oci-umount", "--debug"], + } + "when": { + "hasBindMounts": true, + }, + "stages": ["prestart"] +} +``` + +The following example injects [`nvidia-container-runtime-hook prestart`][nvidia-container-runtime-hook] with particular environment variables in the [pre-start][] phase if the container is configured with an `annotations` entry whose key matches `^com\.example\.department$` and whose value matches `.*fluid-dynamics.*`. + +```console +$ cat /etc/containers/oci/hooks.d/nvidia.json +{ + "hook": { + "path": "/usr/sbin/nvidia-container-runtime-hook", + "args": ["nvidia-container-runtime-hook", "prestart"], + "env": [ + "NVIDIA_REQUIRE_CUDA=cuda>=9.1", + "NVIDIA_VISIBLE_DEVICES=GPU-fef8089b" + ] + }, + "when": { + "annotations": { + "^com\.example\.department$": ".*fluid-dynamics$" + } + }, + "stages": ["prestart"] +} +``` + +### 0.1.0 Hook Schema + +Previous versions of CRI-O and libpod supported the 0.1.0 hook schema: + +* **`hook`** (REQUIRED, string) Sets [`path`][spec-hooks] in the injected hook. +* **`arguments`** (OPTIONAL, array of strings) Additional arguments to pass to the hook. + The injected hook's [`args`][spec-hooks] is `hook` with `arguments` appended. +* **`stages`** (REQUIRED, array of strings) Stages when the hook MUST be injected. + `stage` is an allowed synonym for this property, but you MUST NOT set both `stages` and `stage`. + Entries MUST be chosen from: + * **`prestart`**, to inject [pre-start][]. + * **`poststart`**, to inject [post-start][]. + * **`poststop`**, to inject [post-stop][]. +* **`cmds`** (OPTIONAL, array of strings) The hook MUST be injected if the configured [`process.args[0]`][spec-process] matches an entry. + `cmd` is an allowed synonym for this property, but you MUST NOT set both `cmds` and `cmd`. + Entries MUST be [POSIX extended regular expressions][POSIX-ERE]. +* **`annotations`** (OPTIONAL, array of strings) The hook MUST be injected if an `annotations` entry matches a value from the [configured annotations][spec-annotations]. + `annotation` is an allowed synonym for this property, but you MUST NOT set both `annotations` and `annotation`. + Entries MUST be [POSIX extended regular expressions][POSIX-ERE]. +* **`hasbindmounts`** (OPTIONAL, boolean) The hook MUST be injected if `hasBindMounts` is true and the caller requested host-to-container bind mounts (beyond those that CRI-O or libpod use by default). + +#### Example + +The following configuration injects [`oci-systemd-hook`][oci-systemd-hook] in the [pre-start][] and [post-stop][] stages if [`process.args[0]`][spec-process] ends with `/init` or `/systemd`: + +```console +$ cat /etc/containers/oci/hooks.d/oci-systemd-hook.json +{ + "cmds": [".*/init$" , ".*/systemd$"], + "hook": "/usr/libexec/oci/hooks.d/oci-systemd-hook", + "stages": ["prestart", "poststop"] +} +``` + +The following example injects [`oci-umount --debug`][oci-umount] in the [pre-start][] phase if the container is configured to bind-mount host directories into the container. + +```console +$ cat /etc/containers/oci/hooks.d/oci-umount.json +{ + "hook": "/usr/libexec/oci/hooks.d/oci-umount", + "arguments": ["--debug"], + "hasbindmounts": true, + "stages": ["prestart"] +} +``` + +The following example injects [`nvidia-container-runtime-hook prestart`][nvidia-container-runtime-hook] in the [pre-start][] phase if the container is configured with an `annotations` entry whose value matches `.*fluid-dynamics.*`. + +```console +$ cat /etc/containers/oci/hooks.d/osystemd-hook.json +{ + "hook": "/usr/sbin/nvidia-container-runtime-hook", + "arguments": ["prestart"], + "annotations: [".*fluid-dynamics.*"], + "stages": ["prestart"] +} +``` + +[JSON]: https://tools.ietf.org/html/rfc8259 +[nvidia-container-runtime-hook]: https://github.com/NVIDIA/nvidia-container-runtime/tree/master/hook/nvidia-container-runtime-hook +[oci-systemd-hook]: https://github.com/projectatomic/oci-systemd-hook +[oci-umount]: https://github.com/projectatomic/oci-umount +[POSIX-ERE]: http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap09.html#tag_09_04 +[post-start]: https://github.com/opencontainers/runtime-spec/blob/v1.0.1/config.md#poststart +[post-stop]: https://github.com/opencontainers/runtime-spec/blob/v1.0.1/config.md#poststop +[pre-start]: https://github.com/opencontainers/runtime-spec/blob/v1.0.1/config.md#prestart +[rfc2119]: http://tools.ietf.org/html/rfc2119 +[runc]: https://github.com/opencontainers/runc +[runtime-spec]: https://github.com/opencontainers/runtime-spec/blob/v1.0.1/spec.md +[spec-annotations]: https://github.com/opencontainers/runtime-spec/blob/v1.0.1/config.md#annotations +[spec-hooks]: https://github.com/opencontainers/runtime-spec/blob/v1.0.1/config.md#posix-platform-hooks +[spec-process]: https://github.com/opencontainers/runtime-spec/blob/v1.0.1/config.md#process diff --git a/pkg/hooks/hooks.go b/pkg/hooks/hooks.go index dbcd7b773..f079dd0f7 100644 --- a/pkg/hooks/hooks.go +++ b/pkg/hooks/hooks.go @@ -1,141 +1,104 @@ +// Package hooks implements CRI-O's hook handling. package hooks import ( - "encoding/json" + "context" "fmt" - "io/ioutil" - "os" "path/filepath" - "regexp" - "strings" - "syscall" + "sync" - spec "github.com/opencontainers/runtime-spec/specs-go" - "github.com/opencontainers/runtime-tools/generate" + rspec "github.com/opencontainers/runtime-spec/specs-go" "github.com/pkg/errors" - "github.com/sirupsen/logrus" + current "github.com/projectatomic/libpod/pkg/hooks/1.0.0" ) +// Version is the current hook configuration version. +const Version = current.Version + const ( - // DefaultHooksDir Default directory containing hooks config files - DefaultHooksDir = "/usr/share/containers/oci/hooks.d" - // OverrideHooksDir Directory where admin can override the default configuration - OverrideHooksDir = "/etc/containers/oci/hooks.d" + // DefaultDir is the default directory containing system hook configuration files. + DefaultDir = "/usr/share/containers/oci/hooks.d" + + // OverrideDir is the directory for hook configuration files overriding the default entries. + OverrideDir = "/etc/containers/oci/hooks.d" ) -// HookParams is the structure returned from read the hooks configuration -type HookParams struct { - Hook string `json:"hook"` - Stage []string `json:"stage"` - Cmds []string `json:"cmd"` - Annotations []string `json:"annotation"` - HasBindMounts bool `json:"hasbindmounts"` - Arguments []string `json:"arguments"` +// Manager provides an opaque interface for managing CRI-O hooks. +type Manager struct { + hooks map[string]*current.Hook + directories []string + lock sync.Mutex } -// readHook reads hooks json files, verifies it and returns the json config -func readHook(hookPath string) (HookParams, error) { - var hook HookParams - raw, err := ioutil.ReadFile(hookPath) - if err != nil { - return hook, errors.Wrapf(err, "error Reading hook %q", hookPath) - } - if err := json.Unmarshal(raw, &hook); err != nil { - return hook, errors.Wrapf(err, "error Unmarshalling JSON for %q", hookPath) +// New creates a new hook manager. Directories are ordered by +// increasing preference (hook configurations in later directories +// override configurations with the same filename from earlier +// directories). +func New(ctx context.Context, directories []string) (manager *Manager, err error) { + manager = &Manager{ + hooks: map[string]*current.Hook{}, + directories: directories, } - if _, err := os.Stat(hook.Hook); err != nil { - return hook, errors.Wrapf(err, "unable to stat hook %q in hook config %q", hook.Hook, hookPath) - } - validStage := map[string]bool{"prestart": true, "poststart": true, "poststop": true} - for _, cmd := range hook.Cmds { - if _, err = regexp.Compile(cmd); err != nil { - return hook, errors.Wrapf(err, "invalid cmd regular expression %q defined in hook config %q", cmd, hookPath) - } - } - for _, cmd := range hook.Annotations { - if _, err = regexp.Compile(cmd); err != nil { - return hook, errors.Wrapf(err, "invalid cmd regular expression %q defined in hook config %q", cmd, hookPath) - } - } - for _, stage := range hook.Stage { - if !validStage[stage] { - return hook, errors.Wrapf(err, "unknown stage %q defined in hook config %q", stage, hookPath) - } - } - return hook, nil -} -// readHooks reads hooks json files in directory to setup OCI Hooks -// adding hooks to the passedin hooks map. -func readHooks(hooksPath string, hooks map[string]HookParams) error { - if _, err := os.Stat(hooksPath); err != nil { - if os.IsNotExist(err) { - logrus.Warnf("hooks path: %q does not exist", hooksPath) - return nil + for _, dir := range directories { + err = ReadDir(dir, manager.hooks) + if err != nil { + return nil, err } - return errors.Wrapf(err, "unable to stat hooks path %q", hooksPath) } - files, err := ioutil.ReadDir(hooksPath) - if err != nil { - return err - } + return manager, nil +} - for _, file := range files { - if !strings.HasSuffix(file.Name(), ".json") { - continue - } - hook, err := readHook(filepath.Join(hooksPath, file.Name())) +// Hooks injects OCI runtime hooks for a given container configuration. +func (m *Manager) Hooks(config *rspec.Spec, annotations map[string]string, hasBindMounts bool) (err error) { + m.lock.Lock() + defer m.lock.Unlock() + for name, hook := range m.hooks { + match, err := hook.When.Match(config, annotations, hasBindMounts) if err != nil { - return err + return errors.Wrapf(err, "matching hook %q", name) } - for key, h := range hooks { - // hook.Hook can only be defined in one hook file, unless it has the - // same name in the override path. - if hook.Hook == h.Hook && key != file.Name() { - return errors.Wrapf(syscall.EINVAL, "duplicate path, hook %q from %q already defined in %q", hook.Hook, hooksPath, key) + if match { + if config.Hooks == nil { + config.Hooks = &rspec.Hooks{} + } + for _, stage := range hook.Stages { + switch stage { + case "prestart": + config.Hooks.Prestart = append(config.Hooks.Prestart, hook.Hook) + case "poststart": + config.Hooks.Poststart = append(config.Hooks.Poststart, hook.Hook) + case "poststop": + config.Hooks.Poststop = append(config.Hooks.Poststop, hook.Hook) + default: + return fmt.Errorf("hook %q: unknown stage %q", name, stage) + } } } - hooks[file.Name()] = hook } return nil } -// SetupHooks takes a hookspath and reads all of the hooks in that directory. -// returning a map of the configured hooks -func SetupHooks(hooksPath string) (map[string]HookParams, error) { - hooksMap := make(map[string]HookParams) - if err := readHooks(hooksPath, hooksMap); err != nil { - return nil, err - } - if hooksPath == DefaultHooksDir { - if err := readHooks(OverrideHooksDir, hooksMap); err != nil { - return nil, err - } +// remove remove a hook by name. +func (m *Manager) remove(hook string) (ok bool) { + m.lock.Lock() + defer m.lock.Unlock() + _, ok = m.hooks[hook] + if ok { + delete(m.hooks, hook) } - - return hooksMap, nil + return ok } -// AddOCIHook generates OCI specification using the included hook -func AddOCIHook(g *generate.Generator, hook HookParams) error { - for _, stage := range hook.Stage { - h := spec.Hook{ - Path: hook.Hook, - Args: append([]string{hook.Hook}, hook.Arguments...), - Env: []string{fmt.Sprintf("stage=%s", stage)}, - } - logrus.Debugf("AddOCIHook", h) - switch stage { - case "prestart": - g.AddPreStartHook(h) - - case "poststart": - g.AddPostStartHook(h) - - case "poststop": - g.AddPostStopHook(h) - } +// add adds a hook by path +func (m *Manager) add(path string) (err error) { + m.lock.Lock() + defer m.lock.Unlock() + hook, err := Read(path) + if err != nil { + return err } + m.hooks[filepath.Base(path)] = hook return nil } diff --git a/pkg/hooks/hooks_test.go b/pkg/hooks/hooks_test.go new file mode 100644 index 000000000..109f6b046 --- /dev/null +++ b/pkg/hooks/hooks_test.go @@ -0,0 +1,143 @@ +package hooks + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "testing" + + rspec "github.com/opencontainers/runtime-spec/specs-go" + current "github.com/projectatomic/libpod/pkg/hooks/1.0.0" + "github.com/stretchr/testify/assert" +) + +// path is the path to an example hook executable. +var path string + +func TestGoodNew(t *testing.T) { + ctx := context.Background() + + dir, err := ioutil.TempDir("", "hooks-test-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + jsonPath := filepath.Join(dir, "a.json") + err = ioutil.WriteFile(jsonPath, []byte(fmt.Sprintf("{\"version\": \"1.0.0\", \"hook\": {\"path\": \"%s\"}, \"when\": {\"always\": true}, \"stages\": [\"prestart\", \"poststart\", \"poststop\"]}", path)), 0644) + if err != nil { + t.Fatal(err) + } + + manager, err := New(ctx, []string{dir}) + if err != nil { + t.Fatal(err) + } + + config := &rspec.Spec{} + err = manager.Hooks(config, map[string]string{}, false) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, &rspec.Hooks{ + Prestart: []rspec.Hook{ + { + Path: path, + }, + }, + Poststart: []rspec.Hook{ + { + Path: path, + }, + }, + Poststop: []rspec.Hook{ + { + Path: path, + }, + }, + }, config.Hooks) +} + +func TestBadNew(t *testing.T) { + ctx := context.Background() + + dir, err := ioutil.TempDir("", "hooks-test-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + jsonPath := filepath.Join(dir, "a.json") + err = ioutil.WriteFile(jsonPath, []byte("{\"version\": \"-1\"}"), 0644) + if err != nil { + t.Fatal(err) + } + + _, err = New(ctx, []string{dir}) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^parsing hook \"[^\"]*a.json\": unrecognized hook version: \"-1\"$", err.Error()) +} + +func TestBrokenMatch(t *testing.T) { + manager := Manager{ + hooks: map[string]*current.Hook{ + "a.json": { + Version: current.Version, + Hook: rspec.Hook{ + Path: "/a/b/c", + }, + When: current.When{ + Commands: []string{"["}, + }, + Stages: []string{"prestart"}, + }, + }, + } + config := &rspec.Spec{ + Process: &rspec.Process{ + Args: []string{"/bin/sh"}, + }, + } + err := manager.Hooks(config, map[string]string{}, false) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^matching hook \"a\\.json\": command: error parsing regexp: .*", err.Error()) +} + +func TestInvalidStage(t *testing.T) { + always := true + manager := Manager{ + hooks: map[string]*current.Hook{ + "a.json": { + Version: current.Version, + Hook: rspec.Hook{ + Path: "/a/b/c", + }, + When: current.When{ + Always: &always, + }, + Stages: []string{"does-not-exist"}, + }, + }, + } + err := manager.Hooks(&rspec.Spec{}, map[string]string{}, false) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^hook \"a\\.json\": unknown stage \"does-not-exist\"$", err.Error()) +} + +func init() { + if runtime.GOOS != "windows" { + path = "/bin/sh" + } else { + panic("we need a reliable executable path on Windows") + } +} diff --git a/pkg/hooks/monitor.go b/pkg/hooks/monitor.go new file mode 100644 index 000000000..ba5e0f246 --- /dev/null +++ b/pkg/hooks/monitor.go @@ -0,0 +1,73 @@ +package hooks + +import ( + "context" + "path/filepath" + + "github.com/fsnotify/fsnotify" + "github.com/sirupsen/logrus" +) + +// Monitor dynamically monitors hook directories for additions, +// updates, and removals. +// +// This function write two empty structs to the sync channel: the +// first is written after the watchers are established and the second +// when this function exits. The expected usage is: +// +// ctx, cancel := context.WithCancel(context.Background()) +// sync := make(chan error, 2) +// go m.Monitor(ctx, sync) +// err := <-sync // block until writers are established +// if err != nil { +// return err // failed to establish watchers +// } +// // do stuff +// cancel() +// err = <-sync // block until monitor finishes +func (m *Manager) Monitor(ctx context.Context, sync chan<- error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + sync <- err + return + } + defer watcher.Close() + + for _, dir := range m.directories { + err = watcher.Add(dir) + if err != nil { + logrus.Errorf("failed to watch %q for hooks", dir) + sync <- err + return + } + logrus.Debugf("monitoring %q for hooks", dir) + } + + sync <- nil + + for { + select { + case event := <-watcher.Events: + if event.Op&fsnotify.Remove == fsnotify.Remove { + ok := m.remove(filepath.Base(event.Name)) + if ok { + logrus.Debugf("removed hook %s", event.Name) + } + } + if event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write { + err = m.add(event.Name) + if err == nil { + logrus.Debugf("added hook %s", event.Name) + } else if err != ErrNoJSONSuffix { + logrus.Errorf("failed to add hook %s: %v", event.Name, err) + } + } + case <-ctx.Done(): + err = ctx.Err() + logrus.Debugf("hook monitoring canceled: %v", err) + sync <- err + close(sync) + return + } + } +} diff --git a/pkg/hooks/monitor_test.go b/pkg/hooks/monitor_test.go new file mode 100644 index 000000000..9cbde1986 --- /dev/null +++ b/pkg/hooks/monitor_test.go @@ -0,0 +1,129 @@ +package hooks + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + "time" + + rspec "github.com/opencontainers/runtime-spec/specs-go" + "github.com/stretchr/testify/assert" +) + +func TestMonitorGood(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + dir, err := ioutil.TempDir("", "hooks-test-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + manager, err := New(ctx, []string{dir}) + if err != nil { + t.Fatal(err) + } + + sync := make(chan error, 2) + go manager.Monitor(ctx, sync) + err = <-sync + if err != nil { + t.Fatal(err) + } + + jsonPath := filepath.Join(dir, "a.json") + + t.Run("good-addition", func(t *testing.T) { + err = ioutil.WriteFile(jsonPath, []byte(fmt.Sprintf("{\"version\": \"1.0.0\", \"hook\": {\"path\": \"%s\"}, \"when\": {\"always\": true}, \"stages\": [\"prestart\", \"poststart\", \"poststop\"]}", path)), 0644) + if err != nil { + t.Fatal(err) + } + + time.Sleep(100 * time.Millisecond) // wait for monitor to notice + + config := &rspec.Spec{} + err = manager.Hooks(config, map[string]string{}, false) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, &rspec.Hooks{ + Prestart: []rspec.Hook{ + { + Path: path, + }, + }, + Poststart: []rspec.Hook{ + { + Path: path, + }, + }, + Poststop: []rspec.Hook{ + { + Path: path, + }, + }, + }, config.Hooks) + }) + + t.Run("good-removal", func(t *testing.T) { + err = os.Remove(jsonPath) + if err != nil { + t.Fatal(err) + } + + time.Sleep(100 * time.Millisecond) // wait for monitor to notice + + config := &rspec.Spec{} + expected := config.Hooks + err = manager.Hooks(config, map[string]string{}, false) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, expected, config.Hooks) + }) + + t.Run("bad-addition", func(t *testing.T) { + err = ioutil.WriteFile(jsonPath, []byte("{\"version\": \"-1\"]}"), 0644) + if err != nil { + t.Fatal(err) + } + + time.Sleep(100 * time.Millisecond) // wait for monitor to notice + + config := &rspec.Spec{} + expected := config.Hooks + err = manager.Hooks(config, map[string]string{}, false) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, expected, config.Hooks) + + err = os.Remove(jsonPath) + if err != nil { + t.Fatal(err) + } + }) + + cancel() + err = <-sync + assert.Equal(t, context.Canceled, err) +} + +func TestMonitorBadWatcher(t *testing.T) { + ctx := context.Background() + manager, err := New(ctx, []string{}) + if err != nil { + t.Fatal(err) + } + manager.directories = []string{"/does/not/exist"} + + sync := make(chan error, 2) + go manager.Monitor(ctx, sync) + err = <-sync + if !os.IsNotExist(err) { + t.Fatal("opaque wrapping for not-exist errors") + } +} diff --git a/pkg/hooks/read.go b/pkg/hooks/read.go new file mode 100644 index 000000000..fab37351b --- /dev/null +++ b/pkg/hooks/read.go @@ -0,0 +1,83 @@ +// Package hooks implements CRI-O's hook handling. +package hooks + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "path/filepath" + "strings" + + "github.com/pkg/errors" + current "github.com/projectatomic/libpod/pkg/hooks/1.0.0" +) + +type reader func(content []byte) (*current.Hook, error) + +var ( + // ErrNoJSONSuffix represents hook-add attempts where the filename + // does not end in '.json'. + ErrNoJSONSuffix = errors.New("hook filename does not end in '.json'") + + // Readers registers per-version hook readers. + Readers = map[string]reader{} +) + +// Read reads a hook JSON file, verifies it, and returns the hook configuration. +func Read(path string) (*current.Hook, error) { + if !strings.HasSuffix(path, ".json") { + return nil, ErrNoJSONSuffix + } + content, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + hook, err := read(content) + if err != nil { + return nil, errors.Wrapf(err, "parsing hook %q", path) + } + err = hook.Validate() + return hook, err +} + +func read(content []byte) (hook *current.Hook, err error) { + var ver version + if err := json.Unmarshal(content, &ver); err != nil { + return nil, errors.Wrap(err, "version check") + } + reader, ok := Readers[ver.Version] + if !ok { + return nil, fmt.Errorf("unrecognized hook version: %q", ver.Version) + } + + hook, err = reader(content) + if err != nil { + return hook, errors.Wrap(err, ver.Version) + } + return hook, err +} + +// ReadDir reads hook JSON files from a directory into the given map, +// clobbering any previous entries with the same filenames. +func ReadDir(path string, hooks map[string]*current.Hook) error { + files, err := ioutil.ReadDir(path) + if err != nil { + return err + } + + for _, file := range files { + hook, err := Read(filepath.Join(path, file.Name())) + if err != nil { + if err == ErrNoJSONSuffix { + continue + } + return err + } + hooks[file.Name()] = hook + } + return nil +} + +func init() { + Readers[current.Version] = current.Read +} diff --git a/pkg/hooks/read_test.go b/pkg/hooks/read_test.go new file mode 100644 index 000000000..5e77f5228 --- /dev/null +++ b/pkg/hooks/read_test.go @@ -0,0 +1,193 @@ +package hooks + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + rspec "github.com/opencontainers/runtime-spec/specs-go" + current "github.com/projectatomic/libpod/pkg/hooks/1.0.0" + "github.com/stretchr/testify/assert" +) + +func TestNoJSONSuffix(t *testing.T) { + _, err := Read("abc") + assert.Equal(t, err, ErrNoJSONSuffix) +} + +func TestUnknownPath(t *testing.T) { + _, err := Read(filepath.Join("does", "not", "exist.json")) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^open does/not/exist.json: no such file or directory$", err.Error()) + if !os.IsNotExist(err) { + t.Fatal("opaque wrapping for not-exist errors") + } +} + +func TestGoodFile(t *testing.T) { + dir, err := ioutil.TempDir("", "hooks-test-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + jsonPath := filepath.Join(dir, "hook.json") + err = ioutil.WriteFile(jsonPath, []byte(fmt.Sprintf("{\"version\": \"1.0.0\", \"hook\": {\"path\": \"%s\"}, \"when\": {\"always\": true}, \"stages\": [\"prestart\"]}", path)), 0644) + if err != nil { + t.Fatal(err) + } + + hook, err := Read(jsonPath) + if err != nil { + t.Fatal(err) + } + always := true + assert.Equal(t, ¤t.Hook{ + Version: current.Version, + Hook: rspec.Hook{ + Path: path, + }, + When: current.When{ + Always: &always, + }, + Stages: []string{"prestart"}, + }, hook) +} + +func TestBadFile(t *testing.T) { + dir, err := ioutil.TempDir("", "hooks-test-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + path := filepath.Join(dir, "hook.json") + err = ioutil.WriteFile(path, []byte("{\"version\": \"1.0.0\", \"hook\": \"not-a-string\"}"), 0644) + if err != nil { + t.Fatal(err) + } + + _, err = Read(path) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^parsing hook \"[^\"]*hook.json\": 1.0.0: json: cannot unmarshal string into Go struct field Hook.hook of type specs.Hook$", err.Error()) +} + +func TestGoodBytes(t *testing.T) { + hook, err := read([]byte("{\"version\": \"1.0.0\", \"hook\": {\"path\": \"/a/b/c\"}, \"when\": {\"always\": true}, \"stages\": [\"prestart\"]}")) + if err != nil { + t.Fatal(err) + } + always := true + assert.Equal(t, ¤t.Hook{ + Version: current.Version, + Hook: rspec.Hook{ + Path: "/a/b/c", + }, + When: current.When{ + Always: &always, + }, + Stages: []string{"prestart"}, + }, hook) +} + +func TestInvalidJSON(t *testing.T) { + _, err := read([]byte("{")) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^version check: unexpected end of JSON input$", err.Error()) +} + +func TestInvalidVersion(t *testing.T) { + _, err := read([]byte("{\"version\": \"-1\"}")) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^unrecognized hook version: \"-1\"$", err.Error()) +} + +func TestInvalidCurrentJSON(t *testing.T) { + _, err := read([]byte("{\"version\": \"1.0.0\", \"hook\": \"not-a-string\"}")) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^1.0.0: json: cannot unmarshal string into Go struct field Hook.hook of type specs.Hook$", err.Error()) +} + +func TestGoodDir(t *testing.T) { + dir, err := ioutil.TempDir("", "hooks-test-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + err = ioutil.WriteFile(filepath.Join(dir, "README"), []byte("not a hook"), 0644) + if err != nil { + t.Fatal(err) + } + + jsonPath := filepath.Join(dir, "a.json") + err = ioutil.WriteFile(jsonPath, []byte(fmt.Sprintf("{\"version\": \"1.0.0\", \"hook\": {\"path\": \"%s\"}, \"when\": {\"always\": true}, \"stages\": [\"prestart\"]}", path)), 0644) + if err != nil { + t.Fatal(err) + } + + hooks := map[string]*current.Hook{} + err = ReadDir(dir, hooks) + if err != nil { + t.Fatal(err) + } + + always := true + assert.Equal(t, map[string]*current.Hook{ + "a.json": { + Version: current.Version, + Hook: rspec.Hook{ + Path: path, + }, + When: current.When{ + Always: &always, + }, + Stages: []string{"prestart"}, + }, + }, hooks) +} + +func TestUnknownDir(t *testing.T) { + hooks := map[string]*current.Hook{} + err := ReadDir(filepath.Join("does", "not", "exist"), hooks) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^open does/not/exist: no such file or directory$", err.Error()) + if !os.IsNotExist(err) { + t.Fatal("opaque wrapping for not-exist errors") + } +} + +func TestBadDir(t *testing.T) { + dir, err := ioutil.TempDir("", "hooks-test-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + jsonPath := filepath.Join(dir, "a.json") + err = ioutil.WriteFile(jsonPath, []byte("{\"version\": \"-1\"}"), 0644) + if err != nil { + t.Fatal(err) + } + + hooks := map[string]*current.Hook{} + err = ReadDir(dir, hooks) + if err == nil { + t.Fatal("unexpected success") + } + assert.Regexp(t, "^parsing hook \"[^\"]*a.json\": unrecognized hook version: \"-1\"$", err.Error()) +} diff --git a/pkg/hooks/version.go b/pkg/hooks/version.go new file mode 100644 index 000000000..637d8e2f4 --- /dev/null +++ b/pkg/hooks/version.go @@ -0,0 +1,6 @@ +package hooks + +// version a structure for checking the version of a hook configuration. +type version struct { + Version string `json:"version"` +} |