diff --git a/agent/ec_agent.py b/agent/ec_agent.py index 1d03a68..5152831 100644 --- a/agent/ec_agent.py +++ b/agent/ec_agent.py @@ -30,7 +30,7 @@ import urllib.parse import urllib.request import uuid -VERSION = "1.4.0" +VERSION = "1.4.1" LABEL = "io.electric-cherry" MAX_FILE = 32 * 1024 * 1024 LIBRARY_DOWNLOAD_WORKERS = 4 @@ -169,7 +169,8 @@ def file_digest(path): def status_players(text): lines = text.splitlines() separator = next((index for index, line in enumerate(lines) if re.match(r"^\s*-+\s+-+", line)), None) - if separator is not None and any(re.search(r"\bnum\s+score\s+ping\b", line, re.I) for line in lines[:separator]): + # T6 includes a bot column; T5 places ping directly after score. + if separator is not None and any(re.search(r"\bnum\s+score\s+(?:bot\s+)?ping\b", line, re.I) for line in lines[:separator]): rows = [line.strip() for line in lines[separator + 1:] if line.strip()] if any(not re.match(r"^\d+\s+", row) for row in rows): return None @@ -1013,7 +1014,16 @@ class Agent: raise Held("Container state is unknown; disruption held") result = self.rcon(instance_id, generation, "status") if not result.get("ok") or result.get("players") is None: - raise Held("Fresh local player occupancy is unavailable; disruption held") + # Keep diagnostics bounded and do not include raw replies: they may + # contain player addresses or echoed credentials. + reason = { + "unconfigured": "local RCON password is not configured", + "unreachable": "local RCON endpoint is unreachable", + "invalid-reply": "local RCON reply has an invalid packet prefix", + "timeout": "local RCON status query timed out", + "authentication": "local RCON authentication failed", + }.get(result.get("reason"), "local RCON status reply has no recognized player count" if result.get("ok") else "local RCON query failed") + raise Held(f"Fresh local player occupancy is unavailable: {reason}; disruption held") if result["players"] != 0: raise Held("Players are present; disruption held") diff --git a/agent/tests/test_agent.py b/agent/tests/test_agent.py index 900f44c..5c2b4ff 100644 --- a/agent/tests/test_agent.py +++ b/agent/tests/test_agent.py @@ -153,6 +153,28 @@ class AgentTests(unittest.TestCase): self.agent.rcon = lambda *_: {"ok": True, "players": 0} self.assertEqual(self.agent.execute(operation)["state"], "completed") + def test_occupancy_hold_explains_local_rcon_failure_without_exposing_reply(self): + self.create() + operation = self.operation("instance.restart") + for reason, detail in ( + ("unconfigured", "password is not configured"), + ("unreachable", "endpoint is unreachable"), + ("invalid-reply", "invalid packet prefix"), + ("timeout", "query timed out"), + ("authentication", "authentication failed"), + (None, "no recognized player count"), + ): + with self.subTest(reason=reason): + self.agent.rcon = lambda *_: {"ok": reason is None, "reason": reason, "players": None, "text": "sensitive raw reply"} + result = self.agent.execute(operation) + self.assertEqual(result["state"], "accepted") + message = json.loads(result["result"])["message"] + self.assertIn(detail, message) + self.assertNotIn("sensitive raw reply", message) + self.assertFalse(any(call[:2] == ("container", "restart") for call in self.docker.calls)) + self.agent.rcon = lambda *_: {"ok": True, "players": 0} + self.assertEqual(self.agent.execute(operation)["state"], "completed") + def test_unknown_container_health_cannot_disrupt(self): self.create() self.docker.containers[self.agent.container_name(INSTANCE, 1)]["State"] = {"Status": "unknown", "Running": False} @@ -896,6 +918,26 @@ class AgentTests(unittest.TestCase): self.assertEqual(result["players"], 1) self.assertTrue(result["ok"]) + def test_t6_bot_column_allows_empty_restart_but_holds_players_and_partial_replies(self): + self.create() + header = "map: zm_nuked\nnum score bot ping guid name lastmsg address qport rate\n" + table = header + "--- ----- --- ---- -------------------------------- ---------------- ------- --------------------- ------ -----\n" + operation = self.operation("instance.restart") + for body, players in ( + (header, None), + (table + "0 0 0 CNCT abc player 0 127.0.0.1:28960 123 25000\n", 1), + (table + "0 0 1 0 abc bot 0 loopback 0 25000\n", 1), + (table + "unrecognized trailing output", None), + (table, 0), + ): + with self.subTest(body=body): + self.assertEqual(module.status_players(body), players) + self.agent.rcon = lambda *_: {"ok": True, "players": module.status_players(body)} + result = self.agent.execute(operation) + self.assertEqual(result["state"], "completed" if players == 0 else "accepted") + if players != 0: + self.assertFalse(any(call[:2] == ("container", "restart") for call in self.docker.calls)) + def test_invalid_rebuild_preflight_preserves_running_original(self): payload = self.create() result = self.agent.execute(self.operation("instance.rebuild", {**payload, "image": "--privileged", "nextGeneration": 2})) diff --git a/docs/native-hosts.md b/docs/native-hosts.md index fc43577..a906db4 100644 --- a/docs/native-hosts.md +++ b/docs/native-hosts.md @@ -216,6 +216,16 @@ wait indefinitely, including multi-hour Zombies sessions. Before images returned by the agent populate existing campaign/beta rollback history. A beta cannot be closed while its remote activation receipt is still unresolved. +An empty Fleet card does not replace this final host-local check: the card may +use telemetry, and the controller's RCON parser is more permissive than the +agent's disruption guard. Agent 1.4.1 includes the local RCON failure reason in +the operation's hold message (configuration, timeout, unreachable endpoint, +authentication, invalid packet, or an unrecognized player count). For a parser +hold, inspect the raw `status` reply in **Diagnose connection**. Unknown counts +remain held; RCON availability alone does not prove the lobby is empty. +It also recognizes T6's `num score bot ping` header, which earlier agents +rejected even when the complete player table was empty. + Rebuild uses a new generation, fresh instance storage and the latest promoted configuration/scripts/plugins. It retains the selected game-file version and runtime image. Old generation commands cannot target the replacement. Start, diff --git a/static/agent/ec-agent.py b/static/agent/ec-agent.py index 1d03a68..5152831 100644 --- a/static/agent/ec-agent.py +++ b/static/agent/ec-agent.py @@ -30,7 +30,7 @@ import urllib.parse import urllib.request import uuid -VERSION = "1.4.0" +VERSION = "1.4.1" LABEL = "io.electric-cherry" MAX_FILE = 32 * 1024 * 1024 LIBRARY_DOWNLOAD_WORKERS = 4 @@ -169,7 +169,8 @@ def file_digest(path): def status_players(text): lines = text.splitlines() separator = next((index for index, line in enumerate(lines) if re.match(r"^\s*-+\s+-+", line)), None) - if separator is not None and any(re.search(r"\bnum\s+score\s+ping\b", line, re.I) for line in lines[:separator]): + # T6 includes a bot column; T5 places ping directly after score. + if separator is not None and any(re.search(r"\bnum\s+score\s+(?:bot\s+)?ping\b", line, re.I) for line in lines[:separator]): rows = [line.strip() for line in lines[separator + 1:] if line.strip()] if any(not re.match(r"^\d+\s+", row) for row in rows): return None @@ -1013,7 +1014,16 @@ class Agent: raise Held("Container state is unknown; disruption held") result = self.rcon(instance_id, generation, "status") if not result.get("ok") or result.get("players") is None: - raise Held("Fresh local player occupancy is unavailable; disruption held") + # Keep diagnostics bounded and do not include raw replies: they may + # contain player addresses or echoed credentials. + reason = { + "unconfigured": "local RCON password is not configured", + "unreachable": "local RCON endpoint is unreachable", + "invalid-reply": "local RCON reply has an invalid packet prefix", + "timeout": "local RCON status query timed out", + "authentication": "local RCON authentication failed", + }.get(result.get("reason"), "local RCON status reply has no recognized player count" if result.get("ok") else "local RCON query failed") + raise Held(f"Fresh local player occupancy is unavailable: {reason}; disruption held") if result["players"] != 0: raise Held("Players are present; disruption held") diff --git a/static/agent/manifest.json b/static/agent/manifest.json index 3718558..e1949fc 100644 --- a/static/agent/manifest.json +++ b/static/agent/manifest.json @@ -1,8 +1,8 @@ { - "version": "1.4.0", + "version": "1.4.1", "protocolVersion": 1, "platform": "debian-amd64", - "sha256": "8bb6bf3c433def0540cf0de11304f23820c9afcaf1d90d8cdaa7a2a09a455f2d", + "sha256": "0691d8457f52ef8b53691b627733352b2f187529a4ecc465cdaaa6e27ddaaaa7", "updaterProtocolVersion": 1, "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" }