| //go:build !windows | |
| package daemon | |
| import ( | |
| "io/fs" | |
| "os" | |
| "strings" | |
| "testing" | |
| "dario.cat/mergo" | |
| runtimeoptions_v1 "github.com/containerd/containerd/pkg/runtimeoptions/v1" | |
| "github.com/containerd/containerd/plugin" | |
| v2runcoptions "github.com/containerd/containerd/runtime/v2/runc/options" | |
| "github.com/docker/docker/api/types/system" | |
| "github.com/docker/docker/daemon/config" | |
| "github.com/docker/docker/errdefs" | |
| "github.com/google/go-cmp/cmp/cmpopts" | |
| "google.golang.org/protobuf/proto" | |
| "gotest.tools/v3/assert" | |
| is "gotest.tools/v3/assert/cmp" | |
| ) | |
| func TestSetupRuntimes(t *testing.T) { | |
| cases := []struct { | |
| name string | |
| config *config.Config | |
| expectErr string | |
| }{ | |
| { | |
| name: "Empty", | |
| config: &config.Config{ | |
| Runtimes: map[string]system.Runtime{ | |
| "myruntime": {}, | |
| }, | |
| }, | |
| expectErr: "either a runtimeType or a path must be configured", | |
| }, | |
| { | |
| name: "ArgsOnly", | |
| config: &config.Config{ | |
| Runtimes: map[string]system.Runtime{ | |
| "myruntime": {Args: []string{"foo", "bar"}}, | |
| }, | |
| }, | |
| expectErr: "either a runtimeType or a path must be configured", | |
| }, | |
| { | |
| name: "OptionsOnly", | |
| config: &config.Config{ | |
| Runtimes: map[string]system.Runtime{ | |
| "myruntime": {Options: map[string]interface{}{"hello": "world"}}, | |
| }, | |
| }, | |
| expectErr: "either a runtimeType or a path must be configured", | |
| }, | |
| { | |
| name: "PathAndType", | |
| config: &config.Config{ | |
| Runtimes: map[string]system.Runtime{ | |
| "myruntime": {Path: "/bin/true", Type: "io.containerd.runsc.v1"}, | |
| }, | |
| }, | |
| expectErr: "cannot configure both", | |
| }, | |
| { | |
| name: "PathAndOptions", | |
| config: &config.Config{ | |
| Runtimes: map[string]system.Runtime{ | |
| "myruntime": {Path: "/bin/true", Options: map[string]interface{}{"a": "b"}}, | |
| }, | |
| }, | |
| expectErr: "options cannot be used with a path runtime", | |
| }, | |
| { | |
| name: "TypeAndArgs", | |
| config: &config.Config{ | |
| Runtimes: map[string]system.Runtime{ | |
| "myruntime": {Type: "io.containerd.runsc.v1", Args: []string{"--version"}}, | |
| }, | |
| }, | |
| expectErr: "args cannot be used with a runtimeType runtime", | |
| }, | |
| { | |
| name: "PathArgsOptions", | |
| config: &config.Config{ | |
| Runtimes: map[string]system.Runtime{ | |
| "myruntime": { | |
| Path: "/bin/true", | |
| Args: []string{"--version"}, | |
| Options: map[string]interface{}{"hmm": 3}, | |
| }, | |
| }, | |
| }, | |
| expectErr: "options cannot be used with a path runtime", | |
| }, | |
| { | |
| name: "TypeOptionsArgs", | |
| config: &config.Config{ | |
| Runtimes: map[string]system.Runtime{ | |
| "myruntime": { | |
| Type: "io.containerd.kata.v2", | |
| Options: map[string]interface{}{"a": "b"}, | |
| Args: []string{"--help"}, | |
| }, | |
| }, | |
| }, | |
| expectErr: "args cannot be used with a runtimeType runtime", | |
| }, | |
| { | |
| name: "PathArgsTypeOptions", | |
| config: &config.Config{ | |
| Runtimes: map[string]system.Runtime{ | |
| "myruntime": { | |
| Path: "/bin/true", | |
| Args: []string{"foo"}, | |
| Type: "io.containerd.runsc.v1", | |
| Options: map[string]interface{}{"a": "b"}, | |
| }, | |
| }, | |
| }, | |
| expectErr: "cannot configure both", | |
| }, | |
| { | |
| name: "CannotOverrideStockRuntime", | |
| config: &config.Config{ | |
| Runtimes: map[string]system.Runtime{ | |
| config.StockRuntimeName: {}, | |
| }, | |
| }, | |
| expectErr: `runtime name 'runc' is reserved`, | |
| }, | |
| { | |
| name: "SetStockRuntimeAsDefault", | |
| config: &config.Config{ | |
| CommonConfig: config.CommonConfig{ | |
| DefaultRuntime: config.StockRuntimeName, | |
| }, | |
| }, | |
| }, | |
| { | |
| name: "SetLinuxRuntimeAsDefault", | |
| config: &config.Config{ | |
| CommonConfig: config.CommonConfig{ | |
| DefaultRuntime: linuxV2RuntimeName, | |
| }, | |
| }, | |
| }, | |
| { | |
| name: "CannotSetBogusRuntimeAsDefault", | |
| config: &config.Config{ | |
| CommonConfig: config.CommonConfig{ | |
| DefaultRuntime: "notdefined", | |
| }, | |
| }, | |
| expectErr: "specified default runtime 'notdefined' does not exist", | |
| }, | |
| { | |
| name: "SetDefinedRuntimeAsDefault", | |
| config: &config.Config{ | |
| Runtimes: map[string]system.Runtime{ | |
| "some-runtime": { | |
| Path: "/usr/local/bin/file-not-found", | |
| }, | |
| }, | |
| CommonConfig: config.CommonConfig{ | |
| DefaultRuntime: "some-runtime", | |
| }, | |
| }, | |
| }, | |
| } | |
| for _, tc := range cases { | |
| tc := tc | |
| t.Run(tc.name, func(t *testing.T) { | |
| cfg, err := config.New() | |
| assert.NilError(t, err) | |
| cfg.Root = t.TempDir() | |
| assert.NilError(t, mergo.Merge(cfg, tc.config, mergo.WithOverride)) | |
| assert.Assert(t, initRuntimesDir(cfg)) | |
| _, err = setupRuntimes(cfg) | |
| if tc.expectErr == "" { | |
| assert.NilError(t, err) | |
| } else { | |
| assert.ErrorContains(t, err, tc.expectErr) | |
| } | |
| }) | |
| } | |
| } | |
| func TestGetRuntime(t *testing.T) { | |
| // Configured runtimes can have any arbitrary name, including names | |
| // which would not be allowed as implicit runtime names. Explicit takes | |
| // precedence over implicit. | |
| const configuredRtName = "my/custom.runtime.v1" | |
| configuredRuntime := system.Runtime{Path: "/bin/true"} | |
| const rtWithArgsName = "withargs" | |
| rtWithArgs := system.Runtime{ | |
| Path: "/bin/false", | |
| Args: []string{"--version"}, | |
| } | |
| const shimWithOptsName = "shimwithopts" | |
| shimWithOpts := system.Runtime{ | |
| Type: plugin.RuntimeRuncV2, | |
| Options: map[string]interface{}{"IoUid": 42}, | |
| } | |
| const shimAliasName = "wasmedge" | |
| shimAlias := system.Runtime{Type: "io.containerd.wasmedge.v1"} | |
| const configuredShimByPathName = "shimwithpath" | |
| configuredShimByPath := system.Runtime{Type: "/path/to/my/shim"} | |
| // A runtime configured with the generic 'runtimeoptions/v1.Options' shim configuration options. | |
| // https://gvisor.dev/docs/user_guide/containerd/configuration/#:~:text=to%20the%20shim.-,Containerd%201.3%2B,-Starting%20in%201.3 | |
| const gvisorName = "gvisor" | |
| gvisorRuntime := system.Runtime{ | |
| Type: "io.containerd.runsc.v1", | |
| Options: map[string]interface{}{ | |
| "TypeUrl": "io.containerd.runsc.v1.options", | |
| "ConfigPath": "/path/to/runsc.toml", | |
| }, | |
| } | |
| cfg, err := config.New() | |
| assert.NilError(t, err) | |
| cfg.Root = t.TempDir() | |
| cfg.Runtimes = map[string]system.Runtime{ | |
| configuredRtName: configuredRuntime, | |
| rtWithArgsName: rtWithArgs, | |
| shimWithOptsName: shimWithOpts, | |
| shimAliasName: shimAlias, | |
| configuredShimByPathName: configuredShimByPath, | |
| gvisorName: gvisorRuntime, | |
| } | |
| assert.NilError(t, initRuntimesDir(cfg)) | |
| runtimes, err := setupRuntimes(cfg) | |
| assert.NilError(t, err) | |
| stockRuntime, ok := runtimes.configured[config.StockRuntimeName] | |
| assert.Assert(t, ok, "stock runtime could not be found (test needs to be updated)") | |
| stockRuntime.Features = nil | |
| configdOpts := proto.Clone(stockRuntime.Opts.(*v2runcoptions.Options)).(*v2runcoptions.Options) | |
| configdOpts.BinaryName = configuredRuntime.Path | |
| wantConfigdRuntime := &shimConfig{ | |
| Shim: stockRuntime.Shim, | |
| Opts: configdOpts, | |
| } | |
| for _, tt := range []struct { | |
| name, runtime string | |
| want *shimConfig | |
| }{ | |
| { | |
| name: "StockRuntime", | |
| runtime: config.StockRuntimeName, | |
| want: stockRuntime, | |
| }, | |
| { | |
| name: "ShimName", | |
| runtime: "io.containerd.my-shim.v42", | |
| want: &shimConfig{Shim: "io.containerd.my-shim.v42"}, | |
| }, | |
| { | |
| // containerd is pretty loose about the format of runtime names. Perhaps too | |
| // loose. The only requirements are that the name contain a dot and (depending | |
| // on the containerd version) not start with a dot. It does not enforce any | |
| // particular format of the dot-delimited components of the name. | |
| name: "VersionlessShimName", | |
| runtime: "io.containerd.my-shim", | |
| want: &shimConfig{Shim: "io.containerd.my-shim"}, | |
| }, | |
| { | |
| name: "IllformedShimName", | |
| runtime: "myshim", | |
| }, | |
| { | |
| name: "EmptyString", | |
| runtime: "", | |
| want: stockRuntime, | |
| }, | |
| { | |
| name: "PathToShim", | |
| runtime: "/path/to/runc", | |
| }, | |
| { | |
| name: "PathToShimName", | |
| runtime: "/path/to/io.containerd.runc.v2", | |
| }, | |
| { | |
| name: "RelPathToShim", | |
| runtime: "my/io.containerd.runc.v2", | |
| }, | |
| { | |
| name: "ConfiguredRuntime", | |
| runtime: configuredRtName, | |
| want: wantConfigdRuntime, | |
| }, | |
| { | |
| name: "ShimWithOpts", | |
| runtime: shimWithOptsName, | |
| want: &shimConfig{ | |
| Shim: shimWithOpts.Type, | |
| Opts: &v2runcoptions.Options{IoUid: 42}, | |
| }, | |
| }, | |
| { | |
| name: "ShimAlias", | |
| runtime: shimAliasName, | |
| want: &shimConfig{Shim: shimAlias.Type}, | |
| }, | |
| { | |
| name: "ConfiguredShimByPath", | |
| runtime: configuredShimByPathName, | |
| want: &shimConfig{Shim: configuredShimByPath.Type}, | |
| }, | |
| { | |
| name: "ConfiguredShimWithRuntimeoptionsShimConfig", | |
| runtime: gvisorName, | |
| want: &shimConfig{ | |
| Shim: gvisorRuntime.Type, | |
| Opts: &runtimeoptions_v1.Options{ | |
| TypeUrl: gvisorRuntime.Options["TypeUrl"].(string), | |
| ConfigPath: gvisorRuntime.Options["ConfigPath"].(string), | |
| }, | |
| }, | |
| }, | |
| } { | |
| tt := tt | |
| t.Run(tt.name, func(t *testing.T) { | |
| shim, opts, err := runtimes.Get(tt.runtime) | |
| if tt.want != nil { | |
| assert.Check(t, err) | |
| got := &shimConfig{Shim: shim, Opts: opts} | |
| assert.Check(t, is.DeepEqual(got, tt.want, | |
| cmpopts.IgnoreUnexported(runtimeoptions_v1.Options{}), | |
| cmpopts.IgnoreUnexported(v2runcoptions.Options{}), | |
| )) | |
| } else { | |
| assert.Check(t, is.Equal(shim, "")) | |
| assert.Check(t, is.Nil(opts)) | |
| assert.Check(t, errdefs.IsInvalidParameter(err), "[%T] %[1]v", err) | |
| } | |
| }) | |
| } | |
| t.Run("RuntimeWithArgs", func(t *testing.T) { | |
| shim, opts, err := runtimes.Get(rtWithArgsName) | |
| assert.Check(t, err) | |
| assert.Check(t, is.Equal(shim, stockRuntime.Shim)) | |
| runcopts, ok := opts.(*v2runcoptions.Options) | |
| if assert.Check(t, ok, "runtimes.Get() opts = type %T, want *v2runcoptions.Options", opts) { | |
| wrapper, err := os.ReadFile(runcopts.BinaryName) | |
| if assert.Check(t, err) { | |
| assert.Check(t, is.Contains(string(wrapper), | |
| strings.Join(append([]string{rtWithArgs.Path}, rtWithArgs.Args...), " "))) | |
| } | |
| } | |
| }) | |
| } | |
| func TestGetRuntime_PreflightCheck(t *testing.T) { | |
| cfg, err := config.New() | |
| assert.NilError(t, err) | |
| cfg.Root = t.TempDir() | |
| cfg.Runtimes = map[string]system.Runtime{ | |
| "path-only": { | |
| Path: "/usr/local/bin/file-not-found", | |
| }, | |
| "with-args": { | |
| Path: "/usr/local/bin/file-not-found", | |
| Args: []string{"--arg"}, | |
| }, | |
| } | |
| assert.NilError(t, initRuntimesDir(cfg)) | |
| runtimes, err := setupRuntimes(cfg) | |
| assert.NilError(t, err, "runtime paths should not be validated during setupRuntimes()") | |
| t.Run("PathOnly", func(t *testing.T) { | |
| _, _, err := runtimes.Get("path-only") | |
| assert.NilError(t, err, "custom runtimes without wrapper scripts should not have pre-flight checks") | |
| }) | |
| t.Run("WithArgs", func(t *testing.T) { | |
| _, _, err := runtimes.Get("with-args") | |
| assert.ErrorIs(t, err, fs.ErrNotExist) | |
| }) | |
| } | |
| // TestRuntimeWrapping checks that reloading runtime config does not delete or | |
| // modify existing wrapper scripts, which could break lifecycle management of | |
| // existing containers. | |
| func TestRuntimeWrapping(t *testing.T) { | |
| cfg, err := config.New() | |
| assert.NilError(t, err) | |
| cfg.Root = t.TempDir() | |
| cfg.Runtimes = map[string]system.Runtime{ | |
| "change-args": { | |
| Path: "/bin/true", | |
| Args: []string{"foo", "bar"}, | |
| }, | |
| "dupe": { | |
| Path: "/bin/true", | |
| Args: []string{"foo", "bar"}, | |
| }, | |
| "change-path": { | |
| Path: "/bin/true", | |
| Args: []string{"baz"}, | |
| }, | |
| "drop-args": { | |
| Path: "/bin/true", | |
| Args: []string{"some", "arguments"}, | |
| }, | |
| "goes-away": { | |
| Path: "/bin/true", | |
| Args: []string{"bye"}, | |
| }, | |
| } | |
| assert.NilError(t, initRuntimesDir(cfg)) | |
| rt, err := setupRuntimes(cfg) | |
| assert.Check(t, err) | |
| type WrapperInfo struct{ BinaryName, Content string } | |
| wrappers := make(map[string]WrapperInfo) | |
| for name := range cfg.Runtimes { | |
| _, opts, err := rt.Get(name) | |
| if assert.Check(t, err, "rt.Get(%q)", name) { | |
| binary := opts.(*v2runcoptions.Options).BinaryName | |
| content, err := os.ReadFile(binary) | |
| assert.Check(t, err, "could not read wrapper script contents for runtime %q", binary) | |
| wrappers[name] = WrapperInfo{BinaryName: binary, Content: string(content)} | |
| } | |
| } | |
| cfg.Runtimes["change-args"] = system.Runtime{ | |
| Path: cfg.Runtimes["change-args"].Path, | |
| Args: []string{"baz", "quux"}, | |
| } | |
| cfg.Runtimes["change-path"] = system.Runtime{ | |
| Path: "/bin/false", | |
| Args: cfg.Runtimes["change-path"].Args, | |
| } | |
| cfg.Runtimes["drop-args"] = system.Runtime{ | |
| Path: cfg.Runtimes["drop-args"].Path, | |
| } | |
| delete(cfg.Runtimes, "goes-away") | |
| _, err = setupRuntimes(cfg) | |
| assert.Check(t, err) | |
| for name, info := range wrappers { | |
| t.Run(name, func(t *testing.T) { | |
| content, err := os.ReadFile(info.BinaryName) | |
| assert.NilError(t, err) | |
| assert.DeepEqual(t, info.Content, string(content)) | |
| }) | |
| } | |
| } |