Separate campaigns from server deployment lifecycle tracking

This commit is contained in:
2026-09-17 16:42:37 -04:00
parent 6c6e63fa80
commit 6f83700161
20 changed files with 1434 additions and 1108 deletions
+13
View File
@@ -255,3 +255,16 @@ CREATE TABLE IF NOT EXISTS server_telemetry (
players INTEGER NOT NULL,
received_at TEXT NOT NULL
);
-- Lifecycle history deliberately survives removal of the server registry row.
CREATE TABLE IF NOT EXISTS server_deployments (
id TEXT PRIMARY KEY,
server TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('create', 'destroy')),
state TEXT NOT NULL,
stage TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
triggered_by TEXT NOT NULL,
error TEXT,
warnings TEXT NOT NULL DEFAULT '[]'
);
+38 -3
View File
@@ -1,3 +1,4 @@
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { config, destOfKind } from '../config.js';
@@ -138,13 +139,24 @@ function buildSpec(args: {
let creating = false;
export async function createServer(input: CreateServerInput): Promise<CreateServerResult> {
if (creating) return { ok: false, kind: 'no-slot', message: 'Another server is being created. Please retry shortly.' };
if (creating || destroying.size > 0) return { ok: false, kind: 'no-slot', message: 'Another server operation is in progress. Please retry shortly.' };
creating = true;
try { return await createServerUnlocked(input); }
let operation: string | undefined;
try {
const result = await createServerUnlocked(input, (name) => {
operation = beginDeployment(name, 'create', input.createdBy);
return operation;
});
if (operation) finishDeployment(operation, result);
return result;
} catch (err) {
if (operation) finishDeployment(operation, { ok: false, message: (err as Error).message });
throw err;
}
finally { creating = false; }
}
async function createServerUnlocked(input: CreateServerInput): Promise<CreateServerResult> {
async function createServerUnlocked(input: CreateServerInput, begin: (name: string) => string): Promise<CreateServerResult> {
if (input.gameMode !== 'zombie') {
return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` };
}
@@ -185,6 +197,8 @@ async function createServerUnlocked(input: CreateServerInput): Promise<CreateSer
try { saveServerKey(name, plutoKey); }
catch (err) { return { ok: false, kind: 'bad-request', message: (err as Error).message }; }
const operation = begin(name);
deploymentStage(operation, 'Preparing server files');
const hostDir = `${config.serversRoot}/${name}`;
const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical);
const mapConfig = getServerMapConfig('t5', mapEntry.canonical)!;
@@ -224,6 +238,7 @@ async function createServerUnlocked(input: CreateServerInput): Promise<CreateSer
}
// Step 2: Komodo CreateDeployment.
deploymentStage(operation, 'Creating Komodo deployment');
const spec = buildSpec({ name, port, hostDir, plutoKey });
const createRes = await komodoCreateDeployment(spec);
if (!createRes.ok) {
@@ -274,6 +289,7 @@ async function createServerUnlocked(input: CreateServerInput): Promise<CreateSer
void applyDeploymentTags(name, 't5', mapEntry.canonical);
// Step 5: execute/Deploy — actually start the container.
deploymentStage(operation, 'Starting container');
const deployRes = await komodoDeploy(name);
if (!deployRes.ok) {
// Container never started — undo everything so retrying is clean.
@@ -317,7 +333,23 @@ async function applyDeploymentTags(deployment: string, gameTagName: string, mapT
}
}
const destroying = new Set<string>();
export async function destroyServer(name: string): Promise<DestroyServerResult> {
if (creating || destroying.has(name)) return { ok: false, kind: 'komodo', message: 'A server operation is already in progress. Retry shortly.', warnings: [] };
if (!getServer(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
const operation = beginDeployment(name, 'destroy');
destroying.add(name);
try {
const result = await destroyServerUnlocked(name, operation);
finishDeployment(operation, result);
return result;
} catch (err) {
finishDeployment(operation, { ok: false, message: (err as Error).message });
throw err;
} finally { destroying.delete(name); }
}
async function destroyServerUnlocked(name: string, operation: string): Promise<DestroyServerResult> {
const row = getServer(name);
if (!row) {
return { ok: false, kind: 'not-found', message: `no server named '${name}'`, warnings: [] };
@@ -327,6 +359,7 @@ export async function destroyServer(name: string): Promise<DestroyServerResult>
// 1: stop + remove the container. execute/DestroyDeployment queues the
// teardown; the response comes back before the container is actually gone.
deploymentStage(operation, 'Stopping and removing container');
const destroyRes = await komodoDestroyDeployment(name);
if (!destroyRes.ok) {
warnings.push(`DestroyDeployment failed: ${destroyRes.message}`);
@@ -344,6 +377,7 @@ export async function destroyServer(name: string): Promise<DestroyServerResult>
}
// 3: remove the deployment entity itself.
deploymentStage(operation, 'Deleting Komodo deployment');
const deleteRes = await komodoDeleteDeployment(name);
if (!deleteRes.ok) {
warnings.push(`DeleteDeployment failed: ${deleteRes.message}`);
@@ -351,6 +385,7 @@ export async function destroyServer(name: string): Promise<DestroyServerResult>
// 3: 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 ?? `${config.serversRoot}/${name}`;
try {
await rm(hostDir, { recursive: true, force: true });
+30
View File
@@ -0,0 +1,30 @@
import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js';
export function beginDeployment(server: string, action: 'create' | 'destroy', triggeredBy = 'ui'): string {
const id = randomUUID();
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
VALUES (?, ?, ?, 'running', 'Starting', ?, ?)`).run(id, server, action, new Date().toISOString(), triggeredBy);
return id;
}
export function deploymentStage(id: string, stage: string): void {
db().prepare('UPDATE server_deployments SET stage = ? WHERE id = ?').run(stage, id);
}
export function finishDeployment(id: string, result: { ok: boolean; message?: string; warnings?: string[] }): void {
const state = !result.ok ? 'failed' : result.warnings?.length ? 'warning' : 'done';
db().prepare(`UPDATE server_deployments SET state = ?, finished_at = ?, error = ?, warnings = ? WHERE id = ?`)
.run(state, new Date().toISOString(), result.message ?? null, JSON.stringify(result.warnings ?? []), id);
}
export function listDeployments() {
return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all();
}
export function interruptDeployments(): void {
db().prepare(`UPDATE server_deployments SET state = 'interrupted', finished_at = ?,
error = 'The application restarted before this operation finished. Check the server before retrying.'
WHERE state = 'running'`).run(new Date().toISOString());
}