397 lines
14 KiB
Go
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"},
|
|
{"×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)
|
|
}
|
|
}
|
|
}
|