Add deploy + destroy server flow; migrate to t5_<mode>_<slug>_<n> naming

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>
This commit is contained in:
2026-09-13 18:38:42 -04:00
co-authored by Claude Opus 4.7
parent 8c15cae80d
commit 13a29e5ace
25 changed files with 1103 additions and 123 deletions
+20 -1
View File
@@ -33,7 +33,26 @@ export const config = {
komodo: {
address: env.KOMODO_ADDRESS ?? '',
apiKey: env.KOMODO_API_KEY ?? '',
apiSecret: env.KOMODO_API_SECRET ?? ''
apiSecret: env.KOMODO_API_SECRET ?? '',
// Komodo server (host) new deployments land on. Required for the
// deploy flow — Komodo needs to know which machine to schedule the
// container onto. Look up the exact name in Komodo's UI (Servers page).
targetServer: env.KOMODO_TARGET_SERVER ?? '',
// Default image + config for freshly-provisioned t5 zombie servers.
// Overridable per-deploy in the UI later if we ever need to.
defaultImage: env.PLUTAINER_IMAGE ?? 'ghcr.io/ayymoss/plutainer:latest',
defaultNetwork: env.PLUTAINER_NETWORK ?? 'komodo_default',
// Named docker volume that holds the read-only game files layer.
gameFilesVolume: env.PLUTAINER_GAMEFILES_VOLUME ?? 't5_game_files'
},
// Shared across every newly-provisioned server. Kept in sync with the
// fleet's RCON_PASSWORD (rcon.password) so freshly-created servers respond
// to the same rcon secret the sweep already uses.
// Port range Electric Cherry auto-allocates from when creating a new
// deployment. Update this when the host's firewall opens more.
portRange: {
min: num('SERVER_PORT_MIN', 28960),
max: num('SERVER_PORT_MAX', 28980)
},
rcon: {
// Host every RCON packet is sent to. Defaults to the Docker
+10 -1
View File
@@ -28,7 +28,16 @@ const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' },
// Script-scope columns — static per server, populated via server-edit UI.
{ name: 'game', ddl: 'ALTER TABLE servers ADD COLUMN game TEXT' },
{ name: 'map', ddl: 'ALTER TABLE servers ADD COLUMN map TEXT' }
{ name: 'map', ddl: 'ALTER TABLE servers ADD COLUMN map TEXT' },
// Provisioning columns — populated when EC creates the deployment.
{ name: 'game_mode', ddl: 'ALTER TABLE servers ADD COLUMN game_mode TEXT' },
{ name: 'map_slug', ddl: 'ALTER TABLE servers ADD COLUMN map_slug TEXT' },
{ name: 'host_dir', ddl: 'ALTER TABLE servers ADD COLUMN host_dir TEXT' },
{ name: 'config_file', ddl: 'ALTER TABLE servers ADD COLUMN config_file TEXT' },
{ name: 'game_id', ddl: 'ALTER TABLE servers ADD COLUMN game_id TEXT' },
{ name: 'image', ddl: 'ALTER TABLE servers ADD COLUMN image TEXT' },
{ name: 'created_by', ddl: 'ALTER TABLE servers ADD COLUMN created_by TEXT' },
{ name: 'destroyed_at', ddl: 'ALTER TABLE servers ADD COLUMN destroyed_at TEXT' }
];
function migrateServers(handle: Database.Database): void {
+9
View File
@@ -23,6 +23,15 @@ CREATE TABLE IF NOT EXISTS servers (
last_seen_at TEXT, -- ISO ts of last successful RCON reply
last_error TEXT, -- last RCON failure reason (cleared on success)
last_reply_snippet TEXT, -- hex+ASCII of last bad-reply UDP payload
-- Provisioning fields — set when Electric Cherry creates the deployment.
game_mode TEXT, -- 'zombie' | 'mp' | 'sp' (currently zombie only)
map_slug TEXT, -- 'cosmodrome' — short slug used in server name
host_dir TEXT, -- absolute host path bind-mounted into the container
config_file TEXT, -- e.g. 'dedicated_sp.cfg' (PLUTAINER_CONFIG_FILE)
game_id TEXT, -- e.g. 't5sp' (PLUTAINER_GAME)
image TEXT, -- e.g. 'ghcr.io/ayymoss/plutainer:latest'
created_by TEXT, -- 'ui' | operator identifier
destroyed_at TEXT, -- ISO ts when tombstoned (row usually deleted; kept as safety net)
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
+267
View File
@@ -0,0 +1,267 @@
import { mkdir, chmod, rm } from 'node:fs/promises';
import { config } from '../config.js';
import { db } from '../db/index.js';
import { getServer } from './servers.js';
import { t5ZombieMapBySlug, type GameMode } from './map-aliases.js';
import {
komodoCreateDeployment,
komodoDeploy,
komodoDestroyDeployment,
komodoDeleteDeployment,
type CreateDeploymentSpec,
type DeploymentEnvVar
} from '../integrations/komodo.js';
import { kick } from '../pipeline/worker.js';
// Currently every t5 zombie server uses the same config file + game id. If we
// ever add MP or SP, split these based on mode.
const T5_ZOMBIE_CONFIG_FILE = 'dedicated_sp.cfg';
const T5_ZOMBIE_GAME_ID = 't5sp';
const CONTAINER_PORT = 28960;
export type CreateServerFailure =
| 'unconfigured' // Komodo creds / target server missing
| 'unknown-map' // slug isn't in the catalog
| 'no-slot' // all lobby slots for this map are taken
| 'no-port' // port range exhausted
| 'fs' // couldn't create host dir
| 'komodo' // Komodo CreateDeployment / Deploy rejected
| 'db'; // shouldn't happen, but wraps INSERT failures
export type CreateServerResult =
| { ok: true; name: string; port: number }
| { ok: false; kind: CreateServerFailure; message: string };
export type DestroyServerResult =
| { ok: true; warnings: string[] }
| { ok: false; kind: 'not-found' | 'komodo' | 'fs' | 'db'; message: string; warnings: string[] };
export interface CreateServerInput {
gameMode: GameMode; // 'zombie' — only value supported today
mapSlug: string; // 'cosmodrome'
plutoKey: string; // required; never persisted
createdBy?: string; // 'ui' by default
}
/**
* Find the lowest lobby number `n` such that `t5_<mode>_<slug>_<n>` isn't in
* the servers table. Caps at 20 to stay inside the port range — with 20 total
* ports across the whole fleet, no one map can hold more than that.
*/
function nextLobbyNumber(baseName: string): number | null {
const rows = db()
.prepare('SELECT name FROM servers WHERE name LIKE ?')
.all(`${baseName}_%`) as Array<{ name: string }>;
const taken = new Set<number>();
const prefix = `${baseName}_`;
for (const r of rows) {
const tail = r.name.slice(prefix.length);
if (/^\d+$/.test(tail)) taken.add(Number.parseInt(tail, 10));
}
for (let n = 1; n <= 20; n++) {
if (!taken.has(n)) return n;
}
return null;
}
/**
* Lowest unused port in the configured range. Reads every server's game_port
* from the DB (which EC controls end-to-end under the new naming scheme, so
* the DB is authoritative — no need to ask Komodo).
*/
function nextAvailablePort(): number | null {
const rows = db()
.prepare('SELECT game_port FROM servers WHERE game_port IS NOT NULL')
.all() as Array<{ game_port: number }>;
const taken = new Set(rows.map((r) => r.game_port));
for (let p = config.portRange.min; p <= config.portRange.max; p++) {
if (!taken.has(p)) return p;
}
return null;
}
function buildEnv(args: {
name: string;
port: number;
plutoKey: string;
}): DeploymentEnvVar[] {
return [
{ variable: 'PLUTAINER_SERVER_NAME', value: args.name },
{ variable: 'PLUTAINER_PORT', value: String(args.port) },
{ variable: 'PLUTAINER_GAME', value: T5_ZOMBIE_GAME_ID },
{ variable: 'PLUTAINER_CONFIG_FILE', value: T5_ZOMBIE_CONFIG_FILE },
{ variable: 'PLUTO_SERVER_KEY', value: args.plutoKey },
{ variable: 'PLUTAINER_RCON_PASSWORD', value: config.rcon.password }
];
}
function buildSpec(args: {
name: string;
port: number;
hostDir: string;
plutoKey: string;
}): CreateDeploymentSpec {
return {
name: args.name,
server: config.komodo.targetServer,
image: config.komodo.defaultImage,
network: config.komodo.defaultNetwork,
ports: [
`${args.port}:${CONTAINER_PORT}/udp`,
`${args.port}:${CONTAINER_PORT}/tcp`
],
volumes: [
`${args.hostDir}:/home/plutainer/app`,
`${config.komodo.gameFilesVolume}:/home/plutainer/gamefiles:ro`
],
environment: buildEnv(args)
};
}
export async function createServer(input: CreateServerInput): Promise<CreateServerResult> {
if (input.gameMode !== 'zombie') {
return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` };
}
if (!config.komodo.address || !config.komodo.apiKey || !config.komodo.apiSecret) {
return { ok: false, kind: 'unconfigured', message: 'KOMODO_ADDRESS / KOMODO_API_KEY / KOMODO_API_SECRET must be set' };
}
if (!config.komodo.targetServer) {
return { ok: false, kind: 'unconfigured', message: 'KOMODO_TARGET_SERVER must name the Komodo host that runs new deployments' };
}
const mapEntry = t5ZombieMapBySlug(input.mapSlug);
if (!mapEntry) {
return { ok: false, kind: 'unknown-map', message: `no t5 zombie map with slug '${input.mapSlug}'` };
}
const baseName = `t5_${input.gameMode}_${mapEntry.slug}`;
const lobby = nextLobbyNumber(baseName);
if (lobby === null) {
return { ok: false, kind: 'no-slot', message: `all lobby slots (1..20) for ${baseName} are taken` };
}
const name = `${baseName}_${lobby}`;
const port = nextAvailablePort();
if (port === null) {
return {
ok: false,
kind: 'no-port',
message: `no free port in ${config.portRange.min}..${config.portRange.max} — destroy an existing server first`
};
}
const hostDir = `${config.serversRoot}/${name}`;
// Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly
// after — plutainer expects to be able to write to /home/plutainer/app.
try {
await mkdir(hostDir, { recursive: true, mode: 0o777 });
await chmod(hostDir, 0o777);
} catch (err) {
return { ok: false, kind: 'fs', message: `failed to create ${hostDir}: ${(err as Error).message}` };
}
// Step 2: Komodo CreateDeployment.
const spec = buildSpec({ name, port, hostDir, plutoKey: input.plutoKey });
const createRes = await komodoCreateDeployment(spec);
if (!createRes.ok) {
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
return { ok: false, kind: 'komodo', message: `CreateDeployment: ${createRes.message}` };
}
// Step 3: DB row — insert *before* execute/Deploy so the sweep sees the
// server immediately even if Deploy hangs.
try {
db().prepare(
`INSERT INTO servers
(name, komodo_type, komodo_name, game, map,
game_mode, map_slug, host_dir, config_file, game_id, image,
game_port, created_by)
VALUES
(?, 'deployment', ?, 't5', ?,
?, ?, ?, ?, ?, ?,
?, ?)`
).run(
name, name, mapEntry.canonical,
input.gameMode, mapEntry.slug, hostDir,
T5_ZOMBIE_CONFIG_FILE, T5_ZOMBIE_GAME_ID, config.komodo.defaultImage,
port, input.createdBy ?? 'ui'
);
} catch (err) {
// Roll back Komodo + FS if the DB insert fails (very unlikely — most
// often a race where the same name got inserted twice).
await komodoDestroyDeployment(name).catch(() => {});
await komodoDeleteDeployment(name).catch(() => {});
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
return { ok: false, kind: 'db', message: `failed to insert server row: ${(err as Error).message}` };
}
// Step 4: execute/Deploy — actually start the container.
const deployRes = await komodoDeploy(name);
if (!deployRes.ok) {
// Container never started — undo everything so retrying is clean.
await komodoDeleteDeployment(name).catch(() => {});
db().prepare('DELETE FROM servers WHERE name = ?').run(name);
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
return { ok: false, kind: 'komodo', message: `Deploy: ${deployRes.message}` };
}
// Step 5: kick the sweep so RCON status polling for this server starts
// now instead of on the next 15s tick.
void kick().catch((err) => console.error('[createServer] post-create kick failed:', err));
return { ok: true, name, port };
}
export async function destroyServer(name: string): Promise<DestroyServerResult> {
const row = getServer(name);
if (!row) {
return { ok: false, kind: 'not-found', message: `no server named '${name}'`, warnings: [] };
}
const warnings: string[] = [];
// 1 + 2: Komodo teardown. Try both endpoints even if one fails — the
// deployment entity might already be gone while the container is still up.
const destroyRes = await komodoDestroyDeployment(name);
if (!destroyRes.ok) {
warnings.push(`DestroyDeployment failed: ${destroyRes.message}`);
}
const deleteRes = await komodoDeleteDeployment(name);
if (!deleteRes.ok) {
warnings.push(`DeleteDeployment failed: ${deleteRes.message}`);
}
// 3: host directory. Fall back to derived path if host_dir wasn't set on
// legacy rows.
const hostDir = row.host_dir ?? `${config.serversRoot}/${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.
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 servers WHERE name = ?').run(n);
});
tx(name);
} catch (err) {
return {
ok: false,
kind: 'db',
message: `failed to remove DB rows for ${name}: ${(err as Error).message}`,
warnings
};
}
return { ok: true, warnings };
}
+67 -42
View File
@@ -1,51 +1,66 @@
// T5 (Black Ops 1) zombies maps: friendly aliases → canonical mapname the game
// actually reports via RCON. Server names in the fleet encode the friendly
// alias (t5_ascension_1 → 'ascension') so we can derive game + map from the
// folder name instead of asking the operator to fill it in.
// T5 (Black Ops 1) zombies map catalog. Server names encode `slug` so we can
// derive game, mode, and map from the folder name (t5_zombie_cosmodrome_1 →
// game 't5', mode 'zombie', slug 'cosmodrome', canonical 'zombie_cosmodrome').
//
// Add rows here when new servers land with unrecognized aliases; the parser
// logs unknowns so they're easy to spot in worker startup.
export const T5_MAP_ALIASES: Readonly<Record<string, string>> = Object.freeze({
kino: 'zombie_theater', // Kino Der Toten
five: 'zombie_pentagon', // Five
pentagon: 'zombie_pentagon',
doa: 'zombietron', // Dead Ops Arcade
deadops: 'zombietron',
ascension: 'zombie_cosmodrome',
cotd: 'zombie_coast', // Call Of The Dead
coast: 'zombie_coast',
shangri: 'zombie_temple', // Shangri-La
shangrila: 'zombie_temple',
temple: 'zombie_temple',
moon: 'zombie_moon',
nacht: 'zombie_cod5_prototype', // Nacht Der Untoten (WaW map, playable in T5 via Rezurrection)
verruckt: 'zombie_cod5_asylum',
asylum: 'zombie_cod5_asylum',
shi: 'zombie_cod5_sumpf', // Shi No Numa
shinonuma: 'zombie_cod5_sumpf',
sumpf: 'zombie_cod5_sumpf',
riese: 'zombie_cod5_factory', // Der Riese
derriese: 'zombie_cod5_factory',
factory: 'zombie_cod5_factory'
});
// Fields:
// friendly — human name shown in the deploy UI dropdown
// slug — segment used in server name (t5_zombie_<slug>_<n>)
// canonical — actual mapname Pluto/RCON emits (matches T5 script scoping)
//
// Add rows when new maps land; keep slugs short so paths stay readable.
export interface T5MapEntry {
friendly: string;
slug: string;
canonical: string;
}
export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([
{ friendly: 'Kino Der Toten', slug: 'theater', canonical: 'zombie_theater' },
{ friendly: 'Ascension', slug: 'cosmodrome', canonical: 'zombie_cosmodrome' },
{ friendly: 'Call of the Dead', slug: 'coast', canonical: 'zombie_coast' },
{ friendly: 'Shangri-La', slug: 'temple', canonical: 'zombie_temple' },
{ friendly: 'Moon', slug: 'moon', canonical: 'zombie_moon' },
{ friendly: 'Five', slug: 'pentagon', canonical: 'zombie_pentagon' },
{ friendly: 'Dead Ops Arcade', slug: 'tron', canonical: 'zombietron' },
{ friendly: 'Nacht Der Untoten', slug: 'nacht', canonical: 'zombie_cod5_prototype' },
{ friendly: 'Verrückt', slug: 'verruckt', canonical: 'zombie_cod5_asylum' },
{ friendly: 'Shi No Numa', slug: 'shinonuma', canonical: 'zombie_cod5_sumpf' },
{ friendly: 'Der Riese', slug: 'derriese', canonical: 'zombie_cod5_factory' }
]);
const BY_SLUG: ReadonlyMap<string, T5MapEntry> = new Map(
T5_ZOMBIE_MAPS.map((m) => [m.slug, m])
);
export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined {
return BY_SLUG.get(slug.toLowerCase());
}
export type GameMode = 'zombie' | 'mp' | 'sp';
export interface ParsedServerName {
game: string;
mapAlias: string;
mapCanonical: string | null; // null when the alias isn't in T5_MAP_ALIASES
game: string; // 't5'
gameMode: GameMode | null; // 'zombie' — null when the second segment isn't a known mode
mapSlug: string; // 'cosmodrome' — joined without separators for lookup
mapCanonical: string | null;// 'zombie_cosmodrome' — null when slug not in catalog
lobby: number | null;
}
const KNOWN_MODES: ReadonlySet<GameMode> = new Set(['zombie', 'mp', 'sp']);
/**
* `t5_ascension_1` → { game:'t5', mapAlias:'ascension', mapCanonical:'zombie_cosmodrome', lobby:1 }
* `t5_zombie_cosmodrome_1` → { game:'t5', gameMode:'zombie', mapSlug:'cosmodrome', mapCanonical:'zombie_cosmodrome', lobby:1 }
*
* Rules:
* - First segment = game (e.g. 't5')
* - First segment = game
* - Second segment = mode when it matches a known mode (zombie|mp|sp); otherwise the whole
* tail is treated as legacy `<game>_<slug>_<n>` (mode null, slug from segments 1..-1)
* - Trailing all-digits segment = lobby number
* - Everything in between = map alias (joined without separators)
* - Everything in between = map slug (joined without separators for the catalog lookup)
*
* Multi-word aliases work because we strip underscores when looking up
* (e.g. `t5_shi_no_numa_1` → alias 'shinonuma').
* Multi-word slugs still work because we strip underscores when looking up
* (e.g. `t5_zombie_shi_no_numa_1` → slug 'shinonuma').
*/
export function parseServerName(name: string): ParsedServerName | null {
const parts = name.split('_').filter(Boolean);
@@ -53,9 +68,19 @@ export function parseServerName(name: string): ParsedServerName | null {
const game = parts[0];
const lastIsLobby = /^\d+$/.test(parts[parts.length - 1]);
const lobby = lastIsLobby ? Number.parseInt(parts[parts.length - 1], 10) : null;
const mapParts = lastIsLobby ? parts.slice(1, -1) : parts.slice(1);
if (mapParts.length === 0) return null;
const mapAlias = mapParts.join('').toLowerCase();
const mapCanonical = T5_MAP_ALIASES[mapAlias] ?? null;
return { game, mapAlias, mapCanonical, lobby };
const tail = lastIsLobby ? parts.slice(1, -1) : parts.slice(1);
if (tail.length === 0) return null;
let gameMode: GameMode | null = null;
let slugParts = tail;
const maybeMode = tail[0].toLowerCase();
if (KNOWN_MODES.has(maybeMode as GameMode)) {
gameMode = maybeMode as GameMode;
slugParts = tail.slice(1);
}
if (slugParts.length === 0) return null;
const mapSlug = slugParts.join('').toLowerCase();
const mapCanonical = BY_SLUG.get(mapSlug)?.canonical ?? null;
return { game, gameMode, mapSlug, mapCanonical, lobby };
}
+39 -8
View File
@@ -76,18 +76,36 @@ export interface ServerRow {
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) VALUES (?, ?, ?, ?, ?)'
`INSERT OR IGNORE INTO servers
(name, komodo_type, komodo_name, game, map, game_mode, map_slug, host_dir)
VALUES (?, 'deployment', ?, ?, ?, ?, ?, ?)`
);
// Also backfill existing rows where game/map were never set (upgrade path
// from before name-based auto-derivation existed).
// 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, ?) WHERE name = ? AND (game IS NULL OR map IS NULL)'
`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[]) => {
@@ -95,14 +113,19 @@ export async function seedServerRegistry(): Promise<number> {
const parsed = parseServerName(name);
const game = parsed?.game ?? null;
const map = parsed?.mapCanonical ?? null;
const res = insert.run(name, 'deployment', name.replace(/_/g, '-'), game, map);
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, name);
backfill.run(game, map, gameMode, mapSlug, hostDir, name);
}
if (parsed && !parsed.mapCanonical) {
console.warn(`[servers] unknown map alias '${parsed.mapAlias}' for ${name} — add to T5_MAP_ALIASES`);
console.warn(
`[servers] unknown map slug '${parsed.mapSlug}' for ${name} — add to T5_ZOMBIE_MAPS`
);
}
}
});
@@ -138,7 +161,15 @@ export function setServerField(
'last_roster',
'last_seen_at',
'last_error',
'last_reply_snippet'
'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);
+112
View File
@@ -407,6 +407,118 @@ export async function komodoInspectHealthy(serverName: string): Promise<KomodoHe
};
}
// ---------- Write / Execute (create + destroy deployments) ----------
export type KomodoWriteResult =
| { ok: true }
| { ok: false; kind: 'unconfigured' | 'http-error' | 'unreachable'; message: string; url?: string };
/**
* Env var pairs get flattened into a single newline-delimited string, matching
* Komodo's DeploymentConfig.environment shape.
*/
export interface DeploymentEnvVar {
variable: string;
value: string;
}
export interface CreateDeploymentSpec {
name: string; // Komodo deployment name (== EC server name)
server: string; // Komodo server (host) id or name
image: string; // 'ghcr.io/ayymoss/plutainer:latest'
network?: string; // 'komodo_default'
ports: string[]; // ['28960:28960/udp', '28960:28960/tcp']
volumes: string[]; // ['/host/path:/container/path', 'namedvol:/container/path:ro']
environment: DeploymentEnvVar[];
}
async function postAndClassify(path: string, body: unknown): Promise<KomodoWriteResult> {
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
let res: Response;
try {
res = await komodoCall(path, body);
} catch (err) {
return { ok: false, kind: 'unreachable', message: describeFetchError(err, url), url };
}
if (!res.ok) {
const snippet = await res.text().catch(() => '');
return {
ok: false,
kind: 'http-error',
message: `HTTP ${res.status}${snippet ? ` — ${truncate(snippet)}` : ''}`,
url
};
}
// Some Komodo endpoints return `{ success: false }` with a 200. Treat that
// as a failure so callers can surface the real problem.
try {
const json = await res.json();
if (json && typeof json === 'object' && 'success' in json && json.success === false) {
const msg = typeof (json as { message?: unknown }).message === 'string'
? (json as { message: string }).message
: 'komodo returned success=false';
return { ok: false, kind: 'http-error', message: msg, url };
}
} catch {
// Non-JSON body — trust the 2xx.
}
return { ok: true };
}
/**
* Create a new deployment entity in Komodo. Doesn't start the container —
* caller must follow up with `komodoDeploy(name)` to actually run it.
*
* The config shape here targets Komodo's DeploymentConfig: newline-delimited
* ports/volumes strings and an environment array with `{ variable, value }`
* entries. If Komodo rejects the payload, the error surfaces in the http-error
* message so we can iterate on the shape without guessing.
*/
export async function komodoCreateDeployment(spec: CreateDeploymentSpec): Promise<KomodoWriteResult> {
if (!komodoConfigured()) {
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
}
const body = {
name: spec.name,
config: {
server_id: spec.server,
image: { type: 'Image', params: { image: spec.image } },
network: spec.network ?? 'komodo_default',
ports: spec.ports.join('\n'),
volumes: spec.volumes.join('\n'),
environment: spec.environment,
restart: 'unless-stopped'
}
};
return postAndClassify('write/CreateDeployment', body);
}
/** Start (or restart) the container backing a Komodo deployment. */
export async function komodoDeploy(deployment: string): Promise<KomodoWriteResult> {
if (!komodoConfigured()) {
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
}
return postAndClassify('execute/Deploy', { deployment });
}
/** Stop + remove the container backing a Komodo deployment. Entity remains. */
export async function komodoDestroyDeployment(deployment: string): Promise<KomodoWriteResult> {
if (!komodoConfigured()) {
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
}
return postAndClassify('execute/DestroyDeployment', { deployment });
}
/** Remove the deployment entity from Komodo entirely. */
export async function komodoDeleteDeployment(deployment: string): Promise<KomodoWriteResult> {
if (!komodoConfigured()) {
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
}
// Komodo's DeleteDeployment historically accepts `{ id }` where id may be
// the name; supply both to be forgiving.
return postAndClassify('write/DeleteDeployment', { id: deployment, name: deployment });
}
// ---------- Extractor ----------
// Property names Komodo/Docker use for the collection of port mappings.
@@ -0,0 +1,282 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
interface T5MapEntry {
friendly: string;
slug: string;
canonical: string;
}
let { open = $bindable(false) }: { open?: boolean } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let maps = $state<T5MapEntry[]>([]);
let mapsError = $state<string | null>(null);
let mapsLoaded = $state(false);
let gameMode = $state<'zombie'>('zombie');
let mapSlug = $state<string>('');
let plutoKey = $state('');
let deploying = $state(false);
let flash = $state<string | null>(null);
onMount(async () => {
try {
const res = await fetch('/api/maps');
const body = (await res.json()) as { t5Zombie: T5MapEntry[] };
maps = body.t5Zombie;
if (maps.length > 0) mapSlug = maps[0].slug;
} catch (err) {
mapsError = (err as Error).message;
} finally {
mapsLoaded = true;
}
});
// Track prior open value manually — using $effect with `open` triggers on
// every render and repeatedly re-shows the modal.
let wasOpen = $state(false);
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) {
reset();
dialog.showModal();
} else if (!open && wasOpen) {
if (dialog.open) dialog.close();
}
wasOpen = open;
});
function reset(): void {
plutoKey = '';
flash = null;
deploying = false;
if (maps.length > 0 && !maps.some((m) => m.slug === mapSlug)) mapSlug = maps[0].slug;
}
function close(): void {
open = false;
}
// Click-outside-to-close (native <dialog> only supports Esc).
function onDialogClick(e: MouseEvent): void {
if (!dialog) return;
const rect = dialog.getBoundingClientRect();
const inDialog =
e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom;
if (!inDialog) close();
}
let selectedMap = $derived(maps.find((m) => m.slug === mapSlug));
let previewName = $derived(
selectedMap ? `t5_${gameMode}_${selectedMap.slug}_?` : ''
);
async function submit(e: SubmitEvent): Promise<void> {
e.preventDefault();
if (deploying) return;
if (!selectedMap) {
flash = 'pick a map';
return;
}
if (plutoKey.trim().length === 0) {
flash = 'pluto server key is required';
return;
}
deploying = true;
flash = null;
try {
const res = await fetch('/api/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameMode, mapSlug: selectedMap.slug, plutoKey })
});
const body = (await res.json()) as {
ok: boolean;
name?: string;
port?: number;
error?: string;
kind?: string;
};
if (!body.ok || !body.name) {
flash = body.error ?? 'deploy failed';
deploying = false;
return;
}
close();
await goto(`/servers/${encodeURIComponent(body.name)}`);
} catch (err) {
flash = (err as Error).message;
deploying = false;
}
}
</script>
<dialog bind:this={dialog} onclick={onDialogClick} onclose={() => (open = false)}>
<form class="dlg" onsubmit={submit}>
<h3>Deploy new server</h3>
{#if mapsError}
<p class="flash">failed to load map catalog: {mapsError}</p>
{:else if !mapsLoaded}
<p class="hint">loading map catalog…</p>
{:else}
<div class="row">
<span class="row-label">Game</span>
<div class="readonly mono">Black Ops 1 (t5)</div>
</div>
<div class="row">
<label for="mode">Mode</label>
<select id="mode" bind:value={gameMode} disabled={deploying}>
<option value="zombie">Zombies</option>
</select>
</div>
<div class="row">
<label for="map">Map</label>
<select id="map" bind:value={mapSlug} disabled={deploying}>
{#each maps as m (m.slug)}
<option value={m.slug}>{m.friendly}</option>
{/each}
</select>
</div>
<div class="row">
<label for="key">Pluto server key</label>
<input
id="key"
type="password"
bind:value={plutoKey}
placeholder="paste each deploy — not stored"
autocomplete="off"
disabled={deploying}
/>
</div>
<div class="preview">
<span class="k">will create</span>
<span class="v mono">{previewName || '—'}</span>
<span class="sub">(lobby number + port auto-assigned)</span>
</div>
<div class="dlg-actions">
<button type="button" class="secondary" onclick={close} disabled={deploying}>cancel</button>
<button type="submit" class="primary" disabled={deploying || !selectedMap}>
{deploying ? 'deploying…' : 'deploy server'}
</button>
</div>
{/if}
{#if flash}<p class="flash">{flash}</p>{/if}
</form>
</dialog>
<style>
dialog {
background: var(--bg-nav);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 0;
max-width: 520px;
width: calc(100vw - 40px);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
.dlg { display: flex; flex-direction: column; gap: 14px; padding: var(--pad-panel); }
h3 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--ink-300);
}
.row {
display: grid;
grid-template-columns: 130px 1fr;
gap: 12px;
align-items: center;
}
.row label, .row-label {
color: var(--ink-500);
font-family: var(--font-body);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1px;
}
.row select, .row input {
background: var(--bg-page);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 10px 12px;
font-family: var(--font-mono);
font-size: var(--text-mono);
width: 100%;
}
.row input:focus, .row select:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--accent-glow);
}
.readonly {
color: var(--ink-300);
padding: 10px 0;
}
.preview {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px;
padding: 12px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--r-sm);
font-size: var(--text-mono);
}
.preview .k {
color: var(--ink-500);
font-family: var(--font-body);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1px;
}
.preview .v { color: var(--cream); }
.preview .sub {
color: var(--ink-500);
font-family: var(--font-body);
font-size: var(--text-xs);
}
.mono { font-family: var(--font-mono); }
.dlg-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 6px; }
button.primary {
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
border: 1px solid var(--blue-400);
color: var(--cream);
border-radius: var(--r-sm);
padding: 10px 16px;
font-family: var(--font-body);
font-weight: 600;
cursor: pointer;
}
button.primary:disabled { opacity: 0.4; cursor: default; }
button.secondary {
background: var(--bg-card);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 10px 16px;
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
}
button.secondary:hover:not(:disabled) { border-color: var(--accent); box-shadow: var(--accent-glow); }
button.secondary:disabled { opacity: 0.4; cursor: default; }
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin: 0; }
.hint { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono); margin: 0; }
</style>