Provision instance bank credentials automatically (Refs #146)

This commit is contained in:
2026-10-12 20:44:01 -04:00
parent e071e5b574
commit d14c6a7ff4
21 changed files with 634 additions and 109 deletions
+103
View File
@@ -1644,6 +1644,109 @@ 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_create_materializes_private_bank_config_from_instance_credential(self):
secret = "ec_bank_" + "a" * 43
payload = {"name": "server", "game": "t5", "libraryId": self.library_id,
"image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960,
"bank": {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}}
operation = self.operation("instance.create", payload, "create")
self.assertEqual(self.agent.execute(operation)["state"], "completed")
path = self.agent.instance_dir(INSTANCE, 1) / "app/configs/electric_cherry_bank.cfg"
self.assertEqual(path.read_text(), f'set tws_bank_token "{secret}"\nset tws_bank_url "https://controller.example/api/bank"\n')
self.assertIn("bank.settlement", self.agent.inventory()["capabilities"])
payload["bank"]["token"] = secret + '\nset bad "1"'
with self.assertRaises(AgentError):
self.agent.initial_files(payload)
def bank_journal(self, decisions=("commit", "cancel"), game="t5"):
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-bank/{game}/scope.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)]
module.atomic_json(path, {"version": 1, "server": "t5_zombie_test_1", "endpoint": "https://controller.example/api/bank", "reports": reports})
return path
def bank_acknowledgment(self, method, path, payload):
self.assertEqual((method, path), ("POST", "/api/agent/bank/settle"))
self.assertFalse(self.agent.inspect(INSTANCE, 1)["exists"])
return {"state": "committed" if payload["action"] == "commit" else "cancelled", "request_id": payload["transfer"]["request_id"]}
def test_remove_settles_both_game_journals_after_stop_before_deleting_storage(self):
self.create()
paths = [self.bank_journal(game=game) for game in ("t5", "t6")]
def acknowledge(*args):
self.assertTrue(all(path.exists() for path in paths))
self.assertEqual(args[2]["operationId"], "remove")
return self.bank_acknowledgment(*args)
with patch.object(self.agent, "request", side_effect=acknowledge) as request:
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
self.assertEqual(result["state"], "completed", result)
self.assertEqual(request.call_count, 4)
self.assertTrue(all(not path.exists() for path in paths))
def test_remove_retains_bank_journal_during_outage_and_resumes_after_agent_restart(self):
self.create()
path = self.bank_journal()
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
with patch.object(self.agent, "request", side_effect=AgentError("Controller request failed")):
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertTrue(path.exists())
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
calls = list(self.docker.calls)
with patch.object(self.agent, "request", side_effect=self.bank_acknowledgment):
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertFalse(path.exists())
self.assertEqual(self.docker.calls, calls)
def test_rebuild_settles_before_starting_replacement_and_retains_unknown_decisions(self):
payload = self.create()
path = self.bank_journal(("commit",))
operation = self.operation("instance.rebuild", {"spec": payload, "nextGeneration": 2, "safetyOverride": True}, "rebuild")
with patch.object(self.agent, "request", return_value={"state": "committed", "request_id": "wrong"}):
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertTrue(path.exists())
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
with patch.object(self.agent, "request", side_effect=self.bank_acknowledgment):
# Existing rebuild recovery records the interrupted create as failed;
# a fresh operation can then rebuild without replaying the old command.
self.assertEqual(self.agent.execute(operation)["state"], "failed")
retry = self.operation("instance.rebuild", operation["payload"], "replacement")
self.assertEqual(self.agent.execute(retry)["state"], "completed")
self.assertFalse(path.exists())
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
def test_cancel_creation_retries_bank_settlement_before_deleting_runtime(self):
self.create()
path = self.bank_journal(("cancel",))
operation = self.operation("instance.cancel-create", {"createOperationId": "create"}, "cancel")
with patch.object(self.agent, "request", side_effect=AgentError("offline")):
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertTrue(path.exists())
with patch.object(self.agent, "request", side_effect=self.bank_acknowledgment):
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertFalse(path.exists())
def test_malformed_or_symlinked_bank_journal_blocks_storage_deletion(self):
self.create()
path = self.bank_journal()
path.write_text('{broken')
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
with patch.object(self.agent, "request") as request:
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertTrue(path.exists())
outside = Path(self.temp.name) / "outside.json"
outside.write_text('{"reports": []}')
self.bank_journal()
journal = json.loads(path.read_text())
journal["endpoint"] = "https://untrusted.example/api/bank"
path.write_text(json.dumps(journal))
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
path.unlink()
path.symlink_to(outside)
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertTrue(path.is_symlink())
request.assert_not_called()
def test_rebuild_moves_generation_and_rejects_old_commands(self):
payload = self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}