package api import ( "bytes" "encoding/binary" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "werkdock/internal/engine" "werkdock/internal/instance" "werkdock/internal/store" ) func testServer(t *testing.T) *Server { t.Helper() home := t.TempDir() dir := filepath.Join(home, "images", "postgres", "16-alpine") if err := os.MkdirAll(filepath.Join(dir, "rootfs", "etc"), 0o755); err != nil { t.Fatal(err) } passwd := "root:x:0:0:root:/root:/bin/sh\npostgres:x:70:70::/var/lib/postgresql:/bin/sh\n" if err := os.WriteFile(filepath.Join(dir, "rootfs", "etc", "passwd"), []byte(passwd), 0o644); err != nil { t.Fatal(err) } meta := store.ImageMeta{ Name: "postgres:16-alpine", Architecture: "amd64", OS: "linux", Config: store.ImageConfig{ Env: []string{"PATH=/usr/bin", "PG_MAJOR=16"}, Entrypoint: []string{"docker-entrypoint.sh"}, Cmd: []string{"postgres"}, Volumes: map[string]struct{}{"/var/lib/postgresql/data": {}}, }, } data, _ := json.Marshal(meta) if err := os.WriteFile(filepath.Join(dir, "image.json"), data, 0o644); err != nil { t.Fatal(err) } st := store.Store{Root: home} return &Server{Store: st, Manager: instance.Manager{Root: home}, Version: "test"} } func do(t *testing.T, s *Server, method, path, body string) *httptest.ResponseRecorder { t.Helper() var r *http.Request if body == "" { r = httptest.NewRequest(method, path, nil) } else { r = httptest.NewRequest(method, path, strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") } w := httptest.NewRecorder() s.Handler().ServeHTTP(w, r) return w } func decode(t *testing.T, w *httptest.ResponseRecorder) map[string]interface{} { t.Helper() var out map[string]interface{} if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { t.Fatalf("body %q: %v", w.Body.String(), err) } return out } // The version prefix every docker client sends is not part of a route. func TestVersionPrefixIsStripped(t *testing.T) { s := testServer(t) for _, path := range []string{"/_ping", "/v1.32/_ping", "/v1.41/_ping"} { if w := do(t, s, "GET", path, ""); w.Code != http.StatusOK || w.Body.String() != "OK" { t.Errorf("%s: %d %q", path, w.Code, w.Body.String()) } } } // Testcontainers refuses a server below Docker 1.6.0, so the reported // engine version must not be werkdock's own 0.x. func TestVersionReportsAnEngineVersionAClientAccepts(t *testing.T) { s := testServer(t) body := decode(t, do(t, s, "GET", "/v1.32/version", "")) if body["Version"] != EngineVersion || body["ApiVersion"] != APIVersion { t.Errorf("version: %v", body) } major := strings.Split(EngineVersion, ".")[0] if major < "2" { t.Errorf("engine version %q would fail a client's minimum check", EngineVersion) } components, _ := body["Components"].([]interface{}) if len(components) == 0 { t.Fatal("no components") } first, _ := components[0].(map[string]interface{}) if first["Version"] != "test" { t.Errorf("werkdock's own version is not reported: %v", first) } info := decode(t, do(t, s, "GET", "/info", "")) if os, _ := info["OperatingSystem"].(string); !strings.Contains(os, "test") { t.Errorf("info hides werkdock's version: %v", info["OperatingSystem"]) } } // Everything outside the subset says why, and says it as 501. func TestUnknownEndpointsExplainThemselves(t *testing.T) { s := testServer(t) for _, path := range []string{"/v1.32/build", "/v1.32/networks/create", "/v1.32/swarm/init"} { w := do(t, s, "POST", path, "{}") if w.Code != http.StatusNotImplemented { t.Errorf("%s: %d", path, w.Code) continue } if msg, _ := decode(t, w)["message"].(string); !strings.Contains(msg, "not part of it") { t.Errorf("%s: %q", path, msg) } } } // A missing image must be 404, because that is what makes a client pull. func TestImageInspectIs404WhenMissing(t *testing.T) { s := testServer(t) if w := do(t, s, "GET", "/v1.32/images/redis:7/json", ""); w.Code != http.StatusNotFound { t.Errorf("missing image: %d", w.Code) } w := do(t, s, "GET", "/v1.32/images/postgres:16-alpine/json", "") if w.Code != http.StatusOK { t.Fatalf("present image: %d %s", w.Code, w.Body) } body := decode(t, w) id, _ := body["Id"].(string) if !strings.HasPrefix(id, "sha256:") || len(id) != len("sha256:")+64 { t.Errorf("image id %q is not docker-shaped", id) } // the id a client is given must resolve again if w := do(t, s, "GET", "/v1.32/images/"+id+"/json", ""); w.Code != http.StatusOK { t.Errorf("id lookup: %d", w.Code) } } // create, inspect, list, filter — without starting anything. func TestCreateInspectAndFilter(t *testing.T) { s := testServer(t) create := `{"Image":"postgres:16-alpine","Env":["POSTGRES_PASSWORD=x"],"Cmd":["postgres","-c","fsync=off"], "ExposedPorts":{"5432/tcp":{}},"Labels":{"org.testcontainers":"true","session":"abc"}, "HostConfig":{"PortBindings":{"5432/tcp":[{"HostIp":"","HostPort":""}]},"Binds":[],"NetworkMode":"bridge"}}` w := do(t, s, "POST", "/v1.32/containers/create", create) if w.Code != http.StatusCreated { t.Fatalf("create: %d %s", w.Code, w.Body) } id, _ := decode(t, w)["Id"].(string) if len(id) != 64 { t.Fatalf("id %q", id) } body := decode(t, do(t, s, "GET", "/v1.32/containers/"+id[:12]+"/json", "")) net, _ := body["NetworkSettings"].(map[string]interface{}) ports, _ := net["Ports"].(map[string]interface{}) bindings, _ := ports["5432/tcp"].([]interface{}) if len(bindings) != 1 { t.Fatalf("no port reported: %v", ports) } first, _ := bindings[0].(map[string]interface{}) hostPort, _ := first["HostPort"].(string) if hostPort == "" || hostPort == "5432" { t.Errorf("postgres must be moved off its own port, got %q", hostPort) } // the service is told where to listen config, _ := body["Config"].(map[string]interface{}) env, _ := config["Env"].([]interface{}) found := false for _, e := range env { if e == "PGPORT="+hostPort { found = true } } if !found { t.Errorf("PGPORT=%s not in the environment: %v", hostPort, env) } if user, _ := config["User"].(string); user != "70:70" { t.Errorf("an image that drops privileges itself must start as its own user, got %q", user) } // created instances are hidden unless all=true, and the label filter works if list := listContainers(t, s, "/v1.32/containers/json"); len(list) != 0 { t.Errorf("a created instance is not running: %v", list) } all := listContainers(t, s, `/v1.32/containers/json?all=true&filters={"label":["org.testcontainers=true"]}`) if len(all) != 1 { t.Fatalf("label filter: %v", all) } none := listContainers(t, s, `/v1.32/containers/json?all=true&filters={"label":["org.testcontainers=nope"]}`) if len(none) != 0 { t.Errorf("wrong label still matched: %v", none) } names, _ := all[0]["Names"].([]interface{}) if len(names) != 1 || !strings.HasPrefix(names[0].(string), "/") { t.Errorf("names are docker-shaped with a leading slash: %v", names) } if w := do(t, s, "DELETE", "/v1.32/containers/"+id, ""); w.Code != http.StatusNoContent { t.Errorf("delete: %d %s", w.Code, w.Body) } } func listContainers(t *testing.T, s *Server, path string) []map[string]interface{} { t.Helper() w := do(t, s, "GET", path, "") if w.Code != http.StatusOK { t.Fatalf("%s: %d %s", path, w.Code, w.Body) } var out []map[string]interface{} if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { t.Fatalf("%s: %v", path, err) } return out } func TestCreateRefusesWhatWerkdockCannotPromise(t *testing.T) { s := testServer(t) for _, tt := range []struct{ body, want string }{ {`{"Image":"postgres:16-alpine","HostConfig":{"Privileged":true}}`, "privileged"}, {`{"Image":"postgres:16-alpine","HostConfig":{"NetworkMode":"container:x"}}`, "network mode"}, {`{"Image":"nosuch:1","HostConfig":{}}`, "no such image"}, } { w := do(t, s, "POST", "/v1.32/containers/create", tt.body) if w.Code == http.StatusCreated { t.Errorf("%s: created", tt.body) continue } if msg, _ := decode(t, w)["message"].(string); !strings.Contains(msg, tt.want) { t.Errorf("%s: %q", tt.body, msg) } } } func TestPublishPortsMovesWhatItCanAndIsHonestOtherwise(t *testing.T) { pg := quirkFor("docker.io/library/postgres:16-alpine") if pg.User != "postgres" { t.Fatalf("quirk not found by qualified name: %+v", pg) } ports, m, err := publishPorts(map[string]struct{}{"5432/tcp": {}}, nil, pg, nil, nil) env := m.Env if err != nil { t.Fatal(err) } if len(ports) != 1 || ports[0].Host == 5432 || ports[0].Container != 5432 { t.Errorf("free port not chosen: %+v", ports) } if env["PGPORT"] == "" { t.Errorf("service not told: %v", env) } // a requested host port is honoured for an image that can be moved ports, m, err = publishPorts(nil, map[string][]portBinding{"5432/tcp": {{HostPort: "15432"}}}, pg, nil, nil) env = m.Env if err != nil || len(ports) != 1 || ports[0].Host != 15432 || env["PGPORT"] != "15432" { t.Errorf("requested port: %+v %v %v", ports, env, err) } // an unknown image keeps its port: the sandbox shares the network ports, m, err = publishPorts(map[string]struct{}{"8080/tcp": {}}, nil, quirk{}, nil, nil) env = m.Env if err != nil || len(ports) != 1 || ports[0].Host != 8080 || len(env) != 0 { t.Errorf("identity mapping: %+v %v %v", ports, env, err) } // and cannot be moved, which is said rather than faked _, _, err = publishPorts(nil, map[string][]portBinding{"8080/tcp": {{HostPort: "18080"}}}, quirk{}, nil, nil) if err == nil || !strings.Contains(err.Error(), "cannot map ports") { t.Errorf("moving an unknown image: %v", err) } } func TestBuildSpecMergesImageAndRequest(t *testing.T) { config := store.ImageConfig{ Env: []string{"PATH=/usr/bin", "PG_MAJOR=16"}, Entrypoint: []string{"docker-entrypoint.sh"}, Cmd: []string{"postgres"}, } var req createRequest req.Env = []string{"POSTGRES_PASSWORD=x"} req.Cmd = []string{"postgres", "-c", "fsync=off"} req.HostConfig.Binds = []string{"/srv/data:/data", "/srv/ro:/ro:ro"} spec, warnings, err := buildSpec(req, "/rootfs", config, quirk{PortEnv: map[int]string{5432: "PGPORT"}}, map[string]string{"PGPORT": "40000"}, nil) if err != nil || len(warnings) != 0 { t.Fatal(err, warnings) } wantCmd := []string{"docker-entrypoint.sh", "postgres", "-c", "fsync=off"} if strings.Join(spec.Command, " ") != strings.Join(wantCmd, " ") { t.Errorf("command: %q", spec.Command) } var env []string for _, e := range spec.Env { env = append(env, e.Key+"="+e.Value) } joined := strings.Join(env, ",") if !strings.Contains(joined, "PG_MAJOR=16") || !strings.Contains(joined, "POSTGRES_PASSWORD=x") || !strings.HasSuffix(joined, "PGPORT=40000") { t.Errorf("env: %v", env) } var binds []engine.Mount for _, m := range spec.Mounts { if m.Dest != "/etc/resolv.conf" { binds = append(binds, m) } } want := []engine.Mount{ {Mode: engine.MountBind, Source: "/srv/data", Dest: "/data"}, {Mode: engine.MountRoBind, Source: "/srv/ro", Dest: "/ro"}, } if len(binds) != len(want) || binds[0] != want[0] || binds[1] != want[1] { t.Errorf("binds: %+v", binds) } } // docker's stream framing: an 8-byte header per write, stream first, // length big-endian in the last four bytes. func TestFrameWriterUsesDockersFraming(t *testing.T) { var buf bytes.Buffer out := &frameWriter{w: &buf, stream: streamStdout} errs := &frameWriter{w: &buf, stream: streamStderr} if n, err := out.Write([]byte("hello\n")); n != 6 || err != nil { t.Fatalf("write: %d %v", n, err) } if _, err := errs.Write([]byte("oops\n")); err != nil { t.Fatal(err) } if n, _ := out.Write(nil); n != 0 { t.Errorf("an empty write must not emit a frame") } raw := buf.Bytes() if raw[0] != streamStdout || binary.BigEndian.Uint32(raw[4:8]) != 6 || string(raw[8:14]) != "hello\n" { t.Errorf("stdout frame: %q", raw) } rest := raw[14:] if rest[0] != streamStderr || binary.BigEndian.Uint32(rest[4:8]) != 5 || string(rest[8:13]) != "oops\n" { t.Errorf("stderr frame: %q", rest) } } // An image werkdock guesses a user for, which the image does not // define, must still start — as uid 0, with the guess reported. func TestQuirkUserThatTheImageDoesNotDefineIsOnlyAWarning(t *testing.T) { s := testServer(t) if err := os.Remove(filepath.Join(s.Store.Root, "images", "postgres", "16-alpine", "rootfs", "etc", "passwd")); err != nil { t.Fatal(err) } w := do(t, s, "POST", "/v1.32/containers/create", `{"Image":"postgres:16-alpine","HostConfig":{}}`) if w.Code != http.StatusCreated { t.Fatalf("create: %d %s", w.Code, w.Body) } body := decode(t, w) warnings, _ := body["Warnings"].([]interface{}) if len(warnings) != 1 || !strings.Contains(warnings[0].(string), "uid 0") { t.Errorf("warnings: %v", warnings) } } // A user the caller asked for and the image does not have is an error, // not a warning: that one was an instruction. func TestRequestedUserThatIsMissingIsAnError(t *testing.T) { s := testServer(t) w := do(t, s, "POST", "/v1.32/containers/create", `{"Image":"postgres:16-alpine","User":"nosuch","HostConfig":{}}`) if w.Code != http.StatusBadRequest { t.Fatalf("create: %d %s", w.Code, w.Body) } if msg, _ := decode(t, w)["message"].(string); !strings.Contains(msg, "nosuch") { t.Errorf("message: %q", msg) } } // Log parameters werkdock cannot honour are refused, not ignored: a // caller who asked for ten lines and silently got ten thousand is // worse off than one who was told no. func TestLogParametersAreRefusedRatherThanIgnored(t *testing.T) { s := testServer(t) w := do(t, s, "POST", "/v1.32/containers/create", `{"Image":"postgres:16-alpine","HostConfig":{}}`) if w.Code != http.StatusCreated { t.Fatalf("create: %d %s", w.Code, w.Body) } id, _ := decode(t, w)["Id"].(string) base := "/v1.32/containers/" + id + "/logs?stdout=true&stderr=true" for _, tt := range []struct{ query, want string }{ {"&tail=10", "tail=10 is not available"}, {"×tamps=true", "timestamps are not available"}, {"&since=1757000000", "since=1757000000 is not available"}, {"&until=1757000000", "until=1757000000 is not available"}, } { got := do(t, s, "GET", base+tt.query, "") if got.Code != http.StatusNotImplemented { t.Errorf("%s: %d, want 501", tt.query, got.Code) continue } if msg, _ := decode(t, got)["message"].(string); !strings.Contains(msg, tt.want) { t.Errorf("%s: %q", tt.query, msg) } } // what a client does send is accepted for _, query := range []string{"", "&follow=true&since=0", "&tail=all"} { if got := do(t, s, "GET", base+query, ""); got.Code != http.StatusOK { t.Errorf("%q: %d", query, got.Code) } } } // Redis reads no environment variable for its port; it takes a flag, // and only while it is still running its own program. Measured against // the real image: with REDIS_PORT set the server stayed on 6379. func TestPortMovedByAFlagRatherThanAnEnvironmentVariable(t *testing.T) { redis := quirkFor("redis:7-alpine") command := []string{"docker-entrypoint.sh", "redis-server"} ports, m, err := publishPorts(map[string]struct{}{"6379/tcp": {}}, nil, redis, command, nil) env, args := m.Env, m.Args if err != nil { t.Fatal(err) } if len(ports) != 1 || ports[0].Host == 6379 { t.Fatalf("free port not chosen: %+v", ports) } if len(env) != 0 { t.Errorf("redis does not read an environment variable for this: %v", env) } want := []string{"--port", strconv.Itoa(ports[0].Host)} if strings.Join(args, " ") != strings.Join(want, " ") { t.Errorf("args: %v, want %v", args, want) } // A command the client chose is not werkdock's to append to, so the // port cannot be moved and the identity is reported instead. ports, m, err = publishPorts(map[string]struct{}{"6379/tcp": {}}, nil, redis, []string{"sh", "-c", "sleep 1"}, nil) args = m.Args if err != nil { t.Fatal(err) } if len(args) != 0 || ports[0].Host != 6379 { t.Errorf("werkdock appended a flag to a command it did not choose: %v %+v", args, ports) } } // RabbitMQ derives its Erlang distribution port from the one it is // given, by adding 20000, so a node port above 45535 fails to boot. // The kernel's ephemeral range starts above that, which is why the cap // exists at all. func TestAPortCappedForAnImageThatDerivesASecondOne(t *testing.T) { rabbit := quirkFor("rabbitmq:4") if rabbit.MaxHostPort != 45535 { t.Fatalf("no cap on the quirk: %+v", rabbit) } ports, m, err := publishPorts(map[string]struct{}{"5672/tcp": {}}, nil, rabbit, nil, nil) env := m.Env if err != nil { t.Fatal(err) } if ports[0].Host > rabbit.MaxHostPort || ports[0].Host < 1024 { t.Errorf("chosen port %d is outside what this image can take", ports[0].Host) } if env["RABBITMQ_NODE_PORT"] != strconv.Itoa(ports[0].Host) { t.Errorf("the service was not told: %v", env) } // A port the image cannot take is refused rather than started and // left to fail with an Erlang stack trace. _, _, err = publishPorts(nil, map[string][]portBinding{"5672/tcp": {{HostPort: "56721"}}}, rabbit, nil, nil) if err == nil || !strings.Contains(err.Error(), "derives a second port") { t.Errorf("a port above the cap should be refused with the reason: %v", err) } } // Mosquitto reads its port from nowhere but the config file the image // ships, so werkdock moves it by rewriting a copy of that file — but // only while the command still names the file and the client has not // bound one of their own over it. Both listeners live in the same file. func TestPortMovedByRewritingTheImagesConfigFile(t *testing.T) { mosquitto := quirkFor("eclipse-mosquitto:2") if mosquitto.User != "mosquitto" { t.Fatalf("mosquitto needs its own user to start at all: %+v", mosquitto) } command := []string{"/docker-entrypoint.sh", "/usr/sbin/mosquitto", "-c", "/mosquitto/config/mosquitto.conf"} exposed := map[string]struct{}{"1883/tcp": {}, "9883/tcp": {}} ports, m, err := publishPorts(exposed, nil, mosquitto, command, nil) if err != nil { t.Fatal(err) } if len(ports) != 2 || ports[0].Host == 1883 || ports[1].Host == 9883 || ports[0].Host == ports[1].Host { t.Fatalf("free ports not chosen: %+v", ports) } if len(m.Env) != 0 || len(m.Args) != 0 || len(m.Files) != 2 { t.Fatalf("mosquitto is moved by its config file alone: %+v", m) } rootfs := t.TempDir() if err := os.MkdirAll(filepath.Join(rootfs, "mosquitto/config"), 0o755); err != nil { t.Fatal(err) } shipped := "# the image's own\nlistener 1883\nallow_anonymous true\n\nlistener 9883\nprotocol http_api\n" if err := os.WriteFile(filepath.Join(rootfs, "mosquitto/config/mosquitto.conf"), []byte(shipped), 0o644); err != nil { t.Fatal(err) } files, err := renderPortFiles(rootfs, m.Files) if err != nil { t.Fatal(err) } want := strings.NewReplacer("listener 1883", "listener "+strconv.Itoa(ports[0].Host), "listener 9883", "listener "+strconv.Itoa(ports[1].Host)).Replace(shipped) if len(files) != 1 || files[0].Dest != "/mosquitto/config/mosquitto.conf" || string(files[0].Content) != want { t.Errorf("rendered:\n%s\nwant:\n%s", files[0].Content, want) } dir := t.TempDir() mounts, err := placePortFiles(dir, files) if err != nil { t.Fatal(err) } if len(mounts) != 1 || mounts[0].Mode != engine.MountRoBind || mounts[0].Dest != "/mosquitto/config/mosquitto.conf" { t.Fatalf("mounts: %+v", mounts) } if placed, _ := os.ReadFile(mounts[0].Source); string(placed) != want { t.Errorf("placed file differs from the rendered one") } if unchanged, _ := os.ReadFile(filepath.Join(rootfs, "mosquitto/config/mosquitto.conf")); string(unchanged) != shipped { t.Errorf("the image's own file was touched") } // An image whose file no longer has the line is refused with the // reason, not started on a port werkdock only claimed. if err := os.WriteFile(filepath.Join(rootfs, "mosquitto/config/mosquitto.conf"), []byte("listener 8883\n"), 0o644); err != nil { t.Fatal(err) } if _, err := renderPortFiles(rootfs, m.Files); err == nil || !strings.Contains(err.Error(), "listener 1883") { t.Errorf("a missing line should be refused with the line named, got %v", err) } // A command that no longer names the file, or a bind of the // client's own over it, means the rewritten copy would not be what // the server reads: the port is reported unmoved. ports, m, err = publishPorts(map[string]struct{}{"1883/tcp": {}}, nil, mosquitto, []string{"mosquitto", "-c", "/other.conf"}, nil) if err != nil { t.Fatal(err) } if ports[0].Host != 1883 || len(m.Files) != 0 { t.Errorf("moved a port through a file the command does not read: %+v %+v", ports, m) } ports, m, err = publishPorts(map[string]struct{}{"1883/tcp": {}}, nil, mosquitto, command, []string{"/mosquitto/config/mosquitto.conf"}) if err != nil { t.Fatal(err) } if ports[0].Host != 1883 || len(m.Files) != 0 { t.Errorf("overrode a config file the client supplied: %+v %+v", ports, m) } }