Add deployment blueprints and reviewed launch recipes

This commit is contained in:
2026-09-17 23:43:12 -04:00
parent edd74f09de
commit 782c7c0bda
17 changed files with 975 additions and 319 deletions
+11
View File
@@ -68,6 +68,17 @@ directly.
No token, no session, no auth freeze. If Komodo or RCON aren't configured, No token, no session, no auth freeze. If Komodo or RCON aren't configured,
the sweep marks pending rows `manual` so nothing hangs forever. 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 ## Saved server keys
Use **Keys** to add, replace, or remove a Plutonium key for an exact Use **Keys** to add, replace, or remove a Plutonium key for an exact
+104
View File
@@ -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.
+44
View File
@@ -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;
}
+26
View File
@@ -335,6 +335,32 @@ CREATE TABLE IF NOT EXISTS server_creation_jobs (
expected_map TEXT NOT NULL 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. -- Persist state transitions atomically, including changes made by background workers.
-- Polls with unchanged status/error do not produce duplicate activity entries. -- Polls with unchanged status/error do not produce duplicate activity entries.
CREATE TABLE IF NOT EXISTS beta_events ( CREATE TABLE IF NOT EXISTS beta_events (
+91 -32
View File
@@ -1,5 +1,8 @@
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js'; import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.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 { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { config, destOfKind, serverDir, gameOfServer, configFileForGame } from '../config.js'; import { config, destOfKind, serverDir, gameOfServer, configFileForGame } from '../config.js';
@@ -19,10 +22,9 @@ import {
type DeploymentEnvVar type DeploymentEnvVar
} from '../integrations/komodo.js'; } from '../integrations/komodo.js';
import { kick } from '../pipeline/worker.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 { recordScript } from './inventory.js';
import { sha256Buffer } from './files.js'; import { getVersion } from './scripts.js';
import { promotedArtifacts } from './promoted-artifacts.js';
export type CreateServerFailure = export type CreateServerFailure =
| 'bad-request' | 'bad-request'
@@ -48,6 +50,7 @@ export interface CreateServerInput {
mapSlug: string; // 'cosmodrome' mapSlug: string; // 'cosmodrome'
plutoKey?: string; // saved for this server ID when supplied plutoKey?: string; // saved for this server ID when supplied
expectedName?: string; // optional preview guard; never selects a lobby expectedName?: string; // optional preview guard; never selects a lobby
previewId?: string;
createdBy?: string; // 'ui' by default createdBy?: string; // 'ui' by default
} }
@@ -121,11 +124,12 @@ function buildSpec(args: {
port: number; port: number;
hostDir: string; hostDir: string;
plutoKey: string; plutoKey: string;
recipe: LaunchRecipe;
}): CreateDeploymentSpec { }): CreateDeploymentSpec {
return { return {
name: args.name, name: args.name,
server: config.komodo.targetServer, server: config.komodo.targetServer,
image: config.komodo.defaultImage, image: args.recipe.image,
network: config.komodo.defaultNetwork, network: config.komodo.defaultNetwork,
// Container listens on PLUTAINER_PORT (== args.port), so host and // Container listens on PLUTAINER_PORT (== args.port), so host and
// container ports match — no NAT inside the bridge network. // 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; 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 { export function queueCreateServer(input: CreateServerInput): CreateServerResult {
return db().transaction((): 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); const prepared = prepareCreation(input);
if (!prepared.ok) return prepared; if (!prepared.ok) return prepared;
const frozenRecipe = recipe ?? captureRecipe(input.game ?? 't5', prepared.mapEntry.slug);
const id = beginDeployment(prepared.name, 'create', input.createdBy); 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("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 (?, ?, ?, ?, ?, ?)') 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); .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. // Await provisioning for internal callers; completion still requires worker health verification.
export async function createServer(input: CreateServerInput): Promise<CreateServerResult> { 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.' }; 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); const queued = queueCreateServer(input);
if (!queued.ok) return queued; 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); 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> { async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> {
const { name, port, plutoKey, mapEntry } = prepared; const { name, port, plutoKey, mapEntry } = prepared;
const game = gameOfServer(name); 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'); deploymentStage(operation, 'Preparing server files');
const hostDir = serverDir(name); 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 // Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly
// after — plutainer expects to be able to write to /home/plutainer/app. // after — plutainer expects to be able to write to /home/plutainer/app.
try { try {
await mkdir(hostDir, { recursive: true, mode: 0o777 }); await mkdir(hostDir, { recursive: true, mode: 0o777 });
await chmod(hostDir, 0o777); await chmod(hostDir, 0o777);
for (const { kind, script, version } of initialArtifacts) { for (const file of recipe.files) {
const dest = destOfKind(name, kind); const dest = destOfKind(name, file.kind);
const target = join(dest, script.target_rel_path); const target = join(dest, file.path);
await mkdir(dirname(target), { recursive: true, mode: 0o777 }); await mkdir(dirname(target), { recursive: true, mode: 0o777 });
// Make every newly created ancestor writable by Plutainer, even // Make every newly created ancestor writable by Plutainer, even
// when the manager's umask removed write permissions at mkdir. // when the manager's umask removed write permissions at mkdir.
for (let dir = dirname(target); dir !== join(hostDir) && dir !== dirname(dir); dir = dirname(dir)) { for (let dir = dirname(target); dir !== join(hostDir) && dir !== dirname(dir); dir = dirname(dir)) {
await chmod(dir, 0o777); await chmod(dir, 0o777);
} }
await writeFile(target, version.content); await writeFile(target, Buffer.from(file.content, 'base64'));
await chmod(target, 0o666); 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) { } catch (err) {
return { ok: false, kind: 'fs', message: `failed to create ${hostDir}: ${(err as Error).message}` }; return { ok: false, kind: 'fs', message: `failed to create ${hostDir}: ${(err as Error).message}` };
} }
// Step 2: Komodo CreateDeployment. // Step 2: Komodo CreateDeployment.
deploymentStage(operation, 'Creating Komodo deployment'); 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); const createRes = await komodoCreateDeployment(spec);
if (!createRes.ok) { if (!createRes.ok) {
await rm(hostDir, { recursive: true, force: true }).catch(() => {}); await rm(hostDir, { recursive: true, force: true }).catch(() => {});
@@ -283,15 +345,12 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
).run( ).run(
name, name, game, mapEntry.canonical, name, name, game, mapEntry.canonical,
input.gameMode, mapEntry.slug, hostDir, input.gameMode, mapEntry.slug, hostDir,
configFile, game === 't6' ? 't6zm' : 't5sp', config.komodo.defaultImage, configFile, game === 't6' ? 't6zm' : 't5sp', recipe.image,
port, input.createdBy ?? 'ui' port, input.createdBy ?? 'ui'
); );
if (initialConfig) { for (const file of recipe.files) {
recordScript(name, configFile, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create'); 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');
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');
} }
})(); })();
} catch (err) { } catch (err) {
+24 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { recipeForDeployment, summarizeRecipe } from './launch-recipes.js';
export function beginDeployment(server: string, action: 'create' | 'destroy', triggeredBy = 'ui'): string { export function beginDeployment(server: string, action: 'create' | 'destroy', triggeredBy = 'ui'): string {
const id = randomUUID(); 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); 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; 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); 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 { export function finishDeployment(id: string, result: { ok: boolean; message?: string; warnings?: string[] }): void {
+95
View File
@@ -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;
}
+124 -272
View File
@@ -1,302 +1,154 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import Field from './primitives/Field.svelte'; import LaunchRecipe from './LaunchRecipe.svelte';
import Input from './primitives/Input.svelte'; import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
import Select from './primitives/Select.svelte';
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js'; import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
let { open = $bindable(false) }: { open?: boolean } = $props(); let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', onblueprint }:
{ open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; onblueprint?: () => void } = $props();
let dialog: HTMLDialogElement | undefined = $state(); let dialog: HTMLDialogElement | undefined = $state();
let game = $state<ZombieGame>('t5'); let game = $state<ZombieGame>('t5');
let mapSlug = $state('');
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] }); let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintId = $state('');
let maps = $derived(catalogs[game]); let maps = $derived(catalogs[game]);
let mapsError = $state<string | null>(null); let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
let mapsLoaded = $state(false); let preview = $state<LaunchPreview | null>(null);
let gameMode = $state<'zombie'>('zombie');
let mapSlug = $state<string>('');
let plutoKey = $state(''); let plutoKey = $state('');
let savedIds = $state<string[]>([]); let busy = $state(false);
let deployedIds = $state<string[]>([]); let loaded = $state(false);
let keysLoaded = $state(false); let error = $state('');
let deploying = $state(false); let blueprintName = $state('');
let flash = $state<string | null>(null); let description = $state('');
let savedName = $state('');
let saving = $state(false);
let wasOpen = false;
let session = 0;
onMount(async () => {
try {
const res = await fetch('/api/maps');
if (!res.ok) throw new Error('Could not load map catalog');
const body = (await res.json()) as { t5Zombie: ZombieMapEntry[]; t6Zombie: ZombieMapEntry[] };
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
if (maps.length > 0) mapSlug = maps[0].slug;
} catch (err) {
mapsError = (err as Error).message;
} finally {
mapsLoaded = true;
}
});
let wasOpen = $state(false);
$effect(() => { $effect(() => {
if (!dialog) return; if (!dialog) return;
if (open && !wasOpen) { if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); }
reset(); else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
dialog.showModal();
} else if (!open && wasOpen) {
if (dialog.open) dialog.close();
}
wasOpen = open;
}); });
function reset(): void { async function initialize() {
plutoKey = ''; const current = ++session;
void loadKeys(); preview = null; plutoKey = ''; error = ''; loaded = false; busy = false;
flash = null; blueprintName = ''; description = ''; savedName = ''; saving = false;
deploying = false; blueprintId = initialBlueprintId;
if (maps.length > 0 && !maps.some((m) => m.slug === mapSlug)) mapSlug = maps[0].slug;
}
async function loadKeys() {
keysLoaded = false;
try { try {
const [keysRes, serversRes] = await Promise.all([fetch('/api/server-keys'), fetch('/api/servers')]); const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
if (!keysRes.ok || !serversRes.ok) throw new Error('Could not load saved keys and server slots. Reopen this dialog to retry.'); if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and blueprints. Try again.');
savedIds = (await keysRes.json()).keys.map((k: { server_id: string }) => k.server_id); const [mapBody, blueprintBody] = await Promise.all([mapRes.json(), blueprintRes.json()]);
const fleet = await serversRes.json(); if (current !== session) return;
deployedIds = [...fleet.servers.map((s: { name: string }) => s.name), ...(fleet.reservedNames ?? [])]; catalogs = { t5: mapBody.t5Zombie, t6: mapBody.t6Zombie };
keysLoaded = true; blueprints = blueprintBody.blueprints;
} catch (err) { flash = (err as Error).message; } if (!maps.some(m => m.slug === mapSlug)) mapSlug = maps[0]?.slug ?? '';
} if (blueprintId && !blueprints.some(b => b.id === blueprintId)) throw new Error('This blueprint is no longer available. Close this dialog and choose another starting point.');
function close(): void { open = false; } loaded = true;
if (sourceDeploymentId) await review();
function onDialogClick(e: MouseEvent): void { } catch (err) { if (current === session) error = (err as Error).message; }
if (e.target === dialog) close();
} }
let lobby = $derived(Array.from({ length: 20 }, (_, i) => i + 1) async function review() {
.find(n => !deployedIds.includes(`${game}_${gameMode}_${mapSlug}_${n}`)) ?? null); if (busy) return;
let selectedMap = $derived(maps.find((m) => m.slug === mapSlug)); const current = session;
let previewName = $derived(selectedMap && keysLoaded && lobby !== null ? `${game}_${gameMode}_${selectedMap.slug}_${lobby}` : ''); busy = true; error = ''; plutoKey = ''; preview = null; savedName = '';
let hasSavedKey = $derived(savedIds.includes(previewName));
$effect(() => { game; mapSlug; lobby; plutoKey = ''; });
$effect(() => {
if (maps.length && !maps.some((entry) => entry.slug === mapSlug)) mapSlug = maps[0].slug;
});
let modeOptions = [{ value: 'zombie', label: 'Zombies' }];
let mapOptions = $derived(maps.map((m) => ({ value: m.slug, label: m.friendly })));
async function submit(e: SubmitEvent): Promise<void> {
e.preventDefault();
if (deploying || !keysLoaded || lobby === null) return;
if (!selectedMap) { flash = 'Pick a map'; return; }
if (!hasSavedKey && plutoKey.trim().length === 0) { flash = 'Pluto server key is required'; return; }
deploying = true;
flash = null;
try { try {
const res = await fetch('/api/servers', { const res = await fetch('/api/deployments/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
method: 'POST', body: JSON.stringify(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : blueprintId ? { blueprintId } : { game, mapSlug }) });
headers: { 'Content-Type': 'application/json' }, const body = await res.json();
body: JSON.stringify({ game, gameMode, mapSlug: selectedMap.slug, expectedName: previewName, plutoKey: plutoKey.trim() || undefined }) if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
}); if (current === session) preview = body;
const body = (await res.json()) as { ok: boolean; name?: string; port?: number; deploymentId?: string; error?: string; kind?: string }; } catch (err) { if (current === session) error = (err as Error).message; }
if (!res.ok || !body.ok || !body.deploymentId) { finally { if (current === session) busy = false; }
flash = body.error ?? 'Deploy failed';
if (body.kind === 'no-slot') await loadKeys();
deploying = false;
return;
} }
close();
async function launch() {
if (!preview || !preview.ready || busy) return;
busy = true; error = '';
try {
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ game: preview.recipe.game, gameMode: 'zombie', mapSlug: preview.recipe.mapSlug,
expectedName: preview.name, previewId: preview.id, plutoKey: plutoKey.trim() || undefined }) });
const body = await res.json();
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Could not queue this launch.');
open = false;
await goto(`/deployments/${encodeURIComponent(body.deploymentId)}`); await goto(`/deployments/${encodeURIComponent(body.deploymentId)}`);
} catch (err) { } catch (err) { error = (err as Error).message; }
flash = (err as Error).message; finally { busy = false; }
deploying = false;
} }
async function save() {
if (!preview || saving) return;
const current = session;
saving = true; error = '';
try {
const res = await fetch('/api/blueprints', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: blueprintName, description, previewId: preview.id }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save blueprint.');
if (current === session) { savedName = body.blueprint.name; blueprints = [...blueprints, body.blueprint]; onblueprint?.(); }
} catch (err) { if (current === session) error = (err as Error).message; }
finally { if (current === session) saving = false; }
} }
</script> </script>
<dialog bind:this={dialog} onclick={onDialogClick} onclose={() => (open = false)}> <dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (event.target === dialog && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
<form class="dlg" onsubmit={submit}> <div class="composer">
<header class="dlg-head"> <header><div><span class="eyebrow">{sourceDeploymentId ? 'Launch from history' : 'A new place to play'}</span><h2>{preview ? 'Review your next lobby' : 'Create your next server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close deployment composer">×</button></header>
<h3>Deploy new server</h3> <ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
<button type="button" class="close" onclick={close} aria-label="Close">×</button> {#if error}<div class="error" role="alert">{error}</div>{/if}
</header> {#if !loaded}
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and blueprints…'}</p>
{#if mapsError} {#if error}<Button onclick={initialize}>Try again</Button>{/if}
<div class="alert alert-danger">Failed to load map catalog: {mapsError}</div> {:else if preview}
{:else if !mapsLoaded} <section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From blueprint: {preview.blueprintName}</p>{/if}</section>
<div class="hint">Loading map catalog…</div> <section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
{:else} <ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
<div class="fields"> </section>
<Field label="Game"> <form onsubmit={event => { event.preventDefault(); void launch(); }}>
<Select bind:value={game} options={[{ value: 't5', label: 'Black Ops 1 (T5)' }, { value: 't6', label: 'Black Ops II (T6)' }]} disabled={deploying} /> <div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>{preview.port ? `Port ${preview.port} · ` : ''}Fresh identity, with its own server key.</p></div>
</Field> <label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder={preview.hasSavedKey ? 'Saved key ready — leave blank to reuse' : 'Paste the key for this server ID'} /></label>
<p class="muted">{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.'} <a href="/server-keys" onclick={() => open = false}>Manage keys</a></p>
<Field label="Mode"> <div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
<Select bind:value={gameMode} options={modeOptions} disabled={deploying} /> <p class="caption">Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.</p>
</Field>
<Field label="Map" hint="Loads automatically when the server starts. No map overlay needed.">
<Select bind:value={mapSlug} options={mapOptions} disabled={deploying} />
</Field>
<Field label="Pluto server key" hint={hasSavedKey ? 'Saved key ready. Leave blank to reuse it, or enter a replacement.' : 'Saved for this server ID and reused on future deployments.'}>
<Input
type="password"
bind:value={plutoKey}
placeholder={hasSavedKey ? "Saved key ready" : "paste key"}
autocomplete="off"
disabled={deploying}
/>
</Field>
</div>
<div class="preview">
<span class="preview-k">Will create</span>
<span class="preview-v">{previewName || '—'}</span>
<span class="preview-sub">{!keysLoaded ? "Loading available lobby..." : lobby === null ? "All lobby slots for this map are in use" : "Lowest available lobby; port auto-assigned"}</span>
</div>
<a href="/server-keys" onclick={close}>Manage saved keys</a>
<div class="actions">
<Button variant="ghost" onclick={close} disabled={deploying}>Cancel</Button>
<Button
type="submit"
variant="primary"
disabled={deploying || !selectedMap || !keysLoaded || lobby === null}
loading={deploying}
>
Deploy server
</Button>
</div>
{/if}
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
</form> </form>
<details class="save"><summary>Make this a reusable blueprint</summary><p class="muted">Save this map, image reference, and managed files for another launch. Each launch gets a new lobby, port, and key.</p>
{#if savedName}<p class="success" role="status">“{savedName}” is ready in your blueprint library.</p>{:else}
<form onsubmit={event => { event.preventDefault(); void save(); }}><label>Blueprint name<input bind:value={blueprintName} maxlength="80" required placeholder="Friday night Kino" disabled={saving || busy} /></label><label>Description <span class="muted">(optional)</span><input bind:value={description} maxlength="500" placeholder="What makes this setup special?" disabled={saving || busy} /></label><Button type="submit" disabled={saving || busy || !blueprintName.trim()} loading={saving}>Save blueprint</Button></form>
{/if}
</details>
{:else if sourceDeploymentId}
<p class="muted">{busy ? 'Preparing the original launch recipe…' : 'Review the original recipe with a fresh lobby and port.'}</p><Button disabled={busy} onclick={review}>Review launch</Button>
{:else}
<form onsubmit={event => { event.preventDefault(); void review(); }}>
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.recipe.mapName}</option>{/each}</select></label>
{#if selectedBlueprint}<section class="recipe-panel"><LaunchRecipe recipe={selectedBlueprint.recipe} />{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
{:else}<p class="muted">Pick a map. We’ll gather its promoted scripts, plugins, and configuration into a recipe you can review and save.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the exact managed files, available lobby, and launch prerequisites before creating your server.</p></div>
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && !mapSlug)}>Preview launch →</Button></div>
</form>
{/if}
</div>
</dialog> </dialog>
<style> <style>
dialog { dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 18px; padding: 0; width: min(700px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); }
background: var(--surface); dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
color: var(--text-primary); .composer { display: grid; gap: 24px; padding: 30px; } header, .section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
border: 1px solid var(--border-strong); .eyebrow { font-size: 10px; color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
border-radius: var(--radius-lg); h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
padding: 0; .close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
max-width: 540px; .steps { list-style: none; padding: 0 0 20px; margin: 0; display: flex; gap: 24px; border-bottom: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }.steps span { font-family: var(--font-sans); margin-left: 6px; }.steps .active { color: var(--accent-primary-hover); }
width: calc(100vw - 40px); form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 8px; padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
box-shadow: var(--shadow-raised); .fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: 11px; }
} .recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
dialog[open] { animation: dlg-in var(--dur-base) var(--ease-standard); } .preflight { display: grid; gap: 16px; } .preflight ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; } .preflight li { display: flex; gap: 12px; align-items: flex-start; }.preflight strong { font-size: 13px; font-weight: 550; }.preflight p { color: var(--text-secondary); font-size: 12px; }.check { display: grid; place-items: center; flex: 0 0 22px; height: 22px; border: 1px solid var(--border-strong); border-radius: 50%; font-size: 12px; color: var(--accent-primary-hover); }.blocked .check { color: var(--state-danger); border-color: var(--state-danger); }
@keyframes dlg-in { .identity { padding-top: 20px; border-top: 1px solid var(--border-subtle); }.identity strong { font-family: var(--font-mono); font-size: 15px; overflow-wrap: anywhere; }.identity p { color: var(--text-secondary); margin-top: 6px; }
from { opacity: 0; transform: translateY(6px) scale(0.98); } .launch-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; }.error { padding: 12px; color: var(--state-danger); border: 1px solid var(--state-danger); border-radius: 8px; font-size: 13px; }.success { color: var(--accent-primary-hover); }
to { opacity: 1; transform: translateY(0) scale(1); } a, .text-button { color: var(--accent-primary-hover); }.text-button { background: none; border: 0; cursor: pointer; font: inherit; font-size: 12px; }.save { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.save summary { cursor: pointer; font-size: 13px; }.save p, .save form { margin-top: 16px; }
} @media (max-width: 540px) { .composer { padding: 20px; } .fields { grid-template-columns: 1fr; } .steps { gap: 12px; font-size: 10px; } h2 { font-size: 22px; } }
dialog::backdrop {
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.dlg {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-6);
}
.dlg-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.dlg h3 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
}
.close {
appearance: none;
background: transparent;
border: none;
color: var(--text-tertiary);
font-size: var(--text-xl);
line-height: 1;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
transition: color var(--dur-fast) var(--ease-standard), background-color var(--dur-fast) var(--ease-standard);
}
.close:hover { color: var(--text-primary); background: var(--surface-raised); }
.close:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.fields :global(.field):nth-child(1),
.fields :global(.field):nth-child(4) {
grid-column: 1 / -1;
}
.preview {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-left: 2px solid var(--accent-primary);
border-radius: var(--radius-md);
}
.preview-k {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
.preview-v {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
}
.preview-sub {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
.alert {
margin: 0;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid transparent;
font-size: var(--text-sm);
}
.alert-danger {
background: rgba(255, 86, 112, 0.08);
border-color: rgba(255, 86, 112, 0.28);
color: var(--state-danger);
}
.hint {
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
@media (max-width: 480px) {
.fields { grid-template-columns: 1fr; }
}
</style> </style>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe }: { recipe: LaunchRecipeSummary } = $props();
</script>
<div class="recipe">
<div class="recipe-heading"><div><span class="eyebrow">Launch recipe</span><h3>{recipe.mapName}</h3></div><span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span></div>
<dl>
<div><dt>Configuration</dt><dd>{recipe.configSource}</dd></div>
<div><dt>Container image</dt><dd class="mono">{recipe.image}</dd></div>
</dl>
<details>
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} in this recipe</summary>
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
<p>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.</p>
<p>Future fleet promotions and drift repair still follow the server’s game and map.</p>
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
</details>
</div>
<style>
.recipe { display: grid; gap: 16px; min-width: 0; }
.recipe-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.eyebrow { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; }
h3 { margin: 0; font-size: 22px; letter-spacing: -.03em; }
.game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: 12px; white-space: nowrap; }
dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: 12px; } dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; line-height: 1.5; }
.mono, code, .fingerprint { font-family: var(--font-mono); }
details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: 13px; }
ul { list-style: none; padding: 0; margin: 12px 0; } li { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: 13px; font-weight: 550; } span, code, small { font-size: 11px; } li span, small { color: var(--text-secondary); }
p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
</style>
+14
View File
@@ -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 });
}
}
@@ -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 });
}
@@ -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 });
}
@@ -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 });
}
}
+3 -1
View File
@@ -57,8 +57,9 @@ export async function POST({ request }: { request: Request }) {
mapSlug?: string; mapSlug?: string;
plutoKey?: string; plutoKey?: string;
expectedName?: 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( 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' }, { 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 } { status: 400 }
@@ -70,6 +71,7 @@ export async function POST({ request }: { request: Request }) {
mapSlug: body.mapSlug, mapSlug: body.mapSlug,
plutoKey: body.plutoKey, plutoKey: body.plutoKey,
expectedName: body.expectedName, expectedName: body.expectedName,
previewId: body.previewId,
createdBy: 'ui' createdBy: 'ui'
}); });
if (!result.ok) { if (!result.ok) {
+69 -6
View File
@@ -5,6 +5,7 @@
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte'; import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import type { BlueprintSummary } from '$lib/deployment-plan.js';
interface Deployment { interface Deployment {
id: string; server: string; action: 'create' | 'destroy'; id: string; server: string; action: 'create' | 'destroy';
@@ -16,6 +17,12 @@
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
let open = $state(false); let open = $state(false);
let blueprintId = $state('');
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintsLoading = $state(true);
let blueprintError = $state('');
let removing = $state('');
let confirmRemove = $state('');
let action = $state('all'); let action = $state('all');
let search = $state(''); let search = $state('');
let refreshing = false; let refreshing = false;
@@ -35,9 +42,28 @@
} }
onMount(() => { onMount(() => {
void load(); void load();
void loadBlueprints();
const timer = setInterval(load, 2000); const timer = setInterval(load, 2000);
return () => clearInterval(timer); 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 fmt(value: string) { return new Date(value).toLocaleString(); }
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } } function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
function status(d: Deployment) { function status(d: Deployment) {
@@ -47,11 +73,43 @@
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head> <svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Deployments" subtitle="Server creations and destructions, from work in flight to operation history." /> <SectionHeader title="Deployments" subtitle="Your next game starts here. Save a setup, preview a launch, and follow it all the way online." />
<div class="toolbar"> <div class="launch-banner">
<Button variant="primary" onclick={() => open = true}>Deploy new server</Button> <div><span class="eyebrow">From setup to first round</span><h2>Build it once. Bring it back anytime.</h2><p>Launch from your promoted setup or a saved blueprint, with the files and prerequisites laid out before you commit.</p></div>
<Button variant="primary" onclick={() => { blueprintId = ''; open = true; }}>Create a server →</Button>
</div> </div>
<DeployServerDialog bind:open /> <DeployServerDialog bind:open initialBlueprintId={blueprintId} onblueprint={loadBlueprints} />
<section>
<h2>Your blueprints <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
{#if blueprintsLoading}<p>Loading blueprints…</p>
{:else if !blueprints.length}<div class="blueprint-empty"><strong>A good setup deserves a repeat.</strong><p>Preview a server and choose “Make this a reusable blueprint” to save its map, image reference, and managed files.</p><button onclick={() => { blueprintId = ''; open = true; }}>Create your first blueprint →</button></div>
{:else}
<div class="blueprints">
{#each blueprints as blueprint (blueprint.id)}
<article class="blueprint">
<div class="blueprint-top">
<span class="eyebrow">{blueprint.recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'} · {blueprint.recipe.mapName}</span>
<span class="file-count">{blueprint.recipe.files.length} file{blueprint.recipe.files.length === 1 ? '' : 's'}</span>
</div>
<h3>{blueprint.name}</h3>
<p>{blueprint.description || 'A saved launch recipe, ready for a fresh lobby.'}</p>
<div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; open = true; }}>Preview launch →</Button>
<button class="remove" disabled={!!removing} onclick={() => confirmRemove = blueprint.id}>Remove</button>
</div>
{#if confirmRemove === blueprint.id}
<div class="remove-confirm">
<p>Remove this blueprint? Existing servers and launch history are kept.</p>
<button disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>{removing === blueprint.id ? 'Removing…' : 'Remove blueprint'}</button>
<button disabled={!!removing} onclick={() => confirmRemove = ''}>Keep it</button>
</div>
{/if}
</article>
{/each}
</div>
{/if}
</section>
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if} {#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
<section> <section>
<h2>In flight <span>{active.length}</span></h2> <h2>In flight <span>{active.length}</span></h2>
@@ -87,8 +145,13 @@
<style> <style>
.page { display: flex; flex-direction: column; gap: 28px; } .page { display: flex; flex-direction: column; gap: 28px; }
.toolbar, .row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.toolbar, .row-head { justify-content: space-between; } .row-head { justify-content: space-between; }
.launch-banner { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 24px; border: 1px solid var(--border-strong); border-radius: 16px; padding: 28px; background: linear-gradient(120deg, color-mix(in oklab, var(--accent-primary) 10%, var(--surface)), var(--surface)); }
.launch-banner > div { max-width: 650px; }.launch-banner h2 { font-size: 25px; letter-spacing: -.03em; }.launch-banner p, .blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: 10px; color: var(--text-secondary); margin-bottom: 8px; }
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: 11px; color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
.remove, .blueprint-empty button, .remove-confirm button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.remove-confirm { border-top: 1px solid var(--border-subtle); margin-top: 14px; }.remove-confirm button { margin-right: 12px; }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: 12px; padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
section { display: grid; gap: 16px; } section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; } h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; } a { color: var(--accent); } p { margin: 8px 0; } a { color: var(--accent); }
+52 -6
View File
@@ -4,16 +4,23 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import Badge from '$ui/components/primitives/Badge.svelte'; import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import LaunchRecipe from '$ui/components/LaunchRecipe.svelte';
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
interface Deployment { interface Deployment {
id: string; expected_map?: string; server: string; action: 'create' | 'destroy'; state: string; id: string; expected_map?: string; server: string; action: 'create' | 'destroy'; state: string;
stage: string; started_at: string; finished_at: string | null; stage: string; started_at: string; finished_at: string | null;
triggered_by: string; error: string | null; warnings: string; server_exists: number; triggered_by: string; error: string | null; warnings: string; server_exists: number;
} }
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[] } interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean }
let detail = $state<Detail | null>(null); let detail = $state<Detail | null>(null);
let error = $state(''); let error = $state('');
let refreshing = false; let refreshing = false;
let now = $state(Date.now());
let open = $state(false);
let resuming = $state(false);
let deployment = $derived(detail?.deployment); let deployment = $derived(detail?.deployment);
let active = $derived(['queued', 'awaiting-health', 'waiting', 'running'].includes(deployment?.state ?? '')); let active = $derived(['queued', 'awaiting-health', 'waiting', 'running'].includes(deployment?.state ?? ''));
let warnings = $derived.by(() => { let warnings = $derived.by(() => {
@@ -29,9 +36,43 @@
: ['Starting', 'Preparing server files', 'Creating Komodo deployment', 'Starting container', 'Waiting for Komodo health', 'Waiting for RCON health', 'Waiting for selected map']; : ['Starting', 'Preparing server files', 'Creating Komodo deployment', 'Starting container', 'Waiting for Komodo health', 'Waiting for RCON health', 'Waiting for selected map'];
if (deployment.state === 'waiting') return 1; if (deployment.state === 'waiting') return 1;
if (deployment.state === 'awaiting-health' && deployment.stage.startsWith('Waiting for health verification')) return 4; if (deployment.state === 'awaiting-health' && deployment.stage.startsWith('Waiting for health verification')) return 4;
if (deployment.state === 'done') return stages.length; if (deployment.state === 'done' || deployment.state === 'warning') return stages.length;
return Math.max(0, stages.findIndex(stage => deployment.stage.startsWith(stage))); return Math.max(0, stages.findIndex(stage => deployment.stage.startsWith(stage)));
}); });
let explanation = $derived.by(() => {
if (!deployment) return '';
if (deployment.state === 'done') return deployment.action === 'create'
? 'The container is healthy, RCON responds, and the reported engine map matches your selection.'
: 'The container, deployment, and server registration have been removed.';
if (deployment.state === 'warning') return 'The operation finished with the warnings below. Review any remaining cleanup.';
if (deployment.state === 'failed' || deployment.state === 'interrupted') return detail?.canResumeVerification
? 'The server is still registered. Inspect its status, then resume health verification without creating or restarting a container.'
: 'Inspect the error and any remaining container or files before launching again. The activity below records how far this operation reached.';
if (deployment.state === 'queued') return 'Your request is saved. Provisioning starts when the current server operation releases the worker.';
if (deployment.state === 'waiting') return 'The server stays online while players finish. Removal begins only after a live check confirms it is empty.';
if (deployment.stage.startsWith('Waiting for selected map')) return 'Container and RCON checks passed. Waiting for RCON to report the selected engine map. Inspect the server configuration if this persists.';
if (deployment.stage.startsWith('Waiting for RCON')) return 'The container is healthy. Waiting for the game to answer RCON; check its startup logs, password, and network if this persists.';
if (deployment.state === 'awaiting-health') return 'Waiting for the container to become healthy. Verification runs automatically; startup logs can help explain a delay.';
return deployment.action === 'create' ? 'Installing the saved launch recipe and starting its container. Health checks follow automatically.' : 'Removing the empty server and recording cleanup results.';
});
function duration(start: string, end: number) {
const seconds = Math.max(0, Math.floor((end - new Date(start).getTime()) / 1000));
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`;
}
let stageStarted = $derived(detail?.events.slice().reverse().find(event => event.message === deployment?.stage)?.created_at ?? deployment?.started_at);
async function resume() {
if (!deployment || resuming) return;
resuming = true;
try {
const res = await fetch(`/api/deployments/${encodeURIComponent(deployment.id)}/resume`, { method: 'POST' });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not resume verification.');
await load();
} catch (err) { error = (err as Error).message; }
finally { resuming = false; }
}
function stateLabel(state: string) { function stateLabel(state: string) {
return ({ queued: 'Queued', 'awaiting-health': 'Verifying health', waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' } as Record<string, string>)[state] ?? state; return ({ queued: 'Queued', 'awaiting-health': 'Verifying health', waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' } as Record<string, string>)[state] ?? state;
} }
@@ -49,7 +90,7 @@
} }
$effect(() => { $page.params.id; detail = null; void load(); }); $effect(() => { $page.params.id; detail = null; void load(); });
onMount(() => { onMount(() => {
const timer = setInterval(load, 2000); const timer = setInterval(() => { now = Date.now(); void load(); }, 2000);
return () => clearInterval(timer); return () => clearInterval(timer);
}); });
function fmt(iso: string) { return new Date(iso).toLocaleString(); } function fmt(iso: string) { return new Date(iso).toLocaleString(); }
@@ -67,7 +108,9 @@
{#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if} {#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
</div> </div>
<h2>{deployment.state === 'done' ? (deployment.action === 'destroy' ? 'Server destroyed' : 'Server creation completed') : deployment.stage}</h2> <h2>{deployment.state === 'done' ? (deployment.action === 'destroy' ? 'Server destroyed' : 'Server creation completed') : deployment.stage}</h2>
{#if deployment.state === 'waiting'}<p>The server stays online while players finish. Player count is checked automatically; destruction starts only after a live check reports zero players.</p>{/if} <p class="explanation" aria-live="polite">{explanation}</p>
<div class="timing"><span>Total time <strong>{duration(deployment.started_at, deployment.finished_at ? new Date(deployment.finished_at).getTime() : now)}</strong></span>{#if active && stageStarted}<span>This step <strong>{duration(stageStarted, now)}</strong></span>{/if}</div>
{#if detail?.canResumeVerification}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume health verification</Button></div>{/if}
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if} {#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
{#each warnings as warning}<p class="warning">{warning}</p>{/each} {#each warnings as warning}<p class="warning">{warning}</p>{/each}
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if} {#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
@@ -75,8 +118,9 @@
</section> </section>
<section> <section>
<h2>Operation flow</h2> <h2>Operation flow</h2>
<ol class="flow">{#each steps as step, index}<li class:reached={index < currentStep} class:current={index === currentStep} aria-current={active && index === currentStep ? 'step' : undefined}><span>{index + 1}</span>{step}</li>{/each}</ol> <ol class="flow">{#each steps as step, index}<li class:reached={index < currentStep} class:current={index === currentStep} class:failed={!active && ['failed', 'interrupted'].includes(deployment.state) && index === currentStep} aria-current={active && index === currentStep ? 'step' : undefined}><span>{index < currentStep ? '✓' : index + 1}</span>{step}</li>{/each}</ol>
</section> </section>
{#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch recipe'}</h2><Button onclick={() => open = true}>Launch again →</Button></div><p class="muted">Reuse the original managed files with a fresh lobby and port. Review the launch to save it as a blueprint.</p><LaunchRecipe recipe={detail.recipe} /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if}
<section> <section>
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div> <div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
{#if detail && detail.events.length > 0} {#if detail && detail.events.length > 0}
@@ -89,7 +133,8 @@
<style> <style>
.page { display: grid; gap: 28px; } section { display: grid; gap: 16px; } .page { display: grid; gap: 28px; } section { display: grid; gap: 16px; }
.summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface); } .summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface); }
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .saved-recipe { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface-sunken); }.explanation { color: var(--text-secondary); max-width: 800px; }.timing { display: flex; gap: 24px; font-size: 12px; color: var(--text-secondary); }.timing strong { color: var(--text-primary); margin-left: 8px; font-family: var(--font-mono); font-weight: 500; }
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; overflow-wrap: anywhere; }
h2 { font-size: 18px; margin: 0; } p { margin: 0; line-height: 1.6; } a { color: var(--accent-primary-hover); } h2 { font-size: 18px; margin: 0; } p { margin: 0; line-height: 1.6; } a { color: var(--accent-primary-hover); }
dl { display: flex; gap: 32px; flex-wrap: wrap; margin: 0; } dt, time, .muted { color: var(--text-secondary); font-size: 13px; } dd { margin: 6px 0 0; font-size: 14px; } dl { display: flex; gap: 32px; flex-wrap: wrap; margin: 0; } dt, time, .muted { color: var(--text-secondary); font-size: 13px; } dd { margin: 6px 0 0; font-size: 14px; }
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; } .flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
@@ -98,6 +143,7 @@
.flow li { border: 1px solid var(--border-subtle); color: var(--text-secondary); } .flow li { border: 1px solid var(--border-subtle); color: var(--text-secondary); }
.flow li.reached { color: var(--text-primary); } .flow li.reached { color: var(--text-primary); }
.flow li.current { color: var(--text-primary); border-color: var(--accent-primary); } .flow li.current { color: var(--text-primary); border-color: var(--accent-primary); }
.flow li.failed { border-color: var(--state-danger); }
.timeline { list-style: none; padding: 0 0 0 18px; margin: 0; border-left: 2px solid var(--border-strong); } .timeline { list-style: none; padding: 0 0 0 18px; margin: 0; border-left: 2px solid var(--border-strong); }
.timeline li { position: relative; padding: 0 0 24px 12px; overflow-wrap: anywhere; } .timeline li { position: relative; padding: 0 0 24px 12px; overflow-wrap: anywhere; }
.timeline li::before { content: ''; position: absolute; left: -24px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent-primary); } .timeline li::before { content: ''; position: absolute; left: -24px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent-primary); }
+257
View File
@@ -0,0 +1,257 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
komodoCreateDeployment: vi.fn(async () => ({ ok: true })),
komodoDeploy: vi.fn(async () => ({ ok: true })),
komodoDestroyDeployment: vi.fn(async () => ({ ok: true })),
komodoDeleteDeployment: vi.fn(async () => ({ ok: true })),
komodoInspectHealthy: vi.fn(async () => ({ ok: true, state: 'running' })),
komodoEnsureTag: vi.fn(async (name: string) => ({ ok: true, id: name })),
komodoUpdateTagsOnResource: vi.fn(async () => ({ ok: true }))
}));
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
rconGetPlayerCount: vi.fn(async () => 0),
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '',
status: { players: 0, map: 'zombie_theater', gametype: null, roster: [] } }))
}));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
let fixture: Fixture;
const selection = { game: 't5' as const, mapSlug: 'theater' };
const input = { ...selection, gameMode: 'zombie' as const, plutoKey: 'private-pluto-key' };
beforeEach(async () => {
vi.resetModules(); vi.clearAllMocks();
fixture = await newFixture([]);
vi.stubEnv('KOMODO_ADDRESS', 'http://komodo.test');
vi.stubEnv('KOMODO_API_KEY', 'private-api-key');
vi.stubEnv('KOMODO_API_SECRET', 'private-api-secret');
vi.stubEnv('KOMODO_TARGET_SERVER', 'test-host');
vi.stubEnv('RCON_PASSWORD', 'private-rcon-password');
});
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); });
async function seedArtifacts() {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const baseline = createScript({ name: 'Classic settings', kind: 'baseline', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set sv_voice "1"\n') });
const script = createScript({ name: 'Classic perks', kind: 'script', scopeType: 'game', scopeValue: 't5', targetRelPath: 'perks.gsc', content: Buffer.from('main(){ old_perks(); }') });
for (const item of [baseline, script]) db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(item.version.id, item.script.id);
return { baseline, script };
}
describe('launch recipes and previews', () => {
it('previews without reserving capacity, saving keys, exposing bytes, or calling Komodo', async () => {
await seedArtifacts();
const { previewCreation } = await import('../../src/lib/server/fleet/create.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
const first = previewCreation(selection);
const second = previewCreation(selection);
expect(first).toMatchObject({ name: 't5_zombie_theater_1', port: 28960, hasSavedKey: false, ready: true });
expect(second.name).toBe(first.name);
expect(second.port).toBe(first.port);
expect(first.recipe.files).toHaveLength(3);
expect(first.recipe.files[0]).not.toHaveProperty('content');
expect(db().prepare('SELECT * FROM server_keys').all()).toEqual([]);
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]);
expect(komodoCreateDeployment).not.toHaveBeenCalled();
expect(JSON.stringify(first)).not.toContain('private-');
});
it('launches reviewed bytes even when scripts and configuration are promoted before queueing and provisioning', async () => {
const { baseline, script } = await seedArtifacts();
const { previewCreation, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js');
const preview = previewCreation(selection);
const newScript = uploadNewVersion(script.script.id, Buffer.from('main(){ new_perks(); }'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newScript.id, script.script.id);
const queued = queueCreateServer({ ...input, previewId: preview.id });
if (!queued.ok) throw new Error(queued.message);
const newBaseline = uploadNewVersion(baseline.script.id, Buffer.from('set sv_voice "0"\n'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newBaseline.id, baseline.script.id);
closeDb();
await processCreations();
expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('main(){ old_perks(); }');
expect(await readFile(join(fixture.t5, queued.name, 'configs/dedicated_sp.cfg'), 'utf8')).toContain('set sv_voice "1"');
expect(getDeployment(queued.deploymentId)?.recipe?.fingerprint).toBe(preview.recipe.fingerprint);
expect(getDeployment(queued.deploymentId)?.deployment).toMatchObject({ state: 'done' });
expect(JSON.stringify(getDeployment(queued.deploymentId))).not.toContain('private-');
});
it('freezes recipes for legacy API clients at queue time too', async () => {
const { script } = await seedArtifacts();
const { queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const queued = queueCreateServer(input);
if (!queued.ok) throw new Error(queued.message);
const changed = uploadNewVersion(script.script.id, Buffer.from('changed'));
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(changed.id, script.script.id);
await processCreations();
expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('main(){ old_perks(); }');
});
it('returns the same operation for repeated submission, even after expiry and restart', async () => {
const { previewCreation, queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
const preview = previewCreation(selection);
const queued = queueCreateServer({ ...input, previewId: preview.id });
expect(queued.ok).toBe(true);
db().prepare("UPDATE server_launch_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(preview.id);
closeDb();
expect(queueCreateServer({ ...input, previewId: preview.id, plutoKey: 'replacement-key' })).toEqual(queued);
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(1);
expect(getServerKey(preview.name!)).toBe('private-pluto-key');
});
it('rejects stale slots and ports before saving a key or queueing a second server', async () => {
const { previewCreation, queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
const preview = previewCreation(selection);
expect(queueCreateServer({ ...input, mapSlug: 'moon' }).ok).toBe(true);
expect(queueCreateServer({ ...input, previewId: preview.id })).toMatchObject({ ok: false, kind: 'no-slot' });
expect(getServerKey(preview.name!)).toBeUndefined();
const fresh = previewCreation(selection);
expect(fresh.port).toBe(28961);
expect(queueCreateServer({ ...input, previewId: fresh.id }).ok).toBe(true);
expect(queueCreateServer({ ...input, previewId: preview.id })).toMatchObject({ ok: false, kind: 'no-slot' });
expect(getServerKey('t5_zombie_theater_2')).toBeUndefined();
});
it('does not provision an already accepted preview again through internal callers', async () => {
const { previewCreation, createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoCreateDeployment, komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js');
const preview = previewCreation(selection);
const first = await createServer({ ...input, previewId: preview.id });
expect(first.ok).toBe(true);
expect(await createServer({ ...input, previewId: preview.id })).toEqual(first);
expect(komodoCreateDeployment).toHaveBeenCalledTimes(1);
expect(komodoDeploy).toHaveBeenCalledTimes(1);
});
it('carries preview identity through the public creation API and rejects malformed requests', async () => {
const { POST: preview } = await import('../../src/routes/api/deployments/preview/+server.js');
const { POST: launch } = await import('../../src/routes/api/servers/+server.js');
const request = (body: unknown) => ({ request: new Request('http://localhost/api', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) });
for (const body of [null, [], { game: 't7', mapSlug: 'theater' }]) expect((await preview(request(body))).status).toBe(400);
const response = await preview(request(selection));
const plan = await response.json();
expect(response.status).toBe(200);
expect((await launch(request({ ...input, previewId: '' }))).status).toBe(400);
const first = await launch(request({ ...input, previewId: plan.id, expectedName: plan.name }));
expect(first.status).toBe(202);
const retry = await launch(request({ ...input, previewId: plan.id, expectedName: plan.name }));
expect(await retry.json()).toEqual(await first.json());
});
it('rejects expired, missing, and mismatched previews', async () => {
const { previewCreation, queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
const { db } = await import('../../src/lib/server/db/index.js');
const preview = previewCreation(selection);
expect(queueCreateServer({ ...input, mapSlug: 'moon', previewId: preview.id })).toMatchObject({ ok: false, kind: 'bad-request' });
db().prepare("UPDATE server_launch_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(preview.id);
expect(queueCreateServer({ ...input, previewId: preview.id })).toMatchObject({ ok: false, kind: 'bad-request' });
expect(queueCreateServer({ ...input, previewId: 'missing' })).toMatchObject({ ok: false, kind: 'bad-request' });
expect(db().prepare('SELECT * FROM server_keys').all()).toEqual([]);
});
it('reports configuration blockers while still allowing recipes to be saved', async () => {
vi.stubEnv('KOMODO_TARGET_SERVER', ''); vi.stubEnv('RCON_PASSWORD', '');
const { previewCreation, queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
const preview = previewCreation(selection);
expect(preview.ready).toBe(false);
expect(preview.checks.filter(c => c.state === 'blocked')).toHaveLength(2);
expect(saveBlueprint({ name: 'For later', previewId: preview.id }).name).toBe('For later');
expect(queueCreateServer({ ...input, previewId: preview.id })).toMatchObject({ ok: false, kind: 'unconfigured' });
});
it('keeps blueprint bytes after library deletion and blueprint removal, and allocates a new lobby on replay', async () => {
const { script } = await seedArtifacts();
const { previewCreation, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
const { saveBlueprint, deleteBlueprint, listBlueprints, recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const blueprint = saveBlueprint({ name: 'Classic night', description: 'Original perks', previewId: previewCreation(selection).id });
db().prepare('UPDATE scripts SET current_version_id = NULL WHERE id = ?').run(script.script.id);
db().prepare('DELETE FROM scripts WHERE id = ?').run(script.script.id);
closeDb();
expect(listBlueprints()[0]).toEqual(blueprint);
const preview = previewCreation({ blueprintId: blueprint.id });
deleteBlueprint(blueprint.id);
const queued = queueCreateServer({ ...input, previewId: preview.id });
if (!queued.ok) throw new Error(queued.message);
await processCreations();
expect(await readFile(join(fixture.t5, queued.name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('main(){ old_perks(); }');
expect(recipeForDeployment(queued.deploymentId)?.blueprintName).toBe('Classic night');
const replay = previewCreation({ deploymentId: queued.deploymentId });
expect(replay).toMatchObject({ name: 't5_zombie_theater_2', port: 28961, hasSavedKey: false });
expect(replay.recipe.fingerprint).toBe(blueprint.recipe.fingerprint);
expect(saveBlueprint({ name: 'Restored', deploymentId: queued.deploymentId }).recipe.fingerprint).toBe(blueprint.recipe.fingerprint);
});
it('preserves the exact T6 map variant in a blueprint', async () => {
const { previewCreation, queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
const preview = previewCreation({ game: 't6', mapSlug: 'town' });
const blueprint = saveBlueprint({ name: 'Town survival', previewId: preview.id });
const saved = previewCreation({ blueprintId: blueprint.id });
expect(saved.recipe).toMatchObject({ game: 't6', mapSlug: 'town', expectedMap: 'zm_transit', configFile: 'dedicated_zm.cfg' });
const queued = queueCreateServer({ ...input, game: 't6', mapSlug: 'town', previewId: saved.id });
if (!queued.ok) throw new Error(queued.message);
await processCreations();
expect(await readFile(join(fixture.t6, queued.name, 'configs/electric_cherry_map.cfg'), 'utf8')).toContain('execgts zm_standard_town.cfg map zm_transit');
});
it('validates blueprint names, sources, and case-insensitive duplicates', async () => {
const { previewCreation } = await import('../../src/lib/server/fleet/create.js');
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
const previewId = previewCreation(selection).id;
expect(() => saveBlueprint({ name: '', previewId })).toThrow(/name/);
expect(() => saveBlueprint({ name: 'Valid', previewId, deploymentId: 'also' })).toThrow(/exactly one/);
expect(() => saveBlueprint({ name: 'Valid', previewId: 'missing' })).toThrow(/unavailable/);
saveBlueprint({ name: 'Classic Night', previewId });
expect(() => saveBlueprint({ name: ' classic night ', previewId })).toThrow(/already exists/);
});
});
describe('resuming verification', () => {
it('resumes an interrupted creation without creating or restarting its container again', async () => {
const { createServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { interruptDeployments, getDeployment, resumeVerification } = await import('../../src/lib/server/fleet/deployments.js');
const { komodoCreateDeployment, komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js');
const created = await createServer(input);
if (!created.ok) throw new Error(created.message);
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(created.deploymentId);
interruptDeployments();
expect(getDeployment(created.deploymentId)?.canResumeVerification).toBe(true);
expect(resumeVerification(created.deploymentId)).toBe(true);
expect(resumeVerification(created.deploymentId)).toBe(false);
await processCreations();
expect(getDeployment(created.deploymentId)?.deployment).toMatchObject({ state: 'done', error: null });
expect(komodoCreateDeployment).toHaveBeenCalledTimes(1);
expect(komodoDeploy).toHaveBeenCalledTimes(1);
});
it('refuses to observe a replacement server or interfere with a later destruction', async () => {
const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { finishDeployment, resumeVerification, getDeployment } = await import('../../src/lib/server/fleet/deployments.js');
const created = await createServer(input);
if (!created.ok) throw new Error(created.message);
finishDeployment(created.deploymentId, { ok: false, message: 'Interrupted' });
db().prepare('UPDATE servers SET game_port = game_port + 1 WHERE name = ?').run(created.name);
expect(resumeVerification(created.deploymentId)).toBe(false);
db().prepare('UPDATE servers SET game_port = ? WHERE name = ?').run(created.port, created.name);
expect(getDeployment(created.deploymentId)?.canResumeVerification).toBe(true);
destroyServer(created.name);
expect(resumeVerification(created.deploymentId)).toBe(false);
});
});