Fix server deletion cleanup and allow failed cleanup to resume

This commit is contained in:
2026-09-18 00:49:30 -04:00
parent 274aedb66e
commit 453821805a
6 changed files with 148 additions and 15 deletions
+5
View File
@@ -78,6 +78,11 @@ are removed, while saved keys and blueprints are retained. The preview must be
reviewed again if fleet membership changes before confirmation. Servers created
after confirmation are not included. Progress appears in deployment history.
If deletion fails after Komodo removal, the operation detail page offers
**Resume cleanup** to retry local file and database removal without RCON or
repeating Komodo deletion. Requesting destruction again also resumes that cleanup.
Recovery is blocked if a newer operation exists for the same server name.
Deployments includes a blueprint builder for whole server collections: choose
multiple T5/T6 maps and set the desired total lobbies for each. **Conform fleet**
counts existing and queued servers and creates only the shortfall. The preview
+31 -9
View File
@@ -1,5 +1,5 @@
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
import { beginDeployment, deploymentStage, finishDeployment, REMOVING_SERVER, resumeCleanup } from './deployments.js';
import { randomUUID } from 'node:crypto';
import type { LaunchPreview } from '$lib/deployment-plan.js';
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from './launch-recipes.js';
@@ -423,6 +423,8 @@ function hasActiveCreation(name: string): boolean {
export function destroyServer(name: string, triggeredBy = 'ui'): DestroyServerResult & { deploymentId?: string } {
const existing = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) as { id: string } | undefined;
if (existing) return { ok: true, warnings: [], deploymentId: existing.id };
const previous = db().prepare("SELECT id FROM server_deployments WHERE server = ? ORDER BY rowid DESC LIMIT 1").get(name) as { id: string } | undefined;
if (previous && resumeCleanup(previous.id)) return { ok: true, warnings: [], deploymentId: previous.id };
if (!getServer(name) && !hasActiveCreation(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
const operation = db().transaction(() => {
const id = beginDeployment(name, 'destroy', triggeredBy);
@@ -441,11 +443,22 @@ export function processDestructions(): Promise<void> {
}
async function runDestructions(): Promise<void> {
const queued = db().prepare("SELECT id, server FROM server_deployments WHERE action = 'destroy' AND state = 'waiting' ORDER BY started_at").all() as { id: string; server: string }[];
const queued = db().prepare("SELECT id, server, stage FROM server_deployments WHERE action = 'destroy' AND state = 'waiting' ORDER BY started_at").all() as { id: string; server: string; stage: string }[];
for (const operation of queued) {
if (creating || destroying.has(operation.server)) continue;
destroying.add(operation.server);
try {
if (operation.stage === REMOVING_SERVER) {
const newer = db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND action = 'create'
AND rowid > (SELECT rowid FROM server_deployments WHERE id = ?)`).get(operation.server, operation.id);
if (newer) {
finishDeployment(operation.id, { ok: false, message: 'A newer creation exists; local cleanup cannot safely resume.' });
continue;
}
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(operation.id);
finishDeployment(operation.id, await cleanupDestroyedServer(operation.server, operation.id));
continue;
}
if (!getServer(operation.server)) {
if (hasActiveCreation(operation.server)) {
deploymentStage(operation.id, 'Waiting for server creation to finish');
@@ -508,26 +521,35 @@ async function destroyServerUnlocked(name: string, operation: string): Promise<D
return { ok: false, kind: 'komodo', message: 'DeleteDeployment failed: ' + deleteRes.message, warnings };
}
// 3: host directory. Fall back to derived path if host_dir wasn't set on
return cleanupDestroyedServer(name, operation);
}
async function cleanupDestroyedServer(name: string, operation: string): Promise<DestroyServerResult> {
const row = getServer(name);
const warnings: string[] = [];
// Persist the checkpoint before local work so failed cleanup can resume without RCON.
// Legacy operations already recorded this same stage after Komodo deletion.
deploymentStage(operation, REMOVING_SERVER);
// Host directory. Fall back to derived path if host_dir wasn't set on
// legacy rows.
deploymentStage(operation, 'Removing server files and registry');
const hostDir = row.host_dir ?? serverDir(name);
const hostDir = row?.host_dir ?? serverDir(name);
try {
await rm(hostDir, { recursive: true, force: true });
} catch (err) {
warnings.push(`rm ${hostDir} failed: ${(err as Error).message}`);
}
// 4: DB row + cascading rows. ON DELETE CASCADE covers server_scripts,
// beta_file_snapshots; the rest (deployment_servers, deployment_files,
// beta_servers, beta_server_scripts) reference servers(name) without
// cascade — clean them up explicitly.
// Remove server-owned beta and deployment rows before the registry row.
// Test evidence cascades from runs; inventory and snapshots cascade from servers.
try {
const tx = db().transaction((n: string) => {
db().prepare('DELETE FROM deployment_servers WHERE server = ?').run(n);
db().prepare('DELETE FROM deployment_files WHERE server = ?').run(n);
db().prepare('DELETE FROM beta_servers WHERE server = ?').run(n);
db().prepare('DELETE FROM beta_server_scripts WHERE server = ?').run(n);
db().prepare('DELETE FROM beta_test_runs WHERE server = ?').run(n);
db().prepare('DELETE FROM beta_test_coverage WHERE server = ?').run(n);
db().prepare('DELETE FROM beta_file_changes WHERE server = ?').run(n);
db().prepare('DELETE FROM servers WHERE name = ?').run(n);
});
tx(name);
+20 -1
View File
@@ -30,7 +30,26 @@ export function getDeployment(id: string) {
const collection = db().prepare(`SELECT c.id, c.name FROM server_collection_members m
JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ?`).get(id) ?? null;
return { deployment, events, recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null,
canResumeVerification: canResumeVerification(id), collection };
canResumeVerification: canResumeVerification(id), canResumeCleanup: canResumeCleanup(id), collection };
}
// This stage is written only after Komodo has confirmed deployment deletion.
export const REMOVING_SERVER = 'Removing server files and registry';
export function canResumeCleanup(id: string): boolean {
return !!db().prepare(`SELECT d.id FROM server_deployments d
WHERE d.id = ? AND d.action = 'destroy' AND d.state IN ('failed', 'interrupted') AND d.stage = ?
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`)
.get(id, REMOVING_SERVER);
}
export function resumeCleanup(id: string): boolean {
return db().transaction(() => {
if (!canResumeCleanup(id)) return false;
db().prepare("UPDATE server_deployments SET state = 'waiting', finished_at = NULL, error = NULL WHERE id = ?").run(id);
recordDeploymentEvent(id, 'Resumed local cleanup after confirmed Komodo deletion');
return true;
})();
}
export function canResumeVerification(id: string): boolean {
@@ -1,9 +1,9 @@
import { json } from '@sveltejs/kit';
import { resumeVerification } from '$lib/server/fleet/deployments.js';
import { resumeVerification, resumeCleanup } from '$lib/server/fleet/deployments.js';
import { kick } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
if (!resumeVerification(params.id)) return json({ error: 'This operation cannot resume verification. Refresh and inspect its server.' }, { status: 409 });
if (!resumeVerification(params.id) && !resumeCleanup(params.id)) return json({ error: 'This operation cannot resume. Refresh and inspect its server.' }, { status: 409 });
void kick().catch(err => console.error('[deployment] verification kick failed:', err));
return json({ ok: true }, { status: 202 });
}
+6 -3
View File
@@ -14,7 +14,7 @@
stage: string; started_at: string; finished_at: string | null;
triggered_by: string; error: string | null; warnings: string; server_exists: number;
}
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean; collection: { id: string; name: string } | null }
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean; canResumeCleanup: boolean; collection: { id: string; name: string } | null }
let detail = $state<Detail | null>(null);
let error = $state('');
let refreshing = false;
@@ -34,13 +34,15 @@
const stages = deployment.action === 'destroy'
? ['Starting', 'Waiting', 'Stopping and removing container', 'Deleting Komodo deployment', 'Removing server files and registry']
: ['Starting', 'Preparing server files', 'Creating Komodo deployment', 'Starting container', 'Waiting for Komodo health', 'Waiting for RCON health', 'Waiting for selected map'];
if (deployment.state === 'waiting') return 1;
if (deployment.state === 'waiting' && deployment.stage !== 'Removing server files and registry') return 1;
if (deployment.state === 'awaiting-health' && deployment.stage.startsWith('Waiting for health verification')) return 4;
if (deployment.state === 'done' || deployment.state === 'warning') return stages.length;
return Math.max(0, stages.findIndex(stage => deployment.stage.startsWith(stage)));
});
let explanation = $derived.by(() => {
if (!deployment) return '';
if (detail?.canResumeCleanup) return 'Komodo deletion succeeded. Resume the remaining file and database cleanup; the removed container does not need to answer RCON.';
if (deployment.state === 'waiting' && deployment.stage === 'Removing server files and registry') return 'Local cleanup is queued. Komodo deletion has already succeeded.';
if (deployment.state === 'done') return deployment.action === 'create'
? 'The container is healthy, RCON responds, and the reported engine map matches your selection.'
: 'The container, deployment, and server registration have been removed.';
@@ -68,7 +70,7 @@
try {
const res = await fetch(`/api/deployments/${encodeURIComponent(deployment.id)}/resume`, { method: 'POST' });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not resume verification.');
if (!res.ok) throw new Error(body.error ?? 'Could not resume the operation.');
await load();
} catch (err) { error = (err as Error).message; }
finally { resuming = false; }
@@ -112,6 +114,7 @@
<p class="explanation" aria-live="polite">{explanation}</p>
<div class="timing"><span>Total time <strong>{duration(deployment.started_at, deployment.finished_at ? new Date(deployment.finished_at).getTime() : now)}</strong></span>{#if active && stageStarted}<span>This step <strong>{duration(stageStarted, now)}</strong></span>{/if}</div>
{#if detail?.canResumeVerification}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume health verification</Button></div>{/if}
{#if detail?.canResumeCleanup}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume cleanup</Button></div>{/if}
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
+84
View File
@@ -41,6 +41,90 @@ afterEach(async () => {
vi.unstubAllEnvs();
});
describe('destruction cleanup recovery', () => {
it('removes T6 servers with beta test runs and their dependent evidence', async () => {
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js');
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, reason: 'gone' });
const name = 't6_zombie_town_1';
expect(await createServer({ ...input, game: 't6', mapSlug: 'town' })).toMatchObject({ ok: true, name });
expect(await createServer(input)).toMatchObject({ ok: true });
const { script, version } = createScript({ name: 'test script', kind: 'script', scopeType: 'game', scopeValue: 't6', targetRelPath: 'test.gsc', content: Buffer.from('main(){}') });
const beta = db().prepare("INSERT INTO betas (script_id, script_version_id, trigger_kind, state) VALUES (?, ?, 'manual', 'running')").run(script.id, version.id).lastInsertRowid;
for (const server of [name, serverName]) {
db().prepare("INSERT INTO beta_test_runs (id, beta_id, server, created_at) VALUES (?, ?, ?, '2026-09-18')").run(server, beta, server);
db().prepare("INSERT INTO beta_test_evidence (run_id, event_id, received_at, kind, payload) VALUES (?, 'event', '2026-09-18', 'health', '{}')").run(server);
db().prepare('INSERT INTO beta_test_coverage (beta_id, server) VALUES (?, ?)').run(beta, server);
db().prepare("INSERT INTO beta_file_changes (beta_id, server, rel_path) VALUES (?, ?, 'test.gsc')").run(beta, server);
}
const queued = destroyServer(name);
await processDestructions();
expect(getDeployment(queued.deploymentId!)?.deployment).toMatchObject({ state: 'done', server_exists: 0 });
for (const table of ['beta_test_runs', 'beta_test_coverage', 'beta_file_changes']) {
expect(db().prepare(`SELECT server FROM ${table}`).all()).toEqual([{ server: serverName }]);
}
expect(db().prepare('SELECT run_id FROM beta_test_evidence').all()).toEqual([{ run_id: serverName }]);
expect(db().pragma('foreign_key_check')).toEqual([]);
});
it('resumes failed local cleanup after restart without RCON or another Komodo deletion', async () => {
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
const { getDeployment, interruptDeployments } = await import('../../src/lib/server/fleet/deployments.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
const { komodoInspectHealthy, komodoDestroyDeployment, komodoDeleteDeployment } = await import('../../src/lib/server/integrations/komodo.js');
const { POST } = await import('../../src/routes/api/deployments/[id]/resume/+server.js');
await createServer(input);
db().exec("CREATE TRIGGER fail_cleanup BEFORE DELETE ON servers BEGIN SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed'); END");
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, reason: 'gone' });
const { deploymentId } = destroyServer(serverName);
await processDestructions();
expect(getDeployment(deploymentId!)?.deployment).toMatchObject({ state: 'failed', stage: 'Removing server files and registry', server_exists: 1 });
expect(getDeployment(deploymentId!)?.canResumeCleanup).toBe(true);
db().exec('DROP TRIGGER fail_cleanup');
closeDb();
interruptDeployments();
vi.mocked(rconGetPlayerCount).mockClear();
vi.mocked(komodoDestroyDeployment).mockClear();
vi.mocked(komodoDeleteDeployment).mockClear();
expect((await POST({ params: { id: deploymentId! } })).status).toBe(202);
expect((await POST({ params: { id: deploymentId! } })).status).toBe(409);
await processDestructions();
expect(rconGetPlayerCount).not.toHaveBeenCalled();
expect(komodoDestroyDeployment).not.toHaveBeenCalled();
expect(komodoDeleteDeployment).not.toHaveBeenCalled();
expect(getDeployment(deploymentId!)?.deployment).toMatchObject({ state: 'done', server_exists: 0 });
});
it('reuses an interrupted cleanup when destruction is requested again', async () => {
const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
const { deploymentStage, interruptDeployments, getDeployment, REMOVING_SERVER } = await import('../../src/lib/server/fleet/deployments.js');
const { db } = await import('../../src/lib/server/db/index.js');
await createServer(input);
const first = destroyServer(serverName);
deploymentStage(first.deploymentId!, REMOVING_SERVER);
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(first.deploymentId);
interruptDeployments();
expect(destroyServer(serverName).deploymentId).toBe(first.deploymentId);
expect(getDeployment(first.deploymentId!)?.deployment).toMatchObject({ state: 'waiting', stage: REMOVING_SERVER });
});
it('refuses cleanup before confirmed deletion or after a newer creation', async () => {
const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
const { beginDeployment, deploymentStage, finishDeployment, resumeCleanup, REMOVING_SERVER } = await import('../../src/lib/server/fleet/deployments.js');
await createServer(input);
const { deploymentId } = destroyServer(serverName);
finishDeployment(deploymentId!, { ok: false, message: 'Komodo unavailable' });
expect(resumeCleanup(deploymentId!)).toBe(false);
deploymentStage(deploymentId!, REMOVING_SERVER);
beginDeployment(serverName, 'create');
expect(resumeCleanup(deploymentId!)).toBe(false);
});
});
describe('House Party Protocol', () => {
it('includes registered and queued servers once and reuses destruction requests', async () => {
const { createServer, queueCreateServer } = await import('../../src/lib/server/fleet/create.js');