Files
werkdock/internal/api/api_test.go
miandmhoennig f9c1ae835d Api socket (#5)
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #5
2026-09-10 22:09:58 +02:00

397 lines
14 KiB
Go

package api
import (
"bytes"
"encoding/binary"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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)
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)
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{})
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{})
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"})
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)
}
}
}