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