Files
galaxy-game/rtmanager/internal/adapters/docker/client_test.go
T
2026-04-28 20:39:18 +02:00

562 lines
19 KiB
Go

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)
}