Fix T6 occupancy parsing in host agent
This commit is contained in:
+13
-3
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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}))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user