253 lines
8.9 KiB
Go
253 lines
8.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|