From b72a7cf8433b593ab4d911ff7c50fcdc3861a48a Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:02:05 +0100 Subject: [PATCH] test(multiplayer): cover compose allocator binding --- Dockerfile | 7 ++++ compose.allocated-smoke.yml | 22 ++++++++++++ multiplayer-next.md | 2 +- scripts/fake_agones_provider.py | 44 +++++++++++++++++++++++ scripts/verify_allocated_compose.sh | 7 ++++ server/security/test_compose_manifests.py | 2 ++ 6 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 scripts/fake_agones_provider.py diff --git a/Dockerfile b/Dockerfile index 98d98c4e..10766f31 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,7 @@ COPY server/ ./ RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/game-server-supervisor ./cmd/game-server-supervisor RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/testkit-api ./cmd/testkit-api RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/matcher ./cmd/matcher +RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/allocator ./cmd/allocator # Agones-allocated fleet image: the same dedicated-server export as `server` # (unchanged above; make verify-phase6 exercises that target exactly as @@ -85,3 +86,9 @@ COPY --from=supervisor-build /opt/cosmic-clash/matcher /opt/cosmic-clash/matcher COPY server/migrations /opt/cosmic-clash/migrations RUN chmod 0755 /opt/cosmic-clash/matcher ENTRYPOINT ["/opt/cosmic-clash/matcher"] + +FROM server AS allocator +COPY --from=supervisor-build /opt/cosmic-clash/allocator /opt/cosmic-clash/allocator +COPY server/migrations /opt/cosmic-clash/migrations +RUN chmod 0755 /opt/cosmic-clash/allocator +ENTRYPOINT ["/opt/cosmic-clash/allocator"] diff --git a/compose.allocated-smoke.yml b/compose.allocated-smoke.yml index 408913f2..69da9ea3 100644 --- a/compose.allocated-smoke.yml +++ b/compose.allocated-smoke.yml @@ -36,6 +36,28 @@ services: database: condition: service_healthy + agones-provider: + image: python:3.12-alpine + command: ["python3", "/opt/fake_agones_provider.py"] + volumes: + - ./scripts/fake_agones_provider.py:/opt/fake_agones_provider.py:ro + + allocator: + build: + context: . + target: allocator + environment: + COSMIC_CLASH_POSTGRES_DSN: postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable + COSMIC_CLASH_AGONES_URL: http://agones-provider:8080 + COSMIC_CLASH_AGONES_NAMESPACE: cosmic-clash + COSMIC_CLASH_WORKLOAD_SECRET: compose-workload-secret + command: ["--dsn=postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable", "--migrations=/opt/cosmic-clash/migrations", "--interval=1s", "--transport=enet"] + depends_on: + database: + condition: service_healthy + agones-provider: + condition: service_started + game-server: build: context: . diff --git a/multiplayer-next.md b/multiplayer-next.md index 57279e20..7ee989c3 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1247,7 +1247,7 @@ the local/CI/community transport, not a silent production fallback. | 8.45 `[D:8.2,8.44]` | **LOCAL COMPLETE; PRODUCTION GATE OPEN.** Go observability package turns the documented RTT, allocation/connect latency, result-success, API-latency and tick/headroom thresholds into executable window checks; the API exporter emits a bounded cumulative latency histogram suitable for querying the documented p95 API SLO | `server/observability/slo.go`, `metrics.go`, `deploy/observability/prometheus-rules.yaml` and adversarial tests cover healthy/violating/empty windows, fixed operation/status labels, cumulative bucket boundaries, arbitrary-path cardinality safety, and optional API p95/5xx alerts. Production scrape configuration, alert routing, wait/MMR/proposal/flood/cost series and runbooks remain | | 8.46 `[D:8.5,8.7,8.9,8.10,8.14,8.18,8.21,8.23,8.25]` | **IN PROGRESS.** Go unit/race coverage spans the current domain/store/supervisor policies, and fuzz targets now exercise queue input, result payload hashing and revision events | `server/domain/*_test.go`, `server/store/*_test.go`, `server/supervisor/*_test.go`, `server/migrations/*_test.go` and `server/domain/fuzz_test.go` pass normal/race suites; `go test -race ./...` passes across API, domain, migrations, observability, store, supervisor and testkit; `go vet ./...` passes; each of the three declared domain fuzz targets passes a bounded 4-second run; PostgreSQL live migration execution now runs clean (§8.5), and four real-concurrency cases are covered against a live database with `-race`: §8.14's queue-heartbeat revision race, §8.18's two-matcher contested-ticket race, §8.30's cross-allocator-replica capacity race, and §8.21/§8.25's concurrent identical-result-submission race; the "lost Redis" fixture is covered live against a real server (§8.14: real TTL expiry, repair-after-`FLUSHALL`; fake Steam/allocator fixtures are §8.47's testkit, already done). Further transaction fixtures (e.g. concurrent proposal-recovery expiry races, live Redis failover mid-write under load) remain | | 8.47 `[D:8.7,8.30]` | **IN PROGRESS.** Offline testkit provides deterministic fake Steam verification and fake allocation with forced failure injection; the independent Compose runner drives fake-Steam session issuance, real HTTP queue create/heartbeat/cancel, matcher-backed six-player proposal formation/acceptance, and idempotency-conflict checks | `server/testkit/` covers verified identity/replay, unknown identity, wrong App ID, expiry, no capacity, compatibility-key conflict, idempotent allocation replay and cloud-free forced allocation failure; the Compose API/matcher slice is wired into CI, while live exhaustive matrix and production Steam remain | -| 8.48 `[D:8.10,8.14,8.17,8.18,8.27,8.31,8.35,8.47]` | **IN PROGRESS.** Offline testkit exercises verified queue projection → ranked six-player proposal → ENet allocation → assignment-ready manifest → certified durable result receipt; `compose.allocated-smoke.yml` independently runs the real testkit API against PostgreSQL and the real game-server supervisor with a generated signed roster, verifying authenticated result, idempotent retry, shutdown acknowledgment, durable receipt/audit rows, and SIGTERM-driven game-process drain | `.github/workflows/allocated-compose.yml` runs `make verify-allocated-compose`. Full queue/proposal/allocation orchestration over HTTP, live Docker evidence from this workspace, and legacy fixture non-regression remain open | +| 8.48 `[D:8.10,8.14,8.17,8.18,8.27,8.31,8.35,8.47]` | **IN PROGRESS.** Offline testkit exercises verified queue projection → ranked six-player proposal → ENet allocation → assignment-ready manifest → certified durable result receipt; `compose.allocated-smoke.yml` independently runs the real testkit API, matcher, allocator, Agones-shaped provider, PostgreSQL, and game-server supervisor with a generated signed roster, verifying queue/proposal/allocation binding, authenticated result, idempotent retry, shutdown acknowledgment, durable receipt/audit rows, and SIGTERM-driven game-process drain | `.github/workflows/allocated-compose.yml` runs `make verify-allocated-compose`. Live Docker evidence from this workspace and legacy fixture non-regression remain open | | 8.49 `[D:8.25,8.26,8.28,8.29,8.30,8.31,8.35,8.36]` | **IN PROGRESS.** `scripts/verify_kind_agones.sh` creates a disposable kind cluster, installs pinned Agones, loads the real `game-server` image, applies the Fleet in an explicitly separate Agones-only supervisor/UDP readiness mode, and verifies readiness plus allocation of a dynamic UDP endpoint; `.github/workflows/agones-integration.yml` runs it for infrastructure changes and on demand | The cloud-free runner is committed and fails clearly when Docker/kind/Helm are unavailable. CI/live evidence for production control-plane registration, roster/no-show, both readiness stages, races, multi-match node, result-pending reconciliation, drain, and rollback remains open | | 8.50 `[D:8.25,8.37,8.43,8.49]` | Network/chaos suite: 100 ms RTT, jitter/loss, client/API/matcher restart, game-pod death, node drain, Redis failover and control-plane loss | System recovers to a defined state; infrastructure-caused cases cannot penalise affected players | | 8.51 `[D:8.17,8.18,8.30,8.31,8.45]` | Load test >=10,000 queued clients, >=100 proposals/s and forecast launch concurrency x2 | API p95 <=250 ms, durable matcher fence holds, both readiness/allocation SLOs are met and replicas scale without duplicate claims | diff --git a/scripts/fake_agones_provider.py b/scripts/fake_agones_provider.py new file mode 100644 index 00000000..6da5a914 --- /dev/null +++ b/scripts/fake_agones_provider.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Minimal deterministic Agones HTTP surface for the allocated Compose smoke.""" +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if "/gameservers" not in self.path: + self.send_error(404) + return + body = {"items": [{"metadata": {"name": "allocator-ready-1", "labels": { + "cosmic-clash.io/region": "EU", "cosmic-clash.io/build": "build-1", + "cosmic-clash.io/protocol": "1", "cosmic-clash.io/transport": "enet" + }}, "status": {"state": "Ready"}}]} + self._json(body) + + def do_POST(self): + if "/gameserverallocations" not in self.path: + self.send_error(404) + return + length = int(self.headers.get("Content-Length", "0")) + request = json.loads(self.rfile.read(length)) + selectors = request.get("spec", {}).get("selectors", []) + labels = selectors[0].get("matchLabels", {}) if selectors else {} + if labels.get("cosmic-clash.io/region") != "EU" or labels.get("cosmic-clash.io/transport") != "enet": + self.send_error(422, "incompatible selector") + return + self._json({"status": {"state": "Allocated", "gameServerName": "allocator-ready-1", + "address": "127.0.0.1", "ports": [{"name": "default", "port": 31001}]}}) + + def _json(self, body): + encoded = json.dumps(body).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, *_args): + return + + +HTTPServer(("0.0.0.0", 8080), Handler).serve_forever() diff --git a/scripts/verify_allocated_compose.sh b/scripts/verify_allocated_compose.sh index 9743bf05..c83290ae 100755 --- a/scripts/verify_allocated_compose.sh +++ b/scripts/verify_allocated_compose.sh @@ -136,6 +136,13 @@ for player in 1 2 3 4 5 6; do done [[ "$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT state FROM proposals WHERE proposal_id = '$proposal_id'" | tr -d '\r')" == ACCEPTED ]] [[ "$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM matches WHERE state = 'ALLOCATING'" | tr -d '\r')" == 1 ]] +for attempt in $(seq 1 30); do + allocation_count="$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM allocations WHERE match_id LIKE 'match-%'" | tr -d '\r')" + if [[ "$allocation_count" == 1 ]]; then break; fi + [[ "$attempt" == 30 ]] && { echo "allocator did not bind a provider allocation" >&2; exit 1; } + sleep 1 +done +[[ "$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT server_id FROM matches WHERE state = 'ALLOCATING'" | tr -d '\r')" == allocator-ready-1 ]] # Model the durable state produced by the allocator, then use the real HTTP # workload authentication and mutation boundaries for every action below. diff --git a/server/security/test_compose_manifests.py b/server/security/test_compose_manifests.py index c1cf68a0..f143d22d 100644 --- a/server/security/test_compose_manifests.py +++ b/server/security/test_compose_manifests.py @@ -36,6 +36,8 @@ class ComposeManifestTest(unittest.TestCase): ): self.assertIn(marker, runner) self.assertIn("target: matcher", allocated) + self.assertIn("target: allocator", allocated) + self.assertIn("agones-provider", allocated) if __name__ == "__main__":