feat: runtime manager
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user