This commit is contained in:
@@ -1,5 +1,22 @@
|
||||
# Agent changelog
|
||||
|
||||
## 2026-10-15
|
||||
|
||||
### 1.11.0 — Private player moderation configuration
|
||||
|
||||
- Advertise `moderation.configure` and accept separate generation-bound game
|
||||
credentials through private create/rebuild configuration or the typed
|
||||
`instance.configure-moderation` operation. Existing servers preserve startup
|
||||
through a fixed include in their verified private bank configuration; servers
|
||||
without a verified bootstrap require rebuilding.
|
||||
- Keep moderation credentials out of file reads, logs, manifests, staged changes,
|
||||
shared recipes and operation receipts. Configure loads only the fixed private
|
||||
file, never restarts gameplay, and does not replay RCON after an unknown result.
|
||||
- Controller coverage remains unavailable until the game moderation runtime
|
||||
connects and reports its capabilities. Older agents retain normal launches.
|
||||
- Package release artifacts for offline signing before publication; do not
|
||||
publish unsigned artifacts.
|
||||
|
||||
## 2026-10-14
|
||||
|
||||
### 1.10.1 — Retain verified rebuild artifacts (EC #154)
|
||||
|
||||
+113
-20
@@ -35,7 +35,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
VERSION = "1.10.1"
|
||||
VERSION = "1.11.0"
|
||||
LABEL = "io.electric-cherry"
|
||||
MAX_FILE = 32 * 1024 * 1024
|
||||
MANIFEST_MAX_ENTRIES = 10000
|
||||
@@ -49,6 +49,8 @@ PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
|
||||
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
|
||||
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
|
||||
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
|
||||
MODERATION_CONFIG_PATH = "configs/electric_cherry_moderation.cfg"
|
||||
MODERATION_INCLUDE = b"exec electric_cherry_moderation.cfg\n"
|
||||
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
||||
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
||||
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
|
||||
@@ -85,6 +87,7 @@ class AutoscalingInvalidated(Rejected):
|
||||
|
||||
OPERATION_ERROR_PHASES = {
|
||||
"executing": "executing the operation",
|
||||
"configuring-moderation": "configuring private game moderation",
|
||||
"checking-instance": "checking the existing instance",
|
||||
"validating-create-spec": "validating the container specification",
|
||||
"preparing-create-directory": "preparing the instance directory",
|
||||
@@ -401,13 +404,13 @@ def managed_open(root, value):
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def private_bank_config(value):
|
||||
return isinstance(value, str) and PurePosixPath(value).as_posix() == "configs/electric_cherry_bank.cfg"
|
||||
def private_instance_config(value):
|
||||
return isinstance(value, str) and PurePosixPath(value).as_posix() in ("configs/electric_cherry_bank.cfg", MODERATION_CONFIG_PATH)
|
||||
|
||||
|
||||
def require_public_file(value):
|
||||
if private_bank_config(value):
|
||||
raise Rejected("Private bank configuration cannot be read or changed by file operations")
|
||||
if private_instance_config(value):
|
||||
raise Rejected("Private bank configuration and moderation configuration cannot be read or changed by file operations")
|
||||
|
||||
|
||||
def managed_read(root, value):
|
||||
@@ -457,7 +460,7 @@ def managed_manifest(root, payload, stopping):
|
||||
|
||||
def validate_paths():
|
||||
for value in paths:
|
||||
if private_bank_config(value):
|
||||
if private_instance_config(value):
|
||||
continue
|
||||
check_budget()
|
||||
try:
|
||||
@@ -493,7 +496,7 @@ def managed_manifest(root, payload, stopping):
|
||||
entries += 1
|
||||
if entries > MANIFEST_MAX_ENTRIES:
|
||||
raise AgentError("Manifest scan exceeded its entry limit")
|
||||
if child.name in ignored or private_bank_config(relative + "/" + child.name):
|
||||
if child.name in ignored or private_instance_config(relative + "/" + child.name):
|
||||
continue
|
||||
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
|
||||
if stat_module.S_ISLNK(info.st_mode):
|
||||
@@ -1571,7 +1574,7 @@ class Agent:
|
||||
if line.startswith("PRETTY_NAME="):
|
||||
os_name = line.split("=", 1)[1].strip('"')
|
||||
update = self.update_inventory()
|
||||
capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
||||
capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "moderation.configure", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
||||
plutonium_cache = self.plutonium_cache_inventory()
|
||||
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
||||
|
||||
@@ -1964,6 +1967,8 @@ class Agent:
|
||||
|
||||
def create(self, instance_id, generation, payload, *, before=None, prepared_files=None):
|
||||
self.operation_phase = "checking-instance"
|
||||
if payload.get("moderation") is not None:
|
||||
self.moderation_config(payload["moderation"], generation)
|
||||
self.check_creation_cancellation()
|
||||
directory = self.instance_dir(instance_id, generation)
|
||||
current_marker = directory.parent / "current.json"
|
||||
@@ -2025,18 +2030,92 @@ class Agent:
|
||||
for item in files:
|
||||
require_public_file(item["path"])
|
||||
bank = payload.get("bank")
|
||||
if bank is None:
|
||||
return files
|
||||
if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]):
|
||||
raise Rejected("Invalid instance bank credential")
|
||||
path = bank.get("configPath")
|
||||
scoped(self.root / "agent" / "validation", path)
|
||||
if PurePosixPath(path).name != "electric_cherry_bank.cfg":
|
||||
raise Rejected("Invalid private bank config path")
|
||||
# The game scripts use the public bank API; the host controller address
|
||||
# is only for agent transport and must not become a game URL override.
|
||||
content = f'set tws_bank_token "{bank["token"]}"\n'.encode()
|
||||
return files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
|
||||
if bank is not None:
|
||||
if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]):
|
||||
raise Rejected("Invalid instance bank credential")
|
||||
path = bank.get("configPath")
|
||||
scoped(self.root / "agent" / "validation", path)
|
||||
if PurePosixPath(path).name != "electric_cherry_bank.cfg":
|
||||
raise Rejected("Invalid private bank config path")
|
||||
# Game transport uses its public URL, never the agent's enrollment URL.
|
||||
content = f'set tws_bank_token "{bank["token"]}"\n'.encode()
|
||||
files = files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
|
||||
if payload.get("moderation") is not None:
|
||||
content = self.moderation_config(payload["moderation"])
|
||||
args = payload.get("env", {}).get("PLUTAINER_EXTRA_ARGS", "")
|
||||
if not self.config_exec(args, "electric_cherry_moderation.cfg"):
|
||||
raise Rejected("Moderation configuration must be loaded by the launch arguments")
|
||||
files = files + [{"path": MODERATION_CONFIG_PATH, "contentBase64": base64.b64encode(content).decode()}]
|
||||
return files
|
||||
|
||||
@staticmethod
|
||||
def config_exec(args, filename):
|
||||
return isinstance(args, str) and re.search(r"(?:^|\s)\+exec\s+" + re.escape(filename) + r"(?=\s|$)", args) is not None
|
||||
|
||||
@staticmethod
|
||||
def moderation_config(moderation, generation=None):
|
||||
if not isinstance(moderation, dict) or set(moderation) != {"token", "configPath", "url", "generation"}:
|
||||
raise Rejected("Invalid moderation configuration")
|
||||
token, url, target = moderation["token"], moderation["url"], moderation["generation"]
|
||||
if not isinstance(token, str) or not re.fullmatch(r"ec_moderation_[A-Za-z0-9_-]{43}", token):
|
||||
raise Rejected("Invalid instance moderation credential")
|
||||
if type(target) is not int or target < 1 or generation is not None and target != generation:
|
||||
raise Rejected("Moderation credential belongs to a different instance generation")
|
||||
if moderation["configPath"] != MODERATION_CONFIG_PATH:
|
||||
raise Rejected("Invalid private moderation config path")
|
||||
if not isinstance(url, str) or len(url) > 2048 or re.search(r'[\s\x00-\x1f\x7f";\\]', url):
|
||||
raise Rejected("Invalid moderation controller URL")
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.path != "/api/moderation/runtime" or parsed.query or parsed.fragment or parsed.port == 0:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
raise Rejected("Invalid moderation controller URL") from None
|
||||
return f'set tws_moderation_url "{url}"\nset tws_moderation_token "{token}"\nset tws_moderation_generation "{target}"\n'.encode()
|
||||
|
||||
@staticmethod
|
||||
def private_config_bytes(app, path):
|
||||
with managed_open(app, path) as stream:
|
||||
content = stream.read(8193)
|
||||
if len(content) > 8192:
|
||||
raise Rejected("Private instance configuration exceeds the supported size")
|
||||
return content
|
||||
|
||||
def moderation_bootstrap(self, instance_id, generation):
|
||||
saved = self.spec(instance_id, generation)
|
||||
args = saved.get("env", {}).get("PLUTAINER_EXTRA_ARGS", "")
|
||||
if self.config_exec(args, "electric_cherry_moderation.cfg"):
|
||||
return None
|
||||
if not self.config_exec(args, "electric_cherry_bank.cfg"):
|
||||
raise Rejected("Rebuild this server before configuring moderation; startup cannot load the private configuration")
|
||||
app = self.instance_dir(instance_id, generation) / "app"
|
||||
try:
|
||||
bank = self.private_config_bytes(app, "configs/electric_cherry_bank.cfg")
|
||||
except FileNotFoundError:
|
||||
raise Rejected("Rebuild this server before configuring moderation; private startup configuration is missing") from None
|
||||
if not re.fullmatch(rb'set tws_bank_token "ec_bank_[A-Za-z0-9_-]{32,128}"\r?\n(?:exec electric_cherry_moderation.cfg\r?\n)?', bank):
|
||||
raise Rejected("Rebuild this server before configuring moderation; private startup configuration cannot be verified")
|
||||
return bank if bank.splitlines()[-1] == MODERATION_INCLUDE.rstrip(b"\n") else bank + MODERATION_INCLUDE
|
||||
|
||||
def configure_moderation(self, instance_id, generation, payload):
|
||||
if set(payload) != {"moderation"}:
|
||||
raise Rejected("Invalid moderation configuration request")
|
||||
content = self.moderation_config(payload["moderation"], generation)
|
||||
health = self.inspect(instance_id, generation)
|
||||
if not health["exists"]:
|
||||
raise Rejected("Instance does not exist")
|
||||
bank = self.moderation_bootstrap(instance_id, generation)
|
||||
app = self.instance_dir(instance_id, generation) / "app"
|
||||
self.operation_phase = "configuring-moderation"
|
||||
self.checkpoint({"phase": "configuring-moderation", "generation": generation})
|
||||
uid, gid = int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000))
|
||||
managed_write(app, MODERATION_CONFIG_PATH, content, uid, gid)
|
||||
if bank is not None:
|
||||
managed_write(app, "configs/electric_cherry_bank.cfg", bank, uid, gid)
|
||||
# Loading fixed dvars is not a restart. Never retain a reply which could
|
||||
# echo credentials, and never replay the command after a lost response.
|
||||
loaded = self.rcon(instance_id, generation, "exec electric_cherry_moderation.cfg").get("ok", False) if health["running"] else False
|
||||
return {"configured": True, "generation": generation, "liveLoad": "sent" if loaded else "unconfirmed" if health["running"] else "deferred"}
|
||||
|
||||
def write_initial_files(self, root, prepared_files):
|
||||
for path, content in prepared_files.items():
|
||||
@@ -2169,6 +2248,8 @@ class Agent:
|
||||
return self.create(instance_id, generation, payload)
|
||||
if kind == "instance.inspect":
|
||||
return self.inspect(instance_id, generation)
|
||||
if kind == "instance.configure-moderation":
|
||||
return self.configure_moderation(instance_id, generation, payload)
|
||||
health = self.inspect(instance_id, generation)
|
||||
name = self.container_name(instance_id, generation)
|
||||
before = []
|
||||
@@ -2198,6 +2279,8 @@ class Agent:
|
||||
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation:
|
||||
raise AgentError("Rebuild must use a newer immutable generation")
|
||||
spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
|
||||
if spec.get("moderation") is not None:
|
||||
self.moderation_config(spec["moderation"], next_generation)
|
||||
# Keep the complete verified set on this call's stack only. Never
|
||||
# mutate the immutable spec or cache bytes on the shared agent:
|
||||
# held, failed and later operations must prepare their own files.
|
||||
@@ -3001,6 +3084,16 @@ class Agent:
|
||||
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision")
|
||||
if kind == "instance.cancel-create":
|
||||
return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload, operation["id"]))
|
||||
if kind == "instance.configure-moderation":
|
||||
current = json.loads((self.instance_dir(instance_id, generation).parent / "current.json").read_text())
|
||||
if current.get("generation") != generation or current.get("removed"):
|
||||
return self.journal.update(operation["id"], "failed", error="Moderation configuration generation was superseded or removed")
|
||||
content = self.moderation_config(payload.get("moderation"), generation)
|
||||
app = self.instance_dir(instance_id, generation) / "app"
|
||||
bank = self.moderation_bootstrap(instance_id, generation)
|
||||
if self.private_config_bytes(app, MODERATION_CONFIG_PATH) != content or bank is not None and self.private_config_bytes(app, "configs/electric_cherry_bank.cfg") != bank:
|
||||
return row
|
||||
return self.journal.update(operation["id"], "completed", {"configured": True, "generation": generation, "liveLoad": "unconfirmed", "reconciled": True})
|
||||
if kind == "instance.create":
|
||||
marker = self.instance_dir(instance_id, generation).parent / "current.json"
|
||||
if marker.exists():
|
||||
|
||||
+127
-2
@@ -1650,10 +1650,12 @@ class AgentTests(unittest.TestCase):
|
||||
self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed")
|
||||
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
|
||||
|
||||
def test_private_bank_file_survives_stale_file_plans_without_snapshots(self):
|
||||
def test_private_moderation_file_survives_stale_file_plans_without_snapshots(self):
|
||||
self.test_private_bank_file_survives_stale_file_plans_without_snapshots(value=module.MODERATION_CONFIG_PATH)
|
||||
|
||||
def test_private_bank_file_survives_stale_file_plans_without_snapshots(self, value="configs/electric_cherry_bank.cfg"):
|
||||
self.create()
|
||||
app = self.agent.instance_dir(INSTANCE, 1) / "app"
|
||||
value = "configs/electric_cherry_bank.cfg"
|
||||
target = app / value
|
||||
secret = b'set tws_bank_token "ec_bank_private_regression"\n'
|
||||
target.write_bytes(secret)
|
||||
@@ -1707,6 +1709,129 @@ class AgentTests(unittest.TestCase):
|
||||
with self.assertRaises(AgentError):
|
||||
self.agent.initial_files(payload)
|
||||
|
||||
def moderation_configuration(self, generation=1):
|
||||
return {"token": "ec_moderation_" + "m" * 43, "configPath": module.MODERATION_CONFIG_PATH,
|
||||
"url": "https://public-controller.example/api/moderation/runtime", "generation": generation}
|
||||
|
||||
def create_moderation_bootstrap(self, *, direct=False, game="t5"):
|
||||
payload = {"name": "server", "game": game, "libraryId": self.library_id,
|
||||
"image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960, "rconPassword": "secret",
|
||||
"env": {"PLUTAINER_EXTRA_ARGS": "+exec electric_cherry_bank.cfg +map_rotate"},
|
||||
"bank": {"token": "ec_bank_" + "b" * 43, "configPath": "configs/electric_cherry_bank.cfg"}}
|
||||
if direct:
|
||||
payload["moderation"] = self.moderation_configuration()
|
||||
payload["env"]["PLUTAINER_EXTRA_ARGS"] = "+exec electric_cherry_moderation.cfg +map_rotate"
|
||||
row = self.agent.execute(self.operation("instance.create", payload, "create"))
|
||||
self.assertEqual(row["state"], "completed", row)
|
||||
return payload
|
||||
|
||||
def test_t6_create_materializes_private_moderation_configuration(self):
|
||||
self.prepare_t6_library()
|
||||
self.test_create_materializes_private_moderation_configuration(game="t6")
|
||||
|
||||
def test_create_materializes_private_moderation_configuration(self, game="t5"):
|
||||
payload = self.create_moderation_bootstrap(direct=True, game=game)
|
||||
target = self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH
|
||||
self.assertEqual(target.read_bytes(), self.agent.moderation_config(payload["moderation"], 1))
|
||||
self.assertNotIn(self.config["controller"], target.read_text())
|
||||
self.assertIn("moderation.configure", self.agent.inventory()["capabilities"])
|
||||
self.assertNotIn(payload["moderation"]["token"], self.agent.journal.get("create")["result"])
|
||||
|
||||
def test_configure_moderation_persists_existing_startup_and_discards_rcon_secrets(self):
|
||||
saved = self.create_moderation_bootstrap()
|
||||
payload = {"moderation": self.moderation_configuration()}
|
||||
operation = self.operation("instance.configure-moderation", payload)
|
||||
with patch.object(self.agent, "rcon", return_value={"ok": True, "text": payload["moderation"]["token"]}) as command:
|
||||
row = self.agent.execute(operation)
|
||||
self.assertEqual(row["state"], "completed", row)
|
||||
self.assertEqual(self.agent.execute(operation), row)
|
||||
command.assert_called_once_with(INSTANCE, 1, "exec electric_cherry_moderation.cfg")
|
||||
self.assertNotIn(payload["moderation"]["token"], row["result"])
|
||||
app = self.agent.instance_dir(INSTANCE, 1) / "app"
|
||||
self.assertEqual((app / module.MODERATION_CONFIG_PATH).read_bytes(), self.agent.moderation_config(payload["moderation"], 1))
|
||||
bank = (app / "configs/electric_cherry_bank.cfg").read_bytes()
|
||||
self.assertEqual(bank, f'set tws_bank_token "{saved["bank"]["token"]}"\n'.encode() + module.MODERATION_INCLUDE)
|
||||
self.assertEqual(self.agent.spec(INSTANCE, 1), saved)
|
||||
bank = bank.replace(b"\n", b"\r\n")
|
||||
(app / "configs/electric_cherry_bank.cfg").write_bytes(bank)
|
||||
with patch.object(self.agent, "rcon", return_value={"ok": False, "text": "timeout"}):
|
||||
again = self.agent.execute(self.operation("instance.configure-moderation", payload, "configure-again"))
|
||||
self.assertEqual(json.loads(again["result"])["liveLoad"], "unconfirmed")
|
||||
self.assertEqual((app / "configs/electric_cherry_bank.cfg").read_bytes(), bank)
|
||||
self.assertFalse(any(call[:2] == ("container", "restart") for call in self.docker.calls))
|
||||
|
||||
def test_configure_moderation_rejects_unknown_startup_without_writing(self):
|
||||
self.create()
|
||||
with patch.object(self.agent, "rcon") as command:
|
||||
row = self.agent.execute(self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()}))
|
||||
self.assertEqual(row["state"], "failed", row)
|
||||
self.assertIn("Rebuild this server", row["error"])
|
||||
self.assertFalse((self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH).exists())
|
||||
command.assert_not_called()
|
||||
|
||||
def test_configure_moderation_requires_readable_verified_private_startup(self):
|
||||
self.create_moderation_bootstrap()
|
||||
app = self.agent.instance_dir(INSTANCE, 1) / "app"
|
||||
bank = app / "configs/electric_cherry_bank.cfg"
|
||||
for index, content in enumerate((None, b'unknown startup commands\n')):
|
||||
with self.subTest(content=content), patch.object(self.agent, "rcon") as command:
|
||||
bank.unlink(missing_ok=True)
|
||||
if content is not None:
|
||||
bank.write_bytes(content)
|
||||
row = self.agent.execute(self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()}, f"invalid-bootstrap-{index}"))
|
||||
self.assertEqual(row["state"], "failed", row)
|
||||
self.assertIsNone(row["checkpoint"])
|
||||
self.assertFalse((app / module.MODERATION_CONFIG_PATH).exists())
|
||||
command.assert_not_called()
|
||||
|
||||
def test_configure_moderation_validates_credentials_generation_url_and_private_path(self):
|
||||
self.create_moderation_bootstrap()
|
||||
for index, change in enumerate((
|
||||
{"token": 'ec_moderation_' + 'm' * 43 + '\nset other "1"'},
|
||||
{"generation": 2}, {"generation": True}, {"configPath": "configs/other.cfg"},
|
||||
{"url": "http://controller.example/api/moderation/runtime"},
|
||||
{"url": "https://user:secret@controller.example/api/moderation/runtime"},
|
||||
{"url": "https://controller.example/api/moderation/runtime?secret=1"},
|
||||
{"url": 'https://controller.example/api/moderation/runtime";quit'},
|
||||
{"unknown": "value"}
|
||||
)):
|
||||
with self.subTest(change=change), patch.object(self.agent, "rcon") as command:
|
||||
payload = {"moderation": {**self.moderation_configuration(), **change}}
|
||||
row = self.agent.execute(self.operation("instance.configure-moderation", payload, f"bad-moderation-{index}"))
|
||||
self.assertEqual(row["state"], "failed", row)
|
||||
self.assertIsNone(row["checkpoint"])
|
||||
command.assert_not_called()
|
||||
self.assertFalse((self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH).exists())
|
||||
|
||||
def test_configure_moderation_reconciles_written_configuration_without_replaying_rcon(self):
|
||||
self.create_moderation_bootstrap()
|
||||
operation = self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()})
|
||||
with patch.object(self.agent, "rcon", side_effect=RuntimeError("secret must not leak")) as command:
|
||||
unknown = self.agent.execute(operation)
|
||||
self.assertEqual(unknown["state"], "unknown", unknown)
|
||||
row = self.agent.execute(operation)
|
||||
self.assertEqual(row["state"], "completed", row)
|
||||
command.assert_called_once()
|
||||
self.assertEqual(json.loads(row["result"])["liveLoad"], "unconfirmed")
|
||||
self.assertNotIn("secret must not leak", str(unknown.get("error")))
|
||||
|
||||
def test_rebuild_rejects_moderation_credentials_for_the_previous_generation_before_disruption(self):
|
||||
saved = self.create_moderation_bootstrap(direct=True)
|
||||
row = self.agent.execute(self.operation("instance.rebuild", {"spec": saved, "nextGeneration": 2}, "rebuild"))
|
||||
self.assertEqual(row["state"], "failed", row)
|
||||
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
|
||||
|
||||
def test_rebuild_writes_moderation_credentials_for_only_the_replacement_generation(self):
|
||||
saved = self.create_moderation_bootstrap(direct=True)
|
||||
saved["moderation"] = {**self.moderation_configuration(2), "token": "ec_moderation_" + "n" * 43}
|
||||
row = self.agent.execute(self.operation("instance.rebuild", {"spec": saved, "nextGeneration": 2, "safetyOverride": True}, "rebuild"))
|
||||
self.assertEqual(row["state"], "completed", row)
|
||||
app = self.agent.instance_dir(INSTANCE, 2) / "app"
|
||||
self.assertEqual((app / module.MODERATION_CONFIG_PATH).read_bytes(), self.agent.moderation_config(saved["moderation"], 2))
|
||||
self.assertNotIn(saved["moderation"]["token"], row["result"])
|
||||
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
|
||||
|
||||
def bank_journal(self, decisions=("commit", "cancel"), game="t5", endpoint=module.BANK_GAME_ENDPOINT):
|
||||
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-bank/{game}/{module.digest(endpoint.encode())}.json"
|
||||
reports = [json.dumps({"server": "t5_zombie_test_1", "guid": "ace", "match": "match-one", "request_id": f"{game}-{i}", "operation": "deposit", "amount": 2000, "capacity": 1000000, "decision": decision}) for i, decision in enumerate(decisions)]
|
||||
|
||||
Reference in New Issue
Block a user