diff --git a/README.md b/README.md index e9d8675..8d4c022 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,17 @@ directly. No token, no session, no auth freeze. If Komodo or RCON aren't configured, the sweep marks pending rows `manual` so nothing hangs forever. +## Deployment blueprints and launch previews + +Deployments now includes a blueprint library and a two-step launch composer. +Preview a promoted game/map setup, save its managed files as a reusable blueprint, +or launch again from a recorded deployment. Previews check configuration and +available capacity; every queued creation retains its own launch recipe. +The progress page explains waiting conditions, shows elapsed time, and can resume +health verification of an interrupted creation when its original server remains. +See [Deployment blueprints](docs/deployment-blueprints.md) for the workflow, +reproducibility boundaries, and API details. + ## Saved server keys Use **Keys** to add, replace, or remove a Plutonium key for an exact diff --git a/docs/deployment-blueprints.md b/docs/deployment-blueprints.md new file mode 100644 index 0000000..0cef436 --- /dev/null +++ b/docs/deployment-blueprints.md @@ -0,0 +1,104 @@ +# Deployment blueprints + +The Deployments page is the starting point for reusable server launches. This +release adds saved blueprints, launch previews, immutable managed-file recipes, +launching again from history, and clearer progress and recovery. + +## Create and review a launch + +1. Choose **Create a server** on Deployments (or the deploy action on the fleet). +2. Choose the current promoted setup and select a game/map, or select a blueprint. +3. Choose **Preview launch**. Review the image reference, configuration sources, + managed files and versions, selected map, lobby, port, and prerequisite checks. +4. Enter the Plutonium key for the displayed server ID, or reuse its saved key. +5. Choose **Launch server** and follow the operation to verified health. + +A preview lasts 30 minutes. It neither reserves capacity nor saves a key. If +another launch takes the lobby or port, submission stops before saving the key; +refresh the preview and review the newly assigned ID. Refreshing a preview clears +the typed key so it cannot accidentally be saved against a different lobby. + +Komodo connection settings, target host, RCON configuration, config filename, +lobby availability, and port availability are checked locally. These checks do +not probe the host or verify game/DLC files, images, Docker volumes, firewall +rules, or credentials. Container and RCON verification happen after provisioning. +T6 variants retain their exact catalog slug and launch ruleset; RCON health checks +verify the engine map, not the variant's start location. + +## Save and reuse a blueprint + +Expand **Make this a reusable blueprint** in a launch preview and give it a name +and optional description. You can save a recipe even when host configuration or +available capacity blocks launching. Names are unique without regard to case. + +Blueprints retain the game, map variant, image reference, composed configuration +when available, generated map configuration, and matching promoted script/plugin +bytes. Each blueprint is a saved recipe: saving another variation requires a new +name. Credentials, lobby IDs, and ports are not copied from the source server. + +Use **Preview launch** on a blueprint card to assign fresh capacity. Remove a +blueprint from the library when it is no longer needed; its existing servers, +reviewed previews, and deployment history remain intact. + +On a creation detail page, **Launch again** opens its original recipe with the +next available lobby and port. This reproduces the original managed launch files, +not the server's later live state. The preview also lets you save that historical +recipe as a blueprint. Older deployments without a recipe retain their history +but cannot offer this action. + +## What is frozen + +The exact managed file bytes, file hashes, version metadata, composed config, +selected map, and image reference are stored in SQLite. Promotion or deletion of +an artifact after preview cannot change those bytes. A submitted preview is bound +to one deployment: retrying the same submission returns the original operation, +including after an application restart or a lost HTTP response. + +Existing API clients that omit a preview still work. Their recipes are captured +when queued, before provisioning begins. Queued jobs from before this update use +the promoted setup at provisioning time because they have no recorded recipe. + +This is a launch snapshot, not a permanent version lock. Subsequent campaigns, +betas, and drift reconciliation continue to follow the server's game/map scopes. +Container tags can resolve to newer images; use a digest for a fixed image. +Without a promoted baseline, Plutainer seeds its own defaults on first boot, +which are not captured. Host, network, game-file volumes, destination directories, +and RCON/Plutonium credentials use the environment and key store at launch time. +A changed main config filename blocks reuse rather than silently loading the +wrong configuration. + +Recipes do not include the separately stored Plutonium or integration credentials. +Managed files can themselves contain sensitive configuration, so protect database +backups as you would the existing artifact library and saved-key database. Public +recipe responses contain file metadata and hashes, not file contents. + +## Progress and interrupted operations + +Creation verifies container health, then RCON, then the selected engine map. +The detail page explains the current condition, displays elapsed time, and keeps +its activity history. A completed container start is not reported as a completed +server launch until the health checks pass. + +For failed or interrupted creations, **Resume health verification** appears only +when the original server is still registered on its assigned port and no later +lifecycle operation has targeted that ID. It resumes observation only; it never +recreates or restarts a container. Missing servers and later destruction attempts +require inspection rather than replaying provisioning automatically. + +## API + +- `GET /api/blueprints`: list summaries. +- `POST /api/blueprints`: save `{ name, description?, previewId }` or + `{ name, description?, deploymentId }`. +- `DELETE /api/blueprints/:id`: remove a library entry. +- `POST /api/deployments/preview`: preview `{ game, mapSlug }`, `{ blueprintId }`, + or `{ deploymentId }`. Returns an ID, expiry, recipe summary, capacity, key + availability (never its value), and prerequisite checks. +- `POST /api/servers`: existing request plus optional `previewId`; the game and + map must match that preview. `expectedName` remains supported. +- `GET /api/deployments/:id`: includes `recipe`, `blueprintName`, and + `canResumeVerification` for lifecycle operations. +- `POST /api/deployments/:id/resume`: resume eligible creation health checks. + +Bulk collections, scheduled game nights, and beta-gated expansion are future +layers; they are not activated by saving or launching a blueprint. diff --git a/src/lib/deployment-plan.ts b/src/lib/deployment-plan.ts new file mode 100644 index 0000000..b9b47c0 --- /dev/null +++ b/src/lib/deployment-plan.ts @@ -0,0 +1,44 @@ +import type { ZombieGame } from './map-aliases.js'; + +export interface LaunchFileSummary { + name: string; + kind: 'script' | 'plugin' | 'config'; + path: string; + versionId: number | null; + version: number | null; + sha256: string; + size: number; +} + +export interface LaunchRecipeSummary { + game: ZombieGame; + mapSlug: string; + mapName: string; + expectedMap: string; + capturedAt: string; + image: string; + configFile: string; + configSource: string; + files: LaunchFileSummary[]; + fingerprint: string; +} + +export interface BlueprintSummary { + id: string; + name: string; + description: string; + createdAt: string; + recipe: LaunchRecipeSummary; +} + +export interface LaunchPreview { + id: string; + expiresAt: string; + name: string | null; + port: number | null; + hasSavedKey: boolean; + recipe: LaunchRecipeSummary; + blueprintName: string | null; + checks: { label: string; state: 'pass' | 'blocked' | 'info'; detail: string }[]; + ready: boolean; +} diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 5da0608..c723dd7 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -335,6 +335,32 @@ CREATE TABLE IF NOT EXISTS server_creation_jobs ( expected_map TEXT NOT NULL ); +-- Launch recipes copy managed files, not credentials from the key/integration stores. +-- Independent of the artifact library so history survives version deletion. +CREATE TABLE IF NOT EXISTS server_blueprints ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + recipe TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS server_launch_previews ( + id TEXT PRIMARY KEY, + expires_at TEXT NOT NULL, + server TEXT, + port INTEGER, + recipe TEXT NOT NULL, + blueprint_name TEXT, + deployment_id TEXT REFERENCES server_deployments(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS server_launch_recipes ( + deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id) ON DELETE CASCADE, + recipe TEXT NOT NULL, + blueprint_name TEXT +); + -- Persist state transitions atomically, including changes made by background workers. -- Polls with unchanged status/error do not produce duplicate activity entries. CREATE TABLE IF NOT EXISTS beta_events ( diff --git a/src/lib/server/fleet/create.ts b/src/lib/server/fleet/create.ts index 8b8b6a3..8f9a4b7 100644 --- a/src/lib/server/fleet/create.ts +++ b/src/lib/server/fleet/create.ts @@ -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 { 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 { 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) { diff --git a/src/lib/server/fleet/deployments.ts b/src/lib/server/fleet/deployments.ts index 41bc0e8..3053f58 100644 --- a/src/lib/server/fleet/deployments.ts +++ b/src/lib/server/fleet/deployments.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { db } from '../db/index.js'; +import { recipeForDeployment, summarizeRecipe } from './launch-recipes.js'; export function beginDeployment(server: string, action: 'create' | 'destroy', triggeredBy = 'ui'): string { const id = randomUUID(); @@ -25,7 +26,29 @@ export function getDeployment(id: string) { FROM server_deployments d LEFT JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE d.id = ?`).get(id); if (!deployment) return null; const events = db().prepare('SELECT id, created_at, message FROM server_deployment_events WHERE deployment_id = ? ORDER BY id').all(id); - return { deployment, events }; + const launch = recipeForDeployment(id); + return { deployment, events, recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null, + canResumeVerification: canResumeVerification(id) }; +} + +export function canResumeVerification(id: string): boolean { + return !!db().prepare(`SELECT d.id FROM server_deployments d + JOIN server_creation_jobs j ON j.deployment_id = d.id + JOIN servers s ON s.name = j.server AND s.game_port = j.port + WHERE d.id = ? AND d.action = 'create' AND d.state IN ('failed', 'interrupted') + AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`) + .get(id); +} + +/** Resume observation only. Never repeat a container creation or restart. */ +export function resumeVerification(id: string): boolean { + return db().transaction(() => { + if (!canResumeVerification(id)) return false; + db().prepare("UPDATE server_deployments SET state = 'awaiting-health', finished_at = NULL, error = NULL WHERE id = ?").run(id); + recordDeploymentEvent(id, 'Resumed verification of the existing server'); + deploymentStage(id, 'Waiting for Komodo health'); + return true; + })(); } export function finishDeployment(id: string, result: { ok: boolean; message?: string; warnings?: string[] }): void { diff --git a/src/lib/server/fleet/launch-recipes.ts b/src/lib/server/fleet/launch-recipes.ts new file mode 100644 index 0000000..a8b8b8c --- /dev/null +++ b/src/lib/server/fleet/launch-recipes.ts @@ -0,0 +1,95 @@ +import { randomUUID } from 'node:crypto'; +import type { BlueprintSummary, LaunchRecipeSummary, LaunchFileSummary } from '$lib/deployment-plan.js'; +import { config, configFileForGame } from '../config.js'; +import { db } from '../db/index.js'; +import { zombieMapBySlug, type ZombieGame } from './map-aliases.js'; +import { promotedArtifacts } from './promoted-artifacts.js'; +import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js'; +import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js'; +import { sha256Buffer } from './files.js'; + +export interface LaunchRecipe { + format: 1; + game: ZombieGame; + mapSlug: string; + mapName: string; + expectedMap: string; + capturedAt: string; + image: string; + configFile: string; + configSource: string; + files: (LaunchFileSummary & { content: string })[]; +} + +export function captureRecipe(game: ZombieGame, mapSlug: string): LaunchRecipe { + const map = zombieMapBySlug(game, mapSlug); + if (!map) throw new Error('Choose a supported game and map.'); + const files: LaunchRecipe['files'] = promotedArtifacts(game, map.canonical).map(({ kind, script, version }) => ({ + name: script.name, kind, path: script.target_rel_path, versionId: version.id, + version: version.version, sha256: version.sha256, size: version.size, content: version.content.toString('base64') + })); + const addConfig = (name: string, path: string, content: Buffer) => files.push({ + name, path, kind: 'config', versionId: null, version: null, + sha256: sha256Buffer(content), size: content.length, content: content.toString('base64') + }); + const composed = getPromotedServerConfig(game, map.canonical, map.slug); + let configSource = 'Plutainer defaults, seeded on first boot'; + if (composed) { + const baseline = getBaselineForScope('game', game)!; + const layers = [baseline, ...listPromotedOverlaysForScope('game', game), ...listPromotedOverlaysForScope('map', map.canonical)]; + configSource = layers.map(layer => `${layer.name} v${getVersion(layer.current_version_id!)!.version}`).join(' + '); + addConfig('Composed server configuration', configFileForGame(game), composed); + } + addConfig('Selected map', MAP_CONFIG_FILE, getServerMapConfig(game, map.canonical, map.slug)!); + return { format: 1, game, mapSlug, mapName: map.friendly, expectedMap: map.canonical, + capturedAt: new Date().toISOString(), image: config.komodo.defaultImage, + configFile: configFileForGame(game), configSource, files }; +} + +export function summarizeRecipe(recipe: LaunchRecipe): LaunchRecipeSummary { + const { format: _format, files, ...summary } = recipe; + return { ...summary, files: files.map(({ content: _content, ...file }) => file), + fingerprint: sha256Buffer(Buffer.from(JSON.stringify(recipe))) }; +} + +export function recipeForDeployment(id: string): { recipe: LaunchRecipe; blueprintName: string | null } | null { + const row = db().prepare('SELECT recipe, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?') + .get(id) as { recipe: string; blueprint_name: string | null } | undefined; + return row ? { recipe: JSON.parse(row.recipe), blueprintName: row.blueprint_name } : null; +} + +export function blueprintRecipe(id: string): { recipe: LaunchRecipe; blueprintName: string } { + const row = db().prepare('SELECT recipe, name FROM server_blueprints WHERE id = ?').get(id) as { recipe: string; name: string } | undefined; + if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.'); + return { recipe: JSON.parse(row.recipe), blueprintName: row.name }; +} + +export function listBlueprints(): BlueprintSummary[] { + return (db().prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE').all() as { + id: string; name: string; description: string; created_at: string; recipe: string; + }[]).map(row => ({ id: row.id, name: row.name, description: row.description, + createdAt: row.created_at, recipe: summarizeRecipe(JSON.parse(row.recipe)) })); +} + +export function saveBlueprint(input: { name: unknown; description?: unknown; previewId?: unknown; deploymentId?: unknown }): BlueprintSummary { + if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 80) throw new Error('Give the blueprint a name of 1–80 characters.'); + if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 500)) throw new Error('Keep the description to 500 characters.'); + if ((typeof input.previewId === 'string') === (typeof input.deploymentId === 'string')) throw new Error('Choose exactly one launch preview or recorded deployment.'); + let recipe: LaunchRecipe | undefined; + if (typeof input.previewId === 'string') { + const row = db().prepare('SELECT recipe FROM server_launch_previews WHERE id = ? AND expires_at > ?') + .get(input.previewId, new Date().toISOString()) as { recipe: string } | undefined; + if (row) recipe = JSON.parse(row.recipe); + } else if (typeof input.deploymentId === 'string') recipe = recipeForDeployment(input.deploymentId)?.recipe; + if (!recipe) throw new Error('The launch recipe is unavailable or the preview expired. Generate a fresh preview.'); + if (db().prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE').get(input.name.trim())) throw new Error('A blueprint with this name already exists. Choose a new name.'); + const id = randomUUID(); + const createdAt = new Date().toISOString(); + db().prepare('INSERT INTO server_blueprints (id, name, description, created_at, recipe) VALUES (?, ?, ?, ?, ?)') + .run(id, input.name.trim(), input.description ?? '', createdAt, JSON.stringify(recipe)); + return { id, name: input.name.trim(), description: (input.description as string) ?? '', createdAt, recipe: summarizeRecipe(recipe) }; +} + +export function deleteBlueprint(id: string): boolean { + return db().prepare('DELETE FROM server_blueprints WHERE id = ?').run(id).changes > 0; +} diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 2791be3..5a9b42a 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -1,302 +1,154 @@ - (open = false)}> -
-
-

