mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat: add Godot Agones REST bridge
This commit is contained in:
@@ -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])
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
+3
-2
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user