summaryrefslogtreecommitdiff
path: root/pkg/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/hooks')
-rw-r--r--pkg/hooks/0.1.0/hook.go93
-rw-r--r--pkg/hooks/0.1.0/hook_test.go182
-rw-r--r--pkg/hooks/1.0.0/hook.go78
-rw-r--r--pkg/hooks/1.0.0/hook_test.go200
-rw-r--r--pkg/hooks/1.0.0/when.go92
-rw-r--r--pkg/hooks/1.0.0/when_test.go289
-rw-r--r--pkg/hooks/README.md175
-rw-r--r--pkg/hooks/hooks.go177
-rw-r--r--pkg/hooks/hooks_test.go143
-rw-r--r--pkg/hooks/monitor.go73
-rw-r--r--pkg/hooks/monitor_test.go129
-rw-r--r--pkg/hooks/read.go83
-rw-r--r--pkg/hooks/read_test.go193
-rw-r--r--pkg/hooks/version.go6
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 = &current.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, &current.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, &current.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, &current.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, &current.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, &current.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, &current.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, &current.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, &current.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, &current.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"`
+}