package spec import ( "strings" "testing" ) func TestShellQuote(t *testing.T) { cases := map[string]string{ "simple": "simple", "a/b-c_1.2": "a/b-c_1.2", "": "''", "has space": "'has space'", "it's": `'it'\''s'`, "$(rm -rf /)": "'$(rm -rf /)'", "a;b": "'a;b'", "KEY=value": "KEY=value", "tag:1.0@sha256:ab": "tag:1.0@sha256:ab", "back`tick`": "'back`tick`'", } for in, want := range cases { if got := ShellQuote(in); got != want { t.Errorf("ShellQuote(%q) = %q, want %q", in, got, want) } } } // TestCreateArgsFull checks that a container using most of the surface area of // docker run is rendered back into an equivalent create command. func TestCreateArgsFull(t *testing.T) { stopTimeout := 15 initTrue := true c := &Container{ Name: "web", Image: "nginx:1.27", Hostname: "web-1", User: "101:101", WorkingDir: "/srv", Env: []string{"TZ=Europe/Paris", "SECRET=a b"}, Labels: map[string]string{"team": "infra", "com.docker.compose.project": "shop"}, Cmd: []string{"nginx", "-g", "daemon off;"}, CmdSet: true, Entrypoint: []string{"/entry.sh", "--flag"}, EntrypointSet: true, RestartPolicy: "on-failure", RestartMaxRetries: 3, StopSignal: "SIGQUIT", StopTimeout: &stopTimeout, Init: &initTrue, Privileged: true, CapAdd: []string{"NET_ADMIN"}, CapDrop: []string{"MKNOD"}, Sysctls: map[string]string{"net.core.somaxconn": "1024"}, DNS: []string{"1.1.1.1"}, ExtraHosts: []string{"db:10.0.0.5"}, NetworkMode: "frontend", Endpoints: []Endpoint{ {Network: "frontend", Aliases: []string{"web", "www"}, IPv4Address: "172.20.0.9"}, {Network: "backend", Aliases: []string{"web"}}, }, Ports: []PortBinding{ {ContainerPort: "80/tcp", HostIP: "0.0.0.0", HostPort: "8080"}, {ContainerPort: "53/udp", HostIP: "127.0.0.1", HostPort: "5353"}, }, ExposedPorts: []string{"80/tcp", "9000/tcp"}, Mounts: []Mount{ {Kind: MountVolume, Name: "html", Destination: "/usr/share/nginx/html", ReadOnly: true}, {Kind: MountBind, Source: "/etc/nginx/conf.d", Destination: "/etc/nginx/conf.d"}, {Kind: MountAnonymous, Name: strings.Repeat("a", 64), Destination: "/cache"}, {Kind: MountTmpfs, Destination: "/run", TmpfsOpts: "size=64m"}, }, LogDriver: "json-file", LogOptions: map[string]string{"max-size": "10m"}, Resources: Resources{Memory: 536870912, NanoCPUs: 1500000000, ShmSize: 67108864}, } got := strings.Join(c.CreateArgs(RenderOptions{}), " ") mustContain := []string{ "create --name web", "--hostname web-1", "--user 101:101", "--env TZ=Europe/Paris", "--label team=infra", "--restart on-failure:3", "--stop-timeout 15", "--init", "--privileged", "--cap-add NET_ADMIN", "--sysctl net.core.somaxconn=1024", "--add-host db:10.0.0.5", "--network frontend", "--network-alias web", "--publish 8080:80", "--publish 127.0.0.1:5353:53/udp", "--expose 9000/tcp", "--volume html:/usr/share/nginx/html:ro", "--volume /etc/nginx/conf.d:/etc/nginx/conf.d", "--tmpfs /run:size=64m", "--log-opt max-size=10m", "--memory 536870912", "--cpus 1.5", "--entrypoint /entry.sh", "nginx:1.27", } for _, want := range mustContain { if !strings.Contains(got, want) { t.Errorf("create args missing %q\ngot: %s", want, got) } } // Labels docker or compose manage themselves must not be re-applied. if strings.Contains(got, "com.docker.compose.project") { t.Errorf("compose-managed label was re-applied:\n%s", got) } // A port already published must not also be re-exposed. if strings.Contains(got, "--expose 80/tcp") { t.Errorf("published port was also exposed:\n%s", got) } // The default shm size carries no information and should be omitted. if strings.Contains(got, "--shm-size") { t.Errorf("default shm size was emitted:\n%s", got) } // The image must be the last flag-free token before the command. idx := strings.Index(got, "nginx:1.27") if idx < 0 || !strings.Contains(got[idx:], "--flag") { t.Errorf("entrypoint remainder and command must follow the image:\n%s", got) } } func TestCreateArgsSecondNetworkNeedsConnect(t *testing.T) { c := &Container{ Name: "app", Image: "app:1", NetworkMode: "a", Endpoints: []Endpoint{ {Network: "a"}, {Network: "b", Aliases: []string{"app-b"}, IPv4Address: "10.1.2.3"}, }, } args := c.CreateArgs(RenderOptions{}) if n := strings.Count(strings.Join(args, " "), "--network "); n != 1 { t.Fatalf("docker create accepts one --network, got %d in %v", n, args) } connects := c.NetworkConnectArgs(RenderOptions{}) if len(connects) != 1 { t.Fatalf("expected 1 network connect, got %d", len(connects)) } joined := strings.Join(connects[0], " ") if !strings.Contains(joined, "network connect --alias app-b b app") { t.Errorf("unexpected connect args: %s", joined) } if strings.Contains(joined, "--ip ") { t.Errorf("static IP must not be applied unless requested: %s", joined) } withIP := strings.Join(c.NetworkConnectArgs(RenderOptions{KeepStaticIPs: true})[0], " ") if !strings.Contains(withIP, "--ip 10.1.2.3") { t.Errorf("static IP was requested but not applied: %s", withIP) } } func TestRenderOptionsDropAndRename(t *testing.T) { c := &Container{ Name: "db", Image: "postgres:16", Ports: []PortBinding{{ContainerPort: "5432/tcp", HostPort: "5432"}}, Mounts: []Mount{{Kind: MountVolume, Name: "pgdata", Destination: "/var/lib/postgresql/data"}}, Endpoints: []Endpoint{{Network: "backend"}}, } got := strings.Join(c.CreateArgs(RenderOptions{ NameOverride: "db-new", SkipPorts: true, SkipNetworks: true, DropMounts: map[string]bool{"/var/lib/postgresql/data": true}, }), " ") if !strings.Contains(got, "--name db-new") { t.Errorf("name override not applied: %s", got) } for _, unwanted := range []string{"--publish", "--network", "--volume"} { if strings.Contains(got, unwanted) { t.Errorf("expected %s to be dropped: %s", unwanted, got) } } } // TestCgroupnsPrivateIsNotCarried guards a cross-host hazard: "private" is // simply what a cgroup v2 host reports, and passing it explicitly makes the // create fail on a target whose kernel only has cgroup v1. func TestCgroupnsPrivateIsNotCarried(t *testing.T) { private := &Container{Name: "a", Image: "img", CgroupnsMode: "private"} if got := strings.Join(private.CreateArgs(RenderOptions{}), " "); strings.Contains(got, "--cgroupns") { t.Errorf("the default cgroup namespace must not be pinned: %s", got) } host := &Container{Name: "a", Image: "img", CgroupnsMode: "host"} if got := strings.Join(host.CreateArgs(RenderOptions{}), " "); !strings.Contains(got, "--cgroupns host") { t.Errorf("an explicit host cgroup namespace must be carried across: %s", got) } } func TestAutoRemoveIsNeverReapplied(t *testing.T) { c := &Container{Name: "job", Image: "busybox", AutoRemove: true} if strings.Contains(strings.Join(c.CreateArgs(RenderOptions{}), " "), "--rm") { t.Error("--rm must not be reapplied; the migrated container would delete itself") } } func TestVolumeAndNetworkCreateArgs(t *testing.T) { v := Volume{ Name: "pgdata", Driver: "local", DriverOpts: map[string]string{"type": "nfs", "device": ":/exports/pg"}, Labels: map[string]string{"app": "shop", "com.docker.compose.project": "x"}, } got := strings.Join(v.CreateArgs(), " ") for _, want := range []string{"volume create", "--opt device=:/exports/pg", "--opt type=nfs", "--label app=shop", "pgdata"} { if !strings.Contains(got, want) { t.Errorf("volume args missing %q: %s", want, got) } } if strings.Contains(got, "--driver local") { t.Errorf("the default driver should be omitted: %s", got) } if strings.Contains(got, "compose.project") { t.Errorf("compose label must not be reapplied: %s", got) } n := Network{ Name: "backend", Driver: "bridge", Internal: true, Attachable: true, IPAMPools: []IPAMPool{{Subnet: "172.28.0.0/16", Gateway: "172.28.0.1"}}, Options: map[string]string{"com.docker.network.bridge.name": "br-backend"}, } gotNet := strings.Join(n.CreateArgs(), " ") for _, want := range []string{"network create", "--driver bridge", "--internal", "--attachable", "--subnet 172.28.0.0/16", "--gateway 172.28.0.1", "backend"} { if !strings.Contains(gotNet, want) { t.Errorf("network args missing %q: %s", want, gotNet) } } } func TestPortBindingString(t *testing.T) { cases := []struct { in PortBinding want string }{ {PortBinding{ContainerPort: "80/tcp", HostPort: "8080"}, "8080:80"}, {PortBinding{ContainerPort: "80/tcp", HostIP: "0.0.0.0", HostPort: "80"}, "80:80"}, {PortBinding{ContainerPort: "53/udp", HostIP: "127.0.0.1", HostPort: "5353"}, "127.0.0.1:5353:53/udp"}, } for _, c := range cases { if got := c.in.String(); got != c.want { t.Errorf("PortBinding%+v = %q, want %q", c.in, got, c.want) } } }