Files
Josh Creek ca70568fad fix(agones): make the kind gate's Agones lifecycle actually work
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.
2026-09-05 20:50:01 +01:00

121 lines
3.7 KiB
GDScript

class_name ServerControl
extends Node
# Small loopback HTTP control surface for lifecycle-managed servers. The Go
# supervisor uses GET /ready as the explicit process-ready probe and POST
# /drain during a controlled termination. Direct/community servers outside
# Agones do not start this node.
signal drain_requested
signal initial_connect_ready
var _listener := TCPServer.new()
var _peers: Array = []
var _ready_for_connections := false
var _draining := false
var _drain_token := ""
func start(port: int, drain_token: String = "") -> Error:
if port < 1 or port > 65535:
return ERR_INVALID_PARAMETER
_drain_token = drain_token
return _listener.listen(port, "127.0.0.1")
func stop() -> void:
_listener.stop()
for peer in _peers:
if is_instance_valid(peer):
peer.disconnect_from_host()
_peers.clear()
func set_process_ready(value: bool) -> void:
_ready_for_connections = value and not _draining
func is_draining() -> bool:
return _draining
func _exit_tree() -> void:
stop()
func _process(_delta: float) -> void:
while _listener.is_connection_available():
_peers.append(_listener.take_connection())
for i in range(_peers.size() - 1, -1, -1):
var peer: StreamPeerTCP = _peers[i]
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
_peers.remove_at(i)
continue
var available := peer.get_available_bytes()
if available <= 0:
continue
var request := peer.get_utf8_string(available)
if "\r\n\r\n" not in request:
continue
_respond(peer, request)
_peers.remove_at(i)
func _respond(peer: StreamPeerTCP, request: String) -> void:
var lines := request.split("\r\n")
var first := lines[0].split(" ") if not lines.is_empty() else PackedStringArray()
var method := String(first[0]) if first.size() > 0 else ""
var path := String(first[1]) if first.size() > 1 else ""
var status := 404
var reason := "Not Found"
var body := ""
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 in ["GET", "POST"] and path == "/health":
status = 200
reason = "OK"
elif method == "POST" and path == "/drain":
var supplied := ""
for line in lines:
if line.begins_with("Authorization: Bearer "):
supplied = line.substr("Authorization: Bearer ".length())
if _drain_token.is_empty() or not _constant_time_equal(supplied, _drain_token):
status = 401
reason = "Unauthorized"
else:
_draining = true
_ready_for_connections = false
drain_requested.emit()
status = 202
reason = "Accepted"
elif method == "POST" and path == "/initial-connect-ready":
var supplied := ""
for line in lines:
if line.begins_with("Authorization: Bearer "):
supplied = line.substr("Authorization: Bearer ".length())
if _drain_token.is_empty() or not _constant_time_equal(supplied, _drain_token):
status = 401
reason = "Unauthorized"
else:
initial_connect_ready.emit()
status = 202
reason = "Accepted"
else:
status = 405 if method in ["GET", "POST"] else 400
reason = "Method Not Allowed" if status == 405 else "Bad Request"
body = "{\"status\":\"%s\"}" % ("ready" if status == 200 else "not_ready")
var response := "HTTP/1.1 %d %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" % [status, reason, body.to_utf8_buffer().size(), body]
peer.put_data(response.to_utf8_buffer())
peer.disconnect_from_host()
func _constant_time_equal(a: String, b: String) -> bool:
var left := a.to_utf8_buffer()
var right := b.to_utf8_buffer()
var difference := left.size() ^ right.size()
var length := mini(left.size(), right.size())
for i in length:
difference |= left[i] ^ right[i]
return difference == 0