package docker import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "sync/atomic" "testing" "time" dockerclient "github.com/docker/docker/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "galaxy/rtmanager/internal/ports" ) // newTestClient wires an httptest.Server backed Docker SDK client to our // adapter. The handler is invoked for every Docker API request issued // during the test; tests assert on path and method to route the // response. func newTestClient(t *testing.T, handler http.HandlerFunc) *Client { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) docker, err := dockerclient.NewClientWithOpts( dockerclient.WithHost(server.URL), dockerclient.WithHTTPClient(server.Client()), dockerclient.WithVersion("1.45"), ) require.NoError(t, err) t.Cleanup(func() { _ = docker.Close() }) client, err := NewClient(Config{ Docker: docker, LogDriver: "json-file", LogOpts: "max-size=1m,max-file=3", Clock: func() time.Time { return time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) }, }) require.NoError(t, err) return client } func writeJSON(t *testing.T, w http.ResponseWriter, status int, body any) { t.Helper() w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) require.NoError(t, json.NewEncoder(w).Encode(body)) } func writeNotFound(t *testing.T, w http.ResponseWriter, msg string) { t.Helper() writeJSON(t, w, http.StatusNotFound, map[string]string{"message": msg}) } // Docker SDK uses /v1.45 prefix when client is pinned to API 1.45. func dockerPath(suffix string) string { return "/v1.45" + suffix } func TestNewClientValidatesConfig(t *testing.T) { t.Run("nil docker client", func(t *testing.T) { _, err := NewClient(Config{LogDriver: "json-file"}) require.Error(t, err) assert.Contains(t, err.Error(), "nil docker client") }) t.Run("empty log driver", func(t *testing.T) { docker, err := dockerclient.NewClientWithOpts(dockerclient.WithHost("tcp://127.0.0.1:65535")) require.NoError(t, err) t.Cleanup(func() { _ = docker.Close() }) _, err = NewClient(Config{Docker: docker, LogDriver: " "}) require.Error(t, err) assert.Contains(t, err.Error(), "log driver") }) } func TestEnsureNetwork(t *testing.T) { t.Run("present", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) require.Equal(t, dockerPath("/networks/galaxy-net"), r.URL.Path) writeJSON(t, w, http.StatusOK, map[string]any{"Id": "net-1", "Name": "galaxy-net"}) }) require.NoError(t, client.EnsureNetwork(context.Background(), "galaxy-net")) }) t.Run("missing", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeNotFound(t, w, "no such network") }) err := client.EnsureNetwork(context.Background(), "missing") require.Error(t, err) assert.ErrorIs(t, err, ports.ErrNetworkMissing) }) t.Run("transport error", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) }) err := client.EnsureNetwork(context.Background(), "x") require.Error(t, err) assert.NotErrorIs(t, err, ports.ErrNetworkMissing) }) } func TestInspectImage(t *testing.T) { t.Run("present", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) require.Equal(t, dockerPath("/images/galaxy/game:test/json"), r.URL.Path) writeJSON(t, w, http.StatusOK, map[string]any{ "Id": "sha256:abc", "Config": map[string]any{ "Labels": map[string]string{ "com.galaxy.cpu_quota": "1.0", "com.galaxy.memory": "512m", "com.galaxy.pids_limit": "512", }, }, }) }) got, err := client.InspectImage(context.Background(), "galaxy/game:test") require.NoError(t, err) assert.Equal(t, "galaxy/game:test", got.Ref) assert.Equal(t, "1.0", got.Labels["com.galaxy.cpu_quota"]) assert.Equal(t, "512m", got.Labels["com.galaxy.memory"]) }) t.Run("not found", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeNotFound(t, w, "no such image") }) _, err := client.InspectImage(context.Background(), "galaxy/missing:tag") require.Error(t, err) assert.ErrorIs(t, err, ports.ErrImageNotFound) }) } func TestInspectContainer(t *testing.T) { t.Run("present", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) require.Equal(t, dockerPath("/containers/cont-1/json"), r.URL.Path) writeJSON(t, w, http.StatusOK, map[string]any{ "Id": "cont-1", "RestartCount": 2, "State": map[string]any{ "Status": "running", "OOMKilled": false, "ExitCode": 0, "StartedAt": "2026-04-27T11:00:00.5Z", "FinishedAt": "0001-01-01T00:00:00Z", "Health": map[string]any{"Status": "healthy"}, }, "Config": map[string]any{ "Image": "galaxy/game:test", "Hostname": "galaxy-game-game-1", "Labels": map[string]string{ "com.galaxy.owner": "rtmanager", "com.galaxy.game_id": "game-1", }, }, }) }) got, err := client.InspectContainer(context.Background(), "cont-1") require.NoError(t, err) assert.Equal(t, "cont-1", got.ID) assert.Equal(t, 2, got.RestartCount) assert.Equal(t, "running", got.Status) assert.Equal(t, "healthy", got.Health) assert.Equal(t, "galaxy/game:test", got.ImageRef) assert.Equal(t, "galaxy-game-game-1", got.Hostname) assert.Equal(t, "rtmanager", got.Labels["com.galaxy.owner"]) assert.False(t, got.StartedAt.IsZero()) }) t.Run("not found", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeNotFound(t, w, "no such container") }) _, err := client.InspectContainer(context.Background(), "missing") require.Error(t, err) assert.ErrorIs(t, err, ports.ErrContainerNotFound) }) } func TestPullImagePolicies(t *testing.T) { t.Run("if_missing/found skips pull", func(t *testing.T) { hits := struct { inspect atomic.Int32 pull atomic.Int32 }{} client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/json") && r.Method == http.MethodGet: hits.inspect.Add(1) writeJSON(t, w, http.StatusOK, map[string]any{"Id": "sha256:x"}) case strings.Contains(r.URL.Path, "/images/create"): hits.pull.Add(1) w.WriteHeader(http.StatusOK) default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } }) require.NoError(t, client.PullImage(context.Background(), "alpine:3.21", ports.PullPolicyIfMissing)) assert.Equal(t, int32(1), hits.inspect.Load()) assert.Equal(t, int32(0), hits.pull.Load()) }) t.Run("if_missing/absent triggers pull", func(t *testing.T) { hits := struct { inspect atomic.Int32 pull atomic.Int32 }{} client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/json") && r.Method == http.MethodGet: hits.inspect.Add(1) writeNotFound(t, w, "no such image") case strings.Contains(r.URL.Path, "/images/create"): hits.pull.Add(1) w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, `{"status":"Pulling..."}`+"\n"+`{"status":"Done"}`+"\n") default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } }) require.NoError(t, client.PullImage(context.Background(), "alpine:3.21", ports.PullPolicyIfMissing)) assert.Equal(t, int32(1), hits.inspect.Load()) assert.Equal(t, int32(1), hits.pull.Load()) }) t.Run("always pulls regardless of cache", func(t *testing.T) { var pullCount atomic.Int32 client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Contains(t, r.URL.Path, "/images/create") pullCount.Add(1) w.WriteHeader(http.StatusOK) }) require.NoError(t, client.PullImage(context.Background(), "alpine:3.21", ports.PullPolicyAlways)) assert.Equal(t, int32(1), pullCount.Load()) }) t.Run("never with absent image", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) writeNotFound(t, w, "no such image") }) err := client.PullImage(context.Background(), "alpine:3.21", ports.PullPolicyNever) require.Error(t, err) assert.ErrorIs(t, err, ports.ErrImageNotFound) }) t.Run("never with present image", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) writeJSON(t, w, http.StatusOK, map[string]any{"Id": "x"}) }) require.NoError(t, client.PullImage(context.Background(), "alpine:3.21", ports.PullPolicyNever)) }) t.Run("unknown policy", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("must not call docker on unknown policy") }) err := client.PullImage(context.Background(), "alpine:3.21", ports.PullPolicy("invalid")) require.Error(t, err) }) } func TestRunHappyPath(t *testing.T) { calls := struct { create atomic.Int32 start atomic.Int32 remove atomic.Int32 }{} client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/containers/create"): calls.create.Add(1) require.Equal(t, "galaxy-game-game-1", r.URL.Query().Get("name")) writeJSON(t, w, http.StatusCreated, map[string]any{"Id": "cont-new", "Warnings": []string{}}) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/start"): calls.start.Add(1) require.Equal(t, dockerPath("/containers/cont-new/start"), r.URL.Path) w.WriteHeader(http.StatusNoContent) case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, dockerPath("/containers/")): calls.remove.Add(1) w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) } }) result, err := client.Run(context.Background(), ports.RunSpec{ Name: "galaxy-game-game-1", Image: "galaxy/game:test", Hostname: "galaxy-game-game-1", Network: "galaxy-net", Env: map[string]string{ "GAME_STATE_PATH": "/var/lib/galaxy-game", "STORAGE_PATH": "/var/lib/galaxy-game", }, Labels: map[string]string{"com.galaxy.owner": "rtmanager"}, LogDriver: "json-file", BindMounts: []ports.BindMount{ {HostPath: "/var/lib/galaxy/games/game-1", MountPath: "/var/lib/galaxy-game"}, }, CPUQuota: 1.0, Memory: "512m", PIDsLimit: 512, }) require.NoError(t, err) assert.Equal(t, "cont-new", result.ContainerID) assert.Equal(t, "http://galaxy-game-game-1:8080", result.EngineEndpoint) assert.False(t, result.StartedAt.IsZero()) assert.Equal(t, int32(1), calls.create.Load()) assert.Equal(t, int32(1), calls.start.Load()) assert.Equal(t, int32(0), calls.remove.Load()) } func TestRunStartFailureRemovesContainer(t *testing.T) { calls := struct { create atomic.Int32 start atomic.Int32 remove atomic.Int32 }{} client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/containers/create"): calls.create.Add(1) writeJSON(t, w, http.StatusCreated, map[string]any{"Id": "cont-x"}) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/start"): calls.start.Add(1) http.Error(w, `{"message":"insufficient host resources"}`, http.StatusInternalServerError) case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, dockerPath("/containers/cont-x")): calls.remove.Add(1) require.Equal(t, "1", r.URL.Query().Get("force")) w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) } }) _, err := client.Run(context.Background(), ports.RunSpec{ Name: "x", Image: "img", Hostname: "x", Network: "n", LogDriver: "json-file", CPUQuota: 1.0, Memory: "64m", PIDsLimit: 64, }) require.Error(t, err) assert.Equal(t, int32(1), calls.create.Load()) assert.Equal(t, int32(1), calls.start.Load()) assert.Equal(t, int32(1), calls.remove.Load(), "adapter must roll back the partial container") } func TestRunRejectsInvalidSpec(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("must not contact docker on invalid spec") }) _, err := client.Run(context.Background(), ports.RunSpec{Name: "x"}) require.Error(t, err) assert.Contains(t, err.Error(), "image must not be empty") } func TestStop(t *testing.T) { t.Run("graceful stop", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPost, r.Method) require.Equal(t, dockerPath("/containers/cont-1/stop"), r.URL.Path) require.Equal(t, "30", r.URL.Query().Get("t")) w.WriteHeader(http.StatusNoContent) }) require.NoError(t, client.Stop(context.Background(), "cont-1", 30*time.Second)) }) t.Run("missing container", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeNotFound(t, w, "no such container") }) err := client.Stop(context.Background(), "missing", 30*time.Second) assert.ErrorIs(t, err, ports.ErrContainerNotFound) }) t.Run("negative timeout normalised to zero", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "0", r.URL.Query().Get("t")) w.WriteHeader(http.StatusNoContent) }) require.NoError(t, client.Stop(context.Background(), "x", -5*time.Second)) }) } func TestRemoveIsIdempotent(t *testing.T) { t.Run("present", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodDelete, r.Method) w.WriteHeader(http.StatusNoContent) }) require.NoError(t, client.Remove(context.Background(), "cont-1")) }) t.Run("missing", func(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeNotFound(t, w, "no such container") }) require.NoError(t, client.Remove(context.Background(), "missing")) }) } func TestListAppliesLabelFilter(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) require.Equal(t, dockerPath("/containers/json"), r.URL.Path) require.Equal(t, "1", r.URL.Query().Get("all")) filtersRaw := r.URL.Query().Get("filters") require.NotEmpty(t, filtersRaw) var args map[string]map[string]bool require.NoError(t, json.Unmarshal([]byte(filtersRaw), &args)) require.True(t, args["label"]["com.galaxy.owner=rtmanager"]) writeJSON(t, w, http.StatusOK, []map[string]any{ { "Id": "cont-a", "Image": "galaxy/game:1.2.3", "Names": []string{"/galaxy-game-game-1"}, "Labels": map[string]string{"com.galaxy.owner": "rtmanager"}, "State": "running", "Created": int64(1700000000), }, }) }) got, err := client.List(context.Background(), ports.ListFilter{ Labels: map[string]string{"com.galaxy.owner": "rtmanager"}, }) require.NoError(t, err) require.Len(t, got, 1) assert.Equal(t, "cont-a", got[0].ID) assert.Equal(t, "galaxy/game:1.2.3", got[0].ImageRef) assert.Equal(t, "galaxy-game-game-1", got[0].Hostname) assert.Equal(t, "running", got[0].Status) assert.False(t, got[0].StartedAt.IsZero()) assert.Equal(t, "rtmanager", got[0].Labels["com.galaxy.owner"]) } func TestEventsListenDecodesContainerEvents(t *testing.T) { mu := make(chan struct{}) client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) require.Equal(t, dockerPath("/events"), r.URL.Path) flusher, ok := w.(http.Flusher) require.True(t, ok) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) flusher.Flush() // Container start event writeEvent(t, w, "container", "start", "cont-1", map[string]string{ "image": "galaxy/game:1.2.3", "name": "galaxy-game-game-1", "com.galaxy.game_id": "game-1", }, time.Now()) flusher.Flush() // Container die event with exit code 137 writeEvent(t, w, "container", "die", "cont-1", map[string]string{ "exitCode": "137", }, time.Now()) flusher.Flush() // Image event must be filtered out by adapter writeEvent(t, w, "image", "pull", "img", nil, time.Now()) flusher.Flush() <-mu }) defer close(mu) ctx, cancel := context.WithCancel(context.Background()) defer cancel() events, _, err := client.EventsListen(ctx) require.NoError(t, err) got := []ports.DockerEvent{} deadline := time.After(2 * time.Second) for len(got) < 2 { select { case ev, ok := <-events: if !ok { t.Fatalf("events channel closed; got %d events", len(got)) } got = append(got, ev) case <-deadline: t.Fatalf("did not receive expected events; have %d", len(got)) } } require.Len(t, got, 2) assert.Equal(t, "start", got[0].Action) assert.Equal(t, "cont-1", got[0].ContainerID) assert.Equal(t, "game-1", got[0].Labels["com.galaxy.game_id"]) assert.Equal(t, "die", got[1].Action) assert.Equal(t, 137, got[1].ExitCode) } func writeEvent(t *testing.T, w io.Writer, eventType, action, id string, attributes map[string]string, when time.Time) { t.Helper() payload := map[string]any{ "Type": eventType, "Action": action, "Actor": map[string]any{"ID": id, "Attributes": attributes}, "time": when.Unix(), "timeNano": when.UnixNano(), } data, err := json.Marshal(payload) require.NoError(t, err) _, err = fmt.Fprintln(w, string(data)) require.NoError(t, err) } // Sanity: parsing helpers. func TestParseLogOpts(t *testing.T) { got := parseLogOpts("max-size=1m,max-file=3, ,empty=,=novalue") assert.Equal(t, "1m", got["max-size"]) assert.Equal(t, "3", got["max-file"]) assert.Equal(t, "", got["empty"]) _, hasNovalue := got["=novalue"] assert.False(t, hasNovalue) } func TestParseDockerTime(t *testing.T) { assert.True(t, parseDockerTime("").IsZero()) assert.True(t, parseDockerTime("not-a-date").IsZero()) parsed := parseDockerTime("2026-04-27T11:00:00.5Z") assert.False(t, parsed.IsZero()) assert.Equal(t, time.UTC, parsed.Location()) } func TestEnvMapToSliceDeterministicLength(t *testing.T) { got := envMapToSlice(map[string]string{"A": "1", "B": "2"}) assert.Len(t, got, 2) for _, kv := range got { assert.Contains(t, []string{"A=1", "B=2"}, kv) } assert.Nil(t, envMapToSlice(nil)) } // Compile-time sanity: make sure errors.Is wiring stays intact. func TestSentinelErrorsAreDistinct(t *testing.T) { require.True(t, errors.Is(ports.ErrNetworkMissing, ports.ErrNetworkMissing)) require.False(t, errors.Is(ports.ErrNetworkMissing, ports.ErrImageNotFound)) } func TestURLPathEscapingForCharacters(t *testing.T) { // Ensure the SDK URL path encodes special characters; the adapter // passes raw inputs through and lets the SDK escape. encoded := url.PathEscape("game-1") assert.Equal(t, "game-1", encoded) }