Files
electric-cherry/src/lib/server/fleet/servers.ts
T
josh b904ddbc48 Block server destruction while linked to an active beta
Protect individual, bulk, and queued removals and rebuilds until beta closure. Recheck ownership before teardown and reject beta admission during pending or unresolved destruction.

Fixes #36
2026-09-27 23:21:37 -04:00

244 lines
9.0 KiB
TypeScript

import { readdir, mkdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { config, destOf, serverDir, rootOfServer } from '../config.js';
import { db } from '../db/index.js';
import { parseServerName } from './map-aliases.js';
/**
* All server folders under the game roots. Used by seedServerRegistry to backfill
* the servers table for any on-disk server directory that isn't yet tracked.
*/
export async function serverDirectories(): Promise<string[]> {
const names = new Set<string>();
for (const root of new Set([config.serversRoot, config.t6ServersRoot])) {
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.isDirectory() && rootOfServer(entry.name) === root) names.add(entry.name);
}
}
return [...names].sort();
}
/** Fleet membership is owned by the database, independent of mounted files. */
export async function allServers(): Promise<string[]> {
return listServers().map((server) => server.name);
}
/**
* Match a server name against a list of glob-like patterns (supports `*` and
* `?` and comma-separated lists).
*/
export function matchServers(all: string[], patterns: string[]): string[] {
if (patterns.length === 0 || (patterns.length === 1 && patterns[0] === '*')) {
return [...all];
}
const regexes = patterns
.flatMap((p) => p.split(',').map((s) => s.trim()).filter(Boolean))
.map((p) => new RegExp('^' + p.replace(/[.+^$()|{}\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'));
return all.filter((name) => regexes.some((r) => r.test(name)));
}
/**
* Ensure a server has a script destination. Creates it when create=true.
*/
export async function ensureDest(
server: string,
create: boolean
): Promise<{ ok: true; path: string } | { ok: false; reason: string }> {
const dest = destOf(server);
if (existsSync(dest)) {
try {
const s = await stat(dest);
if (!s.isDirectory()) return { ok: false, reason: 'dest is not a directory' };
} catch (err) {
return { ok: false, reason: `stat failed: ${(err as Error).message}` };
}
return { ok: true, path: dest };
}
if (!create) return { ok: false, reason: 'no script folder — use create=true' };
await mkdir(dest, { recursive: true });
return { ok: true, path: dest };
}
// -- server registry --
export interface ServerRow {
name: string;
komodo_type: 'deployment' | 'stack' | 'container';
komodo_name: string;
komodo_server: string | null;
container_state: string | null;
container_checked_at: string | null;
desired_state: 'running' | 'stopped';
game_port: number | null;
rcon_host: string | null;
game: string | null;
map: string | null;
last_players: number | null;
last_checked_at: string | null;
last_map: string | null;
last_gametype: string | null;
last_roster: string | null;
last_seen_at: string | null;
last_error: string | null;
last_reply_snippet: string | null;
game_mode: string | null;
map_slug: string | null;
host_dir: string | null;
config_file: string | null;
game_id: string | null;
image: string | null;
created_by: string | null;
destroyed_at: string | null;
active_since: string | null;
inactive_since: string | null;
game_started_at: string | null;
game_ended_at: string | null;
game_cycle_key: string | null;
created_at: string;
}
export async function seedServerRegistry(): Promise<number> {
// Explicit legacy import only; never run as part of application startup.
const rows = await serverDirectories();
// Under the new naming scheme Komodo entities match EC server names 1:1
// (both underscore-separated), so komodo_name = name — no transformation.
const insert = db().prepare(
`INSERT OR IGNORE INTO servers
(name, komodo_type, komodo_name, game, map, game_mode, map_slug, host_dir)
VALUES (?, 'deployment', ?, ?, ?, ?, ?, ?)`
);
// Backfill existing rows where derived columns were never set. game_mode
// and map_slug are new; older rows can still surface them from the name.
const backfill = db().prepare(
`UPDATE servers
SET game = COALESCE(game, ?),
map = COALESCE(map, ?),
game_mode = COALESCE(game_mode, ?),
map_slug = COALESCE(map_slug, ?),
host_dir = COALESCE(host_dir, ?)
WHERE name = ?`
);
let n = 0;
const tx = db().transaction((names: string[]) => {
for (const name of names) {
const parsed = parseServerName(name);
const game = parsed?.game ?? null;
const map = parsed?.mapCanonical ?? null;
const gameMode = parsed?.gameMode ?? null;
const mapSlug = parsed?.mapSlug ?? null;
const hostDir = serverDir(name);
const res = insert.run(name, name, game, map, gameMode, mapSlug, hostDir);
if (res.changes > 0) {
n++;
} else {
backfill.run(game, map, gameMode, mapSlug, hostDir, name);
}
if (parsed && !parsed.mapCanonical) {
console.warn(
`[servers] unknown map slug '${parsed.mapSlug}' for ${name} — add to the game map catalog`
);
}
}
});
tx(rows);
return n;
}
export function getServer(name: string): ServerRow | undefined {
return db().prepare('SELECT * FROM servers WHERE name = ?').get(name) as ServerRow | undefined;
}
export function listServers(): ServerRow[] {
return db().prepare('SELECT * FROM servers WHERE destroyed_at IS NULL ORDER BY name').all() as ServerRow[];
}
/** Beta ownership lasts until explicit closure, regardless of test outcome or stage. */
export function activeBetaRemovalBlocker(server: string): string | null {
const betas = db().prepare(`SELECT b.id FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
WHERE bs.server = ? AND bs.server_deleted_at IS NULL AND b.state IN ('running', 'ready-promote')
ORDER BY b.id`).all(server) as { id: number }[];
if (!betas.length) return null;
return `${server} is linked to active beta${betas.length === 1 ? '' : 's'} ${betas.map(beta => '#' + beta.id).join(', ')}. Promote or roll back the beta${betas.length === 1 ? '' : 's'} before destroying or rebuilding this server.`;
}
export function setServerField(
name: string,
field: keyof ServerRow,
value: string | number | null
): void {
const allowed: Array<keyof ServerRow> = [
'komodo_type',
'komodo_name',
'komodo_server',
'container_state',
'container_checked_at',
'game_port',
'rcon_host',
'game',
'map',
'last_players',
'last_checked_at',
'last_map',
'last_gametype',
'last_roster',
'last_seen_at',
'last_error',
'last_reply_snippet',
'game_mode',
'map_slug',
'host_dir',
'config_file',
'game_id',
'image',
'created_by',
'destroyed_at',
'active_since',
'inactive_since',
'game_started_at',
'game_ended_at',
'game_cycle_key'
];
if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`);
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name);
}
/**
* Which target rel_paths on this server are currently held by a running beta?
* Returns Map<relPath, betaId>. A target held by beta X blocks other betas AND
* blocks unrelated writes to that same target on that server.
*/
export function heldByBeta(server: string): Map<string, number> {
const rows = db()
.prepare(
`SELECT s.target_rel_path AS target, b.id AS beta_id
FROM beta_servers bsv
JOIN betas b ON b.id = bsv.beta_id
JOIN beta_scripts bs ON bs.beta_id = b.id
JOIN scripts s ON s.id = bs.script_id
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')`
)
.all(server) as Array<{ target: string; beta_id: number }>;
const map = new Map<string, number>();
for (const r of rows) map.set(r.target, r.beta_id);
return map;
}
/**
* Which running beta (if any) owns a specific target rel_path on a server?
*/
export function ownerOf(server: string, target: string): number | null {
const row = db()
.prepare(
`SELECT b.id AS beta_id
FROM beta_servers bsv
JOIN betas b ON b.id = bsv.beta_id
JOIN beta_scripts bs ON bs.beta_id = b.id
JOIN scripts s ON s.id = bs.script_id
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND s.target_rel_path = ? AND b.state IN ('running','ready-promote')
LIMIT 1`
)
.get(server, target) as { beta_id: number } | undefined;
return row?.beta_id ?? null;
}