Files
electric-cherry/src/lib/server/fleet/create.ts
T

318 lines
13 KiB
TypeScript

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,
komodoInspectHealthy,
komodoEnsureTag,
komodoUpdateTagsOnResource,
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';
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,
// Container listens on PLUTAINER_PORT (== args.port), so host and
// container ports match — no NAT inside the bridge network.
ports: [
`${args.port}:${args.port}/udp`,
`${args.port}:${args.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: apply Komodo tags so operators can filter the deployment list
// by game/map. Best-effort — a tag failure doesn't roll back the server;
// the deployment is fully functional without cosmetic tags. Fires in
// parallel to keep the create latency down.
void applyDeploymentTags(name, 't5', mapEntry.canonical);
// Step 5: 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 6: 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 };
}
/**
* Ensure `game` and `map` tags exist in Komodo, then attach both to the given
* deployment. Purely cosmetic — any failure here logs and returns; the caller
* treats it as fire-and-forget.
*/
async function applyDeploymentTags(deployment: string, gameTagName: string, mapTagName: string): Promise<void> {
try {
const [gameTag, mapTag] = await Promise.all([
komodoEnsureTag(gameTagName),
komodoEnsureTag(mapTagName)
]);
const tagIds: string[] = [];
if (gameTag.ok) tagIds.push(gameTag.id);
else console.warn(`[createServer] tag "${gameTagName}": ${gameTag.message}`);
if (mapTag.ok) tagIds.push(mapTag.id);
else console.warn(`[createServer] tag "${mapTagName}": ${mapTag.message}`);
if (tagIds.length === 0) return;
const res = await komodoUpdateTagsOnResource('Deployment', deployment, tagIds);
if (!res.ok) {
console.warn(`[createServer] UpdateResourceMeta (tags) for ${deployment}: ${res.message}`);
}
} catch (err) {
console.warn(`[createServer] tag application for ${deployment} threw:`, err);
}
}
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: stop + remove the container. execute/DestroyDeployment queues the
// teardown; the response comes back before the container is actually gone.
const destroyRes = await komodoDestroyDeployment(name);
if (!destroyRes.ok) {
warnings.push(`DestroyDeployment failed: ${destroyRes.message}`);
}
// 2: wait for the container to actually disappear. Komodo refuses to
// DeleteDeployment while a container still exists for it, so poll the
// health probe until it stops reporting a running state. `ok:false` here
// is the *success* signal — it means the container is no longer inspectable.
const pollDeadline = Date.now() + 30_000;
while (Date.now() < pollDeadline) {
const health = await komodoInspectHealthy(name);
if (!health.ok) break;
await new Promise((resolve) => setTimeout(resolve, 1500));
}
// 3: remove the deployment entity itself.
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 };
}