feat: runtime manager

This commit is contained in:
Ilia Denisov
2026-04-28 20:39:18 +02:00
committed by GitHub
parent e0a99b346b
commit a7cee15115
289 changed files with 45660 additions and 2207 deletions
@@ -0,0 +1,493 @@
// Package docker provides the production Docker SDK adapter that
// implements `galaxy/rtmanager/internal/ports.DockerClient`. The
// adapter is the single component allowed to talk to the local Docker
// daemon; every Runtime Manager service path that needs container
// lifecycle operations goes through this surface.
//
// The adapter is intentionally narrow — it does not orchestrate, log,
// or retry. Cross-cutting concerns (lease coordination, durable state,
// notification side-effects) live in the service layer.
package docker
import (
"context"
"errors"
"fmt"
"io"
"maps"
"strings"
"sync"
"time"
cerrdefs "github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
dockerclient "github.com/docker/docker/client"
"github.com/docker/go-units"
"galaxy/rtmanager/internal/ports"
)
// EnginePort is the in-container HTTP port the engine listens on. The
// value is fixed by `rtmanager/README.md §Container Model` and by the
// engine's Dockerfile (`game/Dockerfile`); RTM never publishes the port
// to the host. Keeping the constant here lets the adapter own the URL
// shape so the start service does not have to know it.
const EnginePort = 8080
// Config groups the dependencies and per-process defaults required to
// construct a Client. The struct is value-typed so wiring code can
// build it inline without intermediate variables.
type Config struct {
// Docker stores the SDK client this adapter wraps. It must be
// non-nil; callers typically construct it via `client.NewClientWithOpts`.
Docker *dockerclient.Client
// LogDriver stores the Docker logging driver applied to every
// container the adapter creates (e.g. `json-file`).
LogDriver string
// LogOpts stores the comma-separated `key=value` driver options
// forwarded to Docker. Empty disables driver-specific options.
LogOpts string
// Clock supplies the wall-clock used for `RunResult.StartedAt`.
// Defaults to `time.Now` when nil.
Clock func() time.Time
}
// Client is the production adapter implementing `ports.DockerClient`.
// Construct it via NewClient; do not zero-initialise.
type Client struct {
docker *dockerclient.Client
logDriver string
logOpts string
clock func() time.Time
}
// NewClient constructs a Client from cfg. It returns an error if cfg
// does not carry the minimum collaborator set the adapter needs to
// function.
func NewClient(cfg Config) (*Client, error) {
if cfg.Docker == nil {
return nil, errors.New("new docker adapter: nil docker client")
}
if strings.TrimSpace(cfg.LogDriver) == "" {
return nil, errors.New("new docker adapter: log driver must not be empty")
}
clock := cfg.Clock
if clock == nil {
clock = time.Now
}
return &Client{
docker: cfg.Docker,
logDriver: cfg.LogDriver,
logOpts: cfg.LogOpts,
clock: clock,
}, nil
}
// EnsureNetwork verifies the user-defined Docker network is present.
// The adapter never creates networks; provisioning is the operator's
// job per `rtmanager/README.md §Container Model`.
func (client *Client) EnsureNetwork(ctx context.Context, name string) error {
if _, err := client.docker.NetworkInspect(ctx, name, network.InspectOptions{}); err != nil {
if cerrdefs.IsNotFound(err) {
return ports.ErrNetworkMissing
}
return fmt.Errorf("ensure network %q: %w", name, err)
}
return nil
}
// PullImage pulls ref according to policy. The pull stream is drained
// to completion because the Docker SDK only finishes the underlying
// pull when the body is consumed.
func (client *Client) PullImage(ctx context.Context, ref string, policy ports.PullPolicy) error {
if !policy.IsKnown() {
return fmt.Errorf("pull image %q: unknown pull policy %q", ref, policy)
}
switch policy {
case ports.PullPolicyAlways:
return client.runPull(ctx, ref)
case ports.PullPolicyIfMissing:
if present, err := client.imagePresent(ctx, ref); err != nil {
return err
} else if present {
return nil
}
return client.runPull(ctx, ref)
case ports.PullPolicyNever:
present, err := client.imagePresent(ctx, ref)
if err != nil {
return err
}
if !present {
return ports.ErrImageNotFound
}
return nil
default:
return fmt.Errorf("pull image %q: unsupported pull policy %q", ref, policy)
}
}
// InspectImage returns image metadata for ref. RTM only reads labels
// at start time; the broader inspect struct stays accessible for
// diagnostics.
func (client *Client) InspectImage(ctx context.Context, ref string) (ports.ImageInspect, error) {
inspect, err := client.docker.ImageInspect(ctx, ref)
if err != nil {
if cerrdefs.IsNotFound(err) {
return ports.ImageInspect{}, ports.ErrImageNotFound
}
return ports.ImageInspect{}, fmt.Errorf("inspect image %q: %w", ref, err)
}
var labels map[string]string
if inspect.Config != nil {
labels = copyStringMap(inspect.Config.Labels)
}
return ports.ImageInspect{Ref: ref, Labels: labels}, nil
}
// InspectContainer returns container metadata for containerID. The
// adapter best-effort decodes Docker timestamps; malformed values map
// to the zero time so callers do not have to defend against nil
// pointers in the SDK response.
func (client *Client) InspectContainer(ctx context.Context, containerID string) (ports.ContainerInspect, error) {
inspect, err := client.docker.ContainerInspect(ctx, containerID)
if err != nil {
if cerrdefs.IsNotFound(err) {
return ports.ContainerInspect{}, ports.ErrContainerNotFound
}
return ports.ContainerInspect{}, fmt.Errorf("inspect container %q: %w", containerID, err)
}
result := ports.ContainerInspect{ID: inspect.ID}
if inspect.ContainerJSONBase != nil {
result.RestartCount = inspect.RestartCount
if inspect.State != nil {
result.Status = string(inspect.State.Status)
result.OOMKilled = inspect.State.OOMKilled
result.ExitCode = inspect.State.ExitCode
result.StartedAt = parseDockerTime(inspect.State.StartedAt)
result.FinishedAt = parseDockerTime(inspect.State.FinishedAt)
if inspect.State.Health != nil {
result.Health = string(inspect.State.Health.Status)
}
}
}
if inspect.Config != nil {
result.ImageRef = inspect.Config.Image
result.Hostname = inspect.Config.Hostname
result.Labels = copyStringMap(inspect.Config.Labels)
}
return result, nil
}
// Run creates and starts one container according to spec. On
// `ContainerStart` failure the adapter best-effort removes the partial
// container so the start service never has to clean up after a failed
// start path.
func (client *Client) Run(ctx context.Context, spec ports.RunSpec) (ports.RunResult, error) {
if err := spec.Validate(); err != nil {
return ports.RunResult{}, fmt.Errorf("run container: %w", err)
}
memoryBytes, err := units.RAMInBytes(spec.Memory)
if err != nil {
return ports.RunResult{}, fmt.Errorf("run container %q: parse memory %q: %w", spec.Name, spec.Memory, err)
}
pidsLimit := int64(spec.PIDsLimit)
containerCfg := &container.Config{
Image: spec.Image,
Hostname: spec.Hostname,
Env: envMapToSlice(spec.Env),
Labels: copyStringMap(spec.Labels),
Cmd: append([]string(nil), spec.Cmd...),
}
hostCfg := &container.HostConfig{
Binds: bindMountsToBinds(spec.BindMounts),
LogConfig: container.LogConfig{
Type: client.logDriver,
Config: parseLogOpts(client.logOpts),
},
Resources: container.Resources{
NanoCPUs: int64(spec.CPUQuota * 1e9),
Memory: memoryBytes,
PidsLimit: &pidsLimit,
},
}
netCfg := &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
spec.Network: {
Aliases: []string{spec.Hostname},
},
},
}
created, err := client.docker.ContainerCreate(ctx, containerCfg, hostCfg, netCfg, nil, spec.Name)
if err != nil {
return ports.RunResult{}, fmt.Errorf("create container %q: %w", spec.Name, err)
}
if err := client.docker.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
client.cleanupAfterFailedStart(created.ID)
return ports.RunResult{}, fmt.Errorf("start container %q: %w", spec.Name, err)
}
return ports.RunResult{
ContainerID: created.ID,
EngineEndpoint: fmt.Sprintf("http://%s:%d", spec.Hostname, EnginePort),
StartedAt: client.clock(),
}, nil
}
// Stop bounds graceful shutdown by timeout. A missing container is
// surfaced as ErrContainerNotFound so the service layer can treat it
// as already-stopped per `rtmanager/README.md §Lifecycles → Stop`.
func (client *Client) Stop(ctx context.Context, containerID string, timeout time.Duration) error {
seconds := max(int(timeout.Round(time.Second).Seconds()), 0)
if err := client.docker.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &seconds}); err != nil {
if cerrdefs.IsNotFound(err) {
return ports.ErrContainerNotFound
}
return fmt.Errorf("stop container %q: %w", containerID, err)
}
return nil
}
// Remove removes the container without forcing kill. A missing
// container is reported as success so callers can treat the operation
// as idempotent.
func (client *Client) Remove(ctx context.Context, containerID string) error {
if err := client.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{}); err != nil {
if cerrdefs.IsNotFound(err) {
return nil
}
return fmt.Errorf("remove container %q: %w", containerID, err)
}
return nil
}
// List returns container summaries that match filter. Empty Labels
// match every container; the reconciler always passes
// `com.galaxy.owner=rtmanager`.
func (client *Client) List(ctx context.Context, filter ports.ListFilter) ([]ports.ContainerSummary, error) {
args := filters.NewArgs()
for key, value := range filter.Labels {
args.Add("label", key+"="+value)
}
summaries, err := client.docker.ContainerList(ctx, container.ListOptions{All: true, Filters: args})
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
out := make([]ports.ContainerSummary, 0, len(summaries))
for _, summary := range summaries {
hostname := ""
if len(summary.Names) > 0 {
hostname = strings.TrimPrefix(summary.Names[0], "/")
}
out = append(out, ports.ContainerSummary{
ID: summary.ID,
ImageRef: summary.Image,
Hostname: hostname,
Labels: copyStringMap(summary.Labels),
Status: string(summary.State),
StartedAt: time.Unix(summary.Created, 0).UTC(),
})
}
return out, nil
}
// EventsListen subscribes to the Docker events stream and returns a
// typed channel of decoded container events plus an asynchronous
// error channel. The caller cancels ctx to terminate the subscription;
// the goroutine closes both channels on termination.
func (client *Client) EventsListen(ctx context.Context) (<-chan ports.DockerEvent, <-chan error, error) {
msgs, sdkErrs := client.docker.Events(ctx, events.ListOptions{})
out := make(chan ports.DockerEvent)
outErrs := make(chan error, 1)
var closeOnce sync.Once
closeAll := func() {
closeOnce.Do(func() {
close(out)
close(outErrs)
})
}
go func() {
defer closeAll()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-msgs:
if !ok {
return
}
if msg.Type != events.ContainerEventType {
continue
}
select {
case <-ctx.Done():
return
case out <- decodeEvent(msg):
}
case err, ok := <-sdkErrs:
if !ok {
return
}
if err == nil {
continue
}
select {
case <-ctx.Done():
case outErrs <- err:
}
return
}
}
}()
return out, outErrs, nil
}
func (client *Client) cleanupAfterFailedStart(containerID string) {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = client.docker.ContainerRemove(cleanupCtx, containerID, container.RemoveOptions{Force: true})
}
func (client *Client) imagePresent(ctx context.Context, ref string) (bool, error) {
if _, err := client.docker.ImageInspect(ctx, ref); err != nil {
if cerrdefs.IsNotFound(err) {
return false, nil
}
return false, fmt.Errorf("inspect image %q: %w", ref, err)
}
return true, nil
}
func (client *Client) runPull(ctx context.Context, ref string) error {
body, err := client.docker.ImagePull(ctx, ref, image.PullOptions{})
if err != nil {
if cerrdefs.IsNotFound(err) {
return ports.ErrImageNotFound
}
return fmt.Errorf("pull image %q: %w", ref, err)
}
defer body.Close()
if _, err := io.Copy(io.Discard, body); err != nil {
return fmt.Errorf("drain pull stream for %q: %w", ref, err)
}
return nil
}
func envMapToSlice(envMap map[string]string) []string {
if len(envMap) == 0 {
return nil
}
out := make([]string, 0, len(envMap))
for key, value := range envMap {
out = append(out, key+"="+value)
}
return out
}
func bindMountsToBinds(mounts []ports.BindMount) []string {
if len(mounts) == 0 {
return nil
}
binds := make([]string, 0, len(mounts))
for _, mount := range mounts {
bind := mount.HostPath + ":" + mount.MountPath
if mount.ReadOnly {
bind += ":ro"
}
binds = append(binds, bind)
}
return binds
}
func parseLogOpts(raw string) map[string]string {
if strings.TrimSpace(raw) == "" {
return nil
}
out := make(map[string]string)
for part := range strings.SplitSeq(raw, ",") {
entry := strings.TrimSpace(part)
if entry == "" {
continue
}
index := strings.IndexByte(entry, '=')
if index <= 0 {
continue
}
out[entry[:index]] = entry[index+1:]
}
if len(out) == 0 {
return nil
}
return out
}
func parseDockerTime(raw string) time.Time {
if raw == "" {
return time.Time{}
}
parsed, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
return time.Time{}
}
return parsed.UTC()
}
func copyStringMap(in map[string]string) map[string]string {
if in == nil {
return nil
}
out := make(map[string]string, len(in))
maps.Copy(out, in)
return out
}
func decodeEvent(msg events.Message) ports.DockerEvent {
occurredAt := time.Time{}
switch {
case msg.TimeNano != 0:
occurredAt = time.Unix(0, msg.TimeNano).UTC()
case msg.Time != 0:
occurredAt = time.Unix(msg.Time, 0).UTC()
}
exitCode := 0
if raw, ok := msg.Actor.Attributes["exitCode"]; ok {
if value, err := parseExitCode(raw); err == nil {
exitCode = value
}
}
return ports.DockerEvent{
Action: string(msg.Action),
ContainerID: msg.Actor.ID,
Labels: copyStringMap(msg.Actor.Attributes),
ExitCode: exitCode,
OccurredAt: occurredAt,
}
}
func parseExitCode(raw string) (int, error) {
value := 0
for _, r := range raw {
if r < '0' || r > '9' {
return 0, fmt.Errorf("non-numeric exit code %q", raw)
}
value = value*10 + int(r-'0')
}
return value, nil
}
// Compile-time assertion: Client implements ports.DockerClient.
var _ ports.DockerClient = (*Client)(nil)
@@ -0,0 +1,561 @@
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)
}
@@ -0,0 +1,175 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/rtmanager/internal/ports (interfaces: DockerClient)
//
// Generated by this command:
//
// mockgen -destination=../adapters/docker/mocks/mock_dockerclient.go -package=mocks galaxy/rtmanager/internal/ports DockerClient
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
ports "galaxy/rtmanager/internal/ports"
reflect "reflect"
time "time"
gomock "go.uber.org/mock/gomock"
)
// MockDockerClient is a mock of DockerClient interface.
type MockDockerClient struct {
ctrl *gomock.Controller
recorder *MockDockerClientMockRecorder
isgomock struct{}
}
// MockDockerClientMockRecorder is the mock recorder for MockDockerClient.
type MockDockerClientMockRecorder struct {
mock *MockDockerClient
}
// NewMockDockerClient creates a new mock instance.
func NewMockDockerClient(ctrl *gomock.Controller) *MockDockerClient {
mock := &MockDockerClient{ctrl: ctrl}
mock.recorder = &MockDockerClientMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockDockerClient) EXPECT() *MockDockerClientMockRecorder {
return m.recorder
}
// EnsureNetwork mocks base method.
func (m *MockDockerClient) EnsureNetwork(ctx context.Context, name string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "EnsureNetwork", ctx, name)
ret0, _ := ret[0].(error)
return ret0
}
// EnsureNetwork indicates an expected call of EnsureNetwork.
func (mr *MockDockerClientMockRecorder) EnsureNetwork(ctx, name any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnsureNetwork", reflect.TypeOf((*MockDockerClient)(nil).EnsureNetwork), ctx, name)
}
// EventsListen mocks base method.
func (m *MockDockerClient) EventsListen(ctx context.Context) (<-chan ports.DockerEvent, <-chan error, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "EventsListen", ctx)
ret0, _ := ret[0].(<-chan ports.DockerEvent)
ret1, _ := ret[1].(<-chan error)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// EventsListen indicates an expected call of EventsListen.
func (mr *MockDockerClientMockRecorder) EventsListen(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EventsListen", reflect.TypeOf((*MockDockerClient)(nil).EventsListen), ctx)
}
// InspectContainer mocks base method.
func (m *MockDockerClient) InspectContainer(ctx context.Context, containerID string) (ports.ContainerInspect, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InspectContainer", ctx, containerID)
ret0, _ := ret[0].(ports.ContainerInspect)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InspectContainer indicates an expected call of InspectContainer.
func (mr *MockDockerClientMockRecorder) InspectContainer(ctx, containerID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectContainer", reflect.TypeOf((*MockDockerClient)(nil).InspectContainer), ctx, containerID)
}
// InspectImage mocks base method.
func (m *MockDockerClient) InspectImage(ctx context.Context, ref string) (ports.ImageInspect, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InspectImage", ctx, ref)
ret0, _ := ret[0].(ports.ImageInspect)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InspectImage indicates an expected call of InspectImage.
func (mr *MockDockerClientMockRecorder) InspectImage(ctx, ref any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectImage", reflect.TypeOf((*MockDockerClient)(nil).InspectImage), ctx, ref)
}
// List mocks base method.
func (m *MockDockerClient) List(ctx context.Context, filter ports.ListFilter) ([]ports.ContainerSummary, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "List", ctx, filter)
ret0, _ := ret[0].([]ports.ContainerSummary)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// List indicates an expected call of List.
func (mr *MockDockerClientMockRecorder) List(ctx, filter any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockDockerClient)(nil).List), ctx, filter)
}
// PullImage mocks base method.
func (m *MockDockerClient) PullImage(ctx context.Context, ref string, policy ports.PullPolicy) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PullImage", ctx, ref, policy)
ret0, _ := ret[0].(error)
return ret0
}
// PullImage indicates an expected call of PullImage.
func (mr *MockDockerClientMockRecorder) PullImage(ctx, ref, policy any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PullImage", reflect.TypeOf((*MockDockerClient)(nil).PullImage), ctx, ref, policy)
}
// Remove mocks base method.
func (m *MockDockerClient) Remove(ctx context.Context, containerID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Remove", ctx, containerID)
ret0, _ := ret[0].(error)
return ret0
}
// Remove indicates an expected call of Remove.
func (mr *MockDockerClientMockRecorder) Remove(ctx, containerID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockDockerClient)(nil).Remove), ctx, containerID)
}
// Run mocks base method.
func (m *MockDockerClient) Run(ctx context.Context, spec ports.RunSpec) (ports.RunResult, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Run", ctx, spec)
ret0, _ := ret[0].(ports.RunResult)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Run indicates an expected call of Run.
func (mr *MockDockerClientMockRecorder) Run(ctx, spec any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockDockerClient)(nil).Run), ctx, spec)
}
// Stop mocks base method.
func (m *MockDockerClient) Stop(ctx context.Context, containerID string, timeout time.Duration) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Stop", ctx, containerID, timeout)
ret0, _ := ret[0].(error)
return ret0
}
// Stop indicates an expected call of Stop.
func (mr *MockDockerClientMockRecorder) Stop(ctx, containerID, timeout any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockDockerClient)(nil).Stop), ctx, containerID, timeout)
}
@@ -0,0 +1,11 @@
package mocks
import (
"galaxy/rtmanager/internal/ports"
)
// Compile-time assertion that the generated mock satisfies the port
// interface. Future signature drift between the port and the generated
// file fails the build at this line, which is more actionable than a
// runtime check from a service test.
var _ ports.DockerClient = (*MockDockerClient)(nil)
@@ -0,0 +1,202 @@
// Package docker smoke tests exercise the production adapter against a
// real Docker daemon. The tests skip when no Docker socket is reachable
// (`skipUnlessDockerAvailable`), so they run in the default
// `go test ./...` pass without a build tag.
package docker
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"os"
"testing"
"time"
"github.com/docker/docker/api/types/network"
dockerclient "github.com/docker/docker/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"galaxy/rtmanager/internal/ports"
)
const (
smokeImage = "alpine:3.21"
smokeNetPrefix = "rtmanager-smoke-"
)
func skipUnlessDockerAvailable(t *testing.T) {
t.Helper()
if os.Getenv("DOCKER_HOST") == "" {
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
t.Skip("docker daemon not available; set DOCKER_HOST or expose /var/run/docker.sock")
}
}
}
func newSmokeAdapter(t *testing.T) (*Client, *dockerclient.Client) {
t.Helper()
docker, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
require.NoError(t, err)
t.Cleanup(func() { _ = docker.Close() })
pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := docker.Ping(pingCtx); err != nil {
// A reachable socket path may still be unusable in sandboxed
// environments (e.g., macOS sandbox blocking the colima socket).
// The smoke test can only run when the daemon answers ping, so a
// permission-denied / connection-refused error is a runtime
// "Docker unavailable" signal and skips the test.
t.Skipf("docker daemon unavailable: %v", err)
}
adapter, err := NewClient(Config{
Docker: docker,
LogDriver: "json-file",
})
require.NoError(t, err)
return adapter, docker
}
func uniqueSuffix(t *testing.T) string {
t.Helper()
buf := make([]byte, 4)
_, err := rand.Read(buf)
require.NoError(t, err)
return hex.EncodeToString(buf)
}
// TestSmokeFullLifecycle runs the adapter through every method against
// the real Docker daemon: ensure-network → pull → run → events →
// stop → remove.
func TestSmokeFullLifecycle(t *testing.T) {
skipUnlessDockerAvailable(t)
adapter, docker := newSmokeAdapter(t)
suffix := uniqueSuffix(t)
netName := smokeNetPrefix + suffix
containerName := "rtmanager-smoke-cont-" + suffix
// Step 1 — provision a temporary user-defined bridge network.
createCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := docker.NetworkCreate(createCtx, netName, network.CreateOptions{Driver: "bridge"})
require.NoError(t, err)
t.Cleanup(func() {
removeCtx, removeCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer removeCancel()
_ = docker.NetworkRemove(removeCtx, netName)
})
// Step 2 — EnsureNetwork present and missing paths.
require.NoError(t, adapter.EnsureNetwork(createCtx, netName))
missingErr := adapter.EnsureNetwork(createCtx, "rtmanager-smoke-missing-"+suffix)
require.Error(t, missingErr)
assert.ErrorIs(t, missingErr, ports.ErrNetworkMissing)
// Step 3 — pull alpine via the configured policy.
pullCtx, pullCancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer pullCancel()
require.NoError(t, adapter.PullImage(pullCtx, smokeImage, ports.PullPolicyIfMissing))
// Step 4 — subscribe to events before running the container so we
// observe the start event.
listenCtx, listenCancel := context.WithCancel(context.Background())
defer listenCancel()
events, listenErrs, err := adapter.EventsListen(listenCtx)
require.NoError(t, err)
// Step 5 — run a tiny container that sleeps so we can observe it.
stateDir := t.TempDir()
runCtx, runCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer runCancel()
result, err := adapter.Run(runCtx, ports.RunSpec{
Name: containerName,
Image: smokeImage,
Hostname: "smoke-" + suffix,
Network: netName,
Env: map[string]string{
"GAME_STATE_PATH": "/tmp/state",
"STORAGE_PATH": "/tmp/state",
},
Labels: map[string]string{
"com.galaxy.owner": "rtmanager",
"com.galaxy.kind": "smoke",
},
BindMounts: []ports.BindMount{
{HostPath: stateDir, MountPath: "/tmp/state"},
},
LogDriver: "json-file",
CPUQuota: 0.5,
Memory: "64m",
PIDsLimit: 32,
Cmd: []string{"/bin/sh", "-c", "sleep 60"},
})
require.NoError(t, err)
t.Cleanup(func() {
removeCtx, removeCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer removeCancel()
_ = adapter.Remove(removeCtx, result.ContainerID)
})
require.NotEmpty(t, result.ContainerID)
require.Equal(t, "http://smoke-"+suffix+":8080", result.EngineEndpoint)
// Step 6 — wait for a `start` event for the new container id.
startObserved := waitForEvent(t, events, listenErrs, "start", result.ContainerID, 15*time.Second)
require.True(t, startObserved, "did not observe start event for container %s", result.ContainerID)
// Step 7 — InspectContainer returns running state.
inspectCtx, inspectCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer inspectCancel()
inspect, err := adapter.InspectContainer(inspectCtx, result.ContainerID)
require.NoError(t, err)
assert.Equal(t, "running", inspect.Status)
// Step 8 — Stop, then Remove, then InspectContainer must report
// not found.
stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer stopCancel()
require.NoError(t, adapter.Stop(stopCtx, result.ContainerID, 5*time.Second))
require.NoError(t, adapter.Remove(stopCtx, result.ContainerID))
if _, err := adapter.InspectContainer(stopCtx, result.ContainerID); !errors.Is(err, ports.ErrContainerNotFound) {
t.Fatalf("expected ErrContainerNotFound, got %v", err)
}
// Step 9 — terminate the events subscription cleanly.
listenCancel()
select {
case _, ok := <-events:
_ = ok
case <-time.After(5 * time.Second):
t.Log("events channel did not close within timeout (best-effort)")
}
}
func waitForEvent(t *testing.T, events <-chan ports.DockerEvent, errs <-chan error, action, containerID string, timeout time.Duration) bool {
t.Helper()
deadline := time.After(timeout)
for {
select {
case ev, ok := <-events:
if !ok {
return false
}
if ev.Action == action && ev.ContainerID == containerID {
return true
}
case err := <-errs:
if err != nil {
t.Fatalf("events stream error: %v", err)
}
case <-deadline:
return false
}
}
}