mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
ca70568fad
Several independent causes, all of which had to be right before the Fleet could reach Ready. The supervisor pointed --sdk-base-url at 127.0.0.1:9357, which is the Agones sidecar's gRPC port; its HTTP surface is 9358, and that is what AGONES_SDK_HTTP_PORT carries and what agones_sdk.gd reads. An HTTP client against the gRPC port could never have worked, in kind or in production. The supervisor also treated the sidecar's first incomplete /gameserver response as fatal. The sidecar accepts requests before the controller populates status.address and status.ports, so this produced a restart loop precisely during normal Agones startup. It now polls until the endpoint is assigned or ReadyTimeout elapses. server_boot.gd started ServerControl and the Agones SDK only under --allocated-mode, but the kind smoke deliberately strips that flag, so nothing served the readiness probe and the GameServer could never become Ready. Lifecycle now keys on AGONES_SDK_HTTP_PORT, which Agones injects into every managed container, while allocation and roster semantics stay tied to --allocated-mode. The SDK node is added to the tree non-deferred, since start_health() creates a Timer immediately. Fleet: Agones assigns its own SDK service account and masks that token from the game container while keeping it for the injected sidecar, so the manifest must not pin serviceAccountName or automountServiceAccountToken. Godot stores user:// under HOME, so HOME points at the writable runtime volume to keep the root filesystem read-only, and fsGroup makes that volume writable for the non-root user. Namespace: Agones' Dynamic port policy injects a hostPort, which both the baseline and restricted Pod Security Standards forbid, so the workload namespace enforces privileged while continuing to audit and warn against restricted. NetworkPolicy: the injected sidecar reaches the Kubernetes API over HTTPS, and NetworkPolicy applies to the whole Pod rather than to the container whose token was masked. The kind runner creates the namespace before Helm so Agones can install its per-namespace SDK RBAC, scopes gameservers.namespaces to it, forces the allocator and ping Services to ClusterIP because LoadBalancer ingress never becomes ready in plain kind, and labels the node so the production Fleet's on-demand/zone constraints are exercised rather than edited out of the rendered manifest.
291 lines
14 KiB
GDScript
291 lines
14 KiB
GDScript
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")
|
|
const AssignmentState = preload("res://scripts/assignment_state.gd")
|
|
const ConnectionLeaseClientScript = preload("res://scripts/connection_lease_client.gd")
|
|
const ServerResultClientScript = preload("res://scripts/server_result_client.gd")
|
|
|
|
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
|
|
# via NetworkManager, logs structured lines, and watches for physics-tick
|
|
# overrun (§9 gotcha 9: Engine.max_physics_steps_per_frame defaults to 8;
|
|
# a tick overrunning 16.7ms backs up the accumulator and the next frame
|
|
# runs multiple ticks, spiking CPU further — worth logging, not just
|
|
# silently absorbing).
|
|
#
|
|
# Run: godot --headless --path Game res://scenes/server_boot.tscn -- --port=7777
|
|
#
|
|
# Dedicated *exports* reach this scene without the CLI argument above, via the
|
|
# `run/main_scene.dedicated_server` feature override in project.godot — the same
|
|
# project-setting mechanism the training export uses for training.tscn. That
|
|
# override is deliberately uncommented in project.godot: Godot's ConfigFile
|
|
# writer does not round-trip comments, and a `#` block directly above a setting
|
|
# can be spliced into the setting's own line on rewrite, silently commenting it
|
|
# out and sending dedicated builds to the interactive main menu instead of here.
|
|
# `tests/cases/test_project_settings.gd` fails loudly if that ever happens.
|
|
#
|
|
# Deliberately does not spawn a match yet — that's Phase 2's networked_match
|
|
# scene. This is just the process shell: listen, log, idle cheaply.
|
|
|
|
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 _match_loop: ServerMatchLoop = null
|
|
var _agones = null
|
|
var _connection_leases = null
|
|
var _result_client = null
|
|
var _drain_requested := false
|
|
|
|
|
|
func _ready() -> void:
|
|
Engine.max_fps = 60 # a server never renders; this just caps the idle-frame poll rate so it doesn't spin
|
|
|
|
# Task 6.3. This process owns the whole command line, so it parses STRICTLY:
|
|
# an unknown flag or an out-of-range value stops the server with a message
|
|
# rather than starting one that silently ignores half of what it was told.
|
|
config = ServerConfig.parse(OS.get_cmdline_user_args())
|
|
if config.help_requested:
|
|
print(ServerConfig.help_text())
|
|
get_tree().quit(0)
|
|
return
|
|
if not config.is_valid():
|
|
# Straight to stderr-ish plain print rather than through _log: the log
|
|
# level itself may be one of the things that failed to parse, and an
|
|
# operator running this by hand needs to see every problem at once, not
|
|
# the first one.
|
|
printerr("cosmic-clash-server: refusing to start")
|
|
for problem in config.errors:
|
|
printerr(" %s" % problem)
|
|
printerr("try --help")
|
|
get_tree().quit(1)
|
|
return
|
|
ServerLog.configure(String(config.get_value("log-level")))
|
|
var port := int(config.get_value("port"))
|
|
var max_clients := int(config.get_value("max-clients"))
|
|
var allocated_mode := bool(config.get_value("allocated-mode"))
|
|
var assigned_transport := String(config.get_value("transport"))
|
|
# Hosted SDR is not wired into the Godot transport layer yet. Refuse the
|
|
# allocated launch rather than silently opening an ENet endpoint that does
|
|
# not match the signed assignment's transport contract.
|
|
if allocated_mode and assigned_transport != NetworkManager.TRANSPORT_ENET:
|
|
printerr("cosmic-clash-server: allocated transport '%s' is not supported by this build" % assigned_transport)
|
|
get_tree().quit(1)
|
|
return
|
|
# Agones injects its HTTP port into every managed game-server container.
|
|
# Keep lifecycle readiness and health active in the reduced kind smoke even
|
|
# though that environment intentionally omits allocation/roster semantics.
|
|
var agones_managed := not OS.get_environment("AGONES_SDK_HTTP_PORT").is_empty()
|
|
if allocated_mode or agones_managed:
|
|
_control = ServerControlScript.new()
|
|
_control.name = "ServerControl"
|
|
_control.drain_requested.connect(_on_drain_requested)
|
|
_control.initial_connect_ready.connect(_on_initial_connect_ready)
|
|
get_tree().root.add_child.call_deferred(_control)
|
|
var control_err := _control.start(int(config.get_value("readiness-port")), OS.get_environment(String(config.get_value("drain-token-env"))))
|
|
if control_err != OK:
|
|
printerr("cosmic-clash-server: refusing to start with invalid readiness control port")
|
|
get_tree().quit(1)
|
|
return
|
|
if agones_managed:
|
|
_agones = AgonesSDKScript.new()
|
|
_agones.name = "AgonesSDK"
|
|
# Health creates and starts a Timer immediately, so the SDK node must be
|
|
# in the tree before start_health() runs.
|
|
get_tree().root.add_child(_agones)
|
|
if _agones.configure_from_environment():
|
|
_agones.start_health()
|
|
if allocated_mode:
|
|
var roster_file := String(config.get_value("join-authorisations-file"))
|
|
var key_file := String(config.get_value("join-authorisations-key-file"))
|
|
var roster_json := FileAccess.get_file_as_string(roster_file)
|
|
var signing_keys := _load_join_signing_keys(key_file)
|
|
var roster_tokens = JSON.parse_string(roster_json)
|
|
if not roster_tokens is Array or roster_tokens.is_empty() or signing_keys.is_empty() or not MatchNet.configure_join_authorisations(roster_tokens, {
|
|
"match_id": String(config.get_value("match-id")),
|
|
"server_id": String(config.get_value("server-id")),
|
|
"protocol": str(NetCodec.PROTOCOL_VERSION),
|
|
"protocol_version": NetCodec.PROTOCOL_VERSION,
|
|
}, signing_keys) or MatchNet.assigned_player_slots().size() != roster_tokens.size():
|
|
printerr("cosmic-clash-server: refusing to start with invalid join-authorisations-file")
|
|
get_tree().quit(1)
|
|
return
|
|
# An allocated process owns exactly the roster issued for this match.
|
|
# Never let the general-purpose direct-server default (one player) start
|
|
# an allocated match with only a partial assignment admitted.
|
|
config.values["min-players"] = required_min_players(true, roster_tokens.size(), int(config.get_value("min-players")))
|
|
_connection_leases = ConnectionLeaseClientScript.new()
|
|
_connection_leases.name = "ConnectionLeases"
|
|
var lease_url := OS.get_environment("COSMIC_CLASH_CONTROL_PLANE_URL")
|
|
var lease_token := OS.get_environment("COSMIC_CLASH_WORKLOAD_TOKEN")
|
|
if _connection_leases.configure(lease_url, lease_token, String(config.get_value("match-id")), String(config.get_value("server-id"))):
|
|
_connection_leases.reconciliation_failed.connect(_on_connection_lease_reconciliation_failed)
|
|
get_tree().root.add_child.call_deferred(_connection_leases)
|
|
MatchNet.configure_connection_lease_callbacks(_connection_leases.claim, _connection_leases.record_disconnect)
|
|
else:
|
|
_connection_leases.queue_free()
|
|
_connection_leases = null
|
|
# Allocated matches must never fall back to an in-memory connection
|
|
# generation. Doing so would admit a player without the durable fence
|
|
# that prevents a second process (or a stale peer) from owning the same
|
|
# ranked slot. Direct/community servers do not enter this branch.
|
|
printerr("cosmic-clash-server: refusing allocated startup without connection-lease configuration")
|
|
get_tree().quit(1)
|
|
return
|
|
_result_client = ServerResultClientScript.new()
|
|
_result_client.name = "ServerResults"
|
|
if not _result_client.configure(lease_url, lease_token, String(config.get_value("match-id")), String(config.get_value("server-id"))):
|
|
printerr("cosmic-clash-server: refusing allocated startup without result-submission configuration")
|
|
get_tree().quit(1)
|
|
return
|
|
_result_client.accepted.connect(func(): MatchNet.result_submission_accepted.emit())
|
|
_result_client.retrying.connect(func(http_code): MatchNet.result_submission_retrying.emit(http_code))
|
|
get_tree().root.add_child.call_deferred(_result_client)
|
|
MatchNet.configure_result_submission(_result_client.submit)
|
|
|
|
NetworkManager.client_connected.connect(_on_client_connected)
|
|
NetworkManager.client_disconnected.connect(_on_client_disconnected)
|
|
MatchNet.player_joined.connect(_on_player_joined)
|
|
MatchNet.player_left.connect(_on_player_left)
|
|
|
|
var err := NetworkManager.host(port, max_clients)
|
|
if err != OK:
|
|
ServerLog.error("server_boot_failed", {"port": port, "error": error_string(err)})
|
|
get_tree().quit(1)
|
|
return
|
|
if _control != null:
|
|
_control.set_process_ready(true)
|
|
_install_match_loop()
|
|
ServerLog.info("server_started", {
|
|
"port": port, "max_clients": max_clients, "log_level": ServerLog.level_name(),
|
|
"min_players": int(config.get_value("min-players")),
|
|
"max_matches": 1 if allocated_mode else int(config.get_value("max-matches")),
|
|
"arena_rotation": String(config.get_value("arena-rotation")),
|
|
"allocated_mode": allocated_mode,
|
|
"match_id": String(config.get_value("match-id")) if allocated_mode else "",
|
|
"server_id": String(config.get_value("server-id")) if allocated_mode else "",
|
|
"region": String(config.get_value("region")) if allocated_mode else "",
|
|
"transport": assigned_transport if allocated_mode else NetworkManager.TRANSPORT_ENET,
|
|
})
|
|
_last_physics_frame = Engine.get_physics_frames()
|
|
|
|
|
|
# Task 6.5. Parented to the ROOT rather than to this node: the loop calls
|
|
# change_scene_to_file, which frees the current scene — and this boot scene IS
|
|
# the current scene, so a loop parented here would be freed by the first match
|
|
# it started. Same constraint the smoke-test hooks document.
|
|
func _install_match_loop() -> void:
|
|
var loop := ServerMatchLoop.new()
|
|
_match_loop = loop
|
|
loop.name = "ServerMatchLoop"
|
|
loop.min_players = int(config.get_value("min-players"))
|
|
loop.start_countdown_seconds = float(config.get_value("start-countdown"))
|
|
loop.max_matches = 1 if bool(config.get_value("allocated-mode")) else int(config.get_value("max-matches"))
|
|
loop.rotation_mode = String(config.get_value("arena-rotation"))
|
|
loop.allocated_mode = bool(config.get_value("allocated-mode"))
|
|
loop.allocated_playlist = String(config.get_value("playlist"))
|
|
loop.allocated_roster_size = MatchNet.assigned_player_slots().size() if loop.allocated_mode else 0
|
|
loop.allocated_arena_path = String(config.get_value("arena-path"))
|
|
# The backend's fair timeout starts only after durable assignment-ready.
|
|
# When a control plane is present, the supervisor arms this loop through
|
|
# the authenticated local control endpoint after that transition commits.
|
|
loop.allocated_admission_armed = not loop.allocated_mode or OS.get_environment("COSMIC_CLASH_INITIAL_CONNECT_SIGNAL_REQUIRED") != "1"
|
|
get_tree().root.add_child.call_deferred(loop)
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
NetworkManager.poll()
|
|
if _drain_requested:
|
|
var scene := get_tree().current_scene
|
|
if not (is_instance_valid(scene) and scene.is_in_group("game")) and MatchNet.roster.is_empty():
|
|
get_tree().quit(0)
|
|
var current := Engine.get_physics_frames()
|
|
var steps := current - _last_physics_frame
|
|
_last_physics_frame = current
|
|
# §9 gotcha 6: with physics_jitter_fix = 0.0, frames legitimately
|
|
# alternate between 0 and 2 ticks even on an idle, healthy server —
|
|
# that's expected quantisation, not backlog. A real overrun is the
|
|
# accumulator failing to drain back down, i.e. 3+ ticks in one frame.
|
|
if steps > 2 and _watchdog_armed:
|
|
ServerLog.warn("physics_overrun", {"steps": steps})
|
|
_watchdog_armed = true
|
|
|
|
|
|
func _physics_process(_delta: float) -> void:
|
|
NetworkManager.poll()
|
|
|
|
|
|
func _on_client_connected(peer_id: int) -> void:
|
|
ServerLog.debug("peer_connected", {"peer_id": peer_id})
|
|
|
|
|
|
func _on_client_disconnected(peer_id: int) -> void:
|
|
ServerLog.debug("peer_disconnected", {"peer_id": peer_id})
|
|
|
|
|
|
func _on_player_joined(peer_id: int, player_name: String) -> void:
|
|
ServerLog.info("player_joined", {"peer_id": peer_id, "name": player_name, "roster": MatchNet.roster.size()})
|
|
|
|
|
|
func _on_player_left(peer_id: int) -> void:
|
|
ServerLog.info("player_left", {"peer_id": peer_id, "roster": MatchNet.roster.size()})
|
|
|
|
|
|
func _on_drain_requested() -> void:
|
|
_drain_requested = true
|
|
MatchNet.admissions_open = false
|
|
MatchNet.broadcast_server_shutdown("server_draining")
|
|
ServerLog.info("server_draining", {"reason": "control_request"})
|
|
|
|
|
|
func _on_initial_connect_ready() -> void:
|
|
if _match_loop != null and is_instance_valid(_match_loop):
|
|
_match_loop.arm_allocated_admission()
|
|
ServerLog.info("initial_connect_window_started", {"match_id": String(config.get_value("match-id"))})
|
|
|
|
|
|
func _on_connection_lease_reconciliation_failed(reason: String) -> void:
|
|
# A durable/local divergence means this process can no longer prove that a
|
|
# future generation is globally current. Preserve the live match but close
|
|
# admission so it cannot mint additional ambiguous leases.
|
|
MatchNet.admissions_open = false
|
|
ServerLog.error("connection_lease_reconciliation_failed", {"reason": reason})
|
|
|
|
|
|
static func valid_connection_report_configuration(base_url: String, workload_token: String, match_id: String, server_id: String, player_id: String) -> bool:
|
|
return ConnectionLeaseClientScript.valid_configuration(base_url, workload_token, match_id, server_id) and AssignmentState.is_valid_opaque_id(player_id)
|
|
|
|
|
|
static func required_min_players(allocated: bool, roster_size: int, configured: int) -> int:
|
|
if allocated and roster_size > 0:
|
|
return roster_size
|
|
return configured
|
|
|
|
|
|
# The join-signing key file maps key ID -> base64 raw key, so the allocator can
|
|
# rotate the signing key without invalidating authorisations already issued for
|
|
# in-flight matches: a rotation publishes the new key alongside the old, and the
|
|
# old one is dropped only once no live match can still reference it.
|
|
#
|
|
# A file containing raw key bytes (no JSON object) is accepted as a single key
|
|
# under the empty ID, which is what an unrotated deployment and the local smoke
|
|
# fixtures use.
|
|
static func _load_join_signing_keys(key_file: String) -> Dictionary:
|
|
var raw := FileAccess.get_file_as_bytes(key_file)
|
|
if raw.is_empty():
|
|
return {}
|
|
var parsed = JSON.parse_string(raw.get_string_from_utf8())
|
|
if not parsed is Dictionary or (parsed as Dictionary).is_empty():
|
|
return {"": raw}
|
|
var keys := {}
|
|
for key_id in parsed:
|
|
var encoded = parsed[key_id]
|
|
if not encoded is String or String(encoded).is_empty():
|
|
return {}
|
|
var decoded := Marshalls.base64_to_raw(String(encoded))
|
|
if decoded.is_empty():
|
|
return {}
|
|
keys[str(key_id)] = decoded
|
|
return keys
|