Full server lifecycle management from Electric Cherry: pick game/mode/map/pluto key in the Fleet page, EC provisions the deployment via Komodo, allocates a port in 28960-28980, creates the 0o777 host dir, and starts the container. Destroy button tears everything down (Komodo Destroy+Delete, rm -rf host dir, transactional DB cleanup) and surfaces any partial-failure warnings inline. Same phase cuts over to the new naming scheme (t5_zombie_cosmodrome_1 etc.). Komodo entity names now match EC server names 1:1 (no more _/- transform), and the map catalog carries friendly/slug/canonical for each of the 11 t5 zombie maps. Server rows gain game_mode, map_slug, host_dir, config_file, game_id, image, created_by, and destroyed_at columns for provisioning + audit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
216 lines
7.3 KiB
TypeScript
216 lines
7.3 KiB
TypeScript
import { readdir, mkdir, stat } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { config, destOf } from '../config.js';
|
|
import { db } from '../db/index.js';
|
|
import { parseServerName } from './map-aliases.js';
|
|
|
|
/**
|
|
* All server folders under SERVERS_ROOT. Used by seedServerRegistry to backfill
|
|
* the servers table for any on-disk server directory that isn't yet tracked.
|
|
*/
|
|
export async function allServers(): Promise<string[]> {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(config.serversRoot, { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
return entries
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => e.name)
|
|
.sort();
|
|
}
|
|
|
|
/**
|
|
* 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 an sp/ 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 sp 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;
|
|
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;
|
|
created_at: string;
|
|
}
|
|
|
|
export async function seedServerRegistry(): Promise<number> {
|
|
const rows = await allServers();
|
|
// 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 = `${config.serversRoot}/${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 T5_ZOMBIE_MAPS`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
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 ORDER BY name').all() as ServerRow[];
|
|
}
|
|
|
|
export function setServerField(
|
|
name: string,
|
|
field: keyof ServerRow,
|
|
value: string | number | null
|
|
): void {
|
|
const allowed: Array<keyof ServerRow> = [
|
|
'komodo_type',
|
|
'komodo_name',
|
|
'komodo_server',
|
|
'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'
|
|
];
|
|
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 b.state = 'running'`
|
|
)
|
|
.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 s.target_rel_path = ? AND b.state = 'running'
|
|
LIMIT 1`
|
|
)
|
|
.get(server, target) as { beta_id: number } | undefined;
|
|
return row?.beta_id ?? null;
|
|
}
|