Deploy new server

- -
- - {#if mapsError} -
Failed to load map catalog: {mapsError}
- {:else if !mapsLoaded} -
Loading map catalog…
+ { if (event.target === dialog && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}> +
+
{sourceDeploymentId ? 'Launch from history' : 'A new place to play'}

{preview ? 'Review your next lobby' : 'Create your next server'}

+
  1. 01 Choose a starting point
  2. 02 Review & launch
+ {#if error}{/if} + {#if !loaded} +

{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and blueprints…'}

+ {#if error}{/if} + {:else if preview} +
{#if preview.blueprintName}

From blueprint: {preview.blueprintName}

{/if}
+

Before you launch

+
    {#each preview.checks as check}
  • {check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}
    {check.label}

    {check.detail}

  • {/each}
+
+ { event.preventDefault(); void launch(); }}> +
Your new server{preview.name ?? 'Waiting for an available lobby'}

{preview.port ? `Port ${preview.port} · ` : ''}Fresh identity, with its own server key.

+ +

{preview.hasSavedKey ? 'The key saved for this exact lobby will be reused. Enter a replacement to update it.' : 'Your key is saved for this exact lobby when you launch, including if provisioning fails.'} open = false}>Manage keys

+
{#if !sourceDeploymentId}{/if}
+

Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.

+ +
Make this a reusable blueprint

Save this map, image reference, and managed files for another launch. Each launch gets a new lobby, port, and key.

+ {#if savedName}

“{savedName}” is ready in your blueprint library.

{:else} +
{ event.preventDefault(); void save(); }}>
+ {/if} +
+ {:else if sourceDeploymentId} +

{busy ? 'Preparing the original launch recipe…' : 'Review the original recipe with a fresh lobby and port.'}

{:else} -
- - - - - - - -
- -
- Will create - {previewName || '—'} - {!keysLoaded ? "Loading available lobby..." : lobby === null ? "All lobby slots for this map are in use" : "Lowest available lobby; port auto-assigned"} -
- - Manage saved keys -
- - -
+
{ event.preventDefault(); void review(); }}> + + {#if selectedBlueprint}
{#if selectedBlueprint.description}

{selectedBlueprint.description}

{/if}
+ {:else}

Pick a map. We’ll gather its promoted scripts, plugins, and configuration into a recipe you can review and save.

{/if} +
Know what you’re launching.

Review the exact managed files, available lobby, and launch prerequisites before creating your server.

+
+
{/if} - - {#if flash}
{flash}
{/if} - +
diff --git a/src/lib/ui/components/LaunchRecipe.svelte b/src/lib/ui/components/LaunchRecipe.svelte new file mode 100644 index 0000000..992697b --- /dev/null +++ b/src/lib/ui/components/LaunchRecipe.svelte @@ -0,0 +1,33 @@ + + +
+
Launch recipe

{recipe.mapName}

{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}
+
+
Configuration
{recipe.configSource}
+
Container image
{recipe.image}
+
+
+ {recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} in this recipe +
    {#each recipe.files as file}
  • {file.name}{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes
    {file.path}SHA-256 {file.sha256.slice(0, 12)}
  • {/each}
+

Captured {new Date(recipe.capturedAt).toLocaleString()}. Managed file contents are saved with this recipe. Image tags and Plutainer defaults can change; use an image digest and a promoted baseline for more reproducible launches.

+

Future fleet promotions and drift repair still follow the server’s game and map.

+ Recipe {recipe.fingerprint.slice(0, 16)} +
+
+ + diff --git a/src/routes/api/blueprints/+server.ts b/src/routes/api/blueprints/+server.ts new file mode 100644 index 0000000..aeae584 --- /dev/null +++ b/src/routes/api/blueprints/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import { listBlueprints, saveBlueprint } from '$lib/server/fleet/launch-recipes.js'; + +export function GET() { return json({ blueprints: listBlueprints() }); } + +export async function POST({ request }: { request: Request }) { + try { + const body = await request.json(); + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a blueprint name and a launch recipe.'); + return json({ blueprint: saveBlueprint(body) }, { status: 201 }); + } catch (err) { + return json({ error: (err as Error).message }, { status: 400 }); + } +} diff --git a/src/routes/api/blueprints/[id]/+server.ts b/src/routes/api/blueprints/[id]/+server.ts new file mode 100644 index 0000000..eb4ce6a --- /dev/null +++ b/src/routes/api/blueprints/[id]/+server.ts @@ -0,0 +1,6 @@ +import { json } from '@sveltejs/kit'; +import { deleteBlueprint } from '$lib/server/fleet/launch-recipes.js'; + +export function DELETE({ params }: { params: { id: string } }) { + return deleteBlueprint(params.id) ? json({ ok: true }) : json({ error: 'Blueprint not found.' }, { status: 404 }); +} diff --git a/src/routes/api/deployments/[id]/resume/+server.ts b/src/routes/api/deployments/[id]/resume/+server.ts new file mode 100644 index 0000000..6955888 --- /dev/null +++ b/src/routes/api/deployments/[id]/resume/+server.ts @@ -0,0 +1,9 @@ +import { json } from '@sveltejs/kit'; +import { resumeVerification } from '$lib/server/fleet/deployments.js'; +import { kick } from '$lib/server/pipeline/worker.js'; + +export function POST({ params }: { params: { id: string } }) { + if (!resumeVerification(params.id)) return json({ error: 'This operation cannot resume verification. Refresh and inspect its server.' }, { status: 409 }); + void kick().catch(err => console.error('[deployment] verification kick failed:', err)); + return json({ ok: true }, { status: 202 }); +} diff --git a/src/routes/api/deployments/preview/+server.ts b/src/routes/api/deployments/preview/+server.ts new file mode 100644 index 0000000..e25c58c --- /dev/null +++ b/src/routes/api/deployments/preview/+server.ts @@ -0,0 +1,12 @@ +import { json } from '@sveltejs/kit'; +import { previewCreation } from '$lib/server/fleet/create.js'; + +export async function POST({ request }: { request: Request }) { + try { + const body = await request.json(); + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a launch selection.'); + return json(previewCreation(body)); + } catch (err) { + return json({ error: (err as Error).message }, { status: 400 }); + } +} diff --git a/src/routes/api/servers/+server.ts b/src/routes/api/servers/+server.ts index 989ec78..f747f60 100644 --- a/src/routes/api/servers/+server.ts +++ b/src/routes/api/servers/+server.ts @@ -57,8 +57,9 @@ export async function POST({ request }: { request: Request }) { mapSlug?: string; plutoKey?: string; expectedName?: string; + previewId?: string; }; - if (!body || (body.game !== undefined && body.game !== 't5' && body.game !== 't6') || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string')) { + if (!body || (body.game !== undefined && body.game !== 't5' && body.game !== 't6') || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string') || (body.previewId !== undefined && (typeof body.previewId !== 'string' || !body.previewId))) { return json( { ok: false, error: 'game must be t5 or t6; gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings', kind: 'bad-request' }, { status: 400 } @@ -70,6 +71,7 @@ export async function POST({ request }: { request: Request }) { mapSlug: body.mapSlug, plutoKey: body.plutoKey, expectedName: body.expectedName, + previewId: body.previewId, createdBy: 'ui' }); if (!result.ok) { diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte index 8573b70..5e2c2c0 100644 --- a/src/routes/deployments/+page.svelte +++ b/src/routes/deployments/+page.svelte @@ -5,6 +5,7 @@ import Button from '$ui/components/primitives/Button.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import DeployServerDialog from '$ui/components/DeployServerDialog.svelte'; + import type { BlueprintSummary } from '$lib/deployment-plan.js'; interface Deployment { id: string; server: string; action: 'create' | 'destroy'; @@ -16,6 +17,12 @@ let loading = $state(true); let error = $state(''); let open = $state(false); + let blueprintId = $state(''); + let blueprints = $state([]); + let blueprintsLoading = $state(true); + let blueprintError = $state(''); + let removing = $state(''); + let confirmRemove = $state(''); let action = $state('all'); let search = $state(''); let refreshing = false; @@ -35,9 +42,28 @@ } onMount(() => { void load(); + void loadBlueprints(); const timer = setInterval(load, 2000); return () => clearInterval(timer); }); + async function loadBlueprints() { + try { + const res = await fetch('/api/blueprints'); + if (!res.ok) throw new Error('Could not load your blueprints.'); + blueprints = (await res.json()).blueprints; + blueprintError = ''; + } catch (err) { blueprintError = (err as Error).message; } + finally { blueprintsLoading = false; } + } + async function removeBlueprint(id: string) { + removing = id; blueprintError = ''; + try { + const res = await fetch('/api/blueprints/' + encodeURIComponent(id), { method: 'DELETE' }); + if (!res.ok) throw new Error('Could not remove blueprint.'); + blueprints = blueprints.filter(b => b.id !== id); confirmRemove = ''; + } catch (err) { blueprintError = (err as Error).message; } + finally { removing = ''; } + } function fmt(value: string) { return new Date(value).toLocaleString(); } function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } } function status(d: Deployment) { @@ -47,11 +73,43 @@ Server deployments | Electric Cherry
- -
- + +
+
From setup to first round

Build it once. Bring it back anytime.

Launch from your promoted setup or a saved blueprint, with the files and prerequisites laid out before you commit.

+
- + +
+

Your blueprints {blueprints.length}

+ {#if blueprintError}{/if} + {#if blueprintsLoading}

Loading blueprints…

+ {:else if !blueprints.length}
A good setup deserves a repeat.

Preview a server and choose “Make this a reusable blueprint” to save its map, image reference, and managed files.

+ {:else} +
+ {#each blueprints as blueprint (blueprint.id)} +
+
+ {blueprint.recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'} · {blueprint.recipe.mapName} + {blueprint.recipe.files.length} file{blueprint.recipe.files.length === 1 ? '' : 's'} +
+

{blueprint.name}

+

{blueprint.description || 'A saved launch recipe, ready for a fresh lobby.'}

+ + {#if confirmRemove === blueprint.id} +
+

Remove this blueprint? Existing servers and launch history are kept.

+ + +
+ {/if} +
+ {/each} +
+ {/if} +
{#if error}{/if}

In flight {active.length}

@@ -87,8 +145,13 @@