diff --git a/.github/workflows/allocated-compose.yml b/.github/workflows/allocated-compose.yml new file mode 100644 index 00000000..ab8dcccd --- /dev/null +++ b/.github/workflows/allocated-compose.yml @@ -0,0 +1,27 @@ +name: Allocated Compose Smoke + +on: + workflow_dispatch: + pull_request: + paths: + - Dockerfile + - Makefile + - compose.allocated-smoke.yml + - server/api/** + - server/store/** + - server/workload/** + - server/migrations/** + - scripts/verify_allocated_compose.sh + - .github/workflows/allocated-compose.yml + +permissions: + contents: read + +jobs: + allocated-compose: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - name: Verify independent allocated Compose flow + run: make verify-allocated-compose diff --git a/Dockerfile b/Dockerfile index f0942ede..3584ff85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,6 +57,7 @@ COPY server/go.mod server/go.sum ./ RUN go mod download 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 # Agones-allocated fleet image: the same dedicated-server export as `server` # (unchanged above; make verify-phase6 exercises that target exactly as @@ -71,3 +72,9 @@ FROM server AS game-server COPY --from=supervisor-build /opt/cosmic-clash/game-server-supervisor /opt/cosmic-clash/game-server-supervisor RUN chmod 0755 /opt/cosmic-clash/game-server-supervisor ENTRYPOINT ["/opt/cosmic-clash/game-server-supervisor"] + +FROM server AS testkit-api +COPY --from=supervisor-build /opt/cosmic-clash/testkit-api /opt/cosmic-clash/testkit-api +COPY server/migrations /opt/cosmic-clash/migrations +RUN chmod 0755 /opt/cosmic-clash/testkit-api +ENTRYPOINT ["/opt/cosmic-clash/testkit-api"] diff --git a/Makefile b/Makefile index 176093ac..79adc44b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: verify-phase6 verify-enet-integration verify-steam-templates verify-supply-chain verify-kind-agones verify-multiplayer-local +.PHONY: verify-phase6 verify-enet-integration verify-steam-templates verify-supply-chain verify-kind-agones verify-allocated-compose verify-multiplayer-local verify-multiplayer-local: bash scripts/verify_multiplayer_local.sh @@ -17,3 +17,6 @@ verify-supply-chain: verify-kind-agones: bash scripts/verify_kind_agones.sh + +verify-allocated-compose: + bash scripts/verify_allocated_compose.sh diff --git a/compose.allocated-smoke.yml b/compose.allocated-smoke.yml new file mode 100644 index 00000000..523d8a78 --- /dev/null +++ b/compose.allocated-smoke.yml @@ -0,0 +1,26 @@ +services: + database: + image: postgres:17-alpine + environment: + POSTGRES_DB: cosmic_clash_test + POSTGRES_USER: cosmic_clash_test + POSTGRES_PASSWORD: cosmic_clash_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cosmic_clash_test -d cosmic_clash_test"] + interval: 1s + timeout: 3s + retries: 30 + + control-plane: + build: + context: . + target: testkit-api + environment: + COSMIC_CLASH_POSTGRES_DSN: postgres://cosmic_clash_test:cosmic_clash_test@database:5432/cosmic_clash_test?sslmode=disable + COSMIC_CLASH_WORKLOAD_SECRET: compose-workload-secret + command: ["--listen=0.0.0.0:8080", "--migrations=/opt/cosmic-clash/migrations"] + depends_on: + database: + condition: service_healthy + ports: + - "18080:8080" diff --git a/multiplayer-next.md b/multiplayer-next.md index 63e130cf..57e7657a 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 | `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 in `TestOfflineFakesCoverVerificationAndAllocationFailureMatrix`; API/Compose integration and live exhaustive matrix 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 | `server/testkit/pipeline_test.go` covers the cross-domain success path without Steam/cloud secrets; independent Compose fixture, process shutdown, result ack over HTTP and legacy fixture non-regression 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 verifies authenticated result, idempotent retry, shutdown acknowledgment, durable receipt/audit rows, and clean API process stop | `.github/workflows/allocated-compose.yml` runs `make verify-allocated-compose`. Allocated game-process drain, full queue/proposal/allocation orchestration over HTTP, 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, 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 both readiness stages, roster/no-show, 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 | @@ -1460,7 +1460,7 @@ Observability redaction now adds content-aware protection on top of denylisted f ### Current local completion index (2026-09-01) -The following Phase 8 slices have local implementation and verification evidence in this document: 8.29 dynamic allocated launch flags and endpoint handling; 8.30 allocator claim/reconciliation; 8.31 signed assignment/roster validation; 8.35 initial-connect no-show and casual bot policy; 8.36 controlled drain and shutdown acknowledgment; 8.39–8.43 client state, assignment, profile, recovery, and idempotent action retry; 8.44 structured observability and content-aware redaction; 8.45 bounded API metrics export plus optional Prometheus scrape/alert rules; 8.46 normal/race/vet/fuzz coverage; and 8.47–8.48 offline testkit coverage. Their remaining acceptance text is infrastructure or production dependent where explicitly noted below the corresponding row. +The following Phase 8 slices have local implementation and verification evidence in this document: 8.29 dynamic allocated launch flags and endpoint handling; 8.30 allocator claim/reconciliation; 8.31 signed assignment/roster validation; 8.35 initial-connect no-show and casual bot policy; 8.36 controlled drain and shutdown acknowledgment; 8.39–8.43 client state, assignment, profile, recovery, and idempotent action retry; 8.44 structured observability and content-aware redaction; 8.45 bounded API metrics export plus optional Prometheus scrape/alert rules; 8.46 normal/race/vet/fuzz coverage; and 8.47–8.48 offline/testkit/Compose coverage. Their remaining acceptance text is infrastructure or production dependent where explicitly noted below the corresponding row. The following are not locally certifiable from this workspace and remain open prerequisites rather than silently “done”: Valve/GodotSteam credentials and hosted SDR (7.1–7.8), live PostgreSQL/Redis execution where Docker is unavailable, live Agones/kind lifecycle (8.30–8.38, 8.49), public-network chaos/load/cost/release gates (8.50–8.53), and real-hardware graphics profiling (0.15b onward). `make verify-kind-agones` is the committed runner for 8.49; it requires a running Docker daemon plus kind, kubectl, and Helm. `TODO.md`’s AI-training and presentation tasks remain separate from multiplayer and are not marked by this index. diff --git a/scripts/verify_allocated_compose.sh b/scripts/verify_allocated_compose.sh new file mode 100755 index 00000000..729316d0 --- /dev/null +++ b/scripts/verify_allocated_compose.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Independent allocated-flow fixture for multiplayer-next.md §8.48. This +# intentionally does not call compose.phase6-smoke.yml or reuse its ports. +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +compose_file="$root_dir/compose.allocated-smoke.yml" +project="${COMPOSE_PROJECT_NAME:-cosmic-clash-allocated-smoke}" +api_url="http://127.0.0.1:18080" +secret="compose-workload-secret" +compose=(docker compose -p "$project" -f "$compose_file") + +cleanup() { + local rc=$? + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "$rc" +} +trap cleanup EXIT + +command -v docker >/dev/null 2>&1 || { echo "Docker is required for 8.48" >&2; exit 2; } +docker info >/dev/null 2>&1 || { echo "A running Docker daemon is required for 8.48" >&2; exit 2; } + +"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true +"${compose[@]}" up -d --build + +for attempt in $(seq 1 60); do + if curl -fsS "$api_url/healthz" >/dev/null 2>&1; then + break + fi + if [[ "$attempt" == 60 ]]; then + "${compose[@]}" logs >&2 + echo "allocated Compose control plane did not become ready" >&2 + exit 1 + fi + sleep 1 +done + +# Model the durable state produced by the allocator, then use the real HTTP +# workload authentication and mutation boundaries for every action below. +"${compose[@]}" exec -T database psql -v ON_ERROR_STOP=1 -U cosmic_clash_test -d cosmic_clash_test <<'SQL' +INSERT INTO game_servers (server_id, region, build, protocol_version, transport, state) +VALUES ('compose-server', 'EU', 'build-1', 1, 'enet', 'ALLOCATED'); +INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id, revision) +VALUES ('compose-match', 'casual', 'RESULT_PENDING', 'EU', 1, 'compose-server', 2); +INSERT INTO allocations (allocation_id, match_id, server_id, region, build, protocol_version, transport, request_digest, state, allocated_at) +VALUES ('compose-allocation', 'compose-match', 'compose-server', 'EU', 'build-1', 1, 'enet', decode(repeat('00', 32), 'hex'), 'ALLOCATED', now()); +SQL + +token="$(python3 - "$secret" <<'PY' +import base64, hashlib, hmac, json, sys, time +secret = sys.argv[1].encode() +payload = {"a": "compose-allocation", "e": time.time() + 300} +encoded = base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()).rstrip(b"=") +signature = hmac.new(secret, encoded, hashlib.sha256).digest() +sig = base64.urlsafe_b64encode(signature).rstrip(b"=") +print(encoded.decode() + "." + sig.decode()) +PY +)" + +result_body='{"match_id":"compose-match","result_nonce":"compose-result-nonce-1234","score":{"team_0":3,"team_1":2},"integrity_state":"CERTIFIED"}' +curl -fsS -o /dev/null -w '%{http_code}' \ + -X POST "$api_url/v1/servers/compose-server/result" \ + -H "Authorization: Bearer $token" \ + -H 'Idempotency-Key: compose-result-key-123456' \ + -H 'Content-Type: application/json' -d "$result_body" | grep -qx 202 + +# An identical retry must be acknowledged without a second receipt. +curl -fsS -o /dev/null -w '%{http_code}' \ + -X POST "$api_url/v1/servers/compose-server/result" \ + -H "Authorization: Bearer $token" \ + -H 'Idempotency-Key: compose-result-key-123456' \ + -H 'Content-Type: application/json' -d "$result_body" | grep -qx 202 + +"${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT state FROM matches WHERE match_id = 'compose-match'" | grep -qx COMPLETED +"${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM result_receipts WHERE match_id = 'compose-match'" | grep -qx 1 + +curl -fsS -o /dev/null -w '%{http_code}' \ + -X POST "$api_url/v1/servers/compose-server/shutdown" \ + -H "Authorization: Bearer $token" \ + -H 'Idempotency-Key: compose-shutdown-key-123456' \ + -H 'Content-Type: application/json' -d '{"reason":"server_draining"}' | grep -qx 204 + +"${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM audit_events WHERE action = 'SERVER_SHUTDOWN' AND aggregate_id = 'compose-match'" | grep -qx 1 +"${compose[@]}" stop -t 10 control-plane >/dev/null +if "${compose[@]}" ps --status running --services | grep -qx control-plane; then + echo "control-plane did not stop cleanly" >&2 + exit 1 +fi +echo "8.48 PASS: allocated Compose HTTP result/retry/shutdown flow completed" diff --git a/scripts/verify_multiplayer_local.sh b/scripts/verify_multiplayer_local.sh index 76cd47cf..3ef86fb5 100755 --- a/scripts/verify_multiplayer_local.sh +++ b/scripts/verify_multiplayer_local.sh @@ -28,6 +28,7 @@ echo "local multiplayer gate: contracts and manifests" python3 -m json.tool "$root_dir/server/contracts/v1/openapi.json" >/dev/null python3 "$root_dir/server/migrations/test_migration.py" python3 "$root_dir/server/security/test_fleet_manifests.py" +python3 "$root_dir/server/security/test_compose_manifests.py" python3 "$root_dir/server/security/test_kubernetes_policies.py" python3 "$root_dir/server/security/test_supply_chain.py" python3 "$root_dir/scripts/verify_observability_manifests.py" diff --git a/server/security/test_compose_manifests.py b/server/security/test_compose_manifests.py new file mode 100644 index 00000000..a17af047 --- /dev/null +++ b/server/security/test_compose_manifests.py @@ -0,0 +1,32 @@ +from pathlib import Path +import unittest + + +ROOT = Path(__file__).parents[2] + + +class ComposeManifestTest(unittest.TestCase): + def test_allocated_fixture_is_independent_of_legacy_smoke(self): + allocated = (ROOT / "compose.allocated-smoke.yml").read_text() + legacy = (ROOT / "compose.phase6-smoke.yml").read_text() + self.assertIn("target: testkit-api", allocated) + self.assertIn('"18080:8080"', allocated) + self.assertNotIn("compose.phase6-smoke.yml", allocated) + self.assertNotIn("18080:8080", legacy) + self.assertNotIn("max-matches", allocated) + + def test_allocated_runner_checks_durable_retry_and_shutdown(self): + runner = (ROOT / "scripts/verify_allocated_compose.sh").read_text() + for marker in ( + "/v1/servers/compose-server/result", + "compose-result-key-123456", + "result_receipts", + "/v1/servers/compose-server/shutdown", + "SERVER_SHUTDOWN", + "down --volumes --remove-orphans", + ): + self.assertIn(marker, runner) + + +if __name__ == "__main__": + unittest.main()