werkator build succeeded after 00:12
0.9.0 listed mosquitto as the image werkdock cannot move, and that was true of everything mosquitto offers: -p is ignored once a config file declares a listener (the manual says so, and it was measured), no environment variable names the port, and the config file expands none. What mosquitto does read is the config file — and a config file is the one thing werkdock can replace that a flag or a variable cannot reach. A quirk can now name a PortFile: which file the image ships carries the port and how the line reads. Werkdock copies that file into the instance directory with the line rewritten to the chosen port and binds the copy read-only over the image's original, which is never touched. Both of mosquitto's listeners live in the same file, so the dashboard's HTTP API on 9883 moves along with MQTT on 1883. Measured with 2.1.2 through the API socket: two instances at once, each on its own pair of ports, each accepting an anonymous client and answering on its dashboard, 1883 left free. The rewritten copy applies only while the command still names the file and the client has not bound a file of their own over that path; either way the copy would not be what the server reads, and the port is then reported unmoved. An image whose file no longer has the expected line is refused at create with the line named, not started on a port werkdock only claimed. Squashed from branch mosquitto-port-file and pushed to main directly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
548 lines
20 KiB
Go
548 lines
20 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, 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)
|
|
}
|
|
}
|