Fix T6 occupancy parsing in host agent

This commit is contained in:
2026-10-04 20:20:10 -04:00
parent ed004ddbcf
commit 456af32b36
5 changed files with 80 additions and 8 deletions
+13 -3
View File
@@ -30,7 +30,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.4.0" VERSION = "1.4.1"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
LIBRARY_DOWNLOAD_WORKERS = 4 LIBRARY_DOWNLOAD_WORKERS = 4
@@ -169,7 +169,8 @@ def file_digest(path):
def status_players(text): def status_players(text):
lines = text.splitlines() lines = text.splitlines()
separator = next((index for index, line in enumerate(lines) if re.match(r"^\s*-+\s+-+", line)), None) 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()] 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): if any(not re.match(r"^\d+\s+", row) for row in rows):
return None return None
@@ -1013,7 +1014,16 @@ class Agent:
raise Held("Container state is unknown; disruption held") raise Held("Container state is unknown; disruption held")
result = self.rcon(instance_id, generation, "status") result = self.rcon(instance_id, generation, "status")
if not result.get("ok") or result.get("players") is None: 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: if result["players"] != 0:
raise Held("Players are present; disruption held") raise Held("Players are present; disruption held")
+42
View File
@@ -153,6 +153,28 @@ class AgentTests(unittest.TestCase):
self.agent.rcon = lambda *_: {"ok": True, "players": 0} self.agent.rcon = lambda *_: {"ok": True, "players": 0}
self.assertEqual(self.agent.execute(operation)["state"], "completed") 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): def test_unknown_container_health_cannot_disrupt(self):
self.create() self.create()
self.docker.containers[self.agent.container_name(INSTANCE, 1)]["State"] = {"Status": "unknown", "Running": False} 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.assertEqual(result["players"], 1)
self.assertTrue(result["ok"]) 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): def test_invalid_rebuild_preflight_preserves_running_original(self):
payload = self.create() payload = self.create()
result = self.agent.execute(self.operation("instance.rebuild", {**payload, "image": "--privileged", "nextGeneration": 2})) result = self.agent.execute(self.operation("instance.rebuild", {**payload, "image": "--privileged", "nextGeneration": 2}))
+10
View File
@@ -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 by the agent populate existing campaign/beta rollback history. A beta cannot be
closed while its remote activation receipt is still unresolved. 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 Rebuild uses a new generation, fresh instance storage and the latest promoted
configuration/scripts/plugins. It retains the selected game-file version and configuration/scripts/plugins. It retains the selected game-file version and
runtime image. Old generation commands cannot target the replacement. Start, runtime image. Old generation commands cannot target the replacement. Start,
+13 -3
View File
@@ -30,7 +30,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.4.0" VERSION = "1.4.1"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
LIBRARY_DOWNLOAD_WORKERS = 4 LIBRARY_DOWNLOAD_WORKERS = 4
@@ -169,7 +169,8 @@ def file_digest(path):
def status_players(text): def status_players(text):
lines = text.splitlines() lines = text.splitlines()
separator = next((index for index, line in enumerate(lines) if re.match(r"^\s*-+\s+-+", line)), None) 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()] 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): if any(not re.match(r"^\d+\s+", row) for row in rows):
return None return None
@@ -1013,7 +1014,16 @@ class Agent:
raise Held("Container state is unknown; disruption held") raise Held("Container state is unknown; disruption held")
result = self.rcon(instance_id, generation, "status") result = self.rcon(instance_id, generation, "status")
if not result.get("ok") or result.get("players") is None: 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: if result["players"] != 0:
raise Held("Players are present; disruption held") raise Held("Players are present; disruption held")
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "1.4.0", "version": "1.4.1",
"protocolVersion": 1, "protocolVersion": 1,
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "8bb6bf3c433def0540cf0de11304f23820c9afcaf1d90d8cdaa7a2a09a455f2d", "sha256": "0691d8457f52ef8b53691b627733352b2f187529a4ecc465cdaaa6e27ddaaaa7",
"updaterProtocolVersion": 1, "updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
} }