diff options
Diffstat (limited to 'pkg/hooks/1.0.0')
-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 |
4 files changed, 659 insertions, 0 deletions
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()) + }) + } +} |