package cli import ( "os" "path/filepath" "reflect" "strings" "testing" "werkdock/internal/engine" "werkdock/internal/store" ) func noEnv(string) (string, bool) { return "", false } func TestParseRunSupportedFlags(t *testing.T) { opts, err := parseRun([]string{ "--rm", "-v", "/repo:/repo", "--volume", "/cache:/root/.gradle:ro", "-e", "CI=true", "-w", "/repo", "buildenv", "sh", "-c", "./gradlew build", }, noEnv) if err != nil { t.Fatal(err) } if opts.Image != "buildenv" { t.Errorf("image: got %q", opts.Image) } if !reflect.DeepEqual(opts.Args, []string{"sh", "-c", "./gradlew build"}) { t.Errorf("args: got %q", opts.Args) } wantMounts := []engine.Mount{ {Mode: engine.MountBind, Source: "/repo", Dest: "/repo"}, {Mode: engine.MountRoBind, Source: "/cache", Dest: "/root/.gradle"}, } if !reflect.DeepEqual(opts.Mounts, wantMounts) { t.Errorf("mounts: got %+v", opts.Mounts) } if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "CI", Value: "true"}}) { t.Errorf("env: got %+v", opts.Env) } if opts.Workdir != "/repo" { t.Errorf("workdir: got %q", opts.Workdir) } } func TestParseRunCopiesBareEnvKeysFromTheHost(t *testing.T) { lookup := func(key string) (string, bool) { switch key { case "LANG": return "C.UTF-8", true case "EMPTY": return "", true } return "", false } // docker: a bare KEY the host does not have is not passed at all — // not even as KEY="" — while an existing empty one is. opts, err := parseRun([]string{"--rm", "-e", "LANG", "-e", "UNSET", "-e", "EMPTY", "-e", "X=", "img", "true"}, lookup) if err != nil { t.Fatal(err) } want := []engine.EnvVar{{Key: "LANG", Value: "C.UTF-8"}, {Key: "EMPTY", Value: ""}, {Key: "X", Value: ""}} if !reflect.DeepEqual(opts.Env, want) { t.Errorf("env: got %+v, want %+v", opts.Env, want) } } func TestParseRunRefusesDockerFlagsLoudly(t *testing.T) { tests := []struct { args []string wantReason string }{ {[]string{"--rm", "-p", "8080:80", "img", "true"}, "port mapping needs a network of its own"}, {[]string{"--rm", "--publish", "8080:80", "img", "true"}, "port mapping needs a network of its own"}, {[]string{"--rm", "--network", "bridge", "img", "true"}, `network mode "bridge" is not available`}, {[]string{"--rm", "--net", "none", "img", "true"}, `network mode "none" is not available`}, {[]string{"--rm", "--network=container:x", "img", "true"}, "only \"host\" is supported"}, {[]string{"--rm", "--memory", "1g", "img", "true"}, "does not manage resources"}, {[]string{"--rm", "--userns", "host", "img", "true"}, "only keep-id is available"}, {[]string{"--rm", "-t", "img", "true"}, "no pseudo-terminal"}, {[]string{"--restart", "always", "img", "true"}, "no daemon to restart"}, } for _, tt := range tests { t.Run(strings.Join(tt.args, " "), func(t *testing.T) { _, err := parseRun(tt.args, noEnv) if err == nil || !strings.Contains(err.Error(), tt.wantReason) { t.Errorf("got %v, want refusal containing %q", err, tt.wantReason) } }) } } func TestParseRunLifecycleFlags(t *testing.T) { opts, err := parseRun([]string{"img", "true"}, noEnv) if err != nil || opts.Remove || opts.Detach { t.Errorf("a plain run keeps its instance: got %+v, %v", opts, err) } opts, err = parseRun([]string{"-d", "--name", "db", "-i", "img"}, noEnv) if err != nil || !opts.Detach || opts.Name != "db" || !opts.Interactive { t.Errorf("-d --name -i: got %+v, %v", opts, err) } } func TestParseRunValidation(t *testing.T) { tests := []struct { name string args []string wantErr string }{ {"no image", []string{"--rm"}, "no image specified"}, {"volume without dest", []string{"--rm", "-v", "/only-src", "img", "true"}, "expected SRC:DEST"}, {"volume with bad option", []string{"--rm", "-v", "/a:/b:cached", "img", "true"}, "only 'ro' and 'rw' are supported"}, {"relative tmpfs dest", []string{"--rm", "--tmpfs", "rel", "img", "true"}, "absolute"}, {"relative volume source", []string{"--rm", "-v", "rel:/b", "img", "true"}, "absolute"}, {"relative volume dest", []string{"--rm", "-v", "/a:rel", "img", "true"}, "absolute"}, {"relative workdir", []string{"--rm", "-w", "rel", "img", "true"}, "absolute"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := parseRun(tt.args, noEnv) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("got %v, want it to contain %q", err, tt.wantErr) } }) } } func TestParseRunKeepsMountFlagOrderAcrossVolumeAndTmpfs(t *testing.T) { // The git-metadata mask depends on it: ro-bind .git, tmpfs over // .git/werkator, then the workspace bind — in exactly this order. opts, err := parseRun([]string{ "--rm", "-v", "/r/.git:/r/.git:ro", "--tmpfs", "/r/.git/werkator", "-v", "/r/ws:/r/ws", "img", "true", }, noEnv) if err != nil { t.Fatal(err) } want := []engine.Mount{ {Mode: engine.MountRoBind, Source: "/r/.git", Dest: "/r/.git"}, {Mode: engine.MountTmpfs, Dest: "/r/.git/werkator"}, {Mode: engine.MountBind, Source: "/r/ws", Dest: "/r/ws"}, } if !reflect.DeepEqual(opts.Mounts, want) { t.Errorf("mounts: got %+v", opts.Mounts) } } func TestParseRunAcceptsTheExplicitRwVolumeOption(t *testing.T) { opts, err := parseRun([]string{"--rm", "-v", "/a:/b:rw", "img", "true"}, noEnv) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(opts.Mounts, []engine.Mount{{Mode: engine.MountBind, Source: "/a", Dest: "/b"}}) { t.Errorf("mounts: got %+v", opts.Mounts) } } func TestParseRunStopsFlagParsingAtTheImage(t *testing.T) { // Docker semantics: everything after the image belongs to the // command, even if it looks like a flag. opts, err := parseRun([]string{"--rm", "img", "ls", "-la", "/tmp"}, noEnv) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(opts.Args, []string{"ls", "-la", "/tmp"}) { t.Errorf("args: got %q", opts.Args) } } func TestBuildSpecMergesFlagsWithTheImageConfig(t *testing.T) { image := store.ImageConfig{ Env: []string{"JAVA_HOME=/opt/jdk", "LANG=C"}, Entrypoint: []string{"/opt/jdk/bin/java"}, Cmd: []string{"-version"}, WorkingDir: "/work", } tests := []struct { name string args []string config store.ImageConfig wantCommand []string wantWorkdir string wantEnv []engine.EnvVar wantErr string }{ { "image defaults alone", []string{"--rm", "img"}, image, []string{"/opt/jdk/bin/java", "-version"}, "/work", []engine.EnvVar{{Key: "JAVA_HOME", Value: "/opt/jdk"}, {Key: "LANG", Value: "C"}}, "", }, { "positional arguments replace Cmd, keep Entrypoint", []string{"--rm", "img", "-jar", "app.jar"}, image, []string{"/opt/jdk/bin/java", "-jar", "app.jar"}, "/work", nil, "", }, { "--entrypoint replaces Entrypoint and drops Cmd", []string{"--rm", "--entrypoint", "/bin/sh", "img"}, image, []string{"/bin/sh"}, "/work", nil, "", }, { "--entrypoint with arguments", []string{"--rm", "--entrypoint", "/bin/sh", "img", "-c", "java -version"}, image, []string{"/bin/sh", "-c", "java -version"}, "/work", nil, "", }, { "an empty --entrypoint clears it, the arguments are the command", []string{"--rm", "--entrypoint=", "img", "ls"}, image, []string{"ls"}, "/work", nil, "", }, { "-w and -e override the image", []string{"--rm", "-w", "/repo", "-e", "LANG=C.UTF-8", "img"}, image, []string{"/opt/jdk/bin/java", "-version"}, "/repo", []engine.EnvVar{{Key: "JAVA_HOME", Value: "/opt/jdk"}, {Key: "LANG", Value: "C"}, {Key: "LANG", Value: "C.UTF-8"}}, "", }, { "no config, no command", []string{"--rm", "img"}, store.ImageConfig{}, nil, "", nil, "no command specified", }, { "an empty --entrypoint without arguments leaves nothing to run", []string{"--rm", "--entrypoint=", "img"}, image, nil, "", nil, "no command specified", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { opts, err := parseRun(tt.args, noEnv) if err != nil { t.Fatal(err) } spec, err := buildSpec(opts, "/rootfs", tt.config) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("got %v, want it to contain %q", err, tt.wantErr) } return } if err != nil { t.Fatal(err) } if !reflect.DeepEqual(spec.Command, tt.wantCommand) { t.Errorf("command: got %q, want %q", spec.Command, tt.wantCommand) } if spec.Workdir != tt.wantWorkdir { t.Errorf("workdir: got %q, want %q", spec.Workdir, tt.wantWorkdir) } if tt.wantEnv != nil && !reflect.DeepEqual(spec.Env, tt.wantEnv) { t.Errorf("env: got %+v, want %+v", spec.Env, tt.wantEnv) } }) } } func TestParseRunMountIsTheExplicitFormOfVolumeAndTmpfs(t *testing.T) { opts, err := parseRun([]string{ "--rm", "--mount", "type=bind,src=/project,dst=/project", "--mount", "type=bind,source=/cache,target=/root/.gradle,readonly", "--mount", "type=bind,src=/etc/x,destination=/etc/x,ro=true", "--mount", "src=/implicit,dst=/implicit,readonly=false", "-v", "/between:/between", "--mount", "type=tmpfs,dst=/tmp/cache", "img", "true", }, noEnv) if err != nil { t.Fatal(err) } want := []engine.Mount{ {Mode: engine.MountBind, Source: "/project", Dest: "/project"}, {Mode: engine.MountRoBind, Source: "/cache", Dest: "/root/.gradle"}, {Mode: engine.MountRoBind, Source: "/etc/x", Dest: "/etc/x"}, {Mode: engine.MountBind, Source: "/implicit", Dest: "/implicit"}, {Mode: engine.MountBind, Source: "/between", Dest: "/between"}, {Mode: engine.MountTmpfs, Dest: "/tmp/cache"}, } if !reflect.DeepEqual(opts.Mounts, want) { t.Errorf("mounts: got %+v\nwant %+v", opts.Mounts, want) } } func TestParseRunMountRefusals(t *testing.T) { tests := []struct{ mount, wantErr string }{ {"type=bind,src=/a", "destination (dst) is required"}, {"type=bind,dst=/a", "needs a source"}, {"type=tmpfs,src=/a,dst=/b", "has no source"}, {"type=tmpfs,dst=/b,ro", "read-only tmpfs"}, {"type=tmpfs,dst=rel", "absolute"}, {"type=bind,src=rel,dst=/b", "absolute"}, {"type=volume,src=data,dst=/data", "volume layer"}, {"type=npipe,dst=/x", "type \"npipe\" is not supported"}, {"type=bind,src=/a,dst=/b,bind-propagation=rshared", "option \"bind-propagation\" is not supported"}, {"type=tmpfs,dst=/b,tmpfs-size=1g", "option \"tmpfs-size\" is not supported"}, {"type=bind,src=/a,dst=/b,ro=maybe", "must be true or false"}, {"type=bind,,dst=/b", "empty field"}, } for _, tt := range tests { t.Run(tt.mount, func(t *testing.T) { _, err := parseRun([]string{"--rm", "--mount", tt.mount, "img", "true"}, noEnv) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("got %v, want it to contain %q", err, tt.wantErr) } }) } } func TestParseRunAcceptsTheHostNetworkAsANoop(t *testing.T) { for _, args := range [][]string{ {"--rm", "--network", "host", "img", "true"}, {"--rm", "--net=host", "img", "true"}, } { opts, err := parseRun(args, noEnv) if err != nil { t.Errorf("%q: %v", args, err) continue } if opts.Image != "img" || !reflect.DeepEqual(opts.Args, []string{"true"}) { t.Errorf("%q: got image %q, args %q", args, opts.Image, opts.Args) } } } func userRootfs(t *testing.T) string { t.Helper() rootfs := t.TempDir() os.MkdirAll(filepath.Join(rootfs, "etc"), 0o755) os.WriteFile(filepath.Join(rootfs, "etc", "passwd"), []byte("root:x:0:0::/root:/bin/sh\npostgres:x:999:999::/var/lib/postgresql:/bin/sh\n"), 0o644) return rootfs } func TestBuildSpecResolvesTheUserInPodmanOrder(t *testing.T) { rootfs := userRootfs(t) config := store.ImageConfig{Cmd: []string{"id"}, User: "postgres"} spec, err := buildSpec(&runOptions{}, rootfs, config) if err != nil || spec.UID != 999 || spec.GID != 999 || spec.Home != "/var/lib/postgresql" { t.Errorf("image USER: got uid %d gid %d home %q, %v", spec.UID, spec.GID, spec.Home, err) } spec, err = buildSpec(&runOptions{User: "1000:50"}, rootfs, config) if err != nil || spec.UID != 1000 || spec.GID != 50 || spec.Home != "" { t.Errorf("--user over image USER: got uid %d gid %d home %q, %v", spec.UID, spec.GID, spec.Home, err) } spec, err = buildSpec(&runOptions{KeepID: true}, rootfs, config) if err != nil || spec.UID != os.Getuid() || spec.GID != os.Getgid() { t.Errorf("keep-id: got uid %d gid %d, %v", spec.UID, spec.GID, err) } spec, err = buildSpec(&runOptions{}, rootfs, store.ImageConfig{Cmd: []string{"id"}}) if err != nil || spec.UID != 0 || spec.GID != 0 || spec.Home != "" { t.Errorf("default: got uid %d gid %d home %q, %v", spec.UID, spec.GID, spec.Home, err) } if _, err := buildSpec(&runOptions{User: "nobody"}, rootfs, config); err == nil || !strings.Contains(err.Error(), "--user") { t.Errorf("unknown --user: got %v", err) } if _, err := buildSpec(&runOptions{}, rootfs, store.ImageConfig{Cmd: []string{"id"}, User: "ghost"}); err == nil || !strings.Contains(err.Error(), "image USER") { t.Errorf("unknown image USER: got %v", err) } } func TestAnonymousVolumesAreTheUncoveredImageVolumes(t *testing.T) { volumes := map[string]struct{}{"/var/lib/postgresql/data": {}, "/var/run/postgresql": {}, "/data": {}} explicit := []engine.Mount{ {Mode: engine.MountBind, Source: "/srv/pg", Dest: "/var/lib/postgresql"}, {Mode: engine.MountBind, Source: "/srv/data", Dest: "/data/"}, } if got := anonymousVolumes(volumes, explicit); !reflect.DeepEqual(got, []string{"/var/run/postgresql"}) { t.Errorf("got %q", got) } } func TestBuildSpecLeavesImageVolumesToTheInstance(t *testing.T) { config := store.ImageConfig{ Cmd: []string{"postgres"}, Volumes: map[string]struct{}{"/var/lib/postgresql/data": {}, "/var/run/postgresql": {}, "/data": {}}, } opts := &runOptions{Mounts: []engine.Mount{ {Mode: engine.MountBind, Source: "/srv/pg", Dest: "/var/lib/postgresql"}, {Mode: engine.MountBind, Source: "/srv/data", Dest: "/data/"}, }} spec, err := buildSpec(opts, "/rootfs", config) if err != nil { t.Fatal(err) } var got []engine.Mount for _, m := range spec.Mounts { if m.Dest != "/etc/resolv.conf" { got = append(got, m) } } want := []engine.Mount{ {Mode: engine.MountBind, Source: "/srv/pg", Dest: "/var/lib/postgresql"}, {Mode: engine.MountBind, Source: "/srv/data", Dest: "/data/"}, } if !reflect.DeepEqual(got, want) { t.Errorf("mounts:\n got %+v\nwant %+v", got, want) } } func TestParseRunUserFlags(t *testing.T) { opts, err := parseRun([]string{"--rm", "-u", "postgres:staff", "img"}, noEnv) if err != nil || opts.User != "postgres:staff" || opts.KeepID { t.Errorf("-u: got %+v, %v", opts, err) } opts, err = parseRun([]string{"--rm", "--userns=keep-id", "img"}, noEnv) if err != nil || !opts.KeepID { t.Errorf("--userns=keep-id: got %+v, %v", opts, err) } if _, err := parseRun([]string{"--rm", "--userns=host", "img"}, noEnv); err == nil || !strings.Contains(err.Error(), "keep-id") { t.Errorf("--userns=host: got %v", err) } }