diff --git a/.env.example b/.env.example index f152f6c..ed67dfd 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,19 @@ DEPLOY_KEEP_DAYS=7 KOMODO_ADDRESS=https://komodo.example.internal KOMODO_API_KEY= KOMODO_API_SECRET= +# Komodo server (host) that new deployments land on. Must match the exact +# name shown on the Servers page in the Komodo UI. Required to deploy new +# servers from the Fleet page. +KOMODO_TARGET_SERVER= +# Optional overrides for the plutainer defaults used when EC creates a new +# server. Change these if you fork the image or use a different docker network. +# PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest +# PLUTAINER_NETWORK=komodo_default +# PLUTAINER_GAMEFILES_VOLUME=t5_game_files +# Port range EC picks from when auto-assigning a game_port to a new server. +# Must correspond to what's actually forwarded on the Komodo host firewall. +# SERVER_PORT_MIN=28960 +# SERVER_PORT_MAX=28980 # RCON — shared across the fleet (every server uses the same rcon_password). # Host defaults to host.docker.internal so the container can reach game diff --git a/docker-compose.yml b/docker-compose.yml index 702c665..c946e2f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: KOMODO_ADDRESS: ${KOMODO_ADDRESS} KOMODO_API_KEY: ${KOMODO_API_KEY} KOMODO_API_SECRET: ${KOMODO_API_SECRET} + KOMODO_TARGET_SERVER: ${KOMODO_TARGET_SERVER} RCON_PASSWORD: ${RCON_PASSWORD} RCON_HOST: ${RCON_HOST:-host.docker.internal} RCON_TIMEOUT_MS: ${RCON_TIMEOUT_MS:-3000} diff --git a/komodo/stack.toml b/komodo/stack.toml index 7216ea8..71b8cef 100644 --- a/komodo/stack.toml +++ b/komodo/stack.toml @@ -31,6 +31,8 @@ DEPLOY_KEEP_DAYS = "7" KOMODO_ADDRESS = "https://komodo.example.internal" KOMODO_API_KEY = "" KOMODO_API_SECRET = "" +# Host new deployments land on — must match the name shown in Komodo's UI. +KOMODO_TARGET_SERVER = "" # RCON — shared password across every T5 Plutonium server in the fleet. RCON_PASSWORD = "" diff --git a/scripts/seed-fixture.ts b/scripts/seed-fixture.ts index 769f7c4..c6a73fa 100644 --- a/scripts/seed-fixture.ts +++ b/scripts/seed-fixture.ts @@ -25,7 +25,7 @@ async function main(): Promise { await mkdir(betaArchive, { recursive: true }); const rel = 'runtime/plutonium/storage/t5/raw/scripts/sp'; - for (const name of ['t5_kino_1', 't5_ascension_1', 't5_cotd_1']) { + for (const name of ['t5_zombie_theater_1', 't5_zombie_cosmodrome_1', 't5_zombie_coast_1']) { await mkdir(join(t5, name, rel), { recursive: true }); await mkdir(join(t5, name, 'logs'), { recursive: true }); } diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 91def4f..fb60eb0 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -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 diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 63f1ab3..ed858d9 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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 { diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index c995b2f..afcfca5 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -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')) ); diff --git a/src/lib/server/fleet/create.ts b/src/lib/server/fleet/create.ts new file mode 100644 index 0000000..c16d70d --- /dev/null +++ b/src/lib/server/fleet/create.ts @@ -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___` 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(); + 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 { + 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 { + 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 }; +} diff --git a/src/lib/server/fleet/map-aliases.ts b/src/lib/server/fleet/map-aliases.ts index 5d86366..223571b 100644 --- a/src/lib/server/fleet/map-aliases.ts +++ b/src/lib/server/fleet/map-aliases.ts @@ -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> = 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__) +// 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 = 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 = 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 `__` (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 }; } diff --git a/src/lib/server/fleet/servers.ts b/src/lib/server/fleet/servers.ts index 8163d4f..e684045 100644 --- a/src/lib/server/fleet/servers.ts +++ b/src/lib/server/fleet/servers.ts @@ -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 { 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 { 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); diff --git a/src/lib/server/integrations/komodo.ts b/src/lib/server/integrations/komodo.ts index 47d974a..31bdffb 100644 --- a/src/lib/server/integrations/komodo.ts +++ b/src/lib/server/integrations/komodo.ts @@ -407,6 +407,118 @@ export async function komodoInspectHealthy(serverName: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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. diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte new file mode 100644 index 0000000..77f6c9b --- /dev/null +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -0,0 +1,282 @@ + + + (open = false)}> +
+

Deploy new server

+ + {#if mapsError} +

failed to load map catalog: {mapsError}

+ {:else if !mapsLoaded} +

loading map catalog…

+ {:else} +
+ Game +
Black Ops 1 (t5)
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ will create + {previewName || '—'} + (lobby number + port auto-assigned) +
+ +
+ + +
+ {/if} + + {#if flash}

{flash}

{/if} +
+
+ + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index dcff9ce..8ad6e28 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,7 @@