From 7c044b7094763d82e105393b22c0fde78a8a8f9b Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:16:54 +0100 Subject: [PATCH] test(multiplayer): harden agones allocation gate --- multiplayer-next.md | 2 +- .../test_verify_agones_allocation_response.py | 64 +++++++++++++++++++ scripts/verify_agones_allocation_response.py | 59 +++++++++++++++++ scripts/verify_kind_agones.sh | 14 +--- scripts/verify_multiplayer_local.sh | 1 + server/security/test_compose_manifests.py | 5 ++ server/security/test_fleet_manifests.py | 3 +- 7 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 scripts/test_verify_agones_allocation_response.py create mode 100644 scripts/verify_agones_allocation_response.py diff --git a/multiplayer-next.md b/multiplayer-next.md index 4280972f..2d578256 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1462,7 +1462,7 @@ Observability redaction now adds content-aware protection on top of denylisted f 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 including provider-outcome recovery fencing; 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. +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; its response validator is unit-tested against malformed/ambiguous allocation payloads, but the runner still 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. The deferred teamplay TODO prerequisite is now implemented locally but not enabled: team-touch credit is opt-in and the evaluator can run paired 2v2 diff --git a/scripts/test_verify_agones_allocation_response.py b/scripts/test_verify_agones_allocation_response.py new file mode 100644 index 00000000..6e4faa1a --- /dev/null +++ b/scripts/test_verify_agones_allocation_response.py @@ -0,0 +1,64 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from verify_agones_allocation_response import validate_allocation + + +def response(**overrides): + document = { + "status": { + "state": "Allocated", + "gameServer": { + "metadata": {"name": "cosmic-clash-game-abc"}, + "status": { + "address": "10.0.0.7", + "ports": [{"name": "game", "port": 31001}], + }, + }, + } + } + document["status"].update(overrides) + return document + + +class AgonesAllocationResponseTest(unittest.TestCase): + def test_accepts_allocated_game_server_with_named_udp_port(self): + self.assertEqual(validate_allocation(response()), ("cosmic-clash-game-abc", 31001)) + + def test_rejects_non_allocated_state(self): + with self.assertRaises(ValueError): + validate_allocation(response(state="Ready")) + + def test_rejects_missing_identity_or_address(self): + missing_name = response() + missing_name["status"]["gameServer"]["metadata"] = {} + with self.assertRaises(ValueError): + validate_allocation(missing_name) + + missing_address = response() + missing_address["status"]["gameServer"]["status"]["address"] = "0.0.0.0" + with self.assertRaises(ValueError): + validate_allocation(missing_address) + + def test_rejects_ambiguous_or_invalid_game_ports(self): + duplicate = response() + duplicate["status"]["gameServer"]["status"]["ports"].append({"name": "game", "port": 31002}) + with self.assertRaises(ValueError): + validate_allocation(duplicate) + + wrong_name = response() + wrong_name["status"]["gameServer"]["status"]["ports"] = [{"name": "query", "port": 31001}] + with self.assertRaises(ValueError): + validate_allocation(wrong_name) + + invalid_port = response() + invalid_port["status"]["gameServer"]["status"]["ports"][0]["port"] = 70000 + with self.assertRaises(ValueError): + validate_allocation(invalid_port) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_agones_allocation_response.py b/scripts/verify_agones_allocation_response.py new file mode 100644 index 00000000..d93116d8 --- /dev/null +++ b/scripts/verify_agones_allocation_response.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Validate the small Agones allocation response surface used by the smoke gate.""" + +import json +import sys +from typing import Any + + +def validate_allocation(document: dict[str, Any]) -> tuple[str, int]: + status = document.get("status") + if not isinstance(status, dict) or status.get("state") != "Allocated": + raise ValueError(f"allocation state is {status.get('state') if isinstance(status, dict) else None!r}, expected 'Allocated'") + + game_server = status.get("gameServer") + if not isinstance(game_server, dict): + raise ValueError("allocation did not return a GameServer") + metadata = game_server.get("metadata") + name = metadata.get("name") if isinstance(metadata, dict) else None + if not isinstance(name, str) or not name.strip(): + raise ValueError("allocation GameServer has no metadata.name") + + game_status = game_server.get("status") + if not isinstance(game_status, dict): + raise ValueError("allocation GameServer has no status") + address = game_status.get("address") + if not isinstance(address, str) or not address.strip() or any(char.isspace() for char in address): + raise ValueError(f"allocation returned an invalid address: {address!r}") + if address in {"0.0.0.0", "::"}: + raise ValueError(f"allocation returned an unspecified address: {address!r}") + + ports = game_status.get("ports") + if not isinstance(ports, list): + raise ValueError("allocation GameServer has no ports") + game_ports = [ + entry.get("port") + for entry in ports + if isinstance(entry, dict) and entry.get("name") == "game" + ] + if len(game_ports) != 1 or not isinstance(game_ports[0], int) or not 1 <= game_ports[0] <= 65535: + raise ValueError(f"allocation did not return exactly one valid named game port: {ports!r}") + return name, game_ports[0] + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} allocation.json", file=sys.stderr) + return 2 + try: + with open(sys.argv[1], encoding="utf-8") as handle: + name, port = validate_allocation(json.load(handle)) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"8.49 allocation validation failed: {error}", file=sys.stderr) + return 1 + print(f"8.49 PASS: Fleet became ready; GameServer {name} returned game UDP port {port}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_kind_agones.sh b/scripts/verify_kind_agones.sh index 715b75d8..56e2e6fa 100755 --- a/scripts/verify_kind_agones.sh +++ b/scripts/verify_kind_agones.sh @@ -99,16 +99,4 @@ spec: EOF kubectl create -f "$work_dir/allocation.yaml" -o json > "$work_dir/allocation.json" -python3 - "$work_dir/allocation.json" <<'PY' -import json -import sys - -doc = json.load(open(sys.argv[1], encoding="utf-8")) -status = doc.get("status", {}) -if status.get("state") != "Allocated": - raise SystemExit(f"allocation state is {status.get('state')!r}, expected 'Allocated'") -ports = status.get("gameServer", {}).get("status", {}).get("ports", []) -if not ports or not any(p.get("port", 0) > 0 and p.get("port") != 7777 for p in ports): - raise SystemExit(f"allocation did not return a dynamic UDP port: {ports!r}") -print("8.49 PASS: Fleet became ready and allocation returned a dynamic UDP port") -PY +python3 scripts/verify_agones_allocation_response.py "$work_dir/allocation.json" diff --git a/scripts/verify_multiplayer_local.sh b/scripts/verify_multiplayer_local.sh index 3ef86fb5..5c172362 100755 --- a/scripts/verify_multiplayer_local.sh +++ b/scripts/verify_multiplayer_local.sh @@ -32,5 +32,6 @@ 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" +python3 -m unittest "$root_dir/scripts/test_verify_agones_allocation_response.py" echo "LOCAL MULTIPLAYER GATE PASS" diff --git a/server/security/test_compose_manifests.py b/server/security/test_compose_manifests.py index f143d22d..f94226ad 100644 --- a/server/security/test_compose_manifests.py +++ b/server/security/test_compose_manifests.py @@ -39,6 +39,11 @@ class ComposeManifestTest(unittest.TestCase): self.assertIn("target: allocator", allocated) self.assertIn("agones-provider", allocated) + def test_kind_runner_uses_strict_allocation_response_validation(self): + runner = (ROOT / "scripts/verify_kind_agones.sh").read_text() + self.assertIn("verify_agones_allocation_response.py", runner) + self.assertNotIn("p.get(\"port\", 0) > 0", runner) + if __name__ == "__main__": unittest.main() diff --git a/server/security/test_fleet_manifests.py b/server/security/test_fleet_manifests.py index 4963df0c..e38282c2 100644 --- a/server/security/test_fleet_manifests.py +++ b/server/security/test_fleet_manifests.py @@ -95,7 +95,8 @@ class FleetManifestTest(unittest.TestCase): self.assertIn("Agones lifecycle smoke", runner) self.assertIn("--control-plane-url=", runner) self.assertIn("--allocated-mode", runner) - self.assertIn("dynamic UDP port", runner) + validator = (ROOT / "scripts/verify_agones_allocation_response.py").read_text() + self.assertIn("game UDP port", validator) if __name__ == "__main__":