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

203 lines
6.4 KiB
Go

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