8.6 KiB
Adapters
This document explains why the production adapters under
../internal/adapters/ — Docker SDK,
Lobby internal HTTP client, notification-intent publisher, health-event
publisher, job-result publisher — are shaped the way they are. The
PostgreSQL stores and the Redis-coordination adapters live in
postgres-migration.md.
1. mockgen is the repo-wide convention for wide ports
The Docker port has nine methods plus eight value types in the signatures, and most lifecycle services exercise nearly every method pair (start, stop, restart, patch, cleanup, reconcile, events, probe). A hand-rolled fake would either miss methods or balloon to a per-test fixture.
internal/adapters/docker/ therefore uses go.uber.org/mock mocks:
//go:generatedirectives live next to the interface declaration ininternal/ports/dockerclient.go;- generated code is committed under
internal/adapters/docker/mocks/(matching theinternal/adapters/postgres/jet/discipline); make -C rtmanager mocksis the single command operators run after a port-signature change.
The maintained go.uber.org/mock fork is preferred over the archived
github.com/golang/mock. This convention applies to wide / recorder
ports across the repository — Lobby uses the same pipeline for its
narrow recorder ports (RuntimeManager, IntentPublisher,
GMClient, UserService); see
../../ARCHITECTURE.md for the cross-service
rule.
The other two RTM ports (LobbyInternalClient,
NotificationIntentPublisher) keep inline _test.go fakes: small
surfaces, easy to fake by hand inside a single test file when needed.
2. EngineEndpoint is built inside the Docker adapter
The engine port is fixed at 8080. Pushing it into RunSpec would
force the start service to know an engine implementation detail;
pushing it into config would give operators a knob that the engine
image already does not honour. The Docker adapter exposes
EnginePort = 8080 as a package constant and constructs
RunResult.EngineEndpoint = "http://" + spec.Hostname + ":8080"
itself.
The adapter also leaves container.Config.ExposedPorts empty: RTM
never publishes ports to the host. The user-defined Docker bridge
network gives every container in the network DNS access to the engine
via galaxy-game-{game_id}:8080.
3. Run removes the container on ContainerStart failure
README.md §Lifecycles → Start requires no orphan to remain after a
failed start path. If ContainerCreate succeeds but ContainerStart
fails, the adapter calls ContainerRemove(force=true) inside a fresh
context.Background() (with a 10s timeout) so the cleanup runs even
when the original ctx is already cancelled. The cleanup is best-effort:
a remove failure is silently discarded because the original start
failure is the actionable error returned to the caller.
The alternative — leaving rollback to the start service — would either
duplicate the same code in every caller or invite a service that forgets
to do it. Centralising the rule in the adapter keeps the port contract
simple. The start service adds an additional rollback layer for the
post-Run Upsert failure path; see services.md §5.
4. RunSpec.Cmd is optional
ports.RunSpec exposes an optional Cmd []string. Production callers
leave it nil so the engine image's own CMD runs;
internal/adapters/docker/smoke_test.go uses it to drive
["/bin/sh","-c","sleep 60"] against alpine:3.21.
The alternative — building a dedicated test image with a pre-baked
sleep command — would require an extra Dockerfile under testdata
and a build step inside the smoke test. The single new field is
documented as optional and ignored when empty; production behaviour is
unchanged.
5. EventsListen filters at the adapter boundary
The Docker /events API accepts a filters query parameter, but the
daemon treats it as a hint, not a guarantee. The adapter therefore
double-checks at the boundary: only Type == events.ContainerEventType
messages are passed through to the typed <-chan ports.DockerEvent.
Doing the filter at the SDK level would still require a defensive
recheck on the consumer side; consolidating the check in the adapter
keeps the contract crisp and the consumer free of Docker-internal type
discriminants.
The decoded event copies the actor's full Attributes map into
DockerEvent.Labels. Docker mixes container labels and runtime
attributes (exitCode, image, name, etc.) flat in the same map;
RTM consumers filter by the com.galaxy. prefix when they care about
labels, and the adapter extracts exitCode separately for die
events.
6. Lobby HTTP client error mapping
ports.LobbyInternalClient.GetGame fixes:
200→LobbyGameRecorddecoded tolerantly (unknown fields ignored);404→ports.ErrLobbyGameNotFound;- transport, timeout, or any other non-2xx →
ports.ErrLobbyUnavailablewrapped with the original error so callers canerrors.Isand still log the cause.
The start service treats ErrLobbyUnavailable as recoverable: it
continues without the diagnostic data because the start envelope
already carries the only required field (image_ref). The client
mirrors notification/internal/adapters/userservice/client.go: cloned
*http.Transport, otelhttp.NewTransport wrap, per-request
context.WithTimeout, idempotent Close() releasing idle connections.
JSON decoding is tolerant: unknown fields in the success body do not
break the call, so additive changes to Lobby's GameRecord schema do
not require an RTM release.
7. Notification publisher wrapper signature
The wrapper drops the entry id returned by
notificationintent.Publisher.Publish (rationale in
domain-and-ports.md §7). The adapter is a
thin shim:
NewPublisher(cfg)constructs the inner publisher and forwards validation;Publish(ctx, intent)calls the inner publisher and discards the entry id.
The compile-time assertion var _ ports.NotificationIntentPublisher = (*Publisher)(nil) lives in publisher.go.
8. Health-events publisher: snapshot upsert before stream XADD
Every emission goes through
ports.HealthEventPublisher.Publish, which both XADDs to
runtime:health_events and upserts health_snapshots. The snapshot
upsert runs before the XADD: a successful Publish always leaves
the snapshot store at least as fresh as the stream, and a partial
failure leaves the snapshot a best-effort lower bound. Reversing the
order would let consumers observe a stream entry whose
health_snapshots row reflects the prior observation — a misleading
inversion.
The event_type → SnapshotStatus / SnapshotSource mapping mirrors the
table in ../README.md §Health Monitoring. In
particular, container_started collapses to SnapshotStatusHealthy
and probe_recovered does the same (rationale in
domain-and-ports.md §4).
9. Unit-test strategy
Both HTTP-backed adapters (Docker SDK, Lobby client) use
httptest.Server fixtures. The Docker SDK speaks HTTP under the hood
for both unix sockets and TCP, so adapter unit tests construct a
Docker client with client.WithHost(server.URL) and
client.WithHTTPClient(server.Client()), which lets table-driven
handlers fake every Docker API endpoint without touching the real
daemon. The Docker API version is pinned to 1.45
(client.WithVersion("1.45")) so the URL prefix is stable across CI
machines whose daemon advertises a different default. Production
wiring (in internal/app/bootstrap.go) keeps API negotiation enabled.
The notification publisher uses miniredis directly because the
adapter's only side effect is an XADD, which miniredis reproduces
faithfully and matches every other Galaxy intent test.
10. Docker smoke test
internal/adapters/docker/smoke_test.go runs on the default
go test ./... invocation and calls t.Skip unless the local daemon
is reachable (/var/run/docker.sock exists or DOCKER_HOST is set).
The covered sequence:
- provision a temporary user-defined bridge network;
- assert
EnsureNetworkfor present and missing names; - pull
alpine:3.21(PullPolicyIfMissing); - subscribe to events;
- run a sleep container with the full
RunSpecfield set; - observe a
startevent for the new container id; - inspect, stop, remove, and verify
ErrContainerNotFoundis reported afterwards.
This is the production adapter's only end-to-end check that runs from
the default go test pass; the broader service-local integration
suite (integration-tests.md) is gated
behind -tags=integration.