Recover unhealthy containers during create and rebuild

This commit is contained in:
2026-10-08 13:08:33 -04:00
parent 012cd51a66
commit 368e457dc6
18 changed files with 881 additions and 77 deletions
+45 -21
View File
@@ -33,7 +33,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.8.0" VERSION = "1.8.1"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000 MANIFEST_MAX_ENTRIES = 10000
@@ -1394,14 +1394,18 @@ class Agent:
if line.startswith("PRETTY_NAME="): if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
plutonium_cache = self.plutonium_cache_inventory() 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, **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)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} 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, **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)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
def safe_to_disrupt(self, instance_id, generation): def safe_to_disrupt(self, instance_id, generation, *, allow_unhealthy=False):
health = self.inspect(instance_id, generation) health = self.inspect(instance_id, generation)
if health["state"] in ("missing", "stopped", "exited", "created", "dead"): if health["state"] in ("missing", "stopped", "exited", "created", "dead"):
return return
# Provisioning recovery may treat explicitly unhealthy containers as
# empty. Reinspect here so recovered containers still require occupancy.
if allow_unhealthy and health["state"] == "unhealthy":
return
if not health["running"]: if not health["running"]:
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")
@@ -1857,6 +1861,9 @@ class Agent:
raise AutoscalingInvalidated("Autoscaling removal authorization expired before mutation") raise AutoscalingInvalidated("Autoscaling removal authorization expired before mutation")
def lifecycle(self, kind, instance_id, generation, payload): def lifecycle(self, kind, instance_id, generation, payload):
unhealthy_recovery = payload.get("unhealthyRecovery") is True
if unhealthy_recovery and (kind not in ("instance.restart", "instance.rebuild") or payload.get("stageId") or "autoscaling" in payload):
raise AgentError("Unhealthy recovery only permits restart or rebuild without file activation or autoscaling")
if kind == "instance.cancel-create": if kind == "instance.cancel-create":
return self.cancel_creation(instance_id, generation, payload) return self.cancel_creation(instance_id, generation, payload)
if kind == "instance.create": if kind == "instance.create":
@@ -1891,10 +1898,10 @@ class Agent:
next_generation = payload.get("nextGeneration", payload.get("newGeneration")) next_generation = payload.get("nextGeneration", payload.get("newGeneration"))
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation: if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation:
raise AgentError("Rebuild must use a newer immutable 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")} spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
self.validate_spec(spec) self.validate_spec(spec)
if not override_safety: if not override_safety:
self.safe_to_disrupt(instance_id, generation) self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery)
if "autoscaling" in payload: if "autoscaling" in payload:
self.authorize_autoscaling_removal(instance_id, generation, payload) self.authorize_autoscaling_removal(instance_id, generation, payload)
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt")}) self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt")})
@@ -1928,29 +1935,46 @@ class Agent:
Repeating this cleanup after an ambiguous Docker result is safe. Repeating this cleanup after an ambiguous Docker result is safe.
""" """
create_id = identifier(payload.get("createOperationId")) create_id = identifier(payload.get("createOperationId"))
targets = [(generation, create_id)]
if "recoveryRebuildOperationId" in payload or "recoveryGeneration" in payload:
rebuild_id = identifier(payload.get("recoveryRebuildOperationId"))
recovery_generation = payload.get("recoveryGeneration")
if isinstance(recovery_generation, bool) or not isinstance(recovery_generation, int) or recovery_generation != generation + 1:
raise Rejected("Cancellation recovery must target the next generation")
saved = self.journal.db.execute("SELECT request FROM operations WHERE id=?", (rebuild_id,)).fetchone()
if saved:
rebuild = json.loads(saved["request"])
rebuild_payload = rebuild.get("payload", {})
if rebuild["kind"] != "instance.rebuild" or rebuild["instanceId"] != instance_id or rebuild["generation"] != generation or rebuild_payload.get("nextGeneration", rebuild_payload.get("newGeneration")) != recovery_generation or rebuild_payload.get("unhealthyRecovery") is not True:
raise Rejected("Cancellation does not match the creation recovery rebuild")
targets.append((recovery_generation, rebuild_id))
marker = self.instance_dir(instance_id, generation).parent / "current.json" marker = self.instance_dir(instance_id, generation).parent / "current.json"
if marker.exists() and json.loads(marker.read_text())["generation"] > generation: if marker.exists() and json.loads(marker.read_text())["generation"] > targets[-1][0]:
raise Rejected("Cancelled creation was superseded by a newer generation") raise Rejected("Cancelled creation was superseded by a newer generation")
saved = self.journal.db.execute("SELECT request FROM operations WHERE id=?", (create_id,)).fetchone() saved = self.journal.db.execute("SELECT request FROM operations WHERE id=?", (create_id,)).fetchone()
if saved: if saved:
original = json.loads(saved["request"]) original = json.loads(saved["request"])
if original["kind"] != "instance.create" or original["instanceId"] != instance_id or original["generation"] != generation: if original["kind"] != "instance.create" or original["instanceId"] != instance_id or original["generation"] != generation:
raise Rejected("Cancellation does not match the original creation") raise Rejected("Cancellation does not match the original creation")
health = self.inspect(instance_id, generation) health_by_generation = {}
if health["exists"] and health.get("creationOperation") != create_id: for target_generation, operation_id in targets:
raise Rejected("Container belongs to a different creation operation") health = self.inspect(instance_id, target_generation)
if health["exists"] and health.get("creationOperation") != operation_id:
raise Rejected("Container belongs to a different creation operation")
health_by_generation[target_generation] = health
self.checkpoint({"phase": "cancelling-creation", "createOperationId": create_id}) self.checkpoint({"phase": "cancelling-creation", "createOperationId": create_id})
# Fence before deleting: a crash must never permit the old creation to run again. # Fence both generations before deleting: delayed create/rebuild work
self.mark_removed(instance_id, generation) # must never recreate either target after cancellation or a crash.
if health["exists"]: self.mark_removed(instance_id, targets[-1][0])
self.docker.call("container", "rm", "--force", "--volumes", self.container_name(instance_id, generation)) for target_generation, _ in targets:
after = self.inspect(instance_id, generation) if health_by_generation[target_generation]["exists"]:
if after["exists"]: self.docker.call("container", "rm", "--force", "--volumes", self.container_name(instance_id, target_generation))
raise AgentError("Cancelled creation container still exists") if self.inspect(instance_id, target_generation)["exists"]:
directory = self.instance_dir(instance_id, generation) raise AgentError("Cancelled creation container still exists")
if directory.exists(): directory = self.instance_dir(instance_id, target_generation)
shutil.rmtree(directory) if directory.exists():
return {**after, "cancelledCreation": create_id} shutil.rmtree(directory)
return {**self.inspect(instance_id, generation), "cancelledCreation": create_id}
def stage(self, instance_id, generation, payload, operation_id): def stage(self, instance_id, generation, payload, operation_id):
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
@@ -2453,7 +2477,7 @@ class Agent:
current_marker = self.instance_dir(instance_id, generation).parent / "current.json" current_marker = self.instance_dir(instance_id, generation).parent / "current.json"
if current_marker.exists(): if current_marker.exists():
current = json.loads(current_marker.read_text()) current = json.loads(current_marker.read_text())
if current["generation"] > generation or current["generation"] == generation and current.get("removed") and kind not in ("instance.inspect", "instance.remove", "instance.cancel-create"): if kind != "instance.cancel-create" and (current["generation"] > generation or current["generation"] == generation and current.get("removed") and kind not in ("instance.inspect", "instance.remove")):
raise AgentError("Instance generation has been superseded or removed") raise AgentError("Instance generation has been superseded or removed")
if kind.startswith("instance."): if kind.startswith("instance."):
return self.lifecycle(kind, instance_id, generation, payload) return self.lifecycle(kind, instance_id, generation, payload)
+188
View File
@@ -1043,6 +1043,112 @@ class AgentTests(unittest.TestCase):
self.assertEqual(self.agent.execute(self.operation("instance.stop"))["state"], "accepted") self.assertEqual(self.agent.execute(self.operation("instance.stop"))["state"], "accepted")
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls)) self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
def assert_unhealthy_recovery(self, kind):
spec = self.create()
self.docker.containers[self.agent.container_name(INSTANCE, 1)]["State"]["Health"] = {"Status": "unhealthy"}
payload = {"unhealthyRecovery": True}
if kind == "instance.rebuild":
payload.update({**spec, "nextGeneration": 2})
operation = self.operation(kind, payload)
with patch.object(self.agent, "rcon", side_effect=AssertionError("Unhealthy recovery queried player status")):
result = self.agent.execute(operation)
self.assertEqual(result["state"], "completed", result)
calls = list(self.docker.calls)
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertEqual(self.docker.calls, calls)
target_generation = 2 if kind == "instance.rebuild" else 1
self.assertTrue(self.agent.inspect(INSTANCE, target_generation)["healthy"])
self.assertNotIn("unhealthyRecovery", self.agent.spec(INSTANCE, target_generation))
def test_unhealthy_recovery_restarts_without_player_status(self):
self.assert_unhealthy_recovery("instance.restart")
self.assertIn("instance.unhealthy-recovery", self.agent.inventory()["capabilities"])
def test_unhealthy_recovery_rebuilds_without_player_status(self):
self.assert_unhealthy_recovery("instance.rebuild")
def test_unhealthy_recovery_keeps_safety_for_other_health_states(self):
spec = self.create()
container = self.docker.containers[self.agent.container_name(INSTANCE, 1)]
for kind in ("instance.restart", "instance.rebuild"):
payload = {"unhealthyRecovery": True}
if kind == "instance.rebuild":
payload.update({"spec": spec, "nextGeneration": 2})
for state in ("healthy", "starting", "restarting", "unknown"):
with self.subTest(kind=kind, state=state):
container["State"] = {"Status": "unknown" if state == "unknown" else "running",
"Running": state != "unknown", "Health": {"Status": state}}
with patch.object(self.agent, "rcon", return_value={"ok": True, "players": 2}) as rcon:
result = self.agent.execute(self.operation(kind, payload, f"{kind.split('.')[-1]}-{state}"))
self.assertEqual(result["state"], "accepted", result)
self.assertEqual(rcon.call_count, 0 if state == "unknown" else 1)
self.assertFalse(any(call[:2] in (("container", "restart"), ("container", "stop"), ("container", "rm")) for call in self.docker.calls))
def test_unhealthy_recovery_rechecks_health_before_disruption(self):
spec = self.create()
container = self.docker.containers[self.agent.container_name(INSTANCE, 1)]
inspect = self.docker.inspect
for kind in ("instance.restart", "instance.rebuild"):
container["State"]["Health"] = {"Status": "unhealthy"}
observations = []
def recovered(name):
observations.append(name)
if len(observations) == 2:
container["State"]["Health"] = {"Status": "healthy"}
return inspect(name)
payload = {"unhealthyRecovery": True}
if kind == "instance.rebuild":
payload.update({"spec": spec, "nextGeneration": 2})
with patch.object(self.docker, "inspect", side_effect=recovered), patch.object(self.agent, "rcon", return_value={"ok": True, "players": 2}) as rcon:
result = self.agent.execute(self.operation(kind, payload, kind.split(".")[-1]))
self.assertEqual(result["state"], "accepted", result)
rcon.assert_called_once()
self.assertFalse(any(call[:2] in (("container", "restart"), ("container", "stop"), ("container", "rm")) for call in self.docker.calls))
def test_unhealthy_recovery_requires_boolean_true(self):
self.create()
self.docker.containers[self.agent.container_name(INSTANCE, 1)]["State"]["Health"] = {"Status": "unhealthy"}
for index, value in enumerate((False, None, "true", 1, {})):
with self.subTest(value=value), patch.object(self.agent, "rcon", return_value={"ok": False, "players": None, "reason": "unreachable"}) as rcon:
result = self.agent.execute(self.operation("instance.restart", {"unhealthyRecovery": value}, f"attempt-{index}"))
self.assertEqual(result["state"], "accepted", result)
rcon.assert_called_once()
self.assertFalse(any(call[:2] == ("container", "restart") for call in self.docker.calls))
def test_unhealthy_recovery_rejects_other_lifecycle_actions_and_activation(self):
self.create()
self.docker.containers[self.agent.container_name(INSTANCE, 1)]["State"]["Health"] = {"Status": "unhealthy"}
attempts = [(kind, {}) for kind in ("instance.stop", "instance.remove", "instance.start", "instance.create")]
attempts.extend([("instance.restart", {"stageId": "campaign"}), ("instance.restart", {"autoscaling": {}})])
calls = list(self.docker.calls)
for index, (kind, payload) in enumerate(attempts):
with self.subTest(kind=kind, payload=payload):
result = self.agent.execute(self.operation(kind, {**payload, "unhealthyRecovery": True}, f"attempt-{index}"))
self.assertEqual(result["state"], "failed", result)
self.assertIn("Unhealthy recovery only permits", result["error"])
self.assertEqual(self.docker.calls, calls)
with patch.object(self.agent, "rcon", return_value={"ok": True, "players": 2}) as rcon:
result = self.agent.execute(self.operation("files.activate", {"stageId": "campaign", "unhealthyRecovery": True}))
self.assertEqual(result["state"], "accepted", result)
rcon.assert_called_once()
def test_unhealthy_recovery_preserves_ownership_and_generation_checks(self):
spec = self.create()
container = self.docker.containers[self.agent.container_name(INSTANCE, 1)]
container["State"]["Health"] = {"Status": "unhealthy"}
labels = container["Config"]["Labels"]
container["Config"]["Labels"] = {}
result = self.agent.execute(self.operation("instance.restart", {"unhealthyRecovery": True}, "unowned"))
self.assertEqual(result["state"], "failed", result)
container["Config"]["Labels"] = labels
result = self.agent.execute(self.operation("instance.rebuild", {"unhealthyRecovery": True, "spec": spec, "nextGeneration": 1}, "same-generation"))
self.assertEqual(result["state"], "failed", result)
result = self.agent.execute(self.operation("instance.restart", {"unhealthyRecovery": True}, "stale", generation=0))
self.assertEqual(result["state"], "failed", result)
self.assertFalse(any(call[:2] in (("container", "restart"), ("container", "stop"), ("container", "rm")) for call in self.docker.calls))
def assert_override_disruption(self, kind): def assert_override_disruption(self, kind):
spec = self.create() spec = self.create()
payload = {"safetyOverride": True} payload = {"safetyOverride": True}
@@ -1241,6 +1347,88 @@ class AgentTests(unittest.TestCase):
self.assertEqual(result["state"], "failed") self.assertEqual(result["state"], "failed")
self.assertFalse(any(call[:2] == ("container", "rm") for call in self.docker.calls)) self.assertFalse(any(call[:2] == ("container", "rm") for call in self.docker.calls))
def test_cancel_creation_removes_completed_recovery_rebuild(self):
spec = self.create()
self.docker.containers[self.agent.container_name(INSTANCE, 1)]["State"]["Health"] = {"Status": "unhealthy"}
rebuild = self.operation("instance.rebuild", {"spec": spec, "nextGeneration": 2, "unhealthyRecovery": True}, "rebuild")
self.assertEqual(self.agent.execute(rebuild)["state"], "completed")
cancel = self.operation("instance.cancel-create", {"createOperationId": "create", "recoveryRebuildOperationId": "rebuild", "recoveryGeneration": 2}, "cancel")
with patch.object(self.agent, "rcon", side_effect=AssertionError("Cancellation queried player status")):
result = self.agent.execute(cancel)
self.assertEqual(result["state"], "completed", result)
self.assertFalse(json.loads(result["result"])["exists"])
for generation in (1, 2):
self.assertFalse(self.agent.inspect(INSTANCE, generation)["exists"])
self.assertFalse(self.agent.instance_dir(INSTANCE, generation).exists())
self.assertEqual(json.loads((self.agent.instance_dir(INSTANCE, 1).parent / "current.json").read_text()), {"generation": 2, "removed": True})
calls = list(self.docker.calls)
self.assertEqual(self.agent.execute(cancel)["state"], "completed")
self.assertEqual(self.docker.calls, calls)
def test_cancel_creation_fences_undelivered_recovery_rebuild(self):
spec = self.create()
cancel = self.operation("instance.cancel-create", {"createOperationId": "create", "recoveryRebuildOperationId": "late-rebuild", "recoveryGeneration": 2}, "cancel")
self.assertEqual(self.agent.execute(cancel)["state"], "completed")
rebuild = self.operation("instance.rebuild", {"spec": spec, "nextGeneration": 2, "unhealthyRecovery": True}, "late-rebuild")
result = self.agent.execute(rebuild)
self.assertEqual(result["state"], "failed", result)
self.assertFalse(self.agent.inspect(INSTANCE, 1)["exists"])
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
self.assertEqual(sum(call[:2] == ("container", "create") for call in self.docker.calls), 1)
def test_cancel_creation_reconciles_recovery_rebuild_removal(self):
spec = self.create()
self.docker.containers[self.agent.container_name(INSTANCE, 1)]["State"]["Health"] = {"Status": "unhealthy"}
self.docker.lose_reply_after = "start"
rebuild = self.operation("instance.rebuild", {"spec": spec, "nextGeneration": 2, "unhealthyRecovery": True}, "rebuild")
self.assertEqual(self.agent.execute(rebuild)["state"], "unknown")
self.docker.lose_reply_after = "rm"
cancel = self.operation("instance.cancel-create", {"createOperationId": "create", "recoveryRebuildOperationId": "rebuild", "recoveryGeneration": 2}, "cancel")
self.assertEqual(self.agent.execute(cancel)["state"], "unknown")
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
self.assertEqual(self.agent.execute(cancel)["state"], "completed")
for generation in (1, 2):
self.assertFalse(self.agent.inspect(INSTANCE, generation)["exists"])
self.assertFalse(self.agent.instance_dir(INSTANCE, generation).exists())
def test_cancel_creation_rejects_mismatched_recovery_rebuild(self):
spec = self.create()
for index, (kind, instance_id, generation, changes) in enumerate((
("instance.restart", INSTANCE, 1, {}),
("instance.rebuild", "another-instance", 1, {}),
("instance.rebuild", INSTANCE, 2, {}),
("instance.rebuild", INSTANCE, 1, {"nextGeneration": 3}),
("instance.rebuild", INSTANCE, 1, {"unhealthyRecovery": False}),
)):
rebuild = self.operation(kind, {"spec": spec, "nextGeneration": 2, "unhealthyRecovery": True, **changes}, f"rebuild-{index}", generation=generation)
rebuild["instanceId"] = instance_id
self.agent.journal.accept(rebuild)
cancel = self.operation("instance.cancel-create", {"createOperationId": "create", "recoveryRebuildOperationId": rebuild["id"], "recoveryGeneration": 2}, f"cancel-{index}")
result = self.agent.execute(cancel)
self.assertEqual(result["state"], "failed", result)
self.assertIn("does not match", result["error"])
self.assertTrue(self.agent.inspect(INSTANCE, 1)["exists"])
self.assertFalse(any(call[:2] == ("container", "rm") for call in self.docker.calls))
def test_cancel_creation_rejects_unrelated_recovery_container_or_newer_generation(self):
self.create()
original = self.docker.containers[self.agent.container_name(INSTANCE, 1)]
unrelated = json.loads(json.dumps(original))
unrelated["Config"]["Labels"].update({module.LABEL + ".generation": "2", module.LABEL + ".operation": "unrelated"})
self.docker.containers[self.agent.container_name(INSTANCE, 2)] = unrelated
payload = {"createOperationId": "create", "recoveryRebuildOperationId": "rebuild", "recoveryGeneration": 2}
result = self.agent.execute(self.operation("instance.cancel-create", payload, "unrelated-container"))
self.assertEqual(result["state"], "failed", result)
self.assertIn("different creation", result["error"])
marker = self.agent.instance_dir(INSTANCE, 1).parent / "current.json"
module.atomic_json(marker, {"generation": 3})
result = self.agent.execute(self.operation("instance.cancel-create", payload, "newer-generation"))
self.assertEqual(result["state"], "failed", result)
self.assertIn("superseded", result["error"])
self.assertFalse(any(call[:2] == ("container", "rm") for call in self.docker.calls))
self.assertEqual(json.loads(marker.read_text()), {"generation": 3})
def test_unowned_container_never_mutated(self): def test_unowned_container_never_mutated(self):
self.create() self.create()
self.docker.containers[self.agent.container_name(INSTANCE, 1)]["Config"]["Labels"] = {} self.docker.containers[self.agent.container_name(INSTANCE, 1)]["Config"]["Labels"] = {}
+13
View File
@@ -71,6 +71,14 @@ making another request.
### Health and runtime checks ### Health and runtime checks
If the host agent explicitly reports an unhealthy container during Create or
Rebuild verification, that operation forces one restart, then forces one rebuild
if the container remains unhealthy. Recovery preserves the selected launch files,
credentials, server identity, and port. If both attempts leave it unhealthy, the
operation fails for inspection instead of repeating recovery. Starting, restarting,
and unknown container states continue waiting for readiness. This requires host
agent 1.8.1 or newer.
Create and Rebuild finish only after RCON reports a runtime revision at least as Create and Rebuild finish only after RCON reports a runtime revision at least as
new as the current Plutonium production release. The worker checks the production new as the current Plutonium production release. The worker checks the production
manifest and records the runtime revision for fleet and server version status as manifest and records the runtime revision for fleet and server version status as
@@ -114,6 +122,11 @@ campaign, beta, maintenance, or provisioning work. Duplicate pending requests fo
the same action return the existing operation; a different action returns a the same action return the existing operation; a different action returns a
conflict. Requests and events survive application restarts. conflict. Requests and events survive application restarts.
Rebuild and recovery within Create or Rebuild treat an explicitly unhealthy
container as empty without requiring a player count. The agent checks health
again before disruption; if the container has recovered, normal player checks
apply. This exception does not apply to unknown health or other lifecycle actions.
For a failed container stuck waiting for player status, an administrator can open For a failed container stuck waiting for player status, an administrator can open
its queued or waiting **Restart**, **Stop**, **Rebuild**, or **Destroy** operation its queued or waiting **Restart**, **Stop**, **Rebuild**, or **Destroy** operation
and choose **Admin override…**. The confirmation modal explains the interruption and choose **Admin override…**. The confirmation modal explains the interruption
+3
View File
@@ -46,6 +46,9 @@ export function migrateNativeInstances(handle: Database.Database): void {
if (!(handle.pragma('table_info(ec_host_migrations)') as { name: string }[]).some(column => column.name === 'runtime_password')) { if (!(handle.pragma('table_info(ec_host_migrations)') as { name: string }[]).some(column => column.name === 'runtime_password')) {
handle.exec('ALTER TABLE ec_host_migrations ADD COLUMN runtime_password TEXT'); handle.exec('ALTER TABLE ec_host_migrations ADD COLUMN runtime_password TEXT');
} }
if (!(handle.pragma('table_info(native_provisioning)') as { name: string }[]).some(column => column.name === 'recovery_step')) {
handle.exec("ALTER TABLE native_provisioning ADD COLUMN recovery_step TEXT CHECK (recovery_step IN ('restart','rebuild'))");
}
if (!handle.prepare("SELECT 1 FROM settings WHERE key='native_file_plans_v1'").get()) { if (!handle.prepare("SELECT 1 FROM settings WHERE key='native_file_plans_v1'").get()) {
handle.transaction(() => { handle.transaction(() => {
handle.exec(`UPDATE native_provisioning SET files_json=(SELECT json_group_array(json_remove(value,'$.contentBase64')) handle.exec(`UPDATE native_provisioning SET files_json=(SELECT json_group_array(json_remove(value,'$.contentBase64'))
+14 -5
View File
@@ -466,10 +466,13 @@ export function listHostOperationHistory(page = 1, filter: HostOperationFilter =
/** Bound each cleanup pass so retained history cannot stall monitoring or receipts. */ /** Bound each cleanup pass so retained history cannot stall monitoring or receipts. */
export function pruneHostObservations(): number { return pruneHistory('observation'); } export function pruneHostObservations(): number { return pruneHistory('observation'); }
/** Heartbeats can tell an executing create to stop at its next safe command boundary. */ /** Heartbeats stop either launch generation at its next safe command boundary. */
export function pendingCreationCancellations(hostId: string): string[] { export function pendingCreationCancellations(hostId: string): string[] {
return (database().prepare(`SELECT json_extract(payload,'$.createOperationId') AS id FROM ec_host_operations const rows = database().prepare(`SELECT json_extract(payload,'$.createOperationId') AS creation_id,
WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown')`).all(hostId) as { id: string }[]).map(row => row.id); json_extract(payload,'$.recoveryRebuildOperationId') AS recovery_id FROM ec_host_operations
WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown')`)
.all(hostId) as { creation_id: string; recovery_id: string | null }[];
return rows.flatMap(row => row.recovery_id ? [row.creation_id, row.recovery_id] : [row.creation_id]);
} }
type QueueHostOperationOptions = { instanceId?: string; generation?: number; idempotencyKey?: string; id?: string }; type QueueHostOperationOptions = { instanceId?: string; generation?: number; idempotencyKey?: string; id?: string };
export function queueHostOperation(hostId: string, kind: string, payload: Record<string, unknown>, options: QueueHostOperationOptions = {}): HostOperation { export function queueHostOperation(hostId: string, kind: string, payload: Record<string, unknown>, options: QueueHostOperationOptions = {}): HostOperation {
@@ -537,8 +540,11 @@ export function pendingHostOperationRows(hostId: string, lane: OperationLane = '
// busy indefinitely. Drain the old batch by withholding ALL further work; // busy indefinitely. Drain the old batch by withholding ALL further work;
// heartbeats and receipts still flow, and the new capability releases this hold. // heartbeats and receipts still flow, and the new capability releases this hold.
// This must live in the controller so the fix also works before an agent update. // This must live in the controller so the fix also works before an agent update.
if (!host.inventory.capabilities?.includes('instance.cancel-create') && database().prepare(`SELECT 1 FROM ec_host_operations if (database().prepare(`SELECT 1 FROM ec_host_operations
WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId)) return []; WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown')
AND (NOT ? OR (json_extract(payload,'$.recoveryRebuildOperationId') IS NOT NULL AND NOT ?)) LIMIT 1`)
.get(hostId, Number(host.inventory.capabilities?.includes('instance.cancel-create') ?? false),
Number(host.inventory.capabilities?.includes('instance.unhealthy-recovery') ?? false))) return [];
// Accepted/running/unknown commands keep the same immutable ID. The agent // Accepted/running/unknown commands keep the same immutable ID. The agent
// consults its durable journal before acknowledging or executing a replay. // consults its durable journal before acknowledging or executing a replay.
const healthOnly = `(kind IN ('instance.inspect','rcon.status-batch') OR (kind='rcon.execute' AND json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version')))`; const healthOnly = `(kind IN ('instance.inspect','rcon.status-batch') OR (kind='rcon.execute' AND json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version')))`;
@@ -557,6 +563,9 @@ export function pendingHostOperationRows(hostId: string, lane: OperationLane = '
AND (kind!='instance.cancel-create' OR ?) AND (kind!='instance.cancel-create' OR ?)
AND NOT EXISTS (SELECT 1 FROM ec_host_operations cancellation WHERE cancellation.host_id=o.host_id AND NOT EXISTS (SELECT 1 FROM ec_host_operations cancellation WHERE cancellation.host_id=o.host_id
AND cancellation.kind='instance.cancel-create' AND json_extract(cancellation.payload,'$.createOperationId')=o.id) AND cancellation.kind='instance.cancel-create' AND json_extract(cancellation.payload,'$.createOperationId')=o.id)
AND NOT EXISTS (SELECT 1 FROM native_creation_cancellations cancellation
JOIN native_provisioning creation ON creation.deployment_id=cancellation.creation_id
WHERE creation.operation_id=o.id)
${lane === 'health' ? "AND (state NOT IN ('accepted','running') OR MAX(updated_at,COALESCE(last_delivered_at,'')) <= ?)" : ''} ${lane === 'health' ? "AND (state NOT IN ('accepted','running') OR MAX(updated_at,COALESCE(last_delivered_at,'')) <= ?)" : ''}
ORDER BY CASE WHEN kind='instance.cancel-create' THEN 0 WHEN kind='plutonium.prepare' THEN 2 ELSE 1 END, ORDER BY CASE WHEN kind='instance.cancel-create' THEN 0 WHEN kind='plutonium.prepare' THEN 2 ELSE 1 END,
COALESCE(last_delivered_at,''),created_at,rowid LIMIT ${limit}`) COALESCE(last_delivered_at,''),created_at,rowid LIMIT ${limit}`)
+30 -6
View File
@@ -2,14 +2,16 @@ import { pauseScalingForOperation } from '../autoscaling/ownership.js';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { releaseServerKey } from '../fleet/server-keys.js'; import { releaseServerKey } from '../fleet/server-keys.js';
import { nativeInstance } from '../backends/native.js'; import { nativeInstance } from '../backends/native.js';
import { queueHostOperation, releaseHostPorts } from '../hosts/index.js'; import { getHostOperationStatus, queueHostOperation, releaseHostPorts } from '../hosts/index.js';
import { beginDeployment, cancelRemoval, deploymentStage, finishDeployment } from './history.js'; import { beginDeployment, cancelRemoval, deploymentStage, finishDeployment } from './history.js';
import { recordProgress } from './progress.js'; import { recordProgress } from './progress.js';
export function canCancelCreation(id: string): boolean { export function canCancelCreation(id: string): boolean {
return !!db().prepare(`SELECT 1 FROM server_deployments d return !!db().prepare(`SELECT 1 FROM server_deployments d
LEFT JOIN native_provisioning n ON n.deployment_id=d.id LEFT JOIN native_provisioning n ON n.deployment_id=d.id
LEFT JOIN native_instances i ON i.id=n.instance_id AND i.generation=n.generation AND i.retired_at IS NULL LEFT JOIN native_instances i ON i.id=n.instance_id AND i.retired_at IS NULL
AND (i.generation=n.generation OR (n.recovery_step='rebuild' AND i.generation=n.generation+1
AND n.applied_at IS NOT NULL))
WHERE d.id=? AND d.action='create' AND d.server_deleted_at IS NULL WHERE d.id=? AND d.action='create' AND d.server_deleted_at IS NULL
AND (i.id IS NOT NULL OR (n.deployment_id IS NULL AND (i.id IS NOT NULL OR (n.deployment_id IS NULL
AND EXISTS (SELECT 1 FROM native_creation_intents intent WHERE intent.deployment_id=d.id) AND EXISTS (SELECT 1 FROM native_creation_intents intent WHERE intent.deployment_id=d.id)
@@ -29,9 +31,9 @@ export function cancelCreation(id: string, triggeredBy = 'ui', pausePolicy = tru
if (prior) return prior.cleanup_id; if (prior) return prior.cleanup_id;
if (!canCancelCreation(id)) return null; if (!canCancelCreation(id)) return null;
if (pausePolicy) pauseScalingForOperation(id); if (pausePolicy) pauseScalingForOperation(id);
const job = db().prepare(`SELECT d.server,n.operation_id,n.stage_operation_id FROM server_deployments d const job = db().prepare(`SELECT d.server,n.operation_id,n.stage_operation_id,n.generation,n.recovery_step FROM server_deployments d
LEFT JOIN native_provisioning n ON n.deployment_id=d.id WHERE d.id=?`).get(id) as LEFT JOIN native_provisioning n ON n.deployment_id=d.id WHERE d.id=?`).get(id) as
{ server: string; operation_id: string | null; stage_operation_id: string | null }; { server: string; operation_id: string | null; stage_operation_id: string | null; generation: number | null; recovery_step: 'restart' | 'rebuild' | null };
const instance = nativeInstance(job.server); const instance = nativeInstance(job.server);
// Replace a removal still waiting on occupancy, including one whose asynchronous probe is outstanding. // Replace a removal still waiting on occupancy, including one whose asynchronous probe is outstanding.
for (const removal of db().prepare("SELECT id FROM server_deployments WHERE server=? AND action='destroy' AND state='waiting' AND server_deleted_at IS NULL").all(job.server) as { id: string }[]) { for (const removal of db().prepare("SELECT id FROM server_deployments WHERE server=? AND action='destroy' AND state='waiting' AND server_deleted_at IS NULL").all(job.server) as { id: string }[]) {
@@ -54,8 +56,30 @@ export function cancelCreation(id: string, triggeredBy = 'ui', pausePolicy = tru
finishCreationCancellation(cleanupId); finishCreationCancellation(cleanupId);
} else { } else {
if (!instance) throw new Error('The original native instance is unavailable.'); if (!instance) throw new Error('The original native instance is unavailable.');
const operation = queueHostOperation(instance.host_id, 'instance.cancel-create', { createOperationId: job.operation_id ?? id }, let generation = instance.generation;
{ instanceId: instance.id, generation: instance.generation, idempotencyKey: cleanupId }); const payload: { createOperationId: string; recoveryRebuildOperationId?: string; recoveryGeneration?: number } = { createOperationId: job.operation_id ?? id };
if (job.recovery_step) {
// Recovery replaces operation_id, but cancellation must retain the
// immutable ownership label from the original launch.
const original = db().prepare(`SELECT id FROM ec_host_operations WHERE host_id=? AND instance_id=?
AND generation=? AND kind='instance.create' ORDER BY rowid DESC LIMIT 1`)
.get(instance.host_id, instance.id, job.generation) as { id: string } | undefined;
if (!original || !job.generation) throw new Error('The original creation command is unavailable.');
payload.createOperationId = original.id;
generation = job.generation;
if (job.recovery_step === 'rebuild') {
const recovery = job.operation_id ? getHostOperationStatus(job.operation_id) : null;
if (!recovery || recovery.kind !== 'instance.rebuild' || recovery.instanceId !== instance.id || recovery.generation !== generation) {
throw new Error('The creation recovery command is unavailable.');
}
// A delivered rebuild may have created the next generation
// before its receipt arrives. Fence and remove both identities.
payload.recoveryRebuildOperationId = recovery.id;
payload.recoveryGeneration = generation + 1;
}
}
const operation = queueHostOperation(instance.host_id, 'instance.cancel-create', payload,
{ instanceId: instance.id, generation, idempotencyKey: cleanupId });
db().prepare('UPDATE native_provisioning SET operation_id=? WHERE deployment_id=?').run(operation.id, cleanupId); db().prepare('UPDATE native_provisioning SET operation_id=? WHERE deployment_id=?').run(operation.id, cleanupId);
db().prepare('UPDATE server_deployments SET submitted_at=? WHERE id=?').run(new Date().toISOString(), cleanupId); db().prepare('UPDATE server_deployments SET submitted_at=? WHERE id=?').run(new Date().toISOString(), cleanupId);
deploymentStage(cleanupId, 'Waiting for host agent to cancel creation and remove its partial instance', { phase: 'container-action', blocker: null, deploymentStage(cleanupId, 'Waiting for host agent to cancel creation and remove its partial instance', { phase: 'container-action', blocker: null,
+1 -1
View File
@@ -14,7 +14,7 @@ export function lifecycleHolders(): Holder[] {
// Find capacity owners through their active state or outstanding command. // Find capacity owners through their active state or outstanding command.
// A correlated OR over d used to walk every retained job and fetch its agent // A correlated OR over d used to walk every retained job and fetch its agent
// receipt on every admission, even when all that history was completed. // receipt on every admission, even when all that history was completed.
return db().prepare(`SELECT d.id,d.action,i.host_id,m.target_host_id, return db().prepare(`SELECT d.id,CASE WHEN n.recovery_step='rebuild' THEN 'rebuild' ELSE d.action END AS action,i.host_id,m.target_host_id,
CASE WHEN m.remove_operation_id IS NOT NULL AND m.removed_at IS NULL THEN 1 ELSE 0 END AS removing CASE WHEN m.remove_operation_id IS NOT NULL AND m.removed_at IS NULL THEN 1 ELSE 0 END AS removing
FROM server_deployments d FROM server_deployments d
LEFT JOIN native_provisioning n ON n.deployment_id=d.id LEFT JOIN native_provisioning n ON n.deployment_id=d.id
+3 -1
View File
@@ -107,7 +107,9 @@ function capacityFor(deployment: ProvisioningDeployment, evaluatedAt: string): P
pools.push({ kind: 'startup', limit: settings.createConcurrency, used: holders.length, holders, pools.push({ kind: 'startup', limit: settings.createConcurrency, used: holders.length, holders,
requestOrder: order < 0 ? null : order + 1, ownsSlot: holders.some(holder => holder.id === deployment.id) }); requestOrder: order < 0 ? null : order + 1, ownsSlot: holders.some(holder => holder.id === deployment.id) });
} }
if (['start', 'restart', 'rebuild'].includes(deployment.action)) { const creationRecovery = deployment.action === 'create' && db().prepare(`SELECT 1 FROM native_provisioning
WHERE deployment_id=? AND recovery_step IS NOT NULL`).get(deployment.id);
if (['start', 'restart', 'rebuild'].includes(deployment.action) || creationRecovery) {
const holders = db().prepare(`SELECT operation_id AS id, server FROM restart_rollout WHERE healthy_at IS NULL const holders = db().prepare(`SELECT operation_id AS id, server FROM restart_rollout WHERE healthy_at IS NULL
ORDER BY requested_at, server`).all() as { id: string | null; server: string }[]; ORDER BY requested_at, server`).all() as { id: string | null; server: string }[];
pools.push({ kind: 'restart', limit: settings.restartConcurrency, used: holders.length, holders, pools.push({ kind: 'restart', limit: settings.restartConcurrency, used: holders.length, holders,
@@ -15,6 +15,7 @@ export interface NativeLaunchInput {
rconPassword: string; rconPassword: string;
rebuild: boolean; rebuild: boolean;
safetyOverride?: boolean; safetyOverride?: boolean;
unhealthyRecovery?: boolean;
/** Rebalance replays recorded deployed bytes instead of current promoted files. */ /** Rebalance replays recorded deployed bytes instead of current promoted files. */
frozenRecipeId?: string; frozenRecipeId?: string;
} }
+3 -1
View File
@@ -44,7 +44,9 @@ parentPort!.on('message', (message: { id: number; kind: 'launch'; input: NativeL
PLUTO_SERVER_KEY: input.serverKey }, PLUTO_SERVER_KEY: input.serverKey },
files: recipe.files.map(file => ({ path: posix.join(relDestForKind(file.kind, recipe.game), file.path), files: recipe.files.map(file => ({ path: posix.join(relDestForKind(file.kind, recipe.game), file.path),
contentSha256: file.sha256, sha256: file.sha256 })), contentSha256: file.sha256, sha256: file.sha256 })),
...(input.rebuild ? { nextGeneration: instance.generation + 1, ...(input.safetyOverride ? { safetyOverride: true } : {}) } : {}) ...(input.rebuild ? { nextGeneration: instance.generation + 1,
...(input.safetyOverride ? { safetyOverride: true } : {}),
...(input.unhealthyRecovery ? { unhealthyRecovery: true } : {}) } : {})
}; };
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)); const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
const blobs = [...new Map(recipe.files.map(file => [file.sha256, const blobs = [...new Map(recipe.files.map(file => [file.sha256,
+110 -15
View File
@@ -2,7 +2,7 @@ import { assertScalingOwnership } from '../autoscaling/ownership.js';
import { removalAuthorization } from '../autoscaling/authorization.js'; import { removalAuthorization } from '../autoscaling/authorization.js';
import { scalingIntent } from '../autoscaling/admission.js'; import { scalingIntent } from '../autoscaling/admission.js';
import { putFileBlob, getFileBlob, blobSize } from '../db/file-blobs.js'; import { putFileBlob, getFileBlob, blobSize } from '../db/file-blobs.js';
import { concurrencyBlocker, preparingJobs, committedRebalanceStartup } from './concurrency.js'; import { concurrencyBlocker, preparingJobs, committedRebalanceStartup, lifecycleHolders } from './concurrency.js';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { setImmediate as yieldToController } from 'node:timers/promises'; import { setImmediate as yieldToController } from 'node:timers/promises';
import { choosePlacement, availableHostPort, type PlacementOptions } from '../hosts/capacity.js'; import { choosePlacement, availableHostPort, type PlacementOptions } from '../hosts/capacity.js';
@@ -39,12 +39,15 @@ import { cancelCreation, finishCreationCancellation } from './cancel-creation.js
import { reconcileHostDrains } from './host-drain.js'; import { reconcileHostDrains } from './host-drain.js';
import { hostMigration, isRebalanceMigration, processMigrationRemoval } from './migrations.js'; import { hostMigration, isRebalanceMigration, processMigrationRemoval } from './migrations.js';
import { safetyOverride, safetyOverrideCapability } from './safety-override.js'; import { safetyOverride, safetyOverrideCapability } from './safety-override.js';
import { runtimeLaunchSql } from '../fleet/runtime-config.js';
export type NativeSelection = import('../../hosts.js').NativePlacement; export type NativeSelection = import('../../hosts.js').NativePlacement;
type NativeRecipe = LaunchRecipePlan & { native?: NativeSelection }; type NativeRecipe = LaunchRecipePlan & { native?: NativeSelection };
interface Job { selection?: string | null; id: string; server: string; action: ProvisioningAction; state: string; submitted_at: string | null; stage: string; interface Job { selection?: string | null; id: string; server: string; action: ProvisioningAction; state: string; submitted_at: string | null; stage: string;
instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; applied_at: string | null } instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; applied_at: string | null;
recovery_step: 'restart' | 'rebuild' | null }
interface FilePlan { id: number; path: string; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null } interface FilePlan { id: number; path: string; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null }
const unhealthyRecoveryCapability = 'instance.unhealthy-recovery';
/** Validate a launch request without selecting a host or consuming capacity. */ /** Validate a launch request without selecting a host or consuming capacity. */
export function nativeLaunchSelection(input: Record<string, unknown>, game: string): NativeSelection { export function nativeLaunchSelection(input: Record<string, unknown>, game: string): NativeSelection {
@@ -242,7 +245,7 @@ function storedFiles(job: Job): FilePlan[] {
/** Agent receipts are the authoritative before images; do not read a controller mount. */ /** Agent receipts are the authoritative before images; do not read a controller mount. */
function applyReceipt(job: Job, result: Record<string, unknown>) { function applyReceipt(job: Job, result: Record<string, unknown>) {
if (job.applied_at) return; if (job.applied_at || job.recovery_step) return;
const plans = storedFiles(job); const plans = storedFiles(job);
if (!plans.length) return; if (!plans.length) return;
const before = result.before as { id?: number; path: string; contentBase64: string | null; contentSha256?: string }[] | undefined; const before = result.before as { id?: number; path: string; contentBase64: string | null; contentSha256?: string }[] | undefined;
@@ -276,7 +279,7 @@ function applyReceipt(job: Job, result: Record<string, unknown>) {
/** Retain confirmed file snapshots and instance identity even after observation is cancelled. */ /** Retain confirmed file snapshots and instance identity even after observation is cancelled. */
export function reconcileNativeProvisioningReceipt(id: string): void { export function reconcileNativeProvisioningReceipt(id: string): void {
const job = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.operation_id,n.applied_at const job = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.operation_id,n.applied_at,n.recovery_step
FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id
WHERE d.id=? AND d.server_deleted_at IS NULL`).get(id) as Job | undefined; WHERE d.id=? AND d.server_deleted_at IS NULL`).get(id) as Job | undefined;
if (!job) return; if (!job) return;
@@ -309,6 +312,13 @@ export function reconcileNativeProvisioningReceipt(id: string): void {
if (operation?.state !== 'completed') return; if (operation?.state !== 'completed') return;
const instance = nativeInstance(job.server); const instance = nativeInstance(job.server);
if (!instance || instance.id !== job.instance_id) return; if (!instance || instance.id !== job.instance_id) return;
if (job.state === 'cancelled' && job.action === 'create'
&& db().prepare('SELECT 1 FROM native_creation_cancellations WHERE creation_id=?').get(id)) {
// Cleanup owns both generations of a cancelled recovery rebuild. Do not
// advance the registry underneath its already-bound removal command.
db().prepare('UPDATE native_provisioning SET applied_at=COALESCE(applied_at,?) WHERE deployment_id=?').run(operation.updatedAt, id);
return;
}
if (job.action === 'destroy') { if (job.action === 'destroy') {
if (instance.generation !== job.generation) return; if (instance.generation !== job.generation) return;
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(operation.updatedAt, instance.id); db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(operation.updatedAt, instance.id);
@@ -318,7 +328,7 @@ export function reconcileNativeProvisioningReceipt(id: string): void {
finishCreationCancellation(job.id); finishCreationCancellation(job.id);
return; return;
} }
if (job.action === 'rebuild' && !hostMigration(id) && instance.generation === job.generation) { if (operation.kind === 'instance.rebuild' && instance.generation === job.generation) {
db().prepare('UPDATE native_instances SET generation=generation+1 WHERE id=?').run(instance.id); db().prepare('UPDATE native_instances SET generation=generation+1 WHERE id=?').run(instance.id);
// Replacements queued while the delivered rebuild was settling bound the // Replacements queued while the delivered rebuild was settling bound the
// previous generation. Only untouched, newer jobs may follow this receipt. // previous generation. Only untouched, newer jobs may follow this receipt.
@@ -328,7 +338,7 @@ export function reconcileNativeProvisioningReceipt(id: string): void {
WHERE newer.rowid>(SELECT rowid FROM server_deployments WHERE id=?) WHERE newer.rowid>(SELECT rowid FROM server_deployments WHERE id=?)
AND newer.server_deleted_at IS NULL AND newer.state IN ('queued','waiting'))`) AND newer.server_deleted_at IS NULL AND newer.state IN ('queued','waiting'))`)
.run(job.generation + 1, instance.id, job.generation, id); .run(job.generation + 1, instance.id, job.generation, id);
} else if (instance.generation !== job.generation && !(job.action === 'rebuild' && instance.generation === job.generation + 1)) return; } else if (instance.generation !== job.generation && !(operation.kind === 'instance.rebuild' && instance.generation === job.generation + 1)) return;
applyReceipt(job, (operation.result ?? {}) as Record<string, unknown>); applyReceipt(job, (operation.result ?? {}) as Record<string, unknown>);
db().prepare('UPDATE native_provisioning SET applied_at=COALESCE(applied_at,?) WHERE deployment_id=?').run(operation.updatedAt, id); db().prepare('UPDATE native_provisioning SET applied_at=COALESCE(applied_at,?) WHERE deployment_id=?').run(operation.updatedAt, id);
} }
@@ -342,10 +352,78 @@ function complete(job: Job) {
db().transaction(() => { db().transaction(() => {
db().prepare('UPDATE native_provisioning SET verified_at=? WHERE deployment_id=?').run(new Date().toISOString(), job.id); db().prepare('UPDATE native_provisioning SET verified_at=? WHERE deployment_id=?').run(new Date().toISOString(), job.id);
db().prepare('UPDATE restart_rollout SET healthy_at=?,error=NULL WHERE operation_id=?').run(new Date().toISOString(), job.id); db().prepare('UPDATE restart_rollout SET healthy_at=?,error=NULL WHERE operation_id=?').run(new Date().toISOString(), job.id);
if (job.recovery_step) recordProgress(job.id, { recovery: { state: 'released', operationId: job.id,
message: 'Container and game health verified after provisioning recovery.' } });
finishDeployment(job.id, { ok: true }); finishDeployment(job.id, { ok: true });
})(); })();
} }
/** Continue the same launch with at most one restart and one replacement. */
function queueUnhealthyRecovery(job: Job, instance: NativeInstance): void {
db().transaction(() => {
if (!stillActive(job)) return;
const live = nativeInstance(job.server);
if (live?.id !== instance.id || live.generation !== instance.generation) return;
if (job.recovery_step === 'rebuild') {
const message = 'Container is still unhealthy after recovery restart and rebuild. Inspect the server before retrying.';
db().prepare('UPDATE restart_rollout SET error=? WHERE operation_id=?').run(message, job.id);
finishDeployment(job.id, { ok: false, message });
return;
}
const host = getHost(instance.host_id);
if (!host?.connected || host.mode === 'disabled') { hold(job, 'Host is unavailable; waiting for reconnection.'); return; }
if (!host.inventory.capabilities?.includes(unhealthyRecoveryCapability)) {
hold(job, 'Update this host’s agent to recover unhealthy containers during provisioning.'); return;
}
const step = job.recovery_step === 'restart' ? 'rebuild' : 'restart';
const settings = fleetSettings();
const slots = db().prepare(`SELECT i.host_id FROM restart_rollout r LEFT JOIN native_instances i
ON i.server=r.server AND i.retired_at IS NULL WHERE r.healthy_at IS NULL AND (r.operation_id IS NULL OR r.operation_id!=?)`)
.all(job.id) as { host_id: string | null }[];
if (settings.restartConcurrency > 0 && slots.length >= settings.restartConcurrency) {
hold(job, 'Waiting for restart recovery capacity.'); return;
}
if (settings.restartConcurrencyPerHost > 0 && slots.filter(slot => slot.host_id === instance.host_id).length >= settings.restartConcurrencyPerHost) {
hold(job, 'Waiting for restart recovery capacity on this host.'); return;
}
let payload: Record<string, unknown> = { unhealthyRecovery: true };
if (step === 'rebuild') {
const beta = activeBetaRemovalBlocker(job.server);
if (beta) { hold(job, beta); return; }
if (job.action !== 'rebuild' && settings.rebuildConcurrency > 0) {
if (lifecycleHolders().filter(holder => holder.id !== job.id && holder.action === 'rebuild').length >= settings.rebuildConcurrency) {
hold(job, 'Waiting for rebuild capacity.'); return;
}
}
assertReadyLibrary(instance.host_id, instance.library_id);
// Recover the files and credentials selected for this launch, even
// if new versions have been promoted while readiness was pending.
const launch = db().prepare(`SELECT COALESCE(p.payload,o.payload) AS payload FROM ec_host_operations o
JOIN native_instances i ON ${runtimeLaunchSql}
LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id
WHERE i.id=? AND o.state='completed' AND o.history_expired_at IS NULL ORDER BY o.rowid DESC LIMIT 1`)
.get(instance.id) as { payload: string } | undefined;
if (!launch) { hold(job, 'Waiting for the original launch payload before recovery rebuild.'); return; }
payload = { ...JSON.parse(launch.payload), nextGeneration: instance.generation + 1, unhealthyRecovery: true };
delete payload.safetyOverride;
}
const operation = queueHostOperationSerialized(instance.host_id, `instance.${step}`, Buffer.from(JSON.stringify(payload)),
{ instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:unhealthy:${step}` },
{ name: instance.server, unhealthyRecovery: true });
const at = new Date().toISOString();
db().prepare('UPDATE native_provisioning SET operation_id=?,generation=?,recovery_step=?,applied_at=NULL WHERE deployment_id=?')
.run(operation.id, instance.generation, step, job.id);
db().prepare("UPDATE server_deployments SET state='running',submitted_at=?,error=NULL,finished_at=NULL WHERE id=?").run(at, job.id);
db().prepare(`INSERT INTO restart_rollout(server,requested_at,operation_id) VALUES(?,?,?) ON CONFLICT(server)
DO UPDATE SET requested_at=excluded.requested_at,operation_id=excluded.operation_id,healthy_at=NULL,error=NULL`).run(job.server, at, job.id);
const message = `Container unhealthy; ${step === 'restart' ? 'forcing a restart' : 'forcing a rebuild after restart recovery failed'}.`;
deploymentStage(job.id, message, { phase: 'container-action', blocker: null, checks: [],
command: { state: 'attempted', message: `Recovery ${step} queued as agent operation ${operation.id}.` },
recovery: { state: 'held', operationId: job.id, message: 'Recovery runs within this provisioning operation.' },
event: { kind: 'unhealthy-recovery', message, details: { step, operationId: operation.id, generation: instance.generation } } });
})();
}
export async function processNativeProvisioning(eligibility: (operation: { id: string; server: string; action: string; state: string; stage: string }) => string | null, export async function processNativeProvisioning(eligibility: (operation: { id: string; server: string; action: string; state: string; stage: string }) => string | null,
options: { wait?: boolean } = {}): Promise<void> { options: { wait?: boolean } = {}): Promise<void> {
ensureHostSchema(db()); ensureHostSchema(db());
@@ -369,7 +447,7 @@ export async function processNativeProvisioning(eligibility: (operation: { id: s
} }
} }
reconcileHostDrains(); reconcileHostDrains();
const jobs = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.stage_operation_id,n.operation_id,n.applied_at,q.selection const jobs = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.stage_operation_id,n.operation_id,n.applied_at,n.recovery_step,q.selection
FROM server_deployments d LEFT JOIN native_provisioning n ON n.deployment_id=d.id FROM server_deployments d LEFT JOIN native_provisioning n ON n.deployment_id=d.id
LEFT JOIN native_creation_intents q ON q.deployment_id=d.id LEFT JOIN native_creation_intents q ON q.deployment_id=d.id
WHERE (n.deployment_id IS NOT NULL OR q.deployment_id IS NOT NULL) AND d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') ORDER BY d.started_at,d.rowid`).all() as Job[]; WHERE (n.deployment_id IS NOT NULL OR q.deployment_id IS NOT NULL) AND d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') ORDER BY d.started_at,d.rowid`).all() as Job[];
@@ -485,7 +563,8 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
job.instance_id = instance.id; job.generation = instance.generation; job.instance_id = instance.id; job.generation = instance.generation;
} }
const migration = hostMigration(job.id); const migration = hostMigration(job.id);
if (!instance || instance.id !== job.instance_id || (instance.generation !== job.generation && !(job.action === 'rebuild' && !migration && instance.generation === job.generation + 1))) { const rebuilding = job.recovery_step === 'rebuild' || (!job.recovery_step && job.action === 'rebuild' && !migration);
if (!instance || instance.id !== job.instance_id || (instance.generation !== job.generation && !(rebuilding && instance.generation === job.generation + 1))) {
finishDeployment(job.id, { ok: false, message: 'Original instance was retired or replaced; no command was sent.' }); return; finishDeployment(job.id, { ok: false, message: 'Original instance was retired or replaced; no command was sent.' }); return;
} }
if (migration && !migration.removed_at) { if (migration && !migration.removed_at) {
@@ -499,7 +578,10 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
} }
if (job.operation_id) { if (job.operation_id) {
const operation = getHostOperationStatus(job.operation_id); const operation = getHostOperationStatus(job.operation_id);
if (operation?.kind === 'instance.cancel-create' && !['completed', 'failed'].includes(operation.state) && !host.inventory.capabilities?.includes('instance.cancel-create')) { const recoveryCleanup = operation?.kind === 'instance.cancel-create' && !!db().prepare(`SELECT 1 FROM ec_host_operations
WHERE id=? AND json_extract(payload,'$.recoveryRebuildOperationId') IS NOT NULL`).get(operation.id);
if (operation?.kind === 'instance.cancel-create' && !['completed', 'failed'].includes(operation.state)
&& (!host.inventory.capabilities?.includes('instance.cancel-create') || (recoveryCleanup && !host.inventory.capabilities?.includes(unhealthyRecoveryCapability)))) {
hold(job, hostAgentUpdatesEnabled(host.inventory) hold(job, hostAgentUpdatesEnabled(host.inventory)
? 'Waiting for the host agent update. New command delivery is paused so its current batch can finish and the automatic updater can run. Cleanup resumes after the updated agent connects.' ? 'Waiting for the host agent update. New command delivery is paused so its current batch can finish and the automatic updater can run. Cleanup resumes after the updated agent connects.'
: 'Update the host agent to support creation cancellation: rerun the current installer on this host. Automatic updates are not enabled. New command delivery is paused; cleanup and port release await the updated agent.'); return; : 'Update the host agent to support creation cancellation: rerun the current installer on this host. Automatic updates are not enabled. New command delivery is paused; cleanup and port release await the updated agent.'); return;
@@ -514,7 +596,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
})(); })();
return; return;
} }
if (migration) { if (migration && !job.recovery_step) {
if (Date.now() - Date.parse(operation.updatedAt) >= 60_000) { if (Date.now() - Date.parse(operation.updatedAt) >= 60_000) {
db().transaction(() => { db().transaction(() => {
db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(job.id); db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(job.id);
@@ -552,15 +634,17 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
})(); })();
return; return;
} }
if (job.action === 'rebuild' && !migration && instance.generation === job.generation) { if (operation.kind === 'instance.rebuild' && instance.generation === job.generation) {
db().prepare('UPDATE native_instances SET generation=? WHERE id=? AND generation=?').run(job.generation + 1, instance.id, job.generation); db().prepare('UPDATE native_instances SET generation=? WHERE id=? AND generation=?').run(job.generation + 1, instance.id, job.generation);
instance = nativeInstance(job.server)!; instance = nativeInstance(job.server)!;
} }
applyReceipt(job, (operation.result ?? {}) as Record<string, unknown>); applyReceipt(job, (operation.result ?? {}) as Record<string, unknown>);
db().prepare('UPDATE native_provisioning SET applied_at=COALESCE(applied_at,?) WHERE deployment_id=?').run(operation.updatedAt, job.id);
if (job.state !== 'awaiting-health') { if (job.state !== 'awaiting-health') {
db().prepare("UPDATE server_deployments SET state='awaiting-health',error=NULL,finished_at=NULL WHERE id=?").run(job.id); db().prepare("UPDATE server_deployments SET state='awaiting-health',error=NULL,finished_at=NULL WHERE id=?").run(job.id);
recordProgress(job.id, { command: { state: 'accepted', message: `Agent confirmed ${job.action}; verifying the resulting instance.` }, const action = job.recovery_step ? `recovery ${job.recovery_step}` : job.action;
checkpoint: { key: 'container-action', label: `Agent confirmed ${job.action}`, at: operation.updatedAt } }); recordProgress(job.id, { command: { state: 'accepted', message: `Agent confirmed ${action}; verifying the resulting instance.` },
checkpoint: { key: job.recovery_step ? `recovery-${job.recovery_step}` : 'container-action', label: `Agent confirmed ${action}`, at: operation.updatedAt } });
} }
const checks: CheckInput[] = []; const checks: CheckInput[] = [];
const check = (value: CheckInput, phase: import('../../provisioning-detail.js').ProvisioningPhase) => { const check = (value: CheckInput, phase: import('../../provisioning-detail.js').ProvisioningPhase) => {
@@ -580,7 +664,11 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
complete(job); return; complete(job); return;
} }
check({ key: 'container', label: 'Container readiness verified', source: 'agent', result: health.healthy ? 'pass' : 'waiting', actual: health.state }, 'verify-container'); check({ key: 'container', label: 'Container readiness verified', source: 'agent', result: health.healthy ? 'pass' : 'waiting', actual: health.state }, 'verify-container');
if (!health.healthy) { hold(job, `Waiting for container readiness: ${health.state}`); return; } if (!health.healthy) {
if (health.state === 'unhealthy' && (job.action === 'create' || job.action === 'rebuild')) queueUnhealthyRecovery(job, instance);
else hold(job, `Waiting for container readiness: ${health.state}`);
return;
}
deploymentStage(job.id, 'Checking game status', { phase: 'verify-game', blocker: null }); deploymentStage(job.id, 'Checking game status', { phase: 'verify-game', blocker: null });
const status = await probeServerStatus(job.server, operation.updatedAt); const status = await probeServerStatus(job.server, operation.updatedAt);
if (!stillActive(job)) return; if (!stillActive(job)) return;
@@ -669,6 +757,12 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
deploymentStage(job.id, 'Checking container and player occupancy before submission', { phase: 'eligibility', blocker: null }); deploymentStage(job.id, 'Checking container and player occupancy before submission', { phase: 'eligibility', blocker: null });
const health = await nativeRequest<{ state: string; healthy?: boolean }>(instance, 'instance.inspect'); const health = await nativeRequest<{ state: string; healthy?: boolean }>(instance, 'instance.inspect');
if (!stillActive(job)) return false; if (!stillActive(job)) return false;
if (job.action === 'rebuild' && health.state === 'unhealthy') {
if (!getHost(instance.host_id)?.inventory.capabilities?.includes(unhealthyRecoveryCapability)) {
hold(job, 'Update this host’s agent to recover unhealthy containers during provisioning.'); return false;
}
return true;
}
if (!['stopped','exited','created','dead','missing'].includes(health.state)) { if (!['stopped','exited','created','dead','missing'].includes(health.state)) {
const occupancy = await probePlayerEvidence(job.server); const occupancy = await probePlayerEvidence(job.server);
if (!stillActive(job)) return false; if (!stillActive(job)) return false;
@@ -708,7 +802,8 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
preparedPassword = config.rcon.password || randomBytes(32).toString('hex'); preparedPassword = config.rcon.password || randomBytes(32).toString('hex');
prepared = await prepareNativeLaunch({ game: prior.game, mapSlug: prior.mapSlug, native: prior.native, prepared = await prepareNativeLaunch({ game: prior.game, mapSlug: prior.mapSlug, native: prior.native,
instance, serverKey: preparedKey, rconPassword: preparedPassword, instance, serverKey: preparedKey, rconPassword: preparedPassword,
rebuild: job.action === 'rebuild' && !migration, safetyOverride: overrideSafety, frozenRecipeId }); rebuild: job.action === 'rebuild' && !migration, safetyOverride: overrideSafety,
unhealthyRecovery: host.inventory.capabilities?.includes(unhealthyRecoveryCapability), frozenRecipeId });
if (job.action === 'rebuild' && !migration && !await checkOccupancy()) return; if (job.action === 'rebuild' && !migration && !await checkOccupancy()) return;
} }
const commit = db().transaction(() => { const commit = db().transaction(() => {
+17 -2
View File
@@ -22,12 +22,26 @@ const unsettledDeployment = `d.state NOT IN ${terminal}
(a.state NOT IN ('completed','failed') OR (a.id=n.operation_id AND a.state='completed' AND ( (a.state NOT IN ('completed','failed') OR (a.id=n.operation_id AND a.state='completed' AND (
(n.applied_at IS NULL AND COALESCE(json_array_length(n.files_json),0)>0) (n.applied_at IS NULL AND COALESCE(json_array_length(n.files_json),0)>0)
OR (d.action='destroy' AND i.retired_at IS NULL) OR (d.action='destroy' AND i.retired_at IS NULL)
OR (d.action='rebuild' AND i.retired_at IS NULL AND i.generation=n.generation OR (a.kind='instance.rebuild' AND i.retired_at IS NULL AND i.generation=n.generation)))))
AND NOT EXISTS (SELECT 1 FROM ec_host_migrations m WHERE m.deployment_id=d.id))))))
OR EXISTS (SELECT 1 FROM ec_host_migrations m JOIN ec_host_operations a ON a.id=m.remove_operation_id OR EXISTS (SELECT 1 FROM ec_host_migrations m JOIN ec_host_operations a ON a.id=m.remove_operation_id
WHERE m.deployment_id=d.id AND (a.state NOT IN ('completed','failed') OR (a.state='completed' AND m.removed_at IS NULL)))`; WHERE m.deployment_id=d.id AND (a.state NOT IN ('completed','failed') OR (a.state='completed' AND m.removed_at IS NULL)))`;
// Recovery replaces operation_id with its latest command. The original launch
// still supplies rebuild files and the ownership label used by creation cleanup.
const recoveryLaunch = `EXISTS (SELECT 1 FROM native_provisioning n
JOIN server_deployments d ON d.id=n.deployment_id
JOIN native_instances i ON i.id=n.instance_id AND i.retired_at IS NULL
WHERE n.recovery_step IS NOT NULL AND d.server_deleted_at IS NULL AND o.instance_id=i.id AND o.host_id=i.host_id
AND ((o.kind='instance.create' AND o.generation=n.generation)
OR (o.kind='instance.rebuild' AND o.generation+1=n.generation))
AND ((${unsettledDeployment}) OR (d.action='create' AND d.state IN ('failed','cancelled')
AND i.generation BETWEEN n.generation AND n.generation+1
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server=d.server
AND newer.server_deleted_at IS NULL AND newer.rowid>d.rowid AND newer.state!='cancelled'
AND NOT (newer.action='destroy' AND newer.state='waiting'
AND NOT EXISTS (SELECT 1 FROM native_provisioning p WHERE p.deployment_id=newer.id AND p.operation_id IS NOT NULL))))))`;
const unreferencedOperation = `NOT EXISTS (SELECT 1 FROM native_provisioning n WHERE o.id IN (n.operation_id,n.stage_operation_id)) const unreferencedOperation = `NOT EXISTS (SELECT 1 FROM native_provisioning n WHERE o.id IN (n.operation_id,n.stage_operation_id))
AND NOT EXISTS (SELECT 1 FROM native_instances i WHERE i.retired_at IS NULL AND ${runtimeLaunchSql}) AND NOT EXISTS (SELECT 1 FROM native_instances i WHERE i.retired_at IS NULL AND ${runtimeLaunchSql})
AND NOT (${recoveryLaunch})
AND NOT EXISTS (SELECT 1 FROM ec_host_migrations m WHERE m.remove_operation_id=o.id) AND NOT EXISTS (SELECT 1 FROM ec_host_migrations m WHERE m.remove_operation_id=o.id)
AND NOT EXISTS (SELECT 1 FROM ec_host_decommissions d WHERE d.operation_id=o.id) AND NOT EXISTS (SELECT 1 FROM ec_host_decommissions d WHERE d.operation_id=o.id)
AND o.kind NOT IN ('runtime.pull','library.download','library.import','library.remove','host.decommission','host.rotate-credential') AND o.kind NOT IN ('runtime.pull','library.download','library.import','library.remove','host.decommission','host.rotate-credential')
@@ -97,6 +111,7 @@ function policies(): Policy[] {
failed: "o.state='failed'", failed: "o.state='failed'",
eligible: `o.state IN ('completed','failed') AND o.kind IN ('instance.create','instance.rebuild','instance.restart','instance.start','files.stage','files.read') eligible: `o.state IN ('completed','failed') AND o.kind IN ('instance.create','instance.rebuild','instance.restart','instance.start','files.stage','files.read')
AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='operation' AND c.record_id=o.id AND c.protected=1) AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='operation' AND c.record_id=o.id AND c.protected=1)
AND NOT (${recoveryLaunch})
AND NOT EXISTS (SELECT 1 FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id AND NOT EXISTS (SELECT 1 FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id
WHERE o.id IN (n.operation_id,n.stage_operation_id) AND ((${unsettledDeployment}) OR EXISTS ( WHERE o.id IN (n.operation_id,n.stage_operation_id) AND ((${unsettledDeployment}) OR EXISTS (
SELECT 1 FROM retention_controls c WHERE c.category IN ('provisioning','workflow-payload') AND c.record_id=d.id AND c.protected=1))) SELECT 1 FROM retention_controls c WHERE c.category IN ('provisioning','workflow-payload') AND c.record_id=d.id AND c.protected=1)))
+45 -21
View File
@@ -33,7 +33,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.8.0" VERSION = "1.8.1"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000 MANIFEST_MAX_ENTRIES = 10000
@@ -1394,14 +1394,18 @@ class Agent:
if line.startswith("PRETTY_NAME="): if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
plutonium_cache = self.plutonium_cache_inventory() 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, **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)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} 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, **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)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
def safe_to_disrupt(self, instance_id, generation): def safe_to_disrupt(self, instance_id, generation, *, allow_unhealthy=False):
health = self.inspect(instance_id, generation) health = self.inspect(instance_id, generation)
if health["state"] in ("missing", "stopped", "exited", "created", "dead"): if health["state"] in ("missing", "stopped", "exited", "created", "dead"):
return return
# Provisioning recovery may treat explicitly unhealthy containers as
# empty. Reinspect here so recovered containers still require occupancy.
if allow_unhealthy and health["state"] == "unhealthy":
return
if not health["running"]: if not health["running"]:
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")
@@ -1857,6 +1861,9 @@ class Agent:
raise AutoscalingInvalidated("Autoscaling removal authorization expired before mutation") raise AutoscalingInvalidated("Autoscaling removal authorization expired before mutation")
def lifecycle(self, kind, instance_id, generation, payload): def lifecycle(self, kind, instance_id, generation, payload):
unhealthy_recovery = payload.get("unhealthyRecovery") is True
if unhealthy_recovery and (kind not in ("instance.restart", "instance.rebuild") or payload.get("stageId") or "autoscaling" in payload):
raise AgentError("Unhealthy recovery only permits restart or rebuild without file activation or autoscaling")
if kind == "instance.cancel-create": if kind == "instance.cancel-create":
return self.cancel_creation(instance_id, generation, payload) return self.cancel_creation(instance_id, generation, payload)
if kind == "instance.create": if kind == "instance.create":
@@ -1891,10 +1898,10 @@ class Agent:
next_generation = payload.get("nextGeneration", payload.get("newGeneration")) next_generation = payload.get("nextGeneration", payload.get("newGeneration"))
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation: if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation:
raise AgentError("Rebuild must use a newer immutable 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")} spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
self.validate_spec(spec) self.validate_spec(spec)
if not override_safety: if not override_safety:
self.safe_to_disrupt(instance_id, generation) self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery)
if "autoscaling" in payload: if "autoscaling" in payload:
self.authorize_autoscaling_removal(instance_id, generation, payload) self.authorize_autoscaling_removal(instance_id, generation, payload)
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt")}) self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt")})
@@ -1928,29 +1935,46 @@ class Agent:
Repeating this cleanup after an ambiguous Docker result is safe. Repeating this cleanup after an ambiguous Docker result is safe.
""" """
create_id = identifier(payload.get("createOperationId")) create_id = identifier(payload.get("createOperationId"))
targets = [(generation, create_id)]
if "recoveryRebuildOperationId" in payload or "recoveryGeneration" in payload:
rebuild_id = identifier(payload.get("recoveryRebuildOperationId"))
recovery_generation = payload.get("recoveryGeneration")
if isinstance(recovery_generation, bool) or not isinstance(recovery_generation, int) or recovery_generation != generation + 1:
raise Rejected("Cancellation recovery must target the next generation")
saved = self.journal.db.execute("SELECT request FROM operations WHERE id=?", (rebuild_id,)).fetchone()
if saved:
rebuild = json.loads(saved["request"])
rebuild_payload = rebuild.get("payload", {})
if rebuild["kind"] != "instance.rebuild" or rebuild["instanceId"] != instance_id or rebuild["generation"] != generation or rebuild_payload.get("nextGeneration", rebuild_payload.get("newGeneration")) != recovery_generation or rebuild_payload.get("unhealthyRecovery") is not True:
raise Rejected("Cancellation does not match the creation recovery rebuild")
targets.append((recovery_generation, rebuild_id))
marker = self.instance_dir(instance_id, generation).parent / "current.json" marker = self.instance_dir(instance_id, generation).parent / "current.json"
if marker.exists() and json.loads(marker.read_text())["generation"] > generation: if marker.exists() and json.loads(marker.read_text())["generation"] > targets[-1][0]:
raise Rejected("Cancelled creation was superseded by a newer generation") raise Rejected("Cancelled creation was superseded by a newer generation")
saved = self.journal.db.execute("SELECT request FROM operations WHERE id=?", (create_id,)).fetchone() saved = self.journal.db.execute("SELECT request FROM operations WHERE id=?", (create_id,)).fetchone()
if saved: if saved:
original = json.loads(saved["request"]) original = json.loads(saved["request"])
if original["kind"] != "instance.create" or original["instanceId"] != instance_id or original["generation"] != generation: if original["kind"] != "instance.create" or original["instanceId"] != instance_id or original["generation"] != generation:
raise Rejected("Cancellation does not match the original creation") raise Rejected("Cancellation does not match the original creation")
health = self.inspect(instance_id, generation) health_by_generation = {}
if health["exists"] and health.get("creationOperation") != create_id: for target_generation, operation_id in targets:
raise Rejected("Container belongs to a different creation operation") health = self.inspect(instance_id, target_generation)
if health["exists"] and health.get("creationOperation") != operation_id:
raise Rejected("Container belongs to a different creation operation")
health_by_generation[target_generation] = health
self.checkpoint({"phase": "cancelling-creation", "createOperationId": create_id}) self.checkpoint({"phase": "cancelling-creation", "createOperationId": create_id})
# Fence before deleting: a crash must never permit the old creation to run again. # Fence both generations before deleting: delayed create/rebuild work
self.mark_removed(instance_id, generation) # must never recreate either target after cancellation or a crash.
if health["exists"]: self.mark_removed(instance_id, targets[-1][0])
self.docker.call("container", "rm", "--force", "--volumes", self.container_name(instance_id, generation)) for target_generation, _ in targets:
after = self.inspect(instance_id, generation) if health_by_generation[target_generation]["exists"]:
if after["exists"]: self.docker.call("container", "rm", "--force", "--volumes", self.container_name(instance_id, target_generation))
raise AgentError("Cancelled creation container still exists") if self.inspect(instance_id, target_generation)["exists"]:
directory = self.instance_dir(instance_id, generation) raise AgentError("Cancelled creation container still exists")
if directory.exists(): directory = self.instance_dir(instance_id, target_generation)
shutil.rmtree(directory) if directory.exists():
return {**after, "cancelledCreation": create_id} shutil.rmtree(directory)
return {**self.inspect(instance_id, generation), "cancelledCreation": create_id}
def stage(self, instance_id, generation, payload, operation_id): def stage(self, instance_id, generation, payload, operation_id):
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
@@ -2453,7 +2477,7 @@ class Agent:
current_marker = self.instance_dir(instance_id, generation).parent / "current.json" current_marker = self.instance_dir(instance_id, generation).parent / "current.json"
if current_marker.exists(): if current_marker.exists():
current = json.loads(current_marker.read_text()) current = json.loads(current_marker.read_text())
if current["generation"] > generation or current["generation"] == generation and current.get("removed") and kind not in ("instance.inspect", "instance.remove", "instance.cancel-create"): if kind != "instance.cancel-create" and (current["generation"] > generation or current["generation"] == generation and current.get("removed") and kind not in ("instance.inspect", "instance.remove")):
raise AgentError("Instance generation has been superseded or removed") raise AgentError("Instance generation has been superseded or removed")
if kind.startswith("instance."): if kind.startswith("instance."):
return self.lifecycle(kind, instance_id, generation, payload) return self.lifecycle(kind, instance_id, generation, payload)
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "1.8.0", "version": "1.8.1",
"protocolVersion": 1, "protocolVersion": 1,
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "eb36afb3113b92304da42fc78ac7f3b8218456f33c5bc1c0f57b018bbc2ad65d", "sha256": "09c722864c73ddb76404ea994d9a194892a2226d8d9006de8dcac9a6e2591760",
"updaterProtocolVersion": 1, "updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
} }
+15 -1
View File
@@ -44,6 +44,20 @@ it('enforces per-host removal and recovery limits independently of other hosts',
expect(concurrencyBlocker('b','restart','other')).toBeNull(); expect(concurrencyBlocker('b','restart','other')).toBeNull();
}); });
it('counts a creation recovery rebuild against rebuild capacity without losing its startup reservation', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js');
const { concurrencyBlocker, lifecycleHolders } = await import('../../src/lib/server/provisioning/concurrency.js');
db().prepare("INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES('i','t5_test','host','lib','img',1,'2026-01-01')").run();
db().prepare("INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by) VALUES('a','t5_test','create','running','recovery','2026-01-01','test')").run();
db().prepare("INSERT INTO native_provisioning(deployment_id,instance_id,generation,recovery_step) VALUES('a','i',1,'rebuild')").run();
saveFleetSettings({ ...fleetSettings(), rebuildConcurrency: 1, createConcurrencyPerHost: 1 });
expect(lifecycleHolders()).toEqual([expect.objectContaining({ id: 'a', action: 'rebuild' })]);
expect(concurrencyBlocker('b', 'rebuild', 'other')).toContain('rebuild');
expect(concurrencyBlocker('b', 'create', 'host')).toContain('startup');
expect(concurrencyBlocker('a', 'rebuild', 'host')).toBeNull();
});
it('retains every capacity owner across failure, cancellation and migration without counting completed history', async () => { it('retains every capacity owner across failure, cancellation and migration without counting completed history', async () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { createHost } = await import('../../src/lib/server/hosts/index.js'); const { createHost } = await import('../../src/lib/server/hosts/index.js');
@@ -99,7 +113,7 @@ it('seeks capacity owners through active indexes instead of scanning retained jo
let sql: string; let sql: string;
try { try {
expect(lifecycleHolders()).toEqual([]); expect(lifecycleHolders()).toEqual([]);
sql = prepare.mock.calls.find(([sql])=>sql.startsWith('SELECT d.id,d.action'))![0]; sql = prepare.mock.calls.find(([sql])=>sql.startsWith('SELECT d.id,'))![0];
} finally { prepare.mockRestore(); } } finally { prepare.mockRestore(); }
const plan = h.prepare(`EXPLAIN QUERY PLAN ${sql}`).all('[]') as {detail:string}[]; const plan = h.prepare(`EXPLAIN QUERY PLAN ${sql}`).all('[]') as {detail:string}[];
const details = plan.map(row=>row.detail).join('\n'); const details = plan.map(row=>row.detail).join('\n');
+196 -1
View File
@@ -18,7 +18,7 @@ vi.mock('../../src/lib/server/rebalancing/admission.js', () => ({
let queuedKeys = 0; let queuedKeys = 0;
let fixture: Fixture; let hostId: string; let libraryId: string; let fixture: Fixture; let hostId: string; let libraryId: string;
const image = 'example.test/plutainer:test'; const image = 'example.test/plutainer:test';
const inventory = () => ({ cpuCount: 16, cpuUsagePercent: 10, memoryTotal: 64 * 1024 ** 3, memoryAvailable: 60 * 1024 ** 3, protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 100_000_000_000, runtimeImages: [image], libraries: libraryId ? [{ id: libraryId, state: 'ready' as const }] : [] }); const inventory = () => ({ cpuCount: 16, cpuUsagePercent: 10, memoryTotal: 64 * 1024 ** 3, memoryAvailable: 60 * 1024 ** 3, protocolVersion: 1, capabilities: ['instance.unhealthy-recovery'], dockerRunning: true, directoriesWritable: true, storageAvailable: 100_000_000_000, runtimeImages: [image], libraries: libraryId ? [{ id: libraryId, state: 'ready' as const }] : [] });
beforeEach(async () => { beforeEach(async () => {
vi.resetModules(); vi.clearAllMocks(); remote.state = 'running'; remote.players = 0; remote.map = 'zombie_theater'; libraryId = ''; queuedKeys = 0; vi.resetModules(); vi.clearAllMocks(); remote.state = 'running'; remote.players = 0; remote.map = 'zombie_theater'; libraryId = ''; queuedKeys = 0;
fixture = await newFixture([]); fixture = await newFixture([]);
@@ -1503,6 +1503,201 @@ describe('automatic capacity admission and placement', () => {
}); });
}); });
describe('unhealthy recovery within native create and rebuild operations', () => {
async function launch(action: 'create' | 'rebuild') {
const job = await queue(); await advance();
if (action === 'create') return job;
await acknowledge(job.deploymentId);
const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js');
const rebuild = queueNativeRebuild(job.name, 'test');
if (!rebuild.ok) throw new Error(rebuild.message);
await advance();
return rebuild;
}
it.each(['create', 'rebuild'] as const)('recovers an unhealthy %s with one restart then a rebuild using the original launch payload', async action => {
const { config } = await import('../../src/lib/server/config.js');
Object.assign(config.rcon, { password: '' });
const job = await launch(action);
const original = (await operationFor(job.deploymentId))!;
const currentGeneration = action === 'rebuild' ? 2 : 1;
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
vi.mocked(rconStatusResult).mockClear();
remote.state = 'unhealthy'; remote.players = 4;
Object.assign(config.rcon, { password: 'changed-after-original-launch' });
await acknowledge(job.deploymentId);
const restart = (await operationFor(job.deploymentId))!;
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
expect(restart).toMatchObject({ kind: 'instance.restart', state: 'pending', generation: currentGeneration,
instanceId: original.instanceId, payload: { unhealthyRecovery: true } });
expect(getDeployment(job.deploymentId)!.capacity.pools).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'startup', ownsSlot: true }),
expect.objectContaining({ kind: 'restart', ownsSlot: true })
]));
expect(restart.id).not.toBe(original.id);
expect(nativeInstance(job.name)?.generation).toBe(currentGeneration);
expect(db().prepare('SELECT recovery_step,generation FROM native_provisioning WHERE deployment_id=?').get(job.deploymentId))
.toEqual({ recovery_step: 'restart', generation: currentGeneration });
expect(rconStatusResult).not.toHaveBeenCalled();
// Restarting the controller must preserve the current recovery command.
closeDb(); await advance();
expect((await operationFor(job.deploymentId))!.id).toBe(restart.id);
await acknowledge(job.deploymentId);
const rebuild = (await operationFor(job.deploymentId))!;
expect(rebuild).toMatchObject({ kind: 'instance.rebuild', state: 'pending', generation: currentGeneration,
instanceId: original.instanceId, payload: { unhealthyRecovery: true, nextGeneration: currentGeneration + 1,
files: original.payload.files, env: original.payload.env, rconPassword: original.payload.rconPassword,
libraryId: original.payload.libraryId, image: original.payload.image } });
expect(rebuild.id).not.toBe(restart.id);
expect(nativeInstance(job.name)?.generation).toBe(currentGeneration);
expect(rconStatusResult).not.toHaveBeenCalled();
expect(db().prepare('SELECT recovery_step,generation FROM native_provisioning WHERE deployment_id=?').get(job.deploymentId))
.toEqual({ recovery_step: 'rebuild', generation: currentGeneration });
remote.state = 'running'; remote.players = 0;
await acknowledge(job.deploymentId);
expect(nativeInstance(job.name)?.generation).toBe(currentGeneration + 1);
expect(await detail(job.deploymentId)).toMatchObject({ action, state: 'done' });
expect(getDeployment(job.deploymentId)!.progress?.recovery?.state).toBe('released');
expect(getDeployment(job.deploymentId)!.capacity.pools.every(pool => !pool.ownsSlot)).toBe(true);
await advance();
expect((await operationFor(job.deploymentId))!.id).toBe(rebuild.id);
expect(nativeInstance(job.name)?.generation).toBe(currentGeneration + 1);
});
it.each(['create', 'rebuild'] as const)('finishes %s after a successful recovery restart without another rebuild', async action => {
const job = await launch(action);
remote.state = 'unhealthy'; await acknowledge(job.deploymentId);
const restart = (await operationFor(job.deploymentId))!;
expect(restart.kind).toBe('instance.restart');
remote.state = 'running'; await acknowledge(job.deploymentId); await advance();
expect(await detail(job.deploymentId)).toMatchObject({ action, state: 'done' });
expect((await operationFor(job.deploymentId))!.id).toBe(restart.id);
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
expect(nativeInstance(job.name)?.generation).toBe(action === 'rebuild' ? 2 : 1);
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.rebuild'").get())
.toEqual({ count: action === 'rebuild' ? 1 : 0 });
});
it.each(['restart', 'rebuild'] as const)('retains an unknown recovery %s receipt without replaying or advancing recovery', async step => {
const job = await launch('create');
remote.state = 'unhealthy'; await acknowledge(job.deploymentId);
if (step === 'rebuild') await acknowledge(job.deploymentId);
const operation = (await operationFor(job.deploymentId))!;
expect(operation.kind).toBe(`instance.${step}`);
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, operation.id, { state: 'unknown', error: 'Reply lost during recovery' });
const { closeDb, db } = await import('../../src/lib/server/db/index.js'); closeDb();
const { interruptDeployments } = await import('../../src/lib/server/provisioning/history.js'); interruptDeployments();
await advance(); await advance();
expect((await operationFor(job.deploymentId))!.id).toBe(operation.id);
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind IN ('instance.create','instance.restart','instance.rebuild')").get())
.toEqual({ count: step === 'restart' ? 2 : 3 });
expect((await detail(job.deploymentId)).state).not.toBe('failed');
remote.state = 'running'; await acknowledge(job.deploymentId);
expect((await detail(job.deploymentId)).state).toBe('done');
});
it.each(['starting', 'restarting', 'unknown'])('waits for %s container readiness without forcing recovery', async state => {
const job = await launch('create');
const original = (await operationFor(job.deploymentId))!;
remote.state = state; await acknowledge(job.deploymentId); await advance();
expect((await operationFor(job.deploymentId))!.id).toBe(original.id);
expect(await detail(job.deploymentId)).toMatchObject({ state: 'awaiting-health', stage: `Waiting for container readiness: ${state}` });
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT recovery_step FROM native_provisioning WHERE deployment_id=?').get(job.deploymentId))
.toEqual({ recovery_step: null });
});
it.each(['create', 'rebuild'] as const)('does not submit recovery if %s is cancelled during its health probe', async action => {
const job = await launch(action);
const original = (await operationFor(job.deploymentId))!;
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, original.id, { state: 'completed' });
const { nativeRequest } = await import('../../src/lib/server/backends/native.js');
const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js');
let release!: (value: never) => void;
vi.mocked(nativeRequest).mockImplementationOnce(() => new Promise(resolve => { release = resolve; }));
const pending = advance();
await vi.waitFor(() => expect(release).toBeTypeOf('function'));
expect(cancelProvisioningJob(job.deploymentId)).toBe(true);
release({ healthy: false, state: 'unhealthy' } as never);
await pending; await advance();
expect((await detail(job.deploymentId)).state).toBe('cancelled');
expect((await operationFor(job.deploymentId))!.id).toBe(original.id);
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare("SELECT id FROM ec_host_operations WHERE kind='instance.restart'").all()).toEqual([]);
});
it.each(['create', 'rebuild'] as const)('reconciles a delivered recovery rebuild after its %s flow is cancelled', async action => {
const job = await launch(action);
remote.state = 'unhealthy'; await acknowledge(job.deploymentId); await acknowledge(job.deploymentId);
const rebuild = (await operationFor(job.deploymentId))!;
expect(rebuild.kind).toBe('instance.rebuild');
const { pendingHostOperations, reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js');
pendingHostOperations(hostId);
expect(cancelProvisioningJob(job.deploymentId)).toBe(true);
reportHostOperation(hostId, rebuild.id, { state: 'completed' });
await advance(); await advance();
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
expect(nativeInstance(job.name)?.generation).toBe(action === 'rebuild' ? 3 : 2);
expect((await detail(job.deploymentId)).state).toBe('cancelled');
expect((await operationFor(job.deploymentId))!.id).toBe(rebuild.id);
});
it('fails clearly after an unhealthy recovery rebuild instead of looping', async () => {
const job = await launch('create');
remote.state = 'unhealthy';
await acknowledge(job.deploymentId);
await acknowledge(job.deploymentId);
const rebuild = (await operationFor(job.deploymentId))!;
expect(rebuild.kind).toBe('instance.rebuild');
await acknowledge(job.deploymentId); await advance();
expect(await detail(job.deploymentId)).toMatchObject({ state: 'failed', error: expect.stringMatching(/unhealthy.*restart.*rebuild/i) });
expect((await operationFor(job.deploymentId))!.id).toBe(rebuild.id);
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind IN ('instance.create','instance.restart','instance.rebuild')").get())
.toEqual({ count: 3 });
});
it.each(['create', 'rebuild'] as const)('holds unhealthy %s verification until the agent supports recovery', async action => {
const job = await launch(action);
const original = (await operationFor(job.deploymentId))!;
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
heartbeat(hostId, { ...inventory(), capabilities: [] });
remote.state = 'unhealthy'; await acknowledge(job.deploymentId); await advance();
expect((await operationFor(job.deploymentId))!.id).toBe(original.id);
expect(await detail(job.deploymentId)).toMatchObject({ state: 'awaiting-health', stage: expect.stringMatching(/agent/i) });
heartbeat(hostId, inventory()); await advance();
expect(await operationFor(job.deploymentId)).toMatchObject({ kind: 'instance.restart', payload: { unhealthyRecovery: true } });
});
it.each([false, true])('rebuilds an already unhealthy container without requiring RCON emptiness once supported (supported=%s)', async supported => {
const created = await launch('create'); await acknowledge(created.deploymentId);
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
if (!supported) heartbeat(hostId, { ...inventory(), capabilities: [] });
const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js');
const rebuild = queueNativeRebuild(created.name, 'test');
if (!rebuild.ok) throw new Error(rebuild.message);
const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
vi.mocked(rconStatusResult).mockClear();
remote.state = 'unhealthy'; remote.players = 3;
await advance();
if (!supported) {
expect(await operationFor(rebuild.deploymentId)).toBeNull();
expect((await detail(rebuild.deploymentId)).stage).toMatch(/agent/i);
heartbeat(hostId, inventory()); await advance();
}
expect(await operationFor(rebuild.deploymentId)).toMatchObject({ kind: 'instance.rebuild', generation: 1,
payload: { unhealthyRecovery: true, nextGeneration: 2 } });
expect(rconStatusResult).not.toHaveBeenCalled();
});
});
describe('native provisioning cancellation and rollback', () => { describe('native provisioning cancellation and rollback', () => {
it('cancels an unhealthy restart and prevents a late health response from reviving it', async () => { it('cancels an unhealthy restart and prevents a late health response from reviving it', async () => {
const job = await queue(); await advance(); await acknowledge(job.deploymentId); const job = await queue(); await advance(); await acknowledge(job.deploymentId);
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { prepareNativeHost } from '../helpers/native-host.js';
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
let fixture: Fixture;
const server = 't5_zombie_theater_1';
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
});
afterEach(async () => { await fixture.cleanup(); });
async function recovering(step: 'restart' | 'rebuild') {
const { host } = await prepareNativeHost([server]);
const hosts = await import('../../src/lib/server/hosts/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
const { beginDeployment, getDeployment } = await import('../../src/lib/server/provisioning/history.js');
const { cancelCreation, canCancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js');
const { processNativeProvisioning, reconcileNativeProvisioningReceipt } = await import('../../src/lib/server/provisioning/native.js');
hosts.heartbeat(host.id, { ...hosts.getHost(host.id)!.inventory, capabilities: ['instance.cancel-create', 'instance.unhealthy-recovery'] });
db().prepare('INSERT INTO servers(name) VALUES(?)').run(server);
const instance = nativeInstance(server)!;
hosts.reserveHostPort(host.id, instance.id, { port: instance.port, protocol: 'udp' });
hosts.reserveHostPort(host.id, instance.id, { port: instance.port, protocol: 'tcp' });
const deploymentId = beginDeployment(server, 'create');
const original = hosts.queueHostOperation(host.id, 'instance.create', {}, { instanceId: instance.id, generation: 1 });
hosts.reportHostOperation(host.id, original.id, { state: 'completed', result: { state: 'unhealthy', healthy: false } });
const recovery = hosts.queueHostOperation(host.id, `instance.${step}`, { unhealthyRecovery: true, ...(step === 'rebuild' ? { nextGeneration: 2 } : {}) },
{ instanceId: instance.id, generation: 1 });
db().prepare('UPDATE native_provisioning SET operation_id=?,recovery_step=? WHERE deployment_id=?').run(recovery.id, step, deploymentId);
db().prepare("UPDATE server_deployments SET state='awaiting-health' WHERE id=?").run(deploymentId);
const commandFor = (id: string) => {
const row = db().prepare('SELECT operation_id FROM native_provisioning WHERE deployment_id=?').get(id) as { operation_id: string };
return hosts.getHostOperation(row.operation_id)!;
};
return { host, instance, deploymentId, original, recovery, ...hosts, db, nativeInstance, cancelCreation, canCancelCreation,
getDeployment, reconcileNativeProvisioningReceipt, commandFor, advance: () => processNativeProvisioning(() => null) };
}
describe('cancelling creation during unhealthy recovery', () => {
it('keeps the original creation identity and suppresses recovery restart replay', async () => {
const api = await recovering('restart');
expect(api.pendingHostOperations(api.host.id).map(operation => operation.id)).toContain(api.recovery.id);
const cleanup = api.cancelCreation(api.deploymentId)!;
const removal = api.commandFor(cleanup);
expect(removal).toMatchObject({ kind: 'instance.cancel-create', generation: 1, payload: { createOperationId: api.original.id } });
expect(removal.payload).toEqual({ createOperationId: api.original.id });
expect(api.pendingCreationCancellations(api.host.id)).toEqual([api.original.id]);
expect(api.pendingHostOperations(api.host.id).filter(operation => operation.kind.startsWith('instance.')).map(operation => operation.id)).toEqual([removal.id]);
api.reportHostOperation(api.host.id, removal.id, { state: 'completed', result: { exists: false } });
await api.advance();
expect(api.getDeployment(cleanup)?.deployment.state).toBe('done');
expect(api.getHostOperation(api.recovery.id)?.state).toBe('failed');
expect(api.db().prepare('SELECT * FROM ec_host_ports').all()).toEqual([]);
});
it.each(['pending', 'running', 'unknown', 'completed'] as const)('cleans up both rebuild generations when recovery is %s', async state => {
const api = await recovering('rebuild');
if (state !== 'pending') {
api.pendingHostOperations(api.host.id);
api.reportHostOperation(api.host.id, api.recovery.id, { state, result: { state: 'unhealthy', healthy: false } });
}
if (state === 'completed') api.reconcileNativeProvisioningReceipt(api.deploymentId);
const registeredGeneration = state === 'completed' ? 2 : 1;
expect(api.nativeInstance(server)?.generation).toBe(registeredGeneration);
expect(api.canCancelCreation(api.deploymentId)).toBe(true);
const cleanup = api.cancelCreation(api.deploymentId)!;
const removal = api.commandFor(cleanup);
expect(removal).toMatchObject({ kind: 'instance.cancel-create', generation: 1,
payload: { createOperationId: api.original.id, recoveryRebuildOperationId: api.recovery.id, recoveryGeneration: 2 } });
expect(api.pendingCreationCancellations(api.host.id)).toEqual([api.original.id, api.recovery.id]);
expect(api.db().prepare('SELECT generation FROM native_provisioning WHERE deployment_id=?').get(cleanup)).toEqual({ generation: registeredGeneration });
expect(api.pendingHostOperations(api.host.id).filter(operation => operation.kind.startsWith('instance.')).map(operation => operation.id)).toEqual([removal.id]);
// A delivered rebuild may report success after cancellation, while the
// cleanup command already owns deletion of both possible containers.
api.reportHostOperation(api.host.id, api.recovery.id, { state: 'completed', result: { state: 'unhealthy', healthy: false } });
api.reconcileNativeProvisioningReceipt(api.deploymentId);
expect(api.nativeInstance(server)?.generation).toBe(registeredGeneration);
expect(api.db().prepare('SELECT * FROM ec_host_ports').all()).toHaveLength(2);
api.reportHostOperation(api.host.id, removal.id, { state: 'completed', result: { exists: false } });
await api.advance();
expect(api.getDeployment(cleanup)?.deployment.state).toBe('done');
expect(api.getDeployment(api.deploymentId)?.deployment).toMatchObject({ state: 'cancelled', server_deleted_at: expect.any(String) });
expect(api.nativeInstance(server)).toBeNull();
expect(api.db().prepare('SELECT * FROM ec_host_ports').all()).toEqual([]);
expect(api.db().prepare('SELECT name FROM servers WHERE name=?').get(server)).toBeUndefined();
expect(api.pendingCreationCancellations(api.host.id)).toEqual([]);
expect(api.cancelCreation(api.deploymentId)).toBe(cleanup);
});
it('does not adopt a changed generation without a completed recovery receipt', async () => {
const api = await recovering('rebuild');
api.db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(api.instance.id);
expect(api.canCancelCreation(api.deploymentId)).toBe(false);
expect(api.cancelCreation(api.deploymentId)).toBeNull();
api.reportHostOperation(api.host.id, api.recovery.id, { state: 'completed' });
api.db().prepare('UPDATE native_instances SET generation=3 WHERE id=?').run(api.instance.id);
expect(api.cancelCreation(api.deploymentId)).toBeNull();
});
it('holds extended cleanup until the host supports recovery cancellation', async () => {
const api = await recovering('rebuild');
const cleanup = api.cancelCreation(api.deploymentId)!;
api.heartbeat(api.host.id, { ...api.getHost(api.host.id)!.inventory, capabilities: ['instance.cancel-create'] });
expect(api.pendingHostOperations(api.host.id)).toEqual([]);
await api.advance();
expect(api.getDeployment(cleanup)?.deployment.stage).toContain('Update the host agent');
expect(api.db().prepare('SELECT * FROM ec_host_ports').all()).toHaveLength(2);
api.heartbeat(api.host.id, { ...api.getHost(api.host.id)!.inventory, capabilities: ['instance.cancel-create', 'instance.unhealthy-recovery'] });
expect(api.pendingHostOperations(api.host.id).filter(operation => operation.kind.startsWith('instance.')).map(operation => operation.id)).toEqual([api.commandFor(cleanup).id]);
});
});
+80
View File
@@ -230,3 +230,83 @@ it('protects cancelled workflows and linked file history through ambiguous and u
expect(a.pruneHistory('provisioning')).toBe(1); expect(a.pruneHistory('provisioning')).toBe(1);
expect(a.db.pragma('foreign_key_check')).toEqual([]); expect(a.db.pragma('foreign_key_check')).toEqual([]);
}); });
async function recoveryLaunch(action: 'create' | 'rebuild' | 'migration') {
const a = await api();
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Recovery host' });
const { config } = await import('../../src/lib/server/config.js');
hosts.enrollHost(hosts.createEnrollment(host.id).token, { protocolVersion: 1, dockerRunning: true,
directoriesWritable: true, storageAvailable: 100_000_000_000, runtimeImages: [config.defaultRuntimeImage],
libraries: [{ id: 'lib', state: 'ready' }] });
const generation = action === 'create' ? 1 : 2;
a.db.prepare("INSERT INTO native_instances(id,server,host_id,generation,library_id,image,port,created_at) VALUES('instance','s',?,?,'lib','image',1,?)")
.run(host.id, generation, old);
a.db.prepare("INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by) VALUES('recovering','s',?,'awaiting-health','Recovery',?,'test')")
.run(action === 'create' ? 'create' : 'rebuild', old);
const sha256 = putFileBlob(Buffer.from('original launch files'), a.db);
const launch = hosts.queueHostOperation(host.id, action === 'rebuild' ? 'instance.rebuild' : 'instance.create', {
name: 's', files: [{ path: 'configs/server.cfg', sha256, contentSha256: sha256 }]
}, { instanceId: 'instance', generation: action === 'rebuild' ? 1 : generation,
idempotencyKey: action === 'migration' ? 'recovering:migration-create:0' : 'recovering' });
hosts.reportHostOperation(host.id, launch.id, { state: 'completed' });
const restart = hosts.queueHostOperation(host.id, 'instance.restart', { unhealthyRecovery: true }, { instanceId: 'instance', generation });
a.db.prepare("INSERT INTO native_provisioning(deployment_id,instance_id,generation,operation_id,recovery_step) VALUES('recovering','instance',?,?,'restart')")
.run(generation, restart.id);
a.db.prepare('UPDATE ec_host_operations SET updated_at=?').run(old);
a.saveFleetSettings({ ...a.fleetSettings(), actionKeepDays: 1, payloadKeepDays: 1, historyKeepCount: 0 });
return { ...a, hosts, host, generation, launch, restart, sha256 };
}
it.each(['create', 'rebuild', 'migration'] as const)('keeps original %s launch files and ownership through recovery and releases them after success', async action => {
const a = await recoveryLaunch(action);
expect(a.pruneHistory('operation-payload')).toBe(0);
expect(a.hosts.getHostOperation(a.launch.id)?.payload.files).toEqual([
{ path: 'configs/server.cfg', sha256: a.sha256, contentBase64: Buffer.from('original launch files').toString('base64') }
]);
a.hosts.reportHostOperation(a.host.id, a.restart.id, { state: 'completed' });
const rebuild = a.hosts.queueHostOperation(a.host.id, 'instance.rebuild', { unhealthyRecovery: true, nextGeneration: a.generation + 1 },
{ instanceId: 'instance', generation: a.generation });
a.hosts.reportHostOperation(a.host.id, rebuild.id, { state: 'completed' });
a.db.prepare("UPDATE native_provisioning SET operation_id=?,recovery_step='rebuild',applied_at=? WHERE deployment_id='recovering'")
.run(rebuild.id, old);
a.db.prepare("UPDATE native_instances SET generation=generation+1 WHERE id='instance'").run();
a.db.prepare('UPDATE ec_host_operations SET updated_at=?').run(old);
a.pruneHistory('operation-payload');
a.pruneHistory('operation');
expect(a.db.prepare('SELECT history_expired_at FROM ec_host_operations WHERE id=?').get(a.launch.id))
.toEqual({ history_expired_at: null });
expect(a.hosts.getHostOperation(a.launch.id)?.payload.files).toEqual([
{ path: 'configs/server.cfg', sha256: a.sha256, contentBase64: Buffer.from('original launch files').toString('base64') }
]);
a.db.prepare("UPDATE server_deployments SET state='done',finished_at=? WHERE id='recovering'").run(old);
a.pruneHistory('operation-payload');
expect(a.hosts.getHostOperation(a.launch.id)?.payload.files).toEqual([{ path: 'configs/server.cfg', sha256: a.sha256 }]);
a.pruneHistory('operation');
expect(a.db.prepare('SELECT history_expired_at FROM ec_host_operations WHERE id=?').get(a.launch.id))
.toEqual({ history_expired_at: expect.any(String) });
});
it.each(['cancelled', 'failed'])('keeps original ownership for a %s recovered creation until a later lifecycle operation supersedes cleanup', async state => {
const a = await recoveryLaunch('create');
a.hosts.reportHostOperation(a.host.id, a.restart.id, { state: 'completed' });
const rebuild = a.hosts.queueHostOperation(a.host.id, 'instance.rebuild', { unhealthyRecovery: true, nextGeneration: 2 },
{ instanceId: 'instance', generation: 1 });
a.hosts.reportHostOperation(a.host.id, rebuild.id, { state: 'completed' });
a.db.prepare("UPDATE native_provisioning SET operation_id=?,recovery_step='rebuild',applied_at=? WHERE deployment_id='recovering'")
.run(rebuild.id, old);
a.db.prepare("UPDATE native_instances SET generation=2 WHERE id='instance'").run();
a.db.prepare('UPDATE ec_host_operations SET updated_at=?').run(old);
a.db.prepare("UPDATE server_deployments SET state=?,finished_at=? WHERE id='recovering'").run(state, old);
const { canCancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js');
expect(canCancelCreation('recovering')).toBe(true);
a.pruneHistory('operation');
expect(a.db.prepare('SELECT history_expired_at FROM ec_host_operations WHERE id=?').get(a.launch.id))
.toEqual({ history_expired_at: null });
a.db.prepare("INSERT INTO server_deployments(id,server,action,state,stage,started_at,finished_at,triggered_by) VALUES('replacement','s','restart','done','Done',?,?,'test')")
.run(old, old);
expect(canCancelCreation('recovering')).toBe(false);
a.pruneHistory('operation');
expect(a.db.prepare('SELECT history_expired_at FROM ec_host_operations WHERE id=?').get(a.launch.id))
.toEqual({ history_expired_at: expect.any(String) });
});