203 lines
6.4 KiB
Go
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
|
|
}
|
|
}
|
|
}
|