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