Reconcile rebuilds interrupted before replacement creation

This commit is contained in:
2026-10-08 14:24:52 -04:00
parent e01b143a09
commit c4e2ff4384
11 changed files with 571 additions and 14 deletions
+73 -1
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.8.3" VERSION = "1.8.4"
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
@@ -2665,12 +2665,84 @@ class Agent:
atomic_json(self.config_path, self.config) atomic_json(self.config_path, self.config)
return True return True
def reconcile_uncreated_rebuild(self, operation, row):
"""Settle removal only when the replacement create was never submitted.
The write lane and instance lock exclude the original Python execution.
A terminal journal receipt prevents any later replay of that command.
"""
if row["state"] != "unknown":
return row
instance_id, generation = instance_identity(operation.get("instanceId"), operation.get("generation"))
payload = operation.get("payload", {})
next_generation = payload.get("nextGeneration", payload.get("newGeneration"))
if type(next_generation) is not int or next_generation <= generation:
return row
previous = json.loads(row["result"]) if row.get("result") else {}
if not isinstance(previous, dict):
return row
directory = scoped(self.root, f"instances/{instance_id}/{next_generation}")
def unchanged_generation():
try:
marker = scoped(self.root, f"instances/{instance_id}/current.json")
current = json.loads(marker.read_text())
except FileNotFoundError:
return True
return isinstance(current, dict) and type(current.get("generation")) is int and current["generation"] == generation and current.get("removed", False) is False
def both_missing():
for target in (generation, next_generation):
health = self.inspect(instance_id, target)
if health.get("exists") is not False or health.get("running") is not False or health.get("state") != "missing":
return False
return True
if not unchanged_generation() or not both_missing():
return row
# Preparation can leave runtime/configuration files before create's
# checkpoint. A fresh operation must not inherit those partial files.
expected_spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
try:
saved_spec = json.loads(scoped(directory, "spec.json").read_text())
except FileNotFoundError:
pass
else:
if saved_spec != expected_spec:
return row
try:
metadata = directory.lstat()
except FileNotFoundError:
pass
else:
if not stat_module.S_ISDIR(metadata.st_mode):
return row
shutil.rmtree(directory)
# Also sync when a previous reconciliation removed the directory but
# crashed before recording the terminal receipt.
try:
sync_parent(directory)
except FileNotFoundError:
pass
if not unchanged_generation() or not both_missing():
return row
original_error = row.get("error")
result = {**previous, "reconciled": True, "originalError": original_error,
"rebuildFailure": {"phase": "disrupting", "generation": generation, "nextGeneration": next_generation,
"oldContainerMissing": True, "replacementContainerMissing": True, "replacementFilesRemoved": True}}
message = "Old container removed; replacement creation failed."
if original_error:
message += " " + original_error
return self.journal.update(operation["id"], "failed", result, error=message)
def reconcile(self, operation, row): def reconcile(self, operation, row):
"""Prove desired state with immutable ownership; never resubmit a command.""" """Prove desired state with immutable ownership; never resubmit a command."""
kind, payload = operation["kind"], operation.get("payload", {}) kind, payload = operation["kind"], operation.get("payload", {})
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.rebuild" and checkpoint.get("phase") == "disrupting":
return self.reconcile_uncreated_rebuild(operation, row)
if kind == "instance.remove" and "autoscaling" in payload and not checkpoint: if kind == "instance.remove" and "autoscaling" in payload and not checkpoint:
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision") return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision")
if kind == "instance.cancel-create": if kind == "instance.cancel-create":
+168
View File
@@ -1604,6 +1604,174 @@ class AgentTests(unittest.TestCase):
self.assertEqual(self.agent.execute(operation)["state"], "completed") self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertFalse(old.exists()) self.assertFalse(old.exists())
def interrupted_rebuild_before_create(self):
payload = self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
operation = self.operation("instance.rebuild", {"spec": payload, "nextGeneration": 2}, "interrupted-rebuild")
def interrupted_seed(app, image):
(app / "partial-runtime").write_text("incomplete")
raise OSError(module.errno.ENOSPC, "disk full")
with patch.object(self.agent, "seed_plutonium", side_effect=interrupted_seed):
row = self.agent.execute(operation)
self.assertEqual(row["state"], "unknown", row)
self.assertEqual(json.loads(row["checkpoint"])["phase"], "disrupting")
self.assertFalse(self.agent.inspect(INSTANCE, 1)["exists"])
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
return operation, row
def test_rebuild_reconciles_removal_before_create_without_replaying(self):
operation, interrupted = self.interrupted_rebuild_before_create()
original = self.agent.instance_dir(INSTANCE, 1)
partial = self.agent.instance_dir(INSTANCE, 2)
module.atomic_json(partial / "spec.json", operation["payload"]["spec"])
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
calls = list(self.docker.calls)
failed = self.agent.execute(operation)
self.assertEqual(failed["state"], "failed", failed)
result = json.loads(failed["result"])
self.assertEqual(result["rebuildFailure"], {"phase": "disrupting", "generation": 1, "nextGeneration": 2,
"oldContainerMissing": True, "replacementContainerMissing": True,
"replacementFilesRemoved": True})
self.assertTrue(result["reconciled"])
self.assertEqual(result["unexpectedError"], json.loads(interrupted["result"])["unexpectedError"])
self.assertEqual(result["originalError"], interrupted["error"])
self.assertTrue(failed["error"].startswith("Old container removed; replacement creation failed."))
self.assertIn(interrupted["error"], failed["error"])
self.assertEqual((original / "app/configs/server.cfg").read_bytes(), b"original")
self.assertFalse(partial.exists())
self.assertEqual(json.loads((original.parent / "current.json").read_text()), {"generation": 1})
self.assertEqual(self.agent.execute(operation), failed)
self.assertEqual(self.docker.calls, calls)
replacement = self.operation("instance.rebuild", operation["payload"], "replacement-rebuild")
self.assertEqual(self.agent.execute(replacement)["state"], "completed")
self.assertFalse((partial / "app/partial-runtime").exists())
self.assertEqual(self.agent.inspect(INSTANCE, 2)["creationOperation"], replacement["id"])
calls = list(self.docker.calls)
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
self.assertEqual(self.agent.execute(operation), failed)
self.assertEqual(self.docker.calls, calls)
def test_rebuild_removal_reconciles_when_marker_is_absent(self):
operation, _ = self.interrupted_rebuild_before_create()
marker = self.agent.instance_dir(INSTANCE, 1).parent / "current.json"
marker.unlink()
row = self.agent.execute(operation)
self.assertEqual(row["state"], "failed", row)
self.assertIn("rebuildFailure", json.loads(row["result"]))
self.assertFalse(marker.exists())
self.assertTrue(self.agent.instance_dir(INSTANCE, 1).exists())
def test_rebuild_lost_removal_receipt_settles_without_recreating_or_deleting_twice(self):
payload = self.create()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
self.docker.lose_reply_after = "rm"
operation = self.operation("instance.rebuild", {"spec": payload, "nextGeneration": 2}, "rebuild")
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertFalse(self.agent.instance_dir(INSTANCE, 2).exists())
calls = list(self.docker.calls)
result = self.agent.execute(operation)
self.assertEqual(result["state"], "failed", result)
self.assertTrue(json.loads(result["result"])["rebuildFailure"]["replacementFilesRemoved"])
self.assertEqual(self.docker.calls, calls)
self.assertTrue(self.agent.instance_dir(INSTANCE, 1).exists())
def test_rebuild_removal_requires_both_containers_positively_missing(self):
operation, interrupted = self.interrupted_rebuild_before_create()
partial = self.agent.instance_dir(INSTANCE, 2)
for generation in (1, 2):
for status, foreign in (("running", False), ("created", False), ("exited", False), ("unknown", False), ("running", True)):
with self.subTest(generation=generation, status=status, foreign=foreign):
name = self.agent.container_name(INSTANCE, generation)
labels = {} if foreign else {module.LABEL + ".managed": "true", module.LABEL + ".instance": INSTANCE,
module.LABEL + ".generation": str(generation)}
self.docker.containers[name] = {"Config": {"Labels": labels}, "State": {"Status": status, "Running": status == "running"}}
self.assertEqual(self.agent.execute(operation), interrupted)
self.assertTrue(partial.exists())
self.docker.containers.pop(name)
with patch.object(self.agent, "inspect", return_value={"exists": False, "running": False, "state": "unknown"}):
self.assertEqual(self.agent.execute(operation), interrupted)
with patch.object(self.agent, "inspect", side_effect=AgentError("Docker unavailable")):
self.assertEqual(self.agent.execute(operation), interrupted)
self.assertTrue(partial.exists())
def test_rebuild_removal_does_not_settle_after_a_create_checkpoint(self):
operation, interrupted = self.interrupted_rebuild_before_create()
for phase in ("creating", "starting", "unknown"):
with self.subTest(phase=phase):
self.agent.journal.checkpoint(operation["id"], {"phase": phase, "generation": 2})
row = self.agent.execute(operation)
self.assertEqual(row["state"], "unknown", row)
self.assertEqual(row["result"], interrupted["result"])
self.assertTrue(self.agent.instance_dir(INSTANCE, 2).exists())
self.agent.journal.db.execute("UPDATE operations SET checkpoint=NULL WHERE id=?", (operation["id"],))
self.agent.journal.db.commit()
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertTrue(self.agent.instance_dir(INSTANCE, 2).exists())
def test_rebuild_removal_rejects_ambiguous_generation_markers(self):
operation, interrupted = self.interrupted_rebuild_before_create()
marker = self.agent.instance_dir(INSTANCE, 1).parent / "current.json"
for current in ({"generation": 0}, {"generation": 2}, {"generation": 3}, {"generation": True}, {"generation": "1"},
{"generation": 1, "removed": True}, {"generation": 1, "removed": None}, {}, []):
with self.subTest(current=current):
module.atomic_json(marker, current)
self.assertEqual(self.agent.execute(operation), interrupted)
self.assertTrue(self.agent.instance_dir(INSTANCE, 2).exists())
self.assertEqual(json.loads(marker.read_text()), current)
marker.write_text("invalid JSON")
self.assertEqual(self.agent.execute(operation), interrupted)
def test_rebuild_removal_keeps_unknown_for_unrelated_partial_files_and_symlinks(self):
operation, interrupted = self.interrupted_rebuild_before_create()
partial = self.agent.instance_dir(INSTANCE, 2)
module.atomic_json(partial / "spec.json", {**operation["payload"]["spec"], "name": "unrelated"})
self.assertEqual(self.agent.execute(operation), interrupted)
self.assertTrue(partial.exists())
(partial / "spec.json").unlink()
outside = self.agent.root / "retained-files"
partial.rename(outside)
partial.symlink_to(outside, target_is_directory=True)
self.assertEqual(self.agent.execute(operation), interrupted)
self.assertEqual((outside / "app/partial-runtime").read_text(), "incomplete")
partial.unlink()
outside.rename(partial)
marker = partial.parent / "current.json"
retained_marker = self.agent.root / "retained-current.json"
marker.rename(retained_marker)
marker.symlink_to(retained_marker)
self.assertEqual(self.agent.execute(operation), interrupted)
self.assertTrue(partial.exists())
def test_rebuild_removal_keeps_cleanup_errors_and_changed_generation_unresolved(self):
operation, interrupted = self.interrupted_rebuild_before_create()
partial = self.agent.instance_dir(INSTANCE, 2)
with patch.object(module.shutil, "rmtree", side_effect=PermissionError("cleanup denied")):
self.assertEqual(self.agent.execute(operation), interrupted)
self.assertTrue(partial.exists())
cleanup = module.shutil.rmtree
def superseded(path):
cleanup(path)
module.atomic_json(partial.parent / "current.json", {"generation": 3})
with patch.object(module.shutil, "rmtree", side_effect=superseded):
self.assertEqual(self.agent.execute(operation), interrupted)
self.assertEqual(json.loads((partial.parent / "current.json").read_text()), {"generation": 3})
self.assertTrue(self.agent.instance_dir(INSTANCE, 1).exists())
def test_rebuild_removal_waits_for_durable_partial_file_cleanup(self):
operation, interrupted = self.interrupted_rebuild_before_create()
with patch.object(module, "sync_parent", side_effect=OSError("sync unavailable")):
self.assertEqual(self.agent.execute(operation), interrupted)
self.assertFalse(self.agent.instance_dir(INSTANCE, 2).exists())
self.assertEqual(self.agent.execute(operation), interrupted)
self.assertEqual(self.agent.execute(operation)["state"], "failed")
self.assertTrue(self.agent.instance_dir(INSTANCE, 1).exists())
def test_traversal_and_symlinks_are_rejected(self): def test_traversal_and_symlinks_are_rejected(self):
self.create() self.create()
app = self.agent.instance_dir(INSTANCE, 1) / "app" app = self.agent.instance_dir(INSTANCE, 1) / "app"
+10
View File
@@ -415,6 +415,16 @@ Otherwise the result remains **Unknown** and holds dependent work. Running alone
is insufficient evidence that a particular restart happened. EC neither assumes is insufficient evidence that a particular restart happened. EC neither assumes
an unreachable server stopped nor launches a duplicate replacement elsewhere. an unreachable server stopped nor launches a duplicate replacement elsewhere.
An interrupted rebuild can settle as **Failed** when its last checkpoint is
`disrupting`, Docker confirms both the old and replacement containers are absent,
and any existing generation marker still matches the original instance. The agent removes
only the partial replacement files, preserves the old generation's files and
marker, and retains the original diagnostic in its failure report. It does not
replay the interrupted rebuild. A separately queued rebuild can then proceed
through normal eligibility checks. A later creation checkpoint, a changed
generation, an inspection error, or failed partial-file cleanup leaves the result
**Unknown** and keeps dependent work waiting.
### Player checks and activation ### Player checks and activation
Campaign bytes are staged and verified while players remain connected. Only Campaign bytes are staged and verified while players remain connected. Only
+10
View File
@@ -200,6 +200,16 @@ guards still apply. **Resume rebuild cleanup** completes cleanup before creating
the replacement in its reserved slot. Resuming the same operation preserves its the replacement in its reserved slot. Resuming the same operation preserves its
earlier failure and resume events. Uncertain commands have no generic Retry action. earlier failure and resume events. Uncertain commands have no generic Retry action.
Cancelling an operation does not stop a command already delivered to the host.
A later rebuild waits for that command to settle. Its waiting message includes
the host command's state and last reported error; **View cancelled operation**
opens the original command details. If an interrupted rebuild removed its old
container before creating the replacement, the host can confirm failure after
verifying that both containers are absent and the instance identity is unchanged.
The original diagnostic remains available, and the queued rebuild can continue.
Uncertain inspections keep it waiting; the old command is never replayed merely
because its receipt was lost.
Create and rebuild capture a setup when queued and select the latest promoted Create and rebuild capture a setup when queued and select the latest promoted
files again when creation begins. The page distinguishes **Setup captured when files again when creation begins. The page distinguishes **Setup captured when
queued** from **Files selected for this attempt**. A queued capture does not pin queued** from **Files selected for this attempt**. A queued capture does not pin
+11 -5
View File
@@ -60,14 +60,20 @@ export function cancelProvisioningJob(id: string, reason = 'Cancelled by operato
} }
/** A cancelled controller job must not let a replacement overtake a delivered mutation. */ /** A cancelled controller job must not let a replacement overtake a delivered mutation. */
export function unsettledCancelledCommand(server: string, replacementId: string): string | null { export function unsettledCancelledCommand(server: string, replacementId: string): {
const row = db().prepare(`SELECT d.id FROM server_deployments d deploymentId: string; operationId: string; state: string; error: string | null;
} | null {
const row = db().prepare(`SELECT d.id AS deploymentId,o.id AS operationId,o.state,o.error FROM server_deployments d
JOIN native_provisioning n ON n.deployment_id=d.id JOIN native_provisioning n ON n.deployment_id=d.id
LEFT JOIN ec_host_migrations m ON m.deployment_id=d.id LEFT JOIN ec_host_migrations m ON m.deployment_id=d.id
JOIN ec_host_operations o ON o.id=n.operation_id OR o.id=m.remove_operation_id JOIN ec_host_operations o ON o.id=n.operation_id OR o.id=m.remove_operation_id
WHERE d.server=? AND d.server_deleted_at IS NULL AND d.state='cancelled' WHERE d.server=? AND d.server_deleted_at IS NULL AND d.state='cancelled'
AND (o.state NOT IN ('completed','failed') AND (o.state NOT IN ('completed','failed')
OR (o.state='completed' AND o.id=n.operation_id AND n.files_json IS NOT NULL AND n.applied_at IS NULL)) OR (o.state='completed' AND o.id=n.operation_id AND n.files_json IS NOT NULL AND n.applied_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM native_creation_cancellations c WHERE c.creation_id=d.id AND c.cleanup_id=?) LIMIT 1`).get(server, replacementId) as { id: string } | undefined; OR (o.state='failed' AND o.id=n.operation_id AND o.kind='instance.rebuild'
return row?.id ?? null; AND json_type(o.result,'$.rebuildFailure') IS NOT NULL AND n.applied_at IS NULL))
AND NOT EXISTS (SELECT 1 FROM native_creation_cancellations c WHERE c.creation_id=d.id AND c.cleanup_id=?)
ORDER BY d.rowid,o.rowid LIMIT 1`).get(server, replacementId) as
{ deploymentId: string; operationId: string; state: string; error: string | null } | undefined;
return row ?? null;
} }
+56 -4
View File
@@ -13,7 +13,7 @@ import { ensureHostSchema } from '../hosts/schema.js';
import { unsettledCancelledCommand } from './cancellation.js'; import { unsettledCancelledCommand } from './cancellation.js';
import { config, relDestForKind, serverDir } from '../config.js'; import { config, relDestForKind, serverDir } from '../config.js';
import { nativeInstance, nativeRequest, unplacedNativeCreation, type NativeInstance } from '../backends/native.js'; import { nativeInstance, nativeRequest, unplacedNativeCreation, type NativeInstance } from '../backends/native.js';
import { getHost, listHosts, getHostOperationStatus, queueHostOperation, queueHostOperationSerialized, releaseHostPorts, reserveHostPort } from '../hosts/index.js'; import { getHost, listHosts, getHostOperationStatus, queueHostOperation, queueHostOperationSerialized, releaseHostPorts, reserveHostPort, type HostOperationStatus } from '../hosts/index.js';
import { newestReadyLibraryId, assertReadyLibrary } from '../game-files/index.js'; import { newestReadyLibraryId, assertReadyLibrary } from '../game-files/index.js';
import { blueprintRecipe, captureRecipePlan, recipeForDeployment, recipeSummaryForDeployment, recipeStorage, summarizeRecipe, type LaunchRecipePlan } from '../fleet/launch-recipes.js'; import { blueprintRecipe, captureRecipePlan, recipeForDeployment, recipeSummaryForDeployment, recipeStorage, summarizeRecipe, type LaunchRecipePlan } from '../fleet/launch-recipes.js';
import { prepareNativeLaunch, evaluateNativePlacement, type PreparedNativeLaunch } from './launch-preparation.js'; import { prepareNativeLaunch, evaluateNativePlacement, type PreparedNativeLaunch } from './launch-preparation.js';
@@ -277,6 +277,48 @@ function applyReceipt(job: Job, result: Record<string, unknown>) {
})(); })();
} }
/** Consume a conclusive partial failure without treating removal as a healthy rebuild. */
function applyIncompleteRebuildReceipt(job: Job, operation: HostOperationStatus): void {
const result = operation.result as { reconciled?: unknown; rebuildFailure?: {
phase?: unknown; generation?: unknown; nextGeneration?: unknown;
oldContainerMissing?: unknown; replacementContainerMissing?: unknown; replacementFilesRemoved?: unknown;
} | null } | null;
if (!result || !Object.prototype.hasOwnProperty.call(result, 'rebuildFailure')) return;
const failure = result.rebuildFailure;
if (operation.state !== 'failed' || operation.kind !== 'instance.rebuild' || result.reconciled !== true
|| !failure || failure.phase !== 'disrupting' || failure.generation !== job.generation
|| failure.nextGeneration !== job.generation + 1 || failure.oldContainerMissing !== true
|| failure.replacementContainerMissing !== true || failure.replacementFilesRemoved !== true) {
throw new Error('The interrupted rebuild receipt does not confirm safe replacement cleanup. Inspect the original host command.');
}
db().transaction(() => {
const binding = db().prepare('SELECT operation_id,applied_at FROM native_provisioning WHERE deployment_id=?')
.get(job.id) as { operation_id: string | null; applied_at: string | null } | undefined;
if (binding?.operation_id !== operation.id) throw new Error('The interrupted rebuild command no longer belongs to this operation.');
if (binding.applied_at) return;
const requested = db().prepare(`SELECT COALESCE(json_extract(COALESCE(p.payload,o.payload),'$.nextGeneration'),
json_extract(COALESCE(p.payload,o.payload),'$.newGeneration')) AS nextGeneration FROM ec_host_operations o
LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id WHERE o.id=?`).get(operation.id) as
{ nextGeneration: number | null } | undefined;
if (requested?.nextGeneration !== failure.nextGeneration) {
throw new Error('The interrupted rebuild receipt does not match its requested replacement generation.');
}
const instance = nativeInstance(job.server);
if (!instance || instance.id !== job.instance_id || instance.generation !== job.generation
|| operation.instanceId !== instance.id || operation.hostId !== instance.host_id || operation.generation !== instance.generation) {
throw new Error('The interrupted rebuild belongs to a different instance or generation. Inspect its host command.');
}
db().prepare("UPDATE servers SET container_state='missing',container_checked_at=?,last_players=NULL,last_roster=NULL WHERE name=?")
.run(operation.updatedAt, job.server);
db().prepare('UPDATE native_provisioning SET applied_at=? WHERE deployment_id=?').run(operation.updatedAt, job.id);
recordProgress(job.id, { checks: [{ key: 'container', label: 'Container state after interrupted rebuild', source: 'agent',
result: 'fail', actual: 'missing', checkedAt: operation.updatedAt,
message: 'The old container is absent and replacement creation never started. Partial replacement files were removed.' }],
event: { kind: 'reconciled', message: 'Interrupted rebuild settled: old container removed; replacement creation failed.',
details: { operationId: operation.id, generation: job.generation, nextGeneration: failure.nextGeneration } } });
})();
}
/** 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,n.recovery_step const job = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.operation_id,n.applied_at,n.recovery_step
@@ -303,6 +345,7 @@ export function reconcileNativeProvisioningReceipt(id: string): void {
if (!job.operation_id) return; if (!job.operation_id) return;
const operation = getHostOperationStatus(job.operation_id); const operation = getHostOperationStatus(job.operation_id);
if (operation?.state === 'failed') { if (operation?.state === 'failed') {
applyIncompleteRebuildReceipt(job, operation);
for (const file of storedFiles(job)) { for (const file of storedFiles(job)) {
db().prepare("UPDATE staged_file_changes SET state=? WHERE id=? AND state='applying'") db().prepare("UPDATE staged_file_changes SET state=? WHERE id=? AND state='applying'")
.run(job.state === 'cancelled' ? 'cancelled' : 'pending', file.id); .run(job.state === 'cancelled' ? 'cancelled' : 'pending', file.id);
@@ -434,6 +477,7 @@ export async function processNativeProvisioning(eligibility: (operation: { id: s
LEFT JOIN ec_host_operations removal ON removal.id=m.remove_operation_id LEFT JOIN ec_host_operations removal ON removal.id=m.remove_operation_id
WHERE d.state='cancelled' AND d.server_deleted_at IS NULL AND ( WHERE d.state='cancelled' AND d.server_deleted_at IS NULL AND (
(o.state='completed' AND (n.applied_at IS NULL OR d.action='destroy')) (o.state='completed' AND (n.applied_at IS NULL OR d.action='destroy'))
OR (o.state='failed' AND o.kind='instance.rebuild' AND json_type(o.result,'$.rebuildFailure') IS NOT NULL AND n.applied_at IS NULL)
OR (o.state='failed' AND EXISTS (SELECT 1 FROM staged_file_changes f JOIN provisioning_sources s OR (o.state='failed' AND EXISTS (SELECT 1 FROM staged_file_changes f JOIN provisioning_sources s
ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
WHERE s.operation_id=d.id AND f.state='applying')) WHERE s.operation_id=d.id AND f.state='applying'))
@@ -542,7 +586,14 @@ async function placeQueuedCreation(job: Job, instance: NativeInstance | null, el
async function processJob(job: Job, eligibility: (job: Job) => string | null) { async function processJob(job: Job, eligibility: (job: Job) => string | null) {
if (!stillActive(job)) return; if (!stillActive(job)) return;
const unsettled = unsettledCancelledCommand(job.server, job.id); const unsettled = unsettledCancelledCommand(job.server, job.id);
if (unsettled) { hold(job, 'Waiting for the already-delivered command from cancelled operation ' + unsettled + ' to settle.'); return; } if (unsettled) {
const pendingReceipt = unsettled.state === 'completed' || unsettled.state === 'failed';
const message = `Waiting for the host command from cancelled operation ${unsettled.deploymentId} ${pendingReceipt ? 'to be reconciled' : 'to settle'}. Agent state: ${unsettled.state}.`
+ (unsettled.error ? ` Last error: ${unsettled.error.slice(0, 500)}` : '');
deploymentStage(job.id, message, { blocker: { code: 'cancelled-command', message, automatic: true,
href: `/provisioning/${encodeURIComponent(unsettled.deploymentId)}`, label: 'View cancelled operation' } });
return;
}
let instance = nativeInstance(job.server); let instance = nativeInstance(job.server);
if (job.action === 'create' && !job.operation_id && !job.stage_operation_id && !job.submitted_at) { if (job.action === 'create' && !job.operation_id && !job.stage_operation_id && !job.submitted_at) {
// A resumed bound job owns its original identity. An unplaced intent // A resumed bound job owns its original identity. An unplaced intent
@@ -588,6 +639,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
} }
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') {
applyIncompleteRebuildReceipt(job, operation);
if (job.action === 'destroy' && scalingIntent(job.id) && (operation.result as { autoscalingInvalidated?: boolean } | null)?.autoscalingInvalidated === true) { if (job.action === 'destroy' && scalingIntent(job.id) && (operation.result as { autoscalingInvalidated?: boolean } | null)?.autoscalingInvalidated === true) {
db().transaction(() => { db().transaction(() => {
db().prepare("UPDATE server_deployments SET state='cancelled',stage='Automatic removal withdrawn; new empty interval required',finished_at=?,error=NULL WHERE id=?") db().prepare("UPDATE server_deployments SET state='cancelled',stage='Automatic removal withdrawn; new empty interval required',finished_at=?,error=NULL WHERE id=?")
@@ -607,8 +659,8 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
hold(job, `${operation.error ?? 'Destination creation failed before mutation.'} Retrying after fresh capacity checks.`); hold(job, `${operation.error ?? 'Destination creation failed before mutation.'} Retrying after fresh capacity checks.`);
return; return;
} }
// The agent reports failed only when no mutation began. Ambiguous // Failed commands either never mutated or have a conclusively
// post-mutation failures remain unknown and retain their capacity. // reconciled partial rebuild above. Ambiguous outcomes retain capacity.
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);
const files = storedFiles(job); const files = storedFiles(job);
+4 -1
View File
@@ -19,7 +19,10 @@ const unsettledDeployment = `d.state NOT IN ${terminal}
OR EXISTS (SELECT 1 FROM restart_rollout r WHERE r.operation_id=d.id AND r.healthy_at IS NULL) OR EXISTS (SELECT 1 FROM restart_rollout r WHERE r.operation_id=d.id AND r.healthy_at IS NULL)
OR EXISTS (SELECT 1 FROM native_provisioning n JOIN ec_host_operations a ON a.id IN (n.operation_id,n.stage_operation_id) OR EXISTS (SELECT 1 FROM native_provisioning n JOIN ec_host_operations a ON a.id IN (n.operation_id,n.stage_operation_id)
LEFT JOIN native_instances i ON i.id=n.instance_id WHERE n.deployment_id=d.id AND LEFT JOIN native_instances i ON i.id=n.instance_id WHERE n.deployment_id=d.id AND
(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.state='failed' AND a.kind='instance.rebuild' AND a.id=n.operation_id
AND json_type(a.result,'$.rebuildFailure') IS NOT NULL AND n.applied_at IS NULL)
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 (a.kind='instance.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)))))
+73 -1
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.8.3" VERSION = "1.8.4"
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
@@ -2665,12 +2665,84 @@ class Agent:
atomic_json(self.config_path, self.config) atomic_json(self.config_path, self.config)
return True return True
def reconcile_uncreated_rebuild(self, operation, row):
"""Settle removal only when the replacement create was never submitted.
The write lane and instance lock exclude the original Python execution.
A terminal journal receipt prevents any later replay of that command.
"""
if row["state"] != "unknown":
return row
instance_id, generation = instance_identity(operation.get("instanceId"), operation.get("generation"))
payload = operation.get("payload", {})
next_generation = payload.get("nextGeneration", payload.get("newGeneration"))
if type(next_generation) is not int or next_generation <= generation:
return row
previous = json.loads(row["result"]) if row.get("result") else {}
if not isinstance(previous, dict):
return row
directory = scoped(self.root, f"instances/{instance_id}/{next_generation}")
def unchanged_generation():
try:
marker = scoped(self.root, f"instances/{instance_id}/current.json")
current = json.loads(marker.read_text())
except FileNotFoundError:
return True
return isinstance(current, dict) and type(current.get("generation")) is int and current["generation"] == generation and current.get("removed", False) is False
def both_missing():
for target in (generation, next_generation):
health = self.inspect(instance_id, target)
if health.get("exists") is not False or health.get("running") is not False or health.get("state") != "missing":
return False
return True
if not unchanged_generation() or not both_missing():
return row
# Preparation can leave runtime/configuration files before create's
# checkpoint. A fresh operation must not inherit those partial files.
expected_spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
try:
saved_spec = json.loads(scoped(directory, "spec.json").read_text())
except FileNotFoundError:
pass
else:
if saved_spec != expected_spec:
return row
try:
metadata = directory.lstat()
except FileNotFoundError:
pass
else:
if not stat_module.S_ISDIR(metadata.st_mode):
return row
shutil.rmtree(directory)
# Also sync when a previous reconciliation removed the directory but
# crashed before recording the terminal receipt.
try:
sync_parent(directory)
except FileNotFoundError:
pass
if not unchanged_generation() or not both_missing():
return row
original_error = row.get("error")
result = {**previous, "reconciled": True, "originalError": original_error,
"rebuildFailure": {"phase": "disrupting", "generation": generation, "nextGeneration": next_generation,
"oldContainerMissing": True, "replacementContainerMissing": True, "replacementFilesRemoved": True}}
message = "Old container removed; replacement creation failed."
if original_error:
message += " " + original_error
return self.journal.update(operation["id"], "failed", result, error=message)
def reconcile(self, operation, row): def reconcile(self, operation, row):
"""Prove desired state with immutable ownership; never resubmit a command.""" """Prove desired state with immutable ownership; never resubmit a command."""
kind, payload = operation["kind"], operation.get("payload", {}) kind, payload = operation["kind"], operation.get("payload", {})
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.rebuild" and checkpoint.get("phase") == "disrupting":
return self.reconcile_uncreated_rebuild(operation, row)
if kind == "instance.remove" and "autoscaling" in payload and not checkpoint: if kind == "instance.remove" and "autoscaling" in payload and not checkpoint:
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision") return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision")
if kind == "instance.cancel-create": if kind == "instance.cancel-create":
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "1.8.3", "version": "1.8.4",
"protocolVersion": 1, "protocolVersion": 1,
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "388569ffd4566dee63584970838d4d3ae7d2b8ef92675ae7627c31389dcbfec0", "sha256": "7e7c0049161a6f64743210b787a8ec30bd32d726c30587962bc5c705d94dfbac",
"updaterProtocolVersion": 1, "updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
} }
+135
View File
@@ -1946,6 +1946,141 @@ describe('native provisioning cancellation and rollback', () => {
}); });
}); });
describe('partial rebuild failure reconciliation', () => {
const originalError = 'Replacement game library could not be prepared';
const failure = (generation = 1) => ({ reconciled: true, originalError, rebuildFailure: {
phase: 'disrupting', generation, nextGeneration: generation + 1,
oldContainerMissing: true, replacementContainerMissing: true, replacementFilesRemoved: true
} });
async function cancelledRebuild() {
const job = await queue(); await advance(); await acknowledge(job.deploymentId);
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
const first = requestLifecycle(job.name, 'rebuild');
if (!first.ok) throw new Error(first.error);
await advance();
const command = (await operationFor(first.deploymentId))!;
const { pendingHostOperations, reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
pendingHostOperations(hostId);
reportHostOperation(hostId, command.id, { state: 'unknown', error: originalError });
const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js');
expect(cancelProvisioningJob(first.deploymentId)).toBe(true);
const replacement = requestLifecycle(job.name, 'rebuild');
if (!replacement.ok) throw new Error(replacement.error);
return { job, first, command, replacement };
}
it('records missing containers before releasing a queued rebuild without replaying the cancelled command', async () => {
const { job, first, command, replacement } = await cancelledRebuild();
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
const { getHostOperation, reportHostOperation, pendingHostOperations } = await import('../../src/lib/server/hosts/index.js');
const { nativeInstance, nativeRequest } = await import('../../src/lib/server/backends/native.js');
const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
const { unsettledCancelledCommand } = await import('../../src/lib/server/provisioning/cancellation.js');
const ports = db().prepare('SELECT host_id,instance_id,port,protocol FROM ec_host_ports ORDER BY protocol').all();
await advance();
expect(await operationFor(replacement.deploymentId)).toBeNull();
expect(getDeployment(replacement.deploymentId)?.progress?.blocker).toMatchObject({ code: 'cancelled-command',
href: `/provisioning/${first.deploymentId}`, label: 'View cancelled operation',
message: expect.stringContaining(originalError) });
expect(getDeployment(replacement.deploymentId)?.progress?.blocker?.message).toContain('unknown');
reportHostOperation(hostId, command.id, { state: 'failed',
error: `Old container removed; replacement creation failed. ${originalError}`, result: failure() });
expect(unsettledCancelledCommand(job.name, replacement.deploymentId)).not.toBeNull();
expect(db().prepare('SELECT applied_at FROM native_provisioning WHERE deployment_id=?').get(first.deploymentId))
.toEqual({ applied_at: null });
remote.state = 'missing';
vi.mocked(nativeRequest).mockImplementationOnce(async () => {
expect(db().prepare('SELECT container_state,last_players FROM servers WHERE name=?').get(job.name))
.toEqual({ container_state: 'missing', last_players: null });
expect(db().prepare('SELECT applied_at FROM native_provisioning WHERE deployment_id=?').get(first.deploymentId))
.toEqual({ applied_at: expect.any(String) });
expect(unsettledCancelledCommand(job.name, replacement.deploymentId)).toBeNull();
return { exists: false, healthy: false, state: 'missing' } as never;
});
closeDb(); await advance();
const next = (await operationFor(replacement.deploymentId))!;
expect(next).toMatchObject({ kind: 'instance.rebuild', generation: 1, instanceId: command.instanceId,
payload: { nextGeneration: 2, env: { PLUTO_SERVER_KEY: 'saved-key' } } });
expect(next.id).not.toBe(command.id);
expect(nativeInstance(job.name)).toMatchObject({ id: command.instanceId, generation: 1, retired_at: null });
expect(getServerKey(job.name)).toBe('saved-key');
expect(db().prepare('SELECT host_id,instance_id,port,protocol FROM ec_host_ports ORDER BY protocol').all()).toEqual(ports);
expect((await detail(first.deploymentId)).state).toBe('cancelled');
expect(getHostOperation(command.id)).toMatchObject({ state: 'failed', result: failure() });
expect(pendingHostOperations(hostId).some(operation => operation.id === command.id)).toBe(false);
await advance();
expect((await operationFor(replacement.deploymentId))!.id).toBe(next.id);
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.rebuild'").get()).toEqual({ count: 2 });
remote.state = 'running'; await acknowledge(replacement.deploymentId);
expect((await detail(replacement.deploymentId)).state).toBe('done');
expect(nativeInstance(job.name)?.generation).toBe(2);
expect((await detail(first.deploymentId)).state).toBe('cancelled');
});
it.each(['disrupting', 'creating'])('keeps an ambiguous %s rebuild blocked even when inspections currently see no containers', async phase => {
const { job, first, command, replacement } = await cancelledRebuild();
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, command.id, { state: 'unknown', error: originalError,
result: { checkpoint: { phase }, state: 'missing', oldContainerMissing: true, replacementContainerMissing: true } });
remote.state = 'missing';
const { nativeRequest, nativeInstance } = await import('../../src/lib/server/backends/native.js');
vi.mocked(nativeRequest).mockClear();
const { closeDb, db } = await import('../../src/lib/server/db/index.js'); closeDb();
await advance(); await advance();
expect(await operationFor(replacement.deploymentId)).toBeNull();
expect(vi.mocked(nativeRequest)).not.toHaveBeenCalled();
expect((await operationFor(first.deploymentId))!.id).toBe(command.id);
expect(nativeInstance(job.name)?.generation).toBe(1);
expect(db().prepare('SELECT applied_at FROM native_provisioning WHERE deployment_id=?').get(first.deploymentId))
.toEqual({ applied_at: null });
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.rebuild'").get()).toEqual({ count: 1 });
});
it.each(['creation started', 'replacement files remain', 'wrong target generation', 'command target mismatch'])('does not release a replacement from invalid partial evidence: %s', async evidence => {
const { first, command, replacement } = await cancelledRebuild();
const result = failure();
if (evidence === 'creation started') result.rebuildFailure.phase = 'creating';
if (evidence === 'replacement files remain') result.rebuildFailure.replacementFilesRemoved = false;
if (evidence === 'wrong target generation') result.rebuildFailure.nextGeneration = 3;
const { db } = await import('../../src/lib/server/db/index.js');
if (evidence === 'command target mismatch') db().prepare("UPDATE ec_host_operation_payloads SET payload=json_set(payload,'$.nextGeneration',3) WHERE operation_id=?")
.run(command.id);
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, command.id, { state: 'failed', error: originalError, result });
remote.state = 'missing'; await advance();
expect(await operationFor(replacement.deploymentId)).toBeNull();
expect(db().prepare('SELECT applied_at FROM native_provisioning WHERE deployment_id=?').get(first.deploymentId))
.toEqual({ applied_at: null });
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.rebuild'").get()).toEqual({ count: 1 });
});
it('records an uncancelled partial rebuild failure without claiming replacement generation or successful health', async () => {
const job = await queue(); await advance(); await acknowledge(job.deploymentId);
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
const first = requestLifecycle(job.name, 'rebuild');
if (!first.ok) throw new Error(first.error);
await advance();
const command = (await operationFor(first.deploymentId))!;
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, command.id, { state: 'failed', result: failure(),
error: `Old container removed; replacement creation failed. ${originalError}` });
await advance();
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
const { db } = await import('../../src/lib/server/db/index.js');
expect((await detail(first.deploymentId)).state).toBe('failed');
expect(nativeInstance(job.name)).toMatchObject({ generation: 1, retired_at: null });
expect(db().prepare('SELECT container_state,last_players FROM servers WHERE name=?').get(job.name))
.toEqual({ container_state: 'missing', last_players: null });
expect(db().prepare('SELECT applied_at,verified_at FROM native_provisioning WHERE deployment_id=?').get(first.deploymentId))
.toEqual({ applied_at: expect.any(String), verified_at: null });
expect(db().prepare('SELECT COUNT(*) AS count FROM ec_host_ports WHERE instance_id=?').get(command.instanceId)).toEqual({ count: 2 });
const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
expect(getServerKey(job.name)).toBe('saved-key');
});
});
describe('cancelled native mutation bookkeeping', () => { describe('cancelled native mutation bookkeeping', () => {
it.each([null, 'before beta'])('keeps the original host receipt across beta revisions (original=%s)', async original => { it.each([null, 'before beta'])('keeps the original host receipt across beta revisions (original=%s)', async original => {
const job = await queue(); await advance(); await acknowledge(job.deploymentId); const job = await queue(); await advance(); await acknowledge(job.deploymentId);
+29
View File
@@ -231,6 +231,35 @@ it('protects cancelled workflows and linked file history through ambiguous and u
expect(a.db.pragma('foreign_key_check')).toEqual([]); expect(a.db.pragma('foreign_key_check')).toEqual([]);
}); });
it('retains a cancelled rebuild and its source until the partial-failure receipt is applied', async () => {
const a = await api();
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Host' });
a.db.prepare("INSERT INTO servers(name,container_state) VALUES('s','running')").run();
a.db.prepare("INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES('instance','s',?,'lib','image',1,?)").run(host.id, old);
a.db.prepare("INSERT INTO server_deployments(id,server,action,state,stage,started_at,finished_at,triggered_by) VALUES('cancelled','s','rebuild','cancelled','cancelled',?,?,'test')").run(old, old);
const command = hosts.queueHostOperation(host.id, 'instance.rebuild', { nextGeneration: 2 }, { instanceId: 'instance', generation: 1 });
a.db.prepare("INSERT INTO native_provisioning(deployment_id,instance_id,generation,operation_id) VALUES('cancelled','instance',1,?)").run(command.id);
a.db.prepare("INSERT INTO deployments(id,action,started_at,finished_at) VALUES('campaign','promote',?,?)").run(old, old);
a.db.prepare("INSERT INTO provisioning_sources(operation_id,kind,source_id,server) VALUES('cancelled','campaign','campaign','s')").run();
hosts.reportHostOperation(host.id, command.id, { state: 'failed', error: 'Replacement creation failed', result: {
reconciled: true, rebuildFailure: { phase: 'disrupting', generation: 1, nextGeneration: 2,
oldContainerMissing: true, replacementContainerMissing: true, replacementFilesRemoved: true }
} });
a.saveFleetSettings({ ...a.fleetSettings(), deployKeepDays: 1, provisioningKeepDays: 1, historyKeepCount: 0 });
expect(a.pruneHistory('campaign')).toBe(0);
expect(a.pruneHistory('provisioning')).toBe(0);
expect(a.db.prepare("SELECT applied_at FROM native_provisioning WHERE deployment_id='cancelled'").get()).toEqual({ applied_at: null });
const { reconcileNativeProvisioningReceipt } = await import('../../src/lib/server/provisioning/native.js');
reconcileNativeProvisioningReceipt('cancelled');
expect(a.db.prepare("SELECT applied_at FROM native_provisioning WHERE deployment_id='cancelled'").get()).toEqual({ applied_at: expect.any(String) });
expect(a.db.prepare("SELECT container_state FROM servers WHERE name='s'").get()).toEqual({ container_state: 'missing' });
expect(a.pruneHistory('campaign')).toBe(1);
expect(a.pruneHistory('provisioning')).toBe(1);
expect(a.db.pragma('foreign_key_check')).toEqual([]);
});
async function recoveryLaunch(action: 'create' | 'rebuild' | 'migration') { async function recoveryLaunch(action: 'create' | 'rebuild' | 'migration') {
const a = await api(); const a = await api();
const hosts = await import('../../src/lib/server/hosts/index.js'); const hosts = await import('../../src/lib/server/hosts/index.js');