An instance is a directory under instances/<id>/ (config.json, state.json, stdout, stderr, run/, volumes/) written by package instance, the service behind the verbs. run -d and start fork this binary as a supervisor (werkdock supervise ID) that records the pids and the exit code; stop, kill, wait, logs, ps, rm, inspect read the files. exec is a second bwrap over the same instance tree. The command is pid 1 of its sandbox (bwrap --as-pid-1) so stop's SIGTERM reaches it, as in docker; /run and the anonymous volumes are instance directories, kept across stop/start and shared with exec. Version 0.5.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #4
419 lines
15 KiB
Go
419 lines
15 KiB
Go
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)
|
|
}
|
|
}
|