Add cancellation and cleanup for native server creation

This commit is contained in:
2026-10-04 18:00:11 -04:00
parent 19f1f4e279
commit 176b7dd86b
17 changed files with 482 additions and 26 deletions
+57 -3
View File
@@ -30,7 +30,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.2.2" VERSION = "1.3.0"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
LIBRARY_DOWNLOAD_WORKERS = 4 LIBRARY_DOWNLOAD_WORKERS = 4
@@ -672,6 +672,7 @@ class Agent:
self.docker = docker or Docker() self.docker = docker or Docker()
self.stopping = threading.Event() self.stopping = threading.Event()
self.current_operation = None self.current_operation = None
self.cancelled_creations = frozenset()
self.library_progress = {} self.library_progress = {}
self.library_transfers = {} self.library_transfers = {}
self.config_path = config.get("configPath", "/etc/electric-cherry/agent.json") self.config_path = config.get("configPath", "/etc/electric-cherry/agent.json")
@@ -952,7 +953,7 @@ 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 = (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["instance.cancel-create"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "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)), "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, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "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)), "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):
@@ -1040,6 +1041,7 @@ class Agent:
return game, library, image, port, bind, env return game, library, image, port, bind, env
def create(self, instance_id, generation, payload): def create(self, instance_id, generation, payload):
self.check_creation_cancellation()
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
current_marker = directory.parent / "current.json" current_marker = directory.parent / "current.json"
if current_marker.exists(): if current_marker.exists():
@@ -1068,12 +1070,19 @@ class Agent:
for protocol in ("udp", "tcp"): for protocol in ("udp", "tcp"):
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"]) args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
args.append(image) args.append(image)
self.check_creation_cancellation()
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha}) self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
self.docker.call(*args, timeout=120) self.docker.call(*args, timeout=120)
self.check_creation_cancellation()
self.docker.call("container", "start", name) self.docker.call("container", "start", name)
self.check_creation_cancellation()
atomic_json(current_marker, {"generation": generation}) atomic_json(current_marker, {"generation": generation})
return self.inspect(instance_id, generation) return self.inspect(instance_id, generation)
def check_creation_cancellation(self):
if self.executing_id and self.executing_id in self.cancelled_creations:
raise AgentError("Creation cancelled; waiting for instance cleanup")
def write_initial_files(self, root, files): def write_initial_files(self, root, files):
if not isinstance(files, list) or len(files) > 10000: if not isinstance(files, list) or len(files) > 10000:
raise AgentError("Invalid file list") raise AgentError("Invalid file list")
@@ -1093,6 +1102,8 @@ class Agent:
return content return content
def lifecycle(self, kind, instance_id, generation, payload): def lifecycle(self, kind, instance_id, generation, payload):
if kind == "instance.cancel-create":
return self.cancel_creation(instance_id, generation, payload)
if kind == "instance.create": if kind == "instance.create":
return self.create(instance_id, generation, payload) return self.create(instance_id, generation, payload)
if kind == "instance.inspect": if kind == "instance.inspect":
@@ -1146,6 +1157,38 @@ class Agent:
raise AgentError("Unsupported instance operation") raise AgentError("Unsupported instance operation")
return {**self.inspect(instance_id, generation), "before": before} return {**self.inspect(instance_id, generation), "before": before}
def cancel_creation(self, instance_id, generation, payload):
"""Explicit launch cancellation, fenced to its immutable creation identity.
The operation loop serializes this with create. Once delivered, removal
needs no RCON response and a durable tombstone prevents a delayed create.
Repeating this cleanup after an ambiguous Docker result is safe.
"""
create_id = identifier(payload.get("createOperationId"))
marker = self.instance_dir(instance_id, generation).parent / "current.json"
if marker.exists() and json.loads(marker.read_text())["generation"] > 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()
if saved:
original = json.loads(saved["request"])
if original["kind"] != "instance.create" or original["instanceId"] != instance_id or original["generation"] != generation:
raise Rejected("Cancellation does not match the original creation")
health = self.inspect(instance_id, generation)
if health["exists"] and health.get("creationOperation") != create_id:
raise Rejected("Container belongs to a different creation operation")
self.checkpoint({"phase": "cancelling-creation", "createOperationId": create_id})
# Fence before deleting: a crash must never permit the old creation to run again.
self.mark_removed(instance_id, generation)
if health["exists"]:
self.docker.call("container", "rm", "--force", "--volumes", self.container_name(instance_id, generation))
after = self.inspect(instance_id, generation)
if after["exists"]:
raise AgentError("Cancelled creation container still exists")
directory = self.instance_dir(instance_id, generation)
if directory.exists():
shutil.rmtree(directory)
return {**after, "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)
self.spec(instance_id, generation) self.spec(instance_id, generation)
@@ -1609,7 +1652,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"): if current["generation"] > generation or current["generation"] == generation and current.get("removed") and kind not in ("instance.inspect", "instance.remove", "instance.cancel-create"):
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)
@@ -1673,6 +1716,14 @@ class Agent:
instance_id, generation = operation.get("instanceId"), operation.get("generation") instance_id, generation = operation.get("instanceId"), operation.get("generation")
checkpoint = json.loads(row["checkpoint"]) if row.get("checkpoint") else {} checkpoint = json.loads(row["checkpoint"]) if row.get("checkpoint") else {}
try: try:
if kind == "instance.cancel-create":
return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload))
if kind == "instance.create":
marker = self.instance_dir(instance_id, generation).parent / "current.json"
if marker.exists():
current = json.loads(marker.read_text())
if current["generation"] > generation or current["generation"] == generation and current.get("removed"):
return self.journal.update(operation["id"], "failed", error="Creation was cancelled or superseded")
if kind == "host.decommission": if kind == "host.decommission":
self.executing_id = operation["id"] self.executing_id = operation["id"]
try: try:
@@ -1792,6 +1843,9 @@ class Agent:
def heartbeat(self): def heartbeat(self):
response = self.request("POST", "/api/agent/heartbeat", {"inventory": self.inventory(), "operationId": self.current_operation}) response = self.request("POST", "/api/agent/heartbeat", {"inventory": self.inventory(), "operationId": self.current_operation})
# Only the heartbeat thread replaces this immutable snapshot; it never
# mutates the operation journal or races Docker cleanup against create.
self.cancelled_creations = frozenset(value for value in response.get("cancelledCreations", []) if isinstance(value, str))
host = response.get("host", {}) host = response.get("host", {})
if isinstance(host.get("portMin"), int) and isinstance(host.get("portMax"), int): if isinstance(host.get("portMin"), int) and isinstance(host.get("portMax"), int):
self.config["portRange"] = {"min": host["portMin"], "max": host["portMax"]} self.config["portRange"] = {"min": host["portMin"], "max": host["portMax"]}
+91
View File
@@ -209,6 +209,97 @@ class AgentTests(unittest.TestCase):
self.assertTrue((self.library / "content/game.ff").exists()) self.assertTrue((self.library / "content/game.ff").exists())
self.assertFalse(self.agent.instance_dir(INSTANCE, 1).exists()) self.assertFalse(self.agent.instance_dir(INSTANCE, 1).exists())
def test_cancel_creation_bypasses_occupancy_and_preserves_library(self):
self.create()
self.agent.rcon = lambda *_: self.fail("Cancellation must not wait for player evidence")
operation = self.operation("instance.cancel-create", {"createOperationId": "create"}, "cancel")
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertFalse(self.agent.inspect(INSTANCE, 1)["exists"])
self.assertFalse(self.agent.instance_dir(INSTANCE, 1).exists())
self.assertTrue((self.library / "content/game.ff").exists())
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertEqual(sum(call[:2] == ("container", "rm") for call in self.docker.calls), 1)
def test_heartbeat_cancellation_stops_create_before_starting_container(self):
original_call = self.docker.call
def cancel_during_create(*args, **kwargs):
result = original_call(*args, **kwargs)
if args[:2] == ("container", "create"):
self.agent.cancelled_creations = frozenset(["create"])
return result
self.docker.call = cancel_during_create
payload = {"name": "test", "game": "t5", "libraryId": self.library_id, "image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960, "env": {}, "rconPassword": "secret", "files": []}
self.assertEqual(self.agent.execute(self.operation("instance.create", payload, "create"))["state"], "unknown")
self.assertFalse(any(call[:2] == ("container", "start") for call in self.docker.calls))
result = self.agent.execute(self.operation("instance.cancel-create", {"createOperationId": "create"}))
self.assertEqual(result["state"], "completed")
self.assertFalse(self.agent.inspect(INSTANCE, 1)["exists"])
def test_cancellation_cleanup_resumes_after_agent_restart(self):
self.create()
self.docker.lose_reply_after = "rm"
cancel = self.operation("instance.cancel-create", {"createOperationId": "create"}, "cancel")
self.assertEqual(self.agent.execute(cancel)["state"], "unknown")
self.agent.journal.db.close()
self.agent = Agent(self.config, self.docker)
self.assertEqual(self.agent.execute(cancel)["state"], "completed")
self.assertFalse(self.agent.instance_dir(INSTANCE, 1).exists())
def test_cancel_before_creation_delivery_fences_delayed_create(self):
result = self.agent.execute(self.operation("instance.cancel-create", {"createOperationId": "late-create"}))
self.assertEqual(result["state"], "completed", result)
result = self.agent.execute(self.operation("instance.create", {}, "late-create"))
self.assertEqual(result["state"], "failed")
self.assertFalse(any(call[:2] == ("container", "create") for call in self.docker.calls))
def test_cancel_removes_partial_files_without_a_container(self):
directory = self.agent.instance_dir(INSTANCE, 1)
(directory / "app").mkdir(parents=True)
(directory / "app/partial.cfg").write_text("partial")
self.agent.journal.accept(self.operation("instance.create", {}, "create"))
result = self.agent.execute(self.operation("instance.cancel-create", {"createOperationId": "create"}))
self.assertEqual(result["state"], "completed", result)
self.assertFalse(directory.exists())
self.assertTrue((self.library / "content/game.ff").exists())
def test_cancel_rejects_a_creation_id_from_another_instance(self):
other = self.operation("instance.create", {}, "other-create")
other["instanceId"] = "another-instance"
self.agent.journal.accept(other)
result = self.agent.execute(self.operation("instance.cancel-create", {"createOperationId": "other-create"}))
self.assertEqual(result["state"], "failed", result)
self.assertFalse((self.agent.instance_dir(INSTANCE, 1).parent / "current.json").exists())
def test_cancel_creation_rejects_other_creation_and_newer_generation(self):
self.create()
result = self.agent.execute(self.operation("instance.cancel-create", {"createOperationId": "wrong"}))
self.assertEqual(result["state"], "failed")
module.atomic_json(self.agent.instance_dir(INSTANCE, 1).parent / "current.json", {"generation": 2})
result = self.agent.execute(self.operation("instance.cancel-create", {"createOperationId": "create"}, "old-generation"))
self.assertEqual(result["state"], "failed")
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(any(call[:2] == ("container", "rm") for call in self.docker.calls))
def test_cancel_creation_reconciles_lost_removal_reply_and_fences_unknown_create(self):
self.docker.lose_reply_after = "start"
payload = {"name": "test", "game": "t5", "libraryId": self.library_id, "image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960, "env": {}, "rconPassword": "secret", "files": []}
create = self.operation("instance.create", payload, "create")
self.assertEqual(self.agent.execute(create)["state"], "unknown")
self.docker.lose_reply_after = "rm"
cancel = self.operation("instance.cancel-create", {"createOperationId": "create"}, "cancel")
self.assertEqual(self.agent.execute(cancel)["state"], "unknown")
self.assertEqual(self.agent.execute(cancel)["state"], "completed")
self.assertEqual(self.agent.execute(create)["state"], "failed")
self.assertFalse(self.agent.instance_dir(INSTANCE, 1).exists())
self.assertTrue(json.loads((self.agent.instance_dir(INSTANCE, 1).parent / "current.json").read_text())["removed"])
def test_cancel_creation_rejects_unowned_container(self):
self.create()
self.docker.containers[self.agent.container_name(INSTANCE, 1)]["Config"]["Labels"] = {}
result = self.agent.execute(self.operation("instance.cancel-create", {"createOperationId": "create"}))
self.assertEqual(result["state"], "failed")
self.assertFalse(any(call[:2] == ("container", "rm") for call in self.docker.calls))
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"] = {}
+26
View File
@@ -400,3 +400,29 @@ remain enforced at command admission.
Older completed operations whose progress snapshots were already discarded Older completed operations whose progress snapshots were already discarded
cannot recover missing check results from this change. Their surviving events cannot recover missing check results from this change. Their surviving events
and host command records remain available without inventing verification times. and host command records remain available without inventing verification times.
### Cancel an unfinished creation
On the native creation operation page, **Cancel creation and remove server**
stops the launch and queues removal of its exact instance and generation. It
also works when readiness checks are stuck or the creation failed. This explicit
cancellation skips player-count checks on both the controller and agent, even
if players have joined. Completed creations use the normal safe removal flow.
The cancelled operation links to a durable cleanup operation with agent receipts
and progress. An unsubmitted launch releases its reservations immediately. Once
an agent command was queued, ports and the instance identity remain reserved
until the agent confirms container and instance-file removal. Shared game
libraries, runtime images, saved server keys, and operation history are retained.
Repeated cancellation requests return the same cleanup operation. A queued
normal removal waiting for occupancy is superseded by this cleanup.
Remote cancellation requires agent 1.3.0 or later (`instance.cancel-create`
capability). An older agent is never sent the unsupported cleanup command;
the cleanup page explains that it is waiting for an update. Offline or disabled
hosts retain the cleanup request until they reconnect or are enabled. Heartbeats
notify an executing create to stop between Docker commands; an already executing
Docker command must return or time out before cleanup can run. Cancellation
does not require the original creation to acknowledge completion. The agent
records a removal tombstone before cleanup so a delayed creation command cannot
recreate the instance, and resumes interrupted cleanup under the same operation ID.
+2
View File
@@ -95,6 +95,8 @@ export interface ProvisioningCapacity {
}[]; }[];
} }
export interface ProvisioningDetail { export interface ProvisioningDetail {
canCancelCreation?: boolean;
cancellation?: { cleanupId: string; state: string; stage: string; error: string | null } | null;
backend?: 'native' | 'komodo'; backend?: 'native' | 'komodo';
workerActivity?: WorkerActivity[]; workerActivity?: WorkerActivity[];
agentOperation?: { id: string; hostId: string; state: string; updatedAt: string; createdAt: string; error: string | null } | null; agentOperation?: { id: string; hostId: string; state: string; updatedAt: string; createdAt: string; error: string | null } | null;
+4
View File
@@ -16,5 +16,9 @@ export function migrateNativeInstances(handle: Database.Database): void {
stage_operation_id TEXT, operation_id TEXT, files_json TEXT, stage_operation_id TEXT, operation_id TEXT, files_json TEXT,
applied_at TEXT, verified_at TEXT applied_at TEXT, verified_at TEXT
); );
CREATE TABLE IF NOT EXISTS native_creation_cancellations (
creation_id TEXT PRIMARY KEY REFERENCES server_deployments(id),
cleanup_id TEXT NOT NULL UNIQUE REFERENCES server_deployments(id)
);
`); `);
} }
+12 -3
View File
@@ -288,6 +288,11 @@ type OperationRow = { id: string; host_id: string; kind: string; instance_id: st
function operationFromRow(row: OperationRow): HostOperation { return { id: row.id, hostId: row.host_id, kind: row.kind, instanceId: row.instance_id, generation: row.generation, payload: JSON.parse(row.payload), state: row.state, result: row.result === null ? null : JSON.parse(row.result), error: row.error, createdAt: row.created_at, updatedAt: row.updated_at }; } function operationFromRow(row: OperationRow): HostOperation { return { id: row.id, hostId: row.host_id, kind: row.kind, instanceId: row.instance_id, generation: row.generation, payload: JSON.parse(row.payload), state: row.state, result: row.result === null ? null : JSON.parse(row.result), error: row.error, createdAt: row.created_at, updatedAt: row.updated_at }; }
export function getHostOperation(id: string): HostOperation | null { const row = database().prepare('SELECT * FROM ec_host_operations WHERE id=?').get(id) as OperationRow | undefined; return row ? operationFromRow(row) : null; } export function getHostOperation(id: string): HostOperation | null { const row = database().prepare('SELECT * FROM ec_host_operations WHERE id=?').get(id) as OperationRow | undefined; return row ? operationFromRow(row) : null; }
export function listHostOperations(hostId: string, limit = 100): HostOperation[] { return (database().prepare('SELECT * FROM ec_host_operations WHERE host_id=? ORDER BY created_at DESC,rowid DESC LIMIT ?').all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow); } export function listHostOperations(hostId: string, limit = 100): HostOperation[] { return (database().prepare('SELECT * FROM ec_host_operations WHERE host_id=? ORDER BY created_at DESC,rowid DESC LIMIT ?').all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow); }
/** Heartbeats can tell an executing create to stop at its next safe command boundary. */
export function pendingCreationCancellations(hostId: string): string[] {
return (database().prepare(`SELECT json_extract(payload,'$.createOperationId') AS id FROM ec_host_operations
WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown')`).all(hostId) as { id: string }[]).map(row => row.id);
}
export function queueHostOperation(hostId: string, kind: string, payload: Record<string, unknown>, options: { instanceId?: string; generation?: number; idempotencyKey?: string; id?: string } = {}): HostOperation { export function queueHostOperation(hostId: string, kind: string, payload: Record<string, unknown>, options: { instanceId?: string; generation?: number; idempotencyKey?: string; id?: string } = {}): HostOperation {
const host = requireHost(hostId); const host = requireHost(hostId);
if (host.decommission) throw new Error('Host cleanup is in progress; new operations are blocked.'); if (host.decommission) throw new Error('Host cleanup is in progress; new operations are blocked.');
@@ -304,7 +309,7 @@ export function queueHostOperation(hostId: string, kind: string, payload: Record
} }
} }
if (['instance.create','create'].includes(kind)) requireReadyHost(hostId); if (['instance.create','create'].includes(kind)) requireReadyHost(hostId);
if (host.mode === 'disabled') throw new Error('Host is disabled. Enable it before requesting operations.'); if (host.mode === 'disabled' && kind !== 'instance.cancel-create') throw new Error('Host is disabled. Enable it before requesting operations.');
const id = options.id ?? randomUUID(); const timestamp = now(); const id = options.id ?? randomUUID(); const timestamp = now();
database().prepare('INSERT INTO ec_host_operations(id,host_id,kind,instance_id,generation,idempotency_key,payload,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)').run(id, hostId, kind, options.instanceId ?? null, options.generation ?? null, options.idempotencyKey ?? null, JSON.stringify(payload), timestamp, timestamp); database().prepare('INSERT INTO ec_host_operations(id,host_id,kind,instance_id,generation,idempotency_key,payload,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)').run(id, hostId, kind, options.instanceId ?? null, options.generation ?? null, options.idempotencyKey ?? null, JSON.stringify(payload), timestamp, timestamp);
database().prepare('INSERT INTO ec_host_operation_events(operation_id,state,created_at) VALUES(?,?,?)').run(id, 'pending', timestamp); database().prepare('INSERT INTO ec_host_operation_events(operation_id,state,created_at) VALUES(?,?,?)').run(id, 'pending', timestamp);
@@ -317,9 +322,13 @@ export function pendingHostOperations(hostId: string): HostOperation[] {
// 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.
return database().transaction(() => { return database().transaction(() => {
const rows = database().prepare(`SELECT * FROM ec_host_operations WHERE host_id=? AND state IN ('pending','accepted','running','unknown') const rows = database().prepare(`SELECT * FROM ec_host_operations o WHERE host_id=? AND state IN ('pending','accepted','running','unknown')
AND (? OR state != 'pending' OR kind NOT IN ('instance.create','create')) AND (? OR state != 'pending' OR kind NOT IN ('instance.create','create'))
ORDER BY COALESCE(last_delivered_at,''),created_at,rowid LIMIT 25`).all(hostId, Number(host.readiness === 'ready')) as OperationRow[]; AND (kind!='instance.cancel-create' OR ?)
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)
ORDER BY CASE WHEN kind='instance.cancel-create' THEN 0 ELSE 1 END,COALESCE(last_delivered_at,''),created_at,rowid LIMIT 25`)
.all(hostId, Number(host.readiness === 'ready'), Number(host.inventory.capabilities?.includes('instance.cancel-create') ?? false)) as OperationRow[];
const deliveredAt = now(); const deliveredAt = now();
for (const operation of rows) database().prepare('UPDATE ec_host_operations SET last_delivered_at=? WHERE id=?').run(deliveredAt, operation.id); for (const operation of rows) database().prepare('UPDATE ec_host_operations SET last_delivered_at=? WHERE id=?').run(deliveredAt, operation.id);
return rows.map(operationFromRow); return rows.map(operationFromRow);
@@ -0,0 +1,74 @@
import { db } from '../db/index.js';
import { nativeInstance } from '../backends/native.js';
import { queueHostOperation, releaseHostPorts } from '../hosts/index.js';
import { beginDeployment, cancelRemoval, deploymentStage, finishDeployment } from './history.js';
import { recordProgress } from './progress.js';
export function canCancelCreation(id: string): boolean {
return !!db().prepare(`SELECT 1 FROM server_deployments d
JOIN native_provisioning n ON n.deployment_id=d.id
JOIN native_instances i ON i.id=n.instance_id AND i.generation=n.generation AND i.retired_at IS NULL
WHERE d.id=? AND d.action='create' AND d.server_deleted_at IS NULL
AND d.state IN ('queued','waiting','running','awaiting-health','interrupted','failed')
AND NOT EXISTS (SELECT 1 FROM server_deployments other WHERE other.server=d.server
AND other.server_deleted_at IS NULL AND other.rowid>d.rowid AND other.state!='cancelled'
AND NOT (other.action='destroy' AND other.state='waiting'
AND NOT EXISTS (SELECT 1 FROM native_provisioning p WHERE p.deployment_id=other.id AND p.operation_id IS NOT NULL)))`).get(id);
}
/** Stops observation immediately; remote cleanup remains a durable, separately visible job. */
export function cancelCreation(id: string, triggeredBy = 'ui'): string | null {
return db().transaction(() => {
const prior = db().prepare('SELECT cleanup_id FROM native_creation_cancellations WHERE creation_id=?').get(id) as { cleanup_id: string } | undefined;
if (prior) return prior.cleanup_id;
if (!canCancelCreation(id)) return null;
const job = db().prepare(`SELECT d.server,n.operation_id,n.stage_operation_id FROM server_deployments d
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 };
const instance = nativeInstance(job.server)!;
// 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 }[]) {
cancelRemoval(removal.id);
}
const cleanupId = beginDeployment(job.server, 'destroy', triggeredBy);
db().prepare('INSERT INTO native_creation_cancellations(creation_id,cleanup_id) VALUES(?,?)').run(id, cleanupId);
db().prepare("UPDATE server_deployments SET state='cancelled',finished_at=?,error=NULL WHERE id=?").run(new Date().toISOString(), id);
deploymentStage(id, 'Creation cancelled; cleanup requested', { blocker: null,
event: { kind: 'cancelled', message: 'Creation cancelled. Its partial instance will be removed without a player-count check.', details: { cleanupId } } });
if (!job.operation_id && !job.stage_operation_id) {
// No remote mutation was ever queued. The reservation is the only resource to remove.
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(new Date().toISOString(), instance.id);
releaseHostPorts(instance.id);
deploymentStage(cleanupId, 'Unsubmitted creation cancelled; reservations released', { phase: 'cleanup',
checkpoint: { key: 'cleanup', label: 'No host command submitted; local reservations released' } });
finishDeployment(cleanupId, { ok: true });
finishCreationCancellation(cleanupId);
} else {
const operation = queueHostOperation(instance.host_id, 'instance.cancel-create', { createOperationId: job.operation_id ?? id },
{ instanceId: instance.id, generation: instance.generation, idempotencyKey: 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);
deploymentStage(cleanupId, 'Waiting for host agent to cancel creation and remove its partial instance', { phase: 'container-action', blocker: null,
command: { state: 'attempted', message: `Creation cancellation queued as agent operation ${operation.id}. Ports remain reserved until cleanup is confirmed.` } });
}
return cleanupId;
})();
}
export function finishCreationCancellation(cleanupId: string): void {
const cancellation = db().prepare(`SELECT c.creation_id,n.operation_id FROM native_creation_cancellations c
JOIN native_provisioning n ON n.deployment_id=c.creation_id WHERE c.cleanup_id=?`).get(cleanupId) as
{ creation_id: string; operation_id: string | null } | undefined;
if (!cancellation) return;
const now = new Date().toISOString();
db().prepare('UPDATE server_deployments SET server_deleted_at=? WHERE id IN (?,?)').run(now, cancellation.creation_id, cleanupId);
deploymentStage(cancellation.creation_id, 'Creation cancelled; partial instance removed', { blocker: null,
checkpoint: { key: 'cleanup', phase: 'cleanup', label: 'Cancellation cleanup confirmed; instance and reservations removed' } });
// The original command may have lost its receipt. Cleanup proves that its effects are now gone.
if (cancellation.operation_id) {
const result = db().prepare("UPDATE ec_host_operations SET state='failed',error='Creation cancelled; cleanup confirmed',updated_at=? WHERE id=? AND state NOT IN ('completed','failed')")
.run(now, cancellation.operation_id);
if (result.changes) db().prepare("INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) VALUES(?,'failed',?,'Creation cancelled; cleanup confirmed')").run(cancellation.operation_id, now);
}
recordProgress(cleanupId, { event: { kind: 'cleanup', message: 'Cancelled creation cleanup completed.', details: { creationId: cancellation.creation_id } } });
}
+4
View File
@@ -8,6 +8,7 @@ import { activitySummary, deploymentEvents, latestFailureEvent } from './activit
import { readProgress, recordProgress, type ProgressUpdate } from './progress.js'; import { readProgress, recordProgress, type ProgressUpdate } from './progress.js';
import { bindNativeOperation } from './native.js'; import { bindNativeOperation } from './native.js';
import { workerActivity } from '../pipeline/activity.js'; import { workerActivity } from '../pipeline/activity.js';
import { canCancelCreation } from './cancel-creation.js';
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string { export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
const id = randomUUID(); const id = randomUUID();
@@ -105,6 +106,8 @@ export function getDeployment(id: string, options: { events?: 'all' | 'recent' }
const native = db().prepare('SELECT instance_id FROM native_provisioning WHERE deployment_id=?').get(id); const native = db().prepare('SELECT instance_id FROM native_provisioning WHERE deployment_id=?').get(id);
const agentOperation = native ? db().prepare(`SELECT o.id, o.host_id AS hostId, o.state, o.updated_at AS updatedAt, o.created_at AS createdAt, o.error const agentOperation = native ? db().prepare(`SELECT o.id, o.host_id AS hostId, o.state, o.updated_at AS updatedAt, o.created_at AS createdAt, o.error
FROM native_provisioning n JOIN ec_host_operations o ON o.id=n.operation_id WHERE n.deployment_id=?`).get(id) as ProvisioningDetail['agentOperation'] : null; FROM native_provisioning n JOIN ec_host_operations o ON o.id=n.operation_id WHERE n.deployment_id=?`).get(id) as ProvisioningDetail['agentOperation'] : null;
const cancellation = db().prepare(`SELECT c.cleanup_id AS cleanupId,d.state,d.stage,d.error FROM native_creation_cancellations c
JOIN server_deployments d ON d.id=c.cleanup_id WHERE c.creation_id=?`).get(id) as ProvisioningDetail['cancellation'];
const { observation_json: _snapshot, phase_key: _phase, phase_started_at: _phaseStarted, observed_at: _observed, ...deployment } = row; const { observation_json: _snapshot, phase_key: _phase, phase_started_at: _phaseStarted, observed_at: _observed, ...deployment } = row;
const sources = (db().prepare('SELECT kind, source_id, server FROM provisioning_sources WHERE operation_id = ? ORDER BY kind, source_id') const sources = (db().prepare('SELECT kind, source_id, server FROM provisioning_sources WHERE operation_id = ? ORDER BY kind, source_id')
.all(id) as Omit<ProvisioningSource, 'label' | 'href'>[]).map(sourceDetails); .all(id) as Omit<ProvisioningSource, 'label' | 'href'>[]).map(sourceDetails);
@@ -114,6 +117,7 @@ export function getDeployment(id: string, options: { events?: 'all' | 'recent' }
UNION ALL SELECT collection_id, deployment_id FROM server_collection_removals) m UNION ALL SELECT collection_id, deployment_id FROM server_collection_removals) m
JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ? ORDER BY c.created_at, c.id LIMIT 1`).get(id) as { id: string; name: string } | undefined; JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ? ORDER BY c.created_at, c.id LIMIT 1`).get(id) as { id: string; name: string } | undefined;
return { deployment, backend: native ? 'native' as const : 'komodo' as const, agentOperation: agentOperation ?? null, return { deployment, backend: native ? 'native' as const : 'komodo' as const, agentOperation: agentOperation ?? null,
canCancelCreation: canCancelCreation(id), cancellation: cancellation ?? null,
workerActivity: workerActivity(), events: deploymentEvents(id, options.events === 'recent'), workerActivity: workerActivity(), events: deploymentEvents(id, options.events === 'recent'),
activity: { ...activitySummary(id), failureExcerpt: latestFailureEvent(id) }, sources, activity: { ...activitySummary(id), failureExcerpt: latestFailureEvent(id) }, sources,
recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null, recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null,
+7 -9
View File
@@ -21,6 +21,7 @@ import { sha256Buffer } from '../fleet/files.js';
import type { StagedFileChange } from '../fleet/staged-files.js'; import type { StagedFileChange } from '../fleet/staged-files.js';
import type { LaunchPreview } from '../../deployment-plan.js'; import type { LaunchPreview } from '../../deployment-plan.js';
import type { ProvisioningAction } from '../../provisioning.js'; import type { ProvisioningAction } from '../../provisioning.js';
import { cancelCreation, finishCreationCancellation } from './cancel-creation.js';
export interface NativeSelection { hostId: string; libraryId: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number } export interface NativeSelection { hostId: string; libraryId: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number }
type NativeRecipe = LaunchRecipe & { native?: NativeSelection }; type NativeRecipe = LaunchRecipe & { native?: NativeSelection };
@@ -146,15 +147,7 @@ export function cancelQueuedNativeCreation(server: string, triggeredBy: string):
const creation = db().prepare(`SELECT d.id FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id const creation = db().prepare(`SELECT d.id FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id
WHERE n.instance_id=? AND d.action='create' AND d.state='queued' AND n.operation_id IS NULL`).get(instance.id) as { id: string } | undefined; WHERE n.instance_id=? AND d.action='create' AND d.state='queued' AND n.operation_id IS NULL`).get(instance.id) as { id: string } | undefined;
if (!creation) return null; if (!creation) return null;
const id = beginDeployment(server, 'destroy', triggeredBy); return cancelCreation(creation.id, triggeredBy);
const now = new Date().toISOString();
db().prepare("UPDATE server_deployments SET state='cancelled',finished_at=?,stage='Queued native creation cancelled',server_deleted_at=? WHERE id=?")
.run(now, now, creation.id);
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(now, instance.id);
releaseHostPorts(instance.id);
deploymentStage(id, 'Cancelled queued creation; no agent mutation was submitted');
finishDeployment(id, { ok: true });
return id;
})(); })();
} }
@@ -277,6 +270,7 @@ export async function processNativeProvisioning(eligibility: (operation: { id: s
const activeJobs = new Map<string, Promise<void>>(); const activeJobs = new Map<string, Promise<void>>();
async function processJob(job: Job, eligibility: (job: Job) => string | null) { async function processJob(job: Job, eligibility: (job: Job) => string | null) {
if (!stillActive(job)) return;
let instance = nativeInstance(job.server); let instance = nativeInstance(job.server);
if (!instance || instance.id !== job.instance_id || (instance.generation !== job.generation && !(job.action === 'rebuild' && instance.generation === job.generation + 1))) { if (!instance || instance.id !== job.instance_id || (instance.generation !== job.generation && !(job.action === 'rebuild' && 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;
@@ -288,6 +282,9 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
} }
if (job.operation_id) { if (job.operation_id) {
const operation = getHostOperation(job.operation_id); const operation = getHostOperation(job.operation_id);
if (operation?.kind === 'instance.cancel-create' && operation.state === 'pending' && !host.inventory.capabilities?.includes('instance.cancel-create')) {
hold(job, 'Update the host agent to support creation cancellation. Creation is cancelled; cleanup and port release await the updated agent.'); return;
}
if (!operation || operation.state === 'unknown') { hold(job, 'Agent result is unknown. Reconciling the original operation; no replacement or repeated command will be sent.'); return; } if (!operation || operation.state === 'unknown') { hold(job, 'Agent result is unknown. Reconciling the original operation; no replacement or repeated command will be sent.'); return; }
if (operation.state === 'failed') { if (operation.state === 'failed') {
// The agent reports failed only when no mutation began. Ambiguous // The agent reports failed only when no mutation began. Ambiguous
@@ -312,6 +309,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
db().prepare('DELETE FROM servers WHERE name=?').run(job.server); db().prepare('DELETE FROM servers WHERE name=?').run(job.server);
recordProgress(job.id, { checkpoint: { key: 'cleanup', label: 'Instance retired and port reservations released' } }); recordProgress(job.id, { checkpoint: { key: 'cleanup', label: 'Instance retired and port reservations released' } });
complete(job); complete(job);
finishCreationCancellation(job.id);
})(); })();
return; return;
} }
+7
View File
@@ -86,6 +86,9 @@ export function provisioningOutcome(detail: ProvisioningDetail) {
if (detail.canResumeCleanup) description = 'The remote deployment was deleted. Resume the remaining local cleanup without repeating container removal.'; if (detail.canResumeCleanup) description = 'The remote deployment was deleted. Resume the remaining local cleanup without repeating container removal.';
else if (detail.canResumeRebuild) description = 'The remote deployment was deleted. Resume local cleanup, then create a fresh instance in the reserved server slot.'; else if (detail.canResumeRebuild) description = 'The remote deployment was deleted. Resume local cleanup, then create a fresh instance in the reserved server slot.';
else if (detail.canResumeVerification) description = 'Resume observation of the existing instance. This does not create, restart, or rebuild a container.'; else if (detail.canResumeVerification) description = 'Resume observation of the existing instance. This does not create, restart, or rebuild a container.';
else if (detail.cancellation) description = detail.cancellation.state === 'done'
? 'Creation was cancelled. Its instance files, container, and port reservations have been removed.'
: 'Creation was cancelled. Cleanup is tracked separately below; ports remain reserved until the host confirms removal.';
else if (operation.state === 'cancelled') description = 'This request is cancelled. Recorded prior work is preserved below; remaining work will not run.'; else if (operation.state === 'cancelled') description = 'This request is cancelled. Recorded prior work is preserved below; remaining work will not run.';
else if (failure) description = progress?.command?.state === 'uncertain' else if (failure) description = progress?.command?.state === 'uncertain'
? 'The remote response was uncertain. Inspect the recorded result and server before requesting another operation.' ? 'The remote response was uncertain. Inspect the recorded result and server before requesting another operation.'
@@ -160,6 +163,10 @@ export function isProvisioningEventPage(value: unknown): value is ProvisioningEv
/** Validate every nested shape consumed by the page before replacing its last good snapshot. */ /** Validate every nested shape consumed by the page before replacing its last good snapshot. */
export function isProvisioningDetail(value: unknown, id: string): value is ProvisioningDetail { export function isProvisioningDetail(value: unknown, id: string): value is ProvisioningDetail {
if (!record(value) || !record(value.deployment)) return false; if (!record(value) || !record(value.deployment)) return false;
if (value.canCancelCreation !== undefined && typeof value.canCancelCreation !== 'boolean') return false;
if (value.cancellation !== undefined && value.cancellation !== null && !(record(value.cancellation)
&& text(value.cancellation.cleanupId) && text(value.cancellation.state) && text(value.cancellation.stage)
&& nullableText(value.cancellation.error))) return false;
if (value.backend !== undefined && !['native', 'komodo'].includes(value.backend as string)) return false; if (value.backend !== undefined && !['native', 'komodo'].includes(value.backend as string)) return false;
if (value.workerActivity !== undefined && (!Array.isArray(value.workerActivity) || !value.workerActivity.every(item => if (value.workerActivity !== undefined && (!Array.isArray(value.workerActivity) || !value.workerActivity.every(item =>
record(item) && text(item.key) && text(item.label) && timestamp(item.startedAt)))) return false; record(item) && text(item.key) && text(item.label) && timestamp(item.startedAt)))) return false;
+2 -2
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { heartbeat } from '$lib/server/hosts/index.js'; import { heartbeat, pendingCreationCancellations } from '$lib/server/hosts/index.js';
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js'; import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js'; import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
@@ -13,6 +13,6 @@ export async function POST({ request }: { request: Request }) {
updateHostLibraryCaches(host.id, inventory.libraryCaches, { snapshot: true }); updateHostLibraryCaches(host.id, inventory.libraryCaches, { snapshot: true });
} }
reconcileHostPreparation(host.id); reconcileHostPreparation(host.id);
return json({ host, heartbeatSeconds: 15, serverTime: new Date().toISOString() }); return json({ host, cancelledCreations: pendingCreationCancellations(host.id), heartbeatSeconds: 15, serverTime: new Date().toISOString() });
} catch (cause) { return hostApiError(cause); } } catch (cause) { return hostApiError(cause); }
} }
@@ -1,7 +1,14 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { cancelRemoval } from '$lib/server/provisioning/index.js'; import { cancelRemoval } from '$lib/server/provisioning/history.js';
import { cancelCreation } from '$lib/server/provisioning/cancel-creation.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) { export function POST({ params }: { params: { id: string } }) {
if (!cancelRemoval(params.id)) return json({ error: 'Removal has already begun or this request is no longer waiting. Refresh to see its current state.' }, { status: 409 }); const cleanupId = cancelCreation(params.id);
if (cleanupId) {
void kickProvisioning();
return json({ ok: true, cleanupId });
}
if (!cancelRemoval(params.id)) return json({ error: 'This operation can no longer be cancelled. Refresh to see its current state.' }, { status: 409 });
return json({ ok: true }); return json({ ok: true });
} }
+3 -1
View File
@@ -87,14 +87,16 @@
{#if detail.activity.failureExcerpt && detail.activity.failureExcerpt.message !== deployment.error && ['failed', 'interrupted'].includes(deployment.state)}<p class="failure-excerpt"><span>Last failure event · {fmt(detail.activity.failureExcerpt.created_at)}</span>{detail.activity.failureExcerpt.message}</p>{/if} {#if detail.activity.failureExcerpt && detail.activity.failureExcerpt.message !== deployment.error && ['failed', 'interrupted'].includes(deployment.state)}<p class="failure-excerpt"><span>Last failure event · {fmt(detail.activity.failureExcerpt.created_at)}</span>{detail.activity.failureExcerpt.message}</p>{/if}
{#if detail.warningMessages.length}<div class="warnings"><h3>Recorded warnings</h3><ul>{#each detail.warningMessages as warning}<li>{warning}</li>{/each}</ul></div>{/if} {#if detail.warningMessages.length}<div class="warnings"><h3>Recorded warnings</h3><ul>{#each detail.warningMessages as warning}<li>{warning}</li>{/each}</ul></div>{/if}
{#if detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canCancelRemoval} {#if detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canCancelRemoval || detail.canCancelCreation}
<div class="recovery-actions"> <div class="recovery-actions">
{#if detail.canResumeVerification}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Health verification requested. The existing instance will be observed without another creation or restart.')}>Resume health verification</Button>{/if} {#if detail.canResumeVerification}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Health verification requested. The existing instance will be observed without another creation or restart.')}>Resume health verification</Button>{/if}
{#if detail.canResumeRebuild}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Rebuild cleanup requested. Fresh creation waits for local cleanup to finish.')}>Resume rebuild cleanup</Button>{/if} {#if detail.canResumeRebuild}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Rebuild cleanup requested. Fresh creation waits for local cleanup to finish.')}>Resume rebuild cleanup</Button>{/if}
{#if detail.canResumeCleanup}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Local cleanup requested. Remote container deletion will not be repeated.')}>Resume cleanup</Button>{/if} {#if detail.canResumeCleanup}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Local cleanup requested. Remote container deletion will not be repeated.')}>Resume cleanup</Button>{/if}
{#if detail.canCancelRemoval}<div><Button disabled={!!view.busy} loading={view.busy === 'cancel'} onclick={() => controller.act('cancel', 'Queued removal cancelled. The server and its files were kept.')}>Cancel queued removal</Button><p class="action-help">Available until removal begins. Cancelling keeps the server and its files.</p></div>{/if} {#if detail.canCancelRemoval}<div><Button disabled={!!view.busy} loading={view.busy === 'cancel'} onclick={() => controller.act('cancel', 'Queued removal cancelled. The server and its files were kept.')}>Cancel queued removal</Button><p class="action-help">Available until removal begins. Cancelling keeps the server and its files.</p></div>{/if}
{#if detail.canCancelCreation}<div><Button variant="danger" disabled={!!view.busy} loading={view.busy === 'cancel'} onclick={() => controller.act('cancel', 'Creation cancelled. Follow cleanup below until removal is confirmed.')}>Cancel creation and remove server</Button><p class="action-help">Stops this launch and deletes its container and instance files, even if player status is unknown or players have joined. Shared game files and the saved server key are kept.</p></div>{/if}
</div> </div>
{/if} {/if}
{#if detail.cancellation}<div class="recovery-note"><strong>{detail.cancellation.state === 'done' ? 'Cancellation cleanup completed' : 'Cancellation cleanup'}</strong><p>{detail.cancellation.error ?? detail.cancellation.stage}</p><a href={`/provisioning/${encodeURIComponent(detail.cancellation.cleanupId)}`}>View cleanup progress →</a></div>{/if}
{#if view.actionError}<div class="action-error" role="alert"><strong>Request not confirmed</strong><p>{view.actionError}</p></div>{/if} {#if view.actionError}<div class="action-error" role="alert"><strong>Request not confirmed</strong><p>{view.actionError}</p></div>{/if}
{#if view.notice}<p class="notice" role="status">{view.notice}</p>{/if} {#if view.notice}<p class="notice" role="status">{view.notice}</p>{/if}
{#if detail.progress?.blocker?.href}<a class="dependency" href={detail.progress.blocker.href}>{detail.progress.blocker.label ?? 'View blocking work'} →</a>{/if} {#if detail.progress?.blocker?.href}<a class="dependency" href={detail.progress.blocker.href}>{detail.progress.blocker.label ?? 'View blocking work'} →</a>{/if}
+57 -3
View File
@@ -30,7 +30,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.2.2" VERSION = "1.3.0"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
LIBRARY_DOWNLOAD_WORKERS = 4 LIBRARY_DOWNLOAD_WORKERS = 4
@@ -672,6 +672,7 @@ class Agent:
self.docker = docker or Docker() self.docker = docker or Docker()
self.stopping = threading.Event() self.stopping = threading.Event()
self.current_operation = None self.current_operation = None
self.cancelled_creations = frozenset()
self.library_progress = {} self.library_progress = {}
self.library_transfers = {} self.library_transfers = {}
self.config_path = config.get("configPath", "/etc/electric-cherry/agent.json") self.config_path = config.get("configPath", "/etc/electric-cherry/agent.json")
@@ -952,7 +953,7 @@ 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 = (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["instance.cancel-create"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "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)), "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, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "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)), "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):
@@ -1040,6 +1041,7 @@ class Agent:
return game, library, image, port, bind, env return game, library, image, port, bind, env
def create(self, instance_id, generation, payload): def create(self, instance_id, generation, payload):
self.check_creation_cancellation()
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
current_marker = directory.parent / "current.json" current_marker = directory.parent / "current.json"
if current_marker.exists(): if current_marker.exists():
@@ -1068,12 +1070,19 @@ class Agent:
for protocol in ("udp", "tcp"): for protocol in ("udp", "tcp"):
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"]) args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
args.append(image) args.append(image)
self.check_creation_cancellation()
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha}) self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
self.docker.call(*args, timeout=120) self.docker.call(*args, timeout=120)
self.check_creation_cancellation()
self.docker.call("container", "start", name) self.docker.call("container", "start", name)
self.check_creation_cancellation()
atomic_json(current_marker, {"generation": generation}) atomic_json(current_marker, {"generation": generation})
return self.inspect(instance_id, generation) return self.inspect(instance_id, generation)
def check_creation_cancellation(self):
if self.executing_id and self.executing_id in self.cancelled_creations:
raise AgentError("Creation cancelled; waiting for instance cleanup")
def write_initial_files(self, root, files): def write_initial_files(self, root, files):
if not isinstance(files, list) or len(files) > 10000: if not isinstance(files, list) or len(files) > 10000:
raise AgentError("Invalid file list") raise AgentError("Invalid file list")
@@ -1093,6 +1102,8 @@ class Agent:
return content return content
def lifecycle(self, kind, instance_id, generation, payload): def lifecycle(self, kind, instance_id, generation, payload):
if kind == "instance.cancel-create":
return self.cancel_creation(instance_id, generation, payload)
if kind == "instance.create": if kind == "instance.create":
return self.create(instance_id, generation, payload) return self.create(instance_id, generation, payload)
if kind == "instance.inspect": if kind == "instance.inspect":
@@ -1146,6 +1157,38 @@ class Agent:
raise AgentError("Unsupported instance operation") raise AgentError("Unsupported instance operation")
return {**self.inspect(instance_id, generation), "before": before} return {**self.inspect(instance_id, generation), "before": before}
def cancel_creation(self, instance_id, generation, payload):
"""Explicit launch cancellation, fenced to its immutable creation identity.
The operation loop serializes this with create. Once delivered, removal
needs no RCON response and a durable tombstone prevents a delayed create.
Repeating this cleanup after an ambiguous Docker result is safe.
"""
create_id = identifier(payload.get("createOperationId"))
marker = self.instance_dir(instance_id, generation).parent / "current.json"
if marker.exists() and json.loads(marker.read_text())["generation"] > 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()
if saved:
original = json.loads(saved["request"])
if original["kind"] != "instance.create" or original["instanceId"] != instance_id or original["generation"] != generation:
raise Rejected("Cancellation does not match the original creation")
health = self.inspect(instance_id, generation)
if health["exists"] and health.get("creationOperation") != create_id:
raise Rejected("Container belongs to a different creation operation")
self.checkpoint({"phase": "cancelling-creation", "createOperationId": create_id})
# Fence before deleting: a crash must never permit the old creation to run again.
self.mark_removed(instance_id, generation)
if health["exists"]:
self.docker.call("container", "rm", "--force", "--volumes", self.container_name(instance_id, generation))
after = self.inspect(instance_id, generation)
if after["exists"]:
raise AgentError("Cancelled creation container still exists")
directory = self.instance_dir(instance_id, generation)
if directory.exists():
shutil.rmtree(directory)
return {**after, "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)
self.spec(instance_id, generation) self.spec(instance_id, generation)
@@ -1609,7 +1652,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"): if current["generation"] > generation or current["generation"] == generation and current.get("removed") and kind not in ("instance.inspect", "instance.remove", "instance.cancel-create"):
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)
@@ -1673,6 +1716,14 @@ class Agent:
instance_id, generation = operation.get("instanceId"), operation.get("generation") instance_id, generation = operation.get("instanceId"), operation.get("generation")
checkpoint = json.loads(row["checkpoint"]) if row.get("checkpoint") else {} checkpoint = json.loads(row["checkpoint"]) if row.get("checkpoint") else {}
try: try:
if kind == "instance.cancel-create":
return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload))
if kind == "instance.create":
marker = self.instance_dir(instance_id, generation).parent / "current.json"
if marker.exists():
current = json.loads(marker.read_text())
if current["generation"] > generation or current["generation"] == generation and current.get("removed"):
return self.journal.update(operation["id"], "failed", error="Creation was cancelled or superseded")
if kind == "host.decommission": if kind == "host.decommission":
self.executing_id = operation["id"] self.executing_id = operation["id"]
try: try:
@@ -1792,6 +1843,9 @@ class Agent:
def heartbeat(self): def heartbeat(self):
response = self.request("POST", "/api/agent/heartbeat", {"inventory": self.inventory(), "operationId": self.current_operation}) response = self.request("POST", "/api/agent/heartbeat", {"inventory": self.inventory(), "operationId": self.current_operation})
# Only the heartbeat thread replaces this immutable snapshot; it never
# mutates the operation journal or races Docker cleanup against create.
self.cancelled_creations = frozenset(value for value in response.get("cancelledCreations", []) if isinstance(value, str))
host = response.get("host", {}) host = response.get("host", {})
if isinstance(host.get("portMin"), int) and isinstance(host.get("portMax"), int): if isinstance(host.get("portMin"), int) and isinstance(host.get("portMax"), int):
self.config["portRange"] = {"min": host["portMin"], "max": host["portMax"]} self.config["portRange"] = {"min": host["portMin"], "max": host["portMax"]}
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "1.2.2", "version": "1.3.0",
"protocolVersion": 1, "protocolVersion": 1,
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "c266a8099130051ee540cdc3b9cdc7e42144fa67e21cce76eea6cf0a426bf40d", "sha256": "a698bdbba3716726a5a63fbd18392b53bd4c1e0c4cbaca94eabd66d8ca328dc2",
"updaterProtocolVersion": 1, "updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
} }
+114 -1
View File
@@ -9,7 +9,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
rconGetPlayerCount: vi.fn(async () => remote.players), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })) rconGetPlayerCount: vi.fn(async () => remote.players), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null }))
})); }));
vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({ checkRelease: vi.fn(async () => {}), freshRelease: () => 5354, probeVersion: vi.fn(async () => 5354) })); vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({ checkRelease: vi.fn(async () => {}), freshRelease: () => 5354, probeVersion: vi.fn(async () => 5354) }));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
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';
@@ -73,6 +73,119 @@ async function detail(id: string) {
} }
describe('native instance provisioning', () => { describe('native instance provisioning', () => {
it('accepts cancellation through the provisioning API and rejects an already completed creation', async () => {
const job = await queue();
const { POST } = await import('../../src/routes/api/provisioning/[id]/cancel/+server.js');
const response = POST({ params: { id: job.deploymentId } });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ ok: true, cleanupId: expect.any(String) });
const next = await queue(); await advance(); await acknowledge(next.deploymentId);
expect(POST({ params: { id: next.deploymentId } }).status).toBe(409);
expect(POST({ params: { id: job.deploymentId } }).status).toBe(200);
});
it('cancels an unsubmitted creation locally and returns the same cleanup on repeated requests', async () => {
const job = await queue();
const { cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js');
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
const { db } = await import('../../src/lib/server/db/index.js');
expect(getDeployment(job.deploymentId)!.canCancelCreation).toBe(true);
const cleanup = cancelCreation(job.deploymentId)!;
expect(cancelCreation(job.deploymentId)).toBe(cleanup);
expect((await detail(job.deploymentId)).state).toBe('cancelled');
expect((await detail(cleanup)).state).toBe('done');
expect(getDeployment(job.deploymentId)!.cancellation?.state).toBe('done');
expect(db().prepare('SELECT * FROM ec_host_ports').all()).toEqual([]);
await advance();
expect(await operationFor(job.deploymentId)).toBeNull();
expect(await operationFor(cleanup)).toBeNull();
});
it('cancels a submitted creation without occupancy checks, suppresses replay, and releases resources only after cleanup', async () => {
const job = await queue(); await advance(); remote.players = 4;
const original = (await operationFor(job.deploymentId))!;
const { cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js');
const { heartbeat, pendingHostOperations, reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { nativeRequest } = await import('../../src/lib/server/backends/native.js');
heartbeat(hostId, { ...inventory(), capabilities: ['instance.cancel-create'] });
// A previously delivered command might still be executing when cancellation arrives.
expect(pendingHostOperations(hostId).map(op => op.id)).toContain(original.id);
const cleanup = cancelCreation(job.deploymentId)!;
const removal = (await operationFor(cleanup))!;
expect(removal.kind).toBe('instance.cancel-create');
expect(removal.payload).toEqual({ createOperationId: original.id });
expect(cancelCreation(job.deploymentId)).toBe(cleanup);
expect(pendingHostOperations(hostId).map(op => op.id)).toContain(removal.id);
expect(pendingHostOperations(hostId).map(op => op.id)).not.toContain(original.id);
await advance();
expect(nativeRequest).not.toHaveBeenCalled();
expect(db().prepare('SELECT * FROM ec_host_ports').all()).toHaveLength(2);
reportHostOperation(hostId, original.id, { state: 'completed', result: { healthy: true } });
await advance();
expect((await detail(job.deploymentId)).state).toBe('cancelled');
expect(nativeRequest).not.toHaveBeenCalled();
reportHostOperation(hostId, removal.id, { state: 'completed', result: { exists: false } });
await advance();
expect((await detail(cleanup)).state).toBe('done');
expect((await detail(job.deploymentId)).server_deleted_at).toBeTruthy();
expect(db().prepare('SELECT * FROM ec_host_ports').all()).toEqual([]);
expect(db().prepare('SELECT * FROM servers WHERE name=?').get(job.name)).toBeUndefined();
expect(db().prepare('SELECT retired_at FROM native_instances WHERE id=?').get(original.instanceId)).toMatchObject({ retired_at: expect.any(String) });
});
it('holds cancellation for an old or offline host without delivering an unsupported command or losing reservations', async () => {
const job = await queue(); await advance();
const { cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js');
const { heartbeat, pendingHostOperations } = await import('../../src/lib/server/hosts/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
const cleanup = cancelCreation(job.deploymentId)!;
expect(pendingHostOperations(hostId).filter(op => op.kind.startsWith('instance.'))).toEqual([]);
await advance();
expect((await detail(cleanup)).stage).toContain('Update the host agent');
db().prepare('UPDATE ec_hosts SET last_heartbeat=? WHERE id=?').run('2000-01-01T00:00:00.000Z', hostId);
await advance();
expect((await detail(cleanup)).stage).toContain('unavailable');
expect(db().prepare('SELECT * FROM ec_host_ports').all()).toHaveLength(2);
heartbeat(hostId, { ...inventory(), capabilities: ['instance.cancel-create'] });
expect(pendingHostOperations(hostId).filter(op => op.kind === 'instance.cancel-create')).toHaveLength(1);
await acknowledge(cleanup);
expect((await detail(cleanup)).state).toBe('done');
});
it('replaces an occupancy-blocked removal and ignores an outstanding creation health reply', async () => {
const job = await queue(); await advance();
const operation = (await operationFor(job.deploymentId))!;
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, operation.id, { state: 'completed', result: { healthy: true } });
const { nativeRequest } = await import('../../src/lib/server/backends/native.js');
let release!: (value: never) => void;
vi.mocked(nativeRequest).mockImplementationOnce(() => new Promise(resolve => { release = resolve; }));
const pending = advance();
try {
const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js');
const waitingRemoval = destroyServer(job.name).deploymentId!;
const { cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js');
const cleanup = cancelCreation(job.deploymentId)!;
expect(cleanup).toBeTruthy();
expect((await detail(waitingRemoval)).state).toBe('cancelled');
release({ healthy: true, state: 'running' } as never);
await pending;
expect((await detail(job.deploymentId)).state).toBe('cancelled');
expect((await detail(cleanup)).state).toBe('running');
} finally { release({ healthy: true, state: 'running' } as never); await pending; }
});
it('does not offer creation cancellation after success or against a replacement instance', async () => {
const job = await queue(); await advance(); await acknowledge(job.deploymentId);
const { canCancelCreation, cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js');
expect(canCancelCreation(job.deploymentId)).toBe(false);
expect(cancelCreation(job.deploymentId)).toBeNull();
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("UPDATE server_deployments SET state='awaiting-health' WHERE id=?").run(job.deploymentId);
db().prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?').run(job.name);
expect(cancelCreation(job.deploymentId)).toBeNull();
});
it('retains every native launch checkpoint and readiness check through completion and UI validation', async () => { it('retains every native launch checkpoint and readiness check through completion and UI validation', async () => {
const job = await queue(); const job = await queue();
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
@@ -19,6 +19,17 @@ function fixture(): ProvisioningDetail {
} }
describe('provisioning outcome and evidence presentation', () => { describe('provisioning outcome and evidence presentation', () => {
it('distinguishes cancellation from confirmed cleanup and validates its link data', () => {
const detail = fixture();
detail.deployment.state = 'cancelled';
detail.canCancelCreation = false;
detail.cancellation = { cleanupId: 'cleanup-a', state: 'running', stage: 'Waiting for host', error: null };
expect(isProvisioningDetail(detail, detail.deployment.id)).toBe(true);
expect(provisioningOutcome(detail).description).toContain('ports remain reserved');
detail.cancellation.state = 'done';
expect(provisioningOutcome(detail).description).toContain('have been removed');
expect(isProvisioningDetail({ ...detail, cancellation: { state: 'done' } }, detail.deployment.id)).toBe(false);
});
it.each(provisioningActions)('describes cancelled %s as terminal, with no automatic execution claim', action => { it.each(provisioningActions)('describes cancelled %s as terminal, with no automatic execution claim', action => {
const detail = fixture(); const detail = fixture();
detail.deployment.action = action; detail.deployment.action = action;