diff --git a/Game/scripts/agones_sdk.gd b/Game/scripts/agones_sdk.gd new file mode 100644 index 00000000..c0df06ba --- /dev/null +++ b/Game/scripts/agones_sdk.gd @@ -0,0 +1,101 @@ +class_name AgonesSDK +extends Node + +# Dependency-free REST bridge for the Agones sidecar. The Go supervisor owns +# the process-ready probe and /ready transition; this node owns the game +# process's periodic Health pings and terminal Shutdown/annotation calls. + +const HEALTH_INTERVAL_SECONDS := 2.0 +const REQUEST_TIMEOUT_SECONDS := 2.0 +const MAX_ANNOTATION_VALUE_LENGTH := 4096 + +var _base_url := "" +var _health_timer: Timer = null +var _health_in_flight := false + + +func configure_from_environment() -> bool: + var port := OS.get_environment("AGONES_SDK_HTTP_PORT") + if port.is_empty() or not port.is_valid_int() or int(port) < 1 or int(port) > 65535: + return false + _base_url = "http://127.0.0.1:%d" % int(port) + return true + + +func configure_for_testing(base_url: String) -> bool: + if not base_url.begins_with("http://127.0.0.1:") and not base_url.begins_with("http://localhost:"): + return false + _base_url = base_url.trim_suffix("/") + return true + + +func is_available() -> bool: + return not _base_url.is_empty() + + +func start_health() -> void: + if not is_available() or _health_timer != null: + return + _health_timer = Timer.new() + _health_timer.name = "AgonesHealth" + _health_timer.wait_time = HEALTH_INTERVAL_SECONDS + _health_timer.one_shot = false + _health_timer.timeout.connect(_send_health) + add_child(_health_timer) + _health_timer.start() + _send_health() + + +func stop_health() -> void: + if _health_timer != null: + _health_timer.stop() + _health_timer.queue_free() + _health_timer = null + + +func health() -> int: + return await _request(HTTPClient.METHOD_POST, "/health", {}) + + +func mark_ready() -> int: + return await _request(HTTPClient.METHOD_POST, "/ready", {}) + + +func shutdown() -> int: + return await _request(HTTPClient.METHOD_POST, "/shutdown", {}) + + +func set_annotation(key: String, value: String) -> int: + if not annotation_is_valid(key, value): + return 400 + return await _request(HTTPClient.METHOD_PUT, "/metadata/annotation", {"key": key, "value": value}) + + +static func annotation_is_valid(key: String, value: String) -> bool: + return not (key.is_empty() or value.is_empty() or value.length() > MAX_ANNOTATION_VALUE_LENGTH or "\n" in key or "\r" in key or "\n" in value or "\r" in value) + + +func _send_health() -> void: + if _health_in_flight or not is_available(): + return + _health_in_flight = true + var status := await health() + _health_in_flight = false + if status < 200 or status >= 300: + push_warning("Agones health ping failed (%d)" % status) + + +func _request(method: int, path: String, payload: Dictionary) -> int: + if not is_available() or not path.begins_with("/"): + return 408 + var request := HTTPRequest.new() + request.timeout = REQUEST_TIMEOUT_SECONDS + add_child(request) + var body := JSON.stringify(payload) + var err := request.request(_base_url + path, PackedStringArray(["Content-Type: application/json"]), method, body) + if err != OK: + request.queue_free() + return 599 + var result = await request.request_completed + request.queue_free() + return int(result[1]) diff --git a/Game/scripts/server_boot.gd b/Game/scripts/server_boot.gd index ebe6e842..473ba0fe 100644 --- a/Game/scripts/server_boot.gd +++ b/Game/scripts/server_boot.gd @@ -2,6 +2,7 @@ extends Node const NetCodec = preload("res://scripts/net_codec.gd") const ServerControlScript = preload("res://scripts/server_control.gd") +const AgonesSDKScript = preload("res://scripts/agones_sdk.gd") # Headless dedicated server entry point (task 1.6). Parses CLI args, hosts # via NetworkManager, logs structured lines, and watches for physics-tick @@ -28,6 +29,7 @@ var _last_physics_frame := 0 var config: ServerConfig = null var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun var _control: ServerControl = null +var _agones = null var _drain_requested := false @@ -89,6 +91,11 @@ func _ready() -> void: printerr("cosmic-clash-server: refusing to start with invalid readiness control port") get_tree().quit(1) return + _agones = AgonesSDKScript.new() + _agones.name = "AgonesSDK" + get_tree().root.add_child.call_deferred(_agones) + if _agones.configure_from_environment(): + _agones.start_health() NetworkManager.client_connected.connect(_on_client_connected) NetworkManager.client_disconnected.connect(_on_client_disconnected) diff --git a/Game/scripts/server_control.gd b/Game/scripts/server_control.gd index b2ae77f7..a4401c6e 100644 --- a/Game/scripts/server_control.gd +++ b/Game/scripts/server_control.gd @@ -67,10 +67,10 @@ func _respond(peer: StreamPeerTCP, request: String) -> void: var status := 404 var reason := "Not Found" var body := "" - if method == "GET" and path == "/ready": + if method in ["GET", "POST"] and path == "/ready": status = 200 if _ready_for_connections else 503 reason = "OK" if status == 200 else "Service Unavailable" - elif method == "GET" and path == "/health": + elif method in ["GET", "POST"] and path == "/health": status = 200 reason = "OK" elif method == "POST" and path == "/drain": diff --git a/Game/tests/agones_sdk_smoke.gd b/Game/tests/agones_sdk_smoke.gd new file mode 100644 index 00000000..1ed43f98 --- /dev/null +++ b/Game/tests/agones_sdk_smoke.gd @@ -0,0 +1,33 @@ +extends SceneTree + +const ServerControlScript = preload("res://scripts/server_control.gd") +const AgonesSDKScript = preload("res://scripts/agones_sdk.gd") +const PORT := 18081 + + +func _init() -> void: + var fake_sidecar = ServerControlScript.new() + root.add_child(fake_sidecar) + if fake_sidecar.start(PORT) != OK: + printerr("fake sidecar failed to bind") + quit(1) + return + fake_sidecar.set_process_ready(true) + var sdk = AgonesSDKScript.new() + root.add_child(sdk) + if not sdk.configure_for_testing("http://127.0.0.1:%d" % PORT): + printerr("SDK test configuration failed") + quit(1) + return + await process_frame + var health_status := await sdk.health() + var ready_status := await sdk.mark_ready() + var annotation_status := await sdk.set_annotation("match", "result") + var shutdown_status := await sdk.shutdown() + if health_status != 200 or ready_status != 200 or annotation_status < 400 or shutdown_status < 400: + printerr("Agones SDK smoke statuses health=%d ready=%d annotation=%d shutdown=%d" % [health_status, ready_status, annotation_status, shutdown_status]) + quit(1) + return + print("Agones SDK smoke passed") + fake_sidecar.stop() + quit(0) diff --git a/Game/tests/cases/test_agones_sdk.gd b/Game/tests/cases/test_agones_sdk.gd new file mode 100644 index 00000000..7a8c6b27 --- /dev/null +++ b/Game/tests/cases/test_agones_sdk.gd @@ -0,0 +1,19 @@ +extends "res://tests/test_case.gd" + +const AgonesSDKScript = preload("res://scripts/agones_sdk.gd") + + +func test_sdk_requires_loopback_sidecar_url() -> void: + var sdk = AgonesSDKScript.new() + assert_true(not sdk.configure_for_testing("https://agones.example"), "remote sidecar URL is rejected") + assert_true(not sdk.is_available(), "rejected sidecar is unavailable") + assert_true(sdk.configure_for_testing("http://127.0.0.1:9358"), "loopback sidecar URL is accepted") + assert_true(sdk.is_available(), "accepted sidecar is available") + sdk.queue_free() + + +func test_annotation_validation_rejects_header_injection_and_oversized_values() -> void: + assert_true(AgonesSDKScript.annotation_is_valid("match", "result"), "ordinary annotation is accepted") + assert_true(not AgonesSDKScript.annotation_is_valid("bad\nkey", "value"), "annotation key newline is rejected") + assert_true(not AgonesSDKScript.annotation_is_valid("key", "bad\rvalue"), "annotation value newline is rejected") + assert_true(not AgonesSDKScript.annotation_is_valid("key", "x".repeat(4097)), "oversized annotation is rejected") diff --git a/multiplayer-next.md b/multiplayer-next.md index 80d52b59..9c14d854 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -48,8 +48,9 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). - [ ] **IN PROGRESS:** Define assignment compatibility and opt-in `ServerConfig` flags whose defaults reproduce the community-server path. Allocation manifest validation now covers client build and future expiry; allocated servers now - expose loopback process-ready/drain control and fence new admissions while - draining; signed admission remains. + expose loopback process-ready/drain control, an Agones REST bridge for + health/lifecycle calls, and fence new admissions while draining; signed + admission remains. ## Phase 8 — identity and security diff --git a/multiplayer-todo.md b/multiplayer-todo.md index f97f3a89..398410d8 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1210,8 +1210,8 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| | 8.26 `[D:8.1,8.6,8.12]` | **IN PROGRESS.** Provider-neutral Kustomize base now defines a restricted Agones Fleet with region/build/protocol/transport labels and UDP game port, plus distinct EU/NA overlays; the base avoids rewriting cross-namespace Agones RBAC | `deploy/k8s/base/fleet.yaml`, `overlays/eu`, `overlays/na` and `server/security/test_fleet_manifests.py` cover labels, replica floor, UDP declaration, pod hardening, overlay distinction and RBAC namespace safety; live Kustomize/Agones rendering, second-provider fixtures, edge/network/DNS/secret and SDR POP/cert/public-UDP overlays remain | -| 8.27 `[D:8.26]` | **IN PROGRESS.** Go supervisor package provides local-safe Agones REST discovery, validates assigned address/port data, injects dynamic `SDR_LISTEN_PORT`/`SDR_IP`, performs explicit process-ready probing and Ready transition; direct mode bypasses Agones | `server/supervisor/` covers allocated/direct startup, invalid endpoint rejection, dynamic endpoint/Ready ordering and authenticated drain; allocated Godot now supplies a loopback readiness/drain control surface; metadata watch, Agones Health/annotation/Shutdown and emulator integration remain | -| 8.28 `[D:8.6,8.27]` | **IN PROGRESS.** Supervisor separates explicit process-ready from Agones Ready and never scrapes stdout; allocated mode refuses to mark Ready without a configured readiness probe | `server/supervisor/` tests prove Ready follows the probe and direct mode remains functional; `server_control.gd` plus the process-level smoke prove loopback `/ready`, `/health`, bearer-protected `/drain`, and drain admission fencing; detached-container and Health-reclaim integration remain | +| 8.27 `[D:8.26]` | **IN PROGRESS.** Go supervisor package provides local-safe Agones REST discovery, validates assigned address/port data, injects dynamic `SDR_LISTEN_PORT`/`SDR_IP`, performs explicit process-ready probing and Ready transition; direct mode bypasses Agones | `server/supervisor/` covers allocated/direct startup, invalid endpoint rejection, dynamic endpoint/Ready ordering and authenticated drain; allocated Godot now supplies a loopback readiness/drain control surface and `agones_sdk.gd` supplies sidecar Health/Ready/Shutdown/annotation REST operations; metadata watch, real Agones annotation/shutdown confirmation and emulator integration remain | +| 8.28 `[D:8.6,8.27]` | **IN PROGRESS.** Supervisor separates explicit process-ready from Agones Ready and never scrapes stdout; allocated mode refuses to mark Ready without a configured readiness probe | `server/supervisor/` tests prove Ready follows the probe and direct mode remains functional; `server_control.gd`, `agones_sdk.gd` and process-level smokes prove loopback `/ready`, `/health`, bearer-protected `/drain`, sidecar-shaped Health/Ready calls and drain admission fencing; detached-container and Health-reclaim integration remain | | 8.29 `[D:8.26,8.27]` | **IN PROGRESS.** Supervisor discovers and validates the Agones endpoint, propagates the actual dynamic `--port`, and exports `SDR_LISTEN_PORT`/`SDR_IP` only for Hosted-SDR while preserving an isolated ENet path | `server/supervisor/` tests cover invalid address/port rejection, dynamic port argument/env propagation and SDR-vs-ENet separation; real Agones dynamic/passthrough mapping, POP/cert/firewall/NAT and multi-match fixture remain | | 8.30 `[D:8.18,8.26,8.28,8.29]` | **IN PROGRESS.** Pure Go allocator filters Ready servers by region/build/protocol/transport, atomically claims one with idempotent allocation replay, and now owns the assignment-publication boundary | `server/domain/allocator.go` covers deterministic compatible selection, exhaustion, conflicting/identical allocation replay, unknown allocations, and assignment replay/conflict; Agones `GameServerAllocation`, signed roster metadata, bounded cross-replica retry and live integration remain | | 8.31 `[D:8.9,8.30]` | **IN PROGRESS.** Pure Go assignment gate requires Allocated state, exact allocation ID/match/server/region/build/protocol/transport compatibility, non-empty hosted endpoint and verified manifest signature before exposure; allocator publication cannot expose Ready state | `server/domain/assignment.go` and `allocator.go` plus adversarial fixtures cover early-connect, tampered signature/manifest, wrong compatibility, empty endpoint, unknown allocation and post-publication mutation rejection; Agones metadata watch, hosted-address registration, production signer and client-ticket publication remain |