Add deployment blueprints and reviewed launch recipes
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
|
||||
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { LaunchPreview } from '$lib/deployment-plan.js';
|
||||
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from './launch-recipes.js';
|
||||
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { config, destOfKind, serverDir, gameOfServer, configFileForGame } from '../config.js';
|
||||
@@ -19,10 +22,9 @@ import {
|
||||
type DeploymentEnvVar
|
||||
} from '../integrations/komodo.js';
|
||||
import { kick } from '../pipeline/worker.js';
|
||||
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
|
||||
import { MAP_CONFIG_FILE } from './server-config.js';
|
||||
import { recordScript } from './inventory.js';
|
||||
import { sha256Buffer } from './files.js';
|
||||
import { promotedArtifacts } from './promoted-artifacts.js';
|
||||
import { getVersion } from './scripts.js';
|
||||
|
||||
export type CreateServerFailure =
|
||||
| 'bad-request'
|
||||
@@ -48,6 +50,7 @@ export interface CreateServerInput {
|
||||
mapSlug: string; // 'cosmodrome'
|
||||
plutoKey?: string; // saved for this server ID when supplied
|
||||
expectedName?: string; // optional preview guard; never selects a lobby
|
||||
previewId?: string;
|
||||
createdBy?: string; // 'ui' by default
|
||||
}
|
||||
|
||||
@@ -121,11 +124,12 @@ function buildSpec(args: {
|
||||
port: number;
|
||||
hostDir: string;
|
||||
plutoKey: string;
|
||||
recipe: LaunchRecipe;
|
||||
}): CreateDeploymentSpec {
|
||||
return {
|
||||
name: args.name,
|
||||
server: config.komodo.targetServer,
|
||||
image: config.komodo.defaultImage,
|
||||
image: args.recipe.image,
|
||||
network: config.komodo.defaultNetwork,
|
||||
// Container listens on PLUTAINER_PORT (== args.port), so host and
|
||||
// container ports match — no NAT inside the bridge network.
|
||||
@@ -146,11 +150,74 @@ interface PreparedCreation {
|
||||
ok: true; name: string; port: number; plutoKey: string; mapEntry: ZombieMapEntry;
|
||||
}
|
||||
|
||||
interface PreviewRow {
|
||||
id: string; expires_at: string; server: string | null; port: number | null;
|
||||
recipe: string; blueprint_name: string | null; deployment_id: string | null;
|
||||
}
|
||||
|
||||
/** Read-only checks; a preview never reserves a slot or writes credentials. */
|
||||
export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blueprintId?: unknown; deploymentId?: unknown }): LaunchPreview {
|
||||
if (input.blueprintId !== undefined && input.deploymentId !== undefined) throw new Error('Choose one starting point.');
|
||||
let recipe: LaunchRecipe;
|
||||
let blueprintName: string | null = null;
|
||||
if (input.blueprintId !== undefined) {
|
||||
if (typeof input.blueprintId !== 'string') throw new Error('Invalid blueprint.');
|
||||
({ recipe, blueprintName } = blueprintRecipe(input.blueprintId));
|
||||
} else if (input.deploymentId !== undefined) {
|
||||
if (typeof input.deploymentId !== 'string') throw new Error('Invalid deployment.');
|
||||
const source = recipeForDeployment(input.deploymentId);
|
||||
if (!source) throw new Error('This deployment has no recorded launch recipe.');
|
||||
({ recipe, blueprintName } = source);
|
||||
} else {
|
||||
if ((input.game !== 't5' && input.game !== 't6') || typeof input.mapSlug !== 'string') throw new Error('Choose a supported game and map.');
|
||||
recipe = captureRecipe(input.game, input.mapSlug);
|
||||
}
|
||||
const lobby = nextLobbyNumber(`${recipe.game}_zombie_${recipe.mapSlug}`);
|
||||
const name = lobby === null ? null : `${recipe.game}_zombie_${recipe.mapSlug}_${lobby}`;
|
||||
const port = nextAvailablePort();
|
||||
const configured = !!(config.komodo.address && config.komodo.apiKey && config.komodo.apiSecret && config.komodo.targetServer);
|
||||
const compatible = recipe.configFile === configFileForGame(recipe.game);
|
||||
const checks: LaunchPreview['checks'] = [
|
||||
{ label: 'Komodo settings', state: configured ? 'pass' : 'blocked', detail: configured ? 'Connection settings and target host are configured.' : 'Configure Komodo credentials and a target host before launching.' },
|
||||
{ label: 'Health verification', state: config.rcon.password ? 'pass' : 'blocked', detail: config.rcon.password ? 'RCON is configured. Container, RCON, and map checks run after launch.' : 'Configure RCON_PASSWORD so the new server can be verified.' },
|
||||
{ label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All 20 lobbies for this map are occupied or reserved.' },
|
||||
{ label: 'Game port', state: port === null ? 'blocked' : 'pass', detail: port === null ? 'No free game port. Free capacity before launching.' : `${port} / UDP and TCP. Availability is checked again at launch.` },
|
||||
{ label: 'Configuration layout', state: compatible ? 'pass' : 'blocked', detail: compatible ? recipe.configSource : 'The configured filename has changed since this recipe was saved. Create a fresh recipe.' },
|
||||
{ label: 'Game files and host readiness', state: 'info', detail: 'The host must have the selected game and DLC files. This preview does not probe volumes, images, credentials, or network access.' }
|
||||
];
|
||||
const id = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
db().prepare('DELETE FROM server_launch_previews WHERE expires_at < ? AND deployment_id IS NULL').run(new Date().toISOString());
|
||||
db().prepare('INSERT INTO server_launch_previews (id, expires_at, server, port, recipe, blueprint_name) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(id, expiresAt, name, port, JSON.stringify(recipe), blueprintName);
|
||||
return { id, expiresAt, name, port, hasSavedKey: !!name && !!getServerKey(name), recipe: summarizeRecipe(recipe), blueprintName,
|
||||
checks, ready: checks.every(check => check.state !== 'blocked') };
|
||||
}
|
||||
|
||||
export function queueCreateServer(input: CreateServerInput): CreateServerResult {
|
||||
return db().transaction((): CreateServerResult => {
|
||||
const preview = input.previewId ? db().prepare('SELECT * FROM server_launch_previews WHERE id = ?').get(input.previewId) as PreviewRow | undefined : undefined;
|
||||
if (input.previewId && !preview) return { ok: false, kind: 'bad-request', message: 'Launch preview expired or was not found. Review a fresh preview.' };
|
||||
const recipe: LaunchRecipe | undefined = preview ? JSON.parse(preview.recipe) : undefined;
|
||||
if (recipe && (recipe.game !== (input.game ?? 't5') || recipe.mapSlug !== input.mapSlug || input.gameMode !== 'zombie')) {
|
||||
return { ok: false, kind: 'bad-request', message: 'The selection does not match the reviewed launch recipe.' };
|
||||
}
|
||||
// A repeated submit after a lost response returns the same operation.
|
||||
if (preview?.deployment_id) return { ok: true, name: preview.server!, port: preview.port!, deploymentId: preview.deployment_id };
|
||||
if (preview && preview.expires_at <= new Date().toISOString()) return { ok: false, kind: 'bad-request', message: 'Launch preview expired. Review a fresh preview.' };
|
||||
if (preview && (!preview.server || !preview.port || preview.server !== nextName(input) || preview.port !== nextAvailablePort())) {
|
||||
return { ok: false, kind: 'no-slot', message: 'Available lobby or port changed. Refresh the preview and review the new server key.' };
|
||||
}
|
||||
if (recipe && (recipe.configFile !== configFileForGame(recipe.game) || !config.rcon.password)) {
|
||||
return { ok: false, kind: 'unconfigured', message: 'Configuration or health-check settings changed. Refresh the preview.' };
|
||||
}
|
||||
const prepared = prepareCreation(input);
|
||||
if (!prepared.ok) return prepared;
|
||||
const frozenRecipe = recipe ?? captureRecipe(input.game ?? 't5', prepared.mapEntry.slug);
|
||||
const id = beginDeployment(prepared.name, 'create', input.createdBy);
|
||||
db().prepare('INSERT INTO server_launch_recipes (deployment_id, recipe, blueprint_name) VALUES (?, ?, ?)')
|
||||
.run(id, JSON.stringify(frozenRecipe), preview?.blueprint_name ?? null);
|
||||
if (preview) db().prepare('UPDATE server_launch_previews SET deployment_id = ? WHERE id = ?').run(id, preview.id);
|
||||
db().prepare("UPDATE server_deployments SET state = 'queued' WHERE id = ?").run(id);
|
||||
db().prepare('INSERT INTO server_creation_jobs (deployment_id, server, port, game_mode, map_slug, expected_map) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(id, prepared.name, prepared.port, input.gameMode, prepared.mapEntry.slug, prepared.mapEntry.canonical);
|
||||
@@ -159,11 +226,19 @@ export function queueCreateServer(input: CreateServerInput): CreateServerResult
|
||||
})();
|
||||
}
|
||||
|
||||
function nextName(input: CreateServerInput): string | null {
|
||||
const base = `${input.game ?? 't5'}_${input.gameMode}_${input.mapSlug}`;
|
||||
const lobby = nextLobbyNumber(base);
|
||||
return lobby === null ? null : `${base}_${lobby}`;
|
||||
}
|
||||
|
||||
// Await provisioning for internal callers; completion still requires worker health verification.
|
||||
export async function createServer(input: CreateServerInput): Promise<CreateServerResult> {
|
||||
if (creating || destroying.size > 0) return { ok: false, kind: 'no-slot', message: 'Another server operation is in progress. Please retry shortly.' };
|
||||
const queued = queueCreateServer(input);
|
||||
if (!queued.ok) return queued;
|
||||
const existing = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(queued.deploymentId) as { state: string };
|
||||
if (existing.state !== 'queued') return queued;
|
||||
return await provisionCreation(queued.deploymentId);
|
||||
}
|
||||
|
||||
@@ -218,49 +293,36 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
|
||||
async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> {
|
||||
const { name, port, plutoKey, mapEntry } = prepared;
|
||||
const game = gameOfServer(name);
|
||||
const configFile = configFileForGame(game);
|
||||
const recipe = recipeForDeployment(operation)?.recipe ?? captureRecipe(game, mapEntry.slug);
|
||||
const configFile = recipe.configFile;
|
||||
if (configFile !== configFileForGame(game)) return { ok: false, kind: 'bad-request', message: 'Server config filename changed after this launch was queued.' };
|
||||
deploymentStage(operation, 'Preparing server files');
|
||||
const hostDir = serverDir(name);
|
||||
const initialConfig = getPromotedServerConfig(game, mapEntry.canonical, mapEntry.slug);
|
||||
const mapConfig = getServerMapConfig(game, mapEntry.canonical, mapEntry.slug)!;
|
||||
const initialArtifacts = promotedArtifacts(game, mapEntry.canonical);
|
||||
|
||||
// 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);
|
||||
for (const { kind, script, version } of initialArtifacts) {
|
||||
const dest = destOfKind(name, kind);
|
||||
const target = join(dest, script.target_rel_path);
|
||||
for (const file of recipe.files) {
|
||||
const dest = destOfKind(name, file.kind);
|
||||
const target = join(dest, file.path);
|
||||
await mkdir(dirname(target), { recursive: true, mode: 0o777 });
|
||||
// Make every newly created ancestor writable by Plutainer, even
|
||||
// when the manager's umask removed write permissions at mkdir.
|
||||
for (let dir = dirname(target); dir !== join(hostDir) && dir !== dirname(dir); dir = dirname(dir)) {
|
||||
await chmod(dir, 0o777);
|
||||
}
|
||||
await writeFile(target, version.content);
|
||||
await writeFile(target, Buffer.from(file.content, 'base64'));
|
||||
await chmod(target, 0o666);
|
||||
}
|
||||
const mapConfigPath = join(destOfKind(name, 'config'), MAP_CONFIG_FILE);
|
||||
await mkdir(dirname(mapConfigPath), { recursive: true, mode: 0o777 });
|
||||
await chmod(dirname(mapConfigPath), 0o777);
|
||||
await writeFile(mapConfigPath, mapConfig);
|
||||
await chmod(mapConfigPath, 0o666);
|
||||
if (initialConfig) {
|
||||
const configPath = join(destOfKind(name, 'config'), configFile);
|
||||
await mkdir(dirname(configPath), { recursive: true, mode: 0o777 });
|
||||
await chmod(dirname(configPath), 0o777);
|
||||
await writeFile(configPath, initialConfig);
|
||||
await chmod(configPath, 0o666);
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, kind: 'fs', message: `failed to create ${hostDir}: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
// Step 2: Komodo CreateDeployment.
|
||||
deploymentStage(operation, 'Creating Komodo deployment');
|
||||
const spec = buildSpec({ game, name, port, hostDir, plutoKey });
|
||||
const spec = buildSpec({ game, name, port, hostDir, plutoKey, recipe });
|
||||
const createRes = await komodoCreateDeployment(spec);
|
||||
if (!createRes.ok) {
|
||||
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
|
||||
@@ -283,15 +345,12 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
|
||||
).run(
|
||||
name, name, game, mapEntry.canonical,
|
||||
input.gameMode, mapEntry.slug, hostDir,
|
||||
configFile, game === 't6' ? 't6zm' : 't5sp', config.komodo.defaultImage,
|
||||
configFile, game === 't6' ? 't6zm' : 't5sp', recipe.image,
|
||||
port, input.createdBy ?? 'ui'
|
||||
);
|
||||
if (initialConfig) {
|
||||
recordScript(name, configFile, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create');
|
||||
}
|
||||
recordScript(name, MAP_CONFIG_FILE, null, sha256Buffer(mapConfig), mapConfig.byteLength, 'create-map');
|
||||
for (const { script, version } of initialArtifacts) {
|
||||
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, 'create');
|
||||
for (const file of recipe.files) {
|
||||
const versionId = file.versionId && getVersion(file.versionId) ? file.versionId : null;
|
||||
recordScript(name, file.path, versionId, file.sha256, file.size, file.path === MAP_CONFIG_FILE ? 'create-map' : 'create');
|
||||
}
|
||||
})();
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user