Files
werkdock/internal/api/api_test.go
mhoennigandClaude Opus 5 4684693a74
werkator build succeeded after 00:52
Messaging: NATS, RabbitMQ and Mosquitto, each measured
Three servers, three lessons the quirk table could not express before.

NATS was the easy one: nats-server takes --port, and the flag wins over
the port in the config the image ships.

RabbitMQ reads RABBITMQ_NODE_PORT, but the port it is given is not the
only one it derives from it — the Erlang distribution port is that plus
20000. A node port above 45535 makes it refuse to boot, and the kernel's
ephemeral range, which freePort draws from, starts at 32768: more than
half of werkdock's choices would have been wrong. A quirk can now cap
what werkdock picks, and werkdock looks for a free port itself below the
cap. A requested port above it is refused with the reason rather than
handed to a server that dies in an Erlang stack trace.

Mosquitto cannot be moved at all. Its port comes from the config the
image ships, whose explicit listener switches off the default one that
-p sets; measured, the flag is simply ignored. Its entry therefore names
a user and no port knob — and it is still worth having, because without
the user mosquitto does not start (it drops privileges itself and cannot
setgroups in a user namespace), and with the entry werkdock reports 1883
unmoved instead of a port nothing answers on.

All five tests for these are in werkprobe, including queue and topic
delivery through RabbitMQ and a topic through MQTT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:01:15 +02:00

476 lines
17 KiB
Go

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, env, _, err := publishPorts(map[string]struct{}{"5432/tcp": {}}, nil, pg, nil)
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, env, _, err = publishPorts(nil, map[string][]portBinding{"5432/tcp": {{HostPort: "15432"}}}, pg, nil)
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, env, _, err = publishPorts(map[string]struct{}{"8080/tcp": {}}, nil, quirk{}, nil)
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)
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"},
{"&timestamps=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, env, args, err := publishPorts(map[string]struct{}{"6379/tcp": {}}, nil, redis, command)
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, _, args, err = publishPorts(map[string]struct{}{"6379/tcp": {}}, nil, redis, []string{"sh", "-c", "sleep 1"})
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, env, _, err := publishPorts(map[string]struct{}{"5672/tcp": {}}, nil, rabbit, nil)
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)
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 cannot be moved at all — its shipped config declares its
// listeners, which turns off the default one -p would set — so it keeps
// its own port, and werkdock must not pretend otherwise.
func TestAnImageThatCannotBeMovedKeepsItsPort(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)
}
ports, env, args, err := publishPorts(map[string]struct{}{"1883/tcp": {}}, nil, mosquitto, nil)
if err != nil {
t.Fatal(err)
}
if ports[0].Host != 1883 || len(env) != 0 || len(args) != 0 {
t.Errorf("werkdock claimed to move an image it cannot move: %+v %v %v", ports, env, args)
}
}