test(multiplayer): harden agones allocation gate

This commit is contained in:
Josh Creek
2026-09-01 18:16:54 +01:00
parent e3fad7064b
commit 7c044b7094
7 changed files with 133 additions and 15 deletions
+1 -1
View File
@@ -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.398.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.478.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.17.8), live PostgreSQL/Redis execution where Docker is unavailable, live Agones/kind lifecycle (8.308.38, 8.49), public-network chaos/load/cost/release gates (8.508.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.17.8), live PostgreSQL/Redis execution where Docker is unavailable, live Agones/kind lifecycle (8.308.38, 8.49), public-network chaos/load/cost/release gates (8.508.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
@@ -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()
@@ -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())
+1 -13
View File
@@ -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"
+1
View File
@@ -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"
@@ -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()
+2 -1
View File
@@ -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__":