//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))
		})
	}
}
