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.
124 lines
5.7 KiB
Python
124 lines
5.7 KiB
Python
from pathlib import Path
|
|
import unittest
|
|
|
|
|
|
BASE = Path(__file__).parents[2] / "deploy" / "k8s"
|
|
ROOT = Path(__file__).parents[2]
|
|
|
|
|
|
class FleetManifestTest(unittest.TestCase):
|
|
def read(self, path):
|
|
return (BASE / path).read_text()
|
|
|
|
def test_base_fleet_selects_compatible_game_servers(self):
|
|
fleet = self.read("base/fleet.yaml")
|
|
for label in (
|
|
"cosmic-clash.io/region: EU", "cosmic-clash.io/build: build-1",
|
|
'cosmic-clash.io/protocol: "1"', "cosmic-clash.io/transport: enet",
|
|
"protocol: UDP", "containerPort: 7777", "replicas: 2",
|
|
):
|
|
self.assertIn(label, fleet)
|
|
for hardening in (
|
|
"runAsNonRoot: true", "readOnlyRootFilesystem: true",
|
|
"allowPrivilegeEscalation: false", "fsGroup: 10001",
|
|
"name: HOME", "value: /run/cosmic-clash",
|
|
):
|
|
self.assertIn(hardening, fleet)
|
|
# Agones must assign its SDK service account so it can keep the token
|
|
# available to its injected sidecar while masking it from the game.
|
|
self.assertNotIn("serviceAccountName:", fleet)
|
|
self.assertNotIn("automountServiceAccountToken:", fleet)
|
|
for runtime in (
|
|
"ghcr.io/cosmic-clash/game-server@sha256:",
|
|
"--sdk-base-url=http://127.0.0.1:9358",
|
|
"--control-plane-url=http://control-plane.cosmic-clash.svc.cluster.local:8080",
|
|
"--protocol-version=1",
|
|
"/opt/cosmic-clash/CosmicClashServer.x86_64",
|
|
"--roster-path=/run/cosmic-clash/join-roster.json",
|
|
"--allocated-mode",
|
|
"--join-authorisations-key-file=/run/secrets/cosmic-clash/join-signing-key",
|
|
"fieldPath: metadata.annotations['cosmic-clash.io/image-digest']",
|
|
"secretName: cosmic-clash-game-server",
|
|
"emptyDir: {}",
|
|
):
|
|
self.assertIn(runtime, fleet)
|
|
for scheduling in (
|
|
"cosmic-clash.io/capacity-type: on-demand",
|
|
"topologyKey: topology.kubernetes.io/zone",
|
|
"whenUnsatisfiable: DoNotSchedule",
|
|
"maxSkew: 1",
|
|
):
|
|
self.assertIn(scheduling, fleet)
|
|
|
|
def test_autoscaler_preserves_ready_floor_and_owns_fleet(self):
|
|
autoscaler = self.read("base/fleet-autoscaler.yaml")
|
|
for field in (
|
|
"kind: FleetAutoscaler", "namespace: cosmic-clash",
|
|
"fleetName: cosmic-clash-game", "type: Buffer",
|
|
"minReady: 2", "maxReady: 6", "bufferSize: 2",
|
|
):
|
|
self.assertIn(field, autoscaler)
|
|
|
|
def test_pdb_protects_the_ready_floor_and_matches_game_servers(self):
|
|
pdb = self.read("base/game-server-pdb.yaml")
|
|
for field in (
|
|
"kind: PodDisruptionBudget", "apiVersion: policy/v1",
|
|
"namespace: cosmic-clash", "minAvailable: 2",
|
|
"app.kubernetes.io/name: game-server",
|
|
):
|
|
self.assertIn(field, pdb)
|
|
|
|
def test_eu_and_na_overlays_are_distinct_and_namespaced(self):
|
|
eu = self.read("overlays/eu/region.yaml")
|
|
na = self.read("overlays/na/region.yaml")
|
|
na_kustomization = self.read("overlays/na/kustomization.yaml")
|
|
self.assertIn("cosmic-clash.io/region: EU", eu)
|
|
self.assertIn("cosmic-clash.io/region: NA", na)
|
|
self.assertIn("path: /spec/template/spec/template/spec/containers/0/args/21", na_kustomization)
|
|
self.assertIn("value: --region=NA", na_kustomization)
|
|
self.assertNotEqual(eu, na)
|
|
self.assertEqual(na_kustomization.count("value: --region=NA"), 1)
|
|
self.assertEqual(na_kustomization.count("value: --region=EU"), 0)
|
|
for document in (eu, na):
|
|
self.assertIn("namespace: cosmic-clash", document)
|
|
|
|
def test_allocator_agones_rbac_is_in_the_game_server_namespace(self):
|
|
base = self.read("base/kustomization.yaml")
|
|
rbac = self.read("base/rbac.yaml")
|
|
self.assertNotIn("namespace: cosmic-clash", base)
|
|
self.assertNotIn("namespace: agones-system", rbac)
|
|
self.assertGreaterEqual(rbac.count("namespace: cosmic-clash"), 3)
|
|
self.assertIn("name: allocator", rbac)
|
|
|
|
def test_control_plane_service_and_game_server_egress_are_declared(self):
|
|
service = self.read("base/control-plane-service.yaml")
|
|
network = self.read("base/network-policies.yaml")
|
|
base = self.read("base/kustomization.yaml")
|
|
for field in ("kind: Service", "name: control-plane", "port: 8080", "targetPort: http"):
|
|
self.assertIn(field, service)
|
|
game_server_egress = network.split("name: game-server-allowed-egress", 1)[-1].split("---", 1)[0]
|
|
for field in ("app.kubernetes.io/name: game-server", "port: 8080", "port: 443"):
|
|
self.assertIn(field, game_server_egress)
|
|
self.assertIn("control-plane-service.yaml", base)
|
|
|
|
def test_kind_runner_is_explicitly_separate_from_production_roster_flow(self):
|
|
runner = (ROOT / "scripts/verify_kind_agones.sh").read_text()
|
|
self.assertIn("Agones lifecycle smoke", runner)
|
|
self.assertIn("gameservers.namespaces[0]=cosmic-clash", runner)
|
|
for service in (
|
|
"agones.allocator.service.serviceType=ClusterIP",
|
|
"agones.ping.http.serviceType=ClusterIP",
|
|
"agones.ping.udp.serviceType=ClusterIP",
|
|
):
|
|
self.assertIn(service, runner)
|
|
self.assertIn("cosmic-clash.io/capacity-type=on-demand", runner)
|
|
self.assertIn("topology.kubernetes.io/zone=kind-smoke", runner)
|
|
self.assertIn("--control-plane-url=", runner)
|
|
self.assertIn("--allocated-mode", runner)
|
|
validator = (ROOT / "scripts/verify_agones_allocation_response.py").read_text()
|
|
self.assertIn("game UDP port", validator)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|