Add deployment blueprints and reviewed launch recipes
This commit is contained in:
@@ -68,6 +68,17 @@ directly.
|
||||
No token, no session, no auth freeze. If Komodo or RCON aren't configured,
|
||||
the sweep marks pending rows `manual` so nothing hangs forever.
|
||||
|
||||
## Deployment blueprints and launch previews
|
||||
|
||||
Deployments now includes a blueprint library and a two-step launch composer.
|
||||
Preview a promoted game/map setup, save its managed files as a reusable blueprint,
|
||||
or launch again from a recorded deployment. Previews check configuration and
|
||||
available capacity; every queued creation retains its own launch recipe.
|
||||
The progress page explains waiting conditions, shows elapsed time, and can resume
|
||||
health verification of an interrupted creation when its original server remains.
|
||||
See [Deployment blueprints](docs/deployment-blueprints.md) for the workflow,
|
||||
reproducibility boundaries, and API details.
|
||||
|
||||
## Saved server keys
|
||||
|
||||
Use **Keys** to add, replace, or remove a Plutonium key for an exact
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -335,6 +335,32 @@ CREATE TABLE IF NOT EXISTS server_creation_jobs (
|
||||
expected_map TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Launch recipes copy managed files, not credentials from the key/integration stores.
|
||||
-- Independent of the artifact library so history survives version deletion.
|
||||
CREATE TABLE IF NOT EXISTS server_blueprints (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
recipe TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_launch_previews (
|
||||
id TEXT PRIMARY KEY,
|
||||
expires_at TEXT NOT NULL,
|
||||
server TEXT,
|
||||
port INTEGER,
|
||||
recipe TEXT NOT NULL,
|
||||
blueprint_name TEXT,
|
||||
deployment_id TEXT REFERENCES server_deployments(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_launch_recipes (
|
||||
deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id) ON DELETE CASCADE,
|
||||
recipe TEXT NOT NULL,
|
||||
blueprint_name TEXT
|
||||
);
|
||||
|
||||
-- Persist state transitions atomically, including changes made by background workers.
|
||||
-- Polls with unchanged status/error do not produce duplicate activity entries.
|
||||
CREATE TABLE IF NOT EXISTS beta_events (
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
|
||||
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { LaunchPreview } from '$lib/deployment-plan.js';
|
||||
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from './launch-recipes.js';
|
||||
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { config, destOfKind, serverDir, gameOfServer, configFileForGame } from '../config.js';
|
||||
@@ -19,10 +22,9 @@ import {
|
||||
type DeploymentEnvVar
|
||||
} from '../integrations/komodo.js';
|
||||
import { kick } from '../pipeline/worker.js';
|
||||
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
|
||||
import { MAP_CONFIG_FILE } from './server-config.js';
|
||||
import { recordScript } from './inventory.js';
|
||||
import { sha256Buffer } from './files.js';
|
||||
import { promotedArtifacts } from './promoted-artifacts.js';
|
||||
import { getVersion } from './scripts.js';
|
||||
|
||||
export type CreateServerFailure =
|
||||
| 'bad-request'
|
||||
@@ -48,6 +50,7 @@ export interface CreateServerInput {
|
||||
mapSlug: string; // 'cosmodrome'
|
||||
plutoKey?: string; // saved for this server ID when supplied
|
||||
expectedName?: string; // optional preview guard; never selects a lobby
|
||||
previewId?: string;
|
||||
createdBy?: string; // 'ui' by default
|
||||
}
|
||||
|
||||
@@ -121,11 +124,12 @@ function buildSpec(args: {
|
||||
port: number;
|
||||
hostDir: string;
|
||||
plutoKey: string;
|
||||
recipe: LaunchRecipe;
|
||||
}): CreateDeploymentSpec {
|
||||
return {
|
||||
name: args.name,
|
||||
server: config.komodo.targetServer,
|
||||
image: config.komodo.defaultImage,
|
||||
image: args.recipe.image,
|
||||
network: config.komodo.defaultNetwork,
|
||||
// Container listens on PLUTAINER_PORT (== args.port), so host and
|
||||
// container ports match — no NAT inside the bridge network.
|
||||
@@ -146,11 +150,74 @@ interface PreparedCreation {
|
||||
ok: true; name: string; port: number; plutoKey: string; mapEntry: ZombieMapEntry;
|
||||
}
|
||||
|
||||
interface PreviewRow {
|
||||
id: string; expires_at: string; server: string | null; port: number | null;
|
||||
recipe: string; blueprint_name: string | null; deployment_id: string | null;
|
||||
}
|
||||
|
||||
/** Read-only checks; a preview never reserves a slot or writes credentials. */
|
||||
export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blueprintId?: unknown; deploymentId?: unknown }): LaunchPreview {
|
||||
if (input.blueprintId !== undefined && input.deploymentId !== undefined) throw new Error('Choose one starting point.');
|
||||
let recipe: LaunchRecipe;
|
||||
let blueprintName: string | null = null;
|
||||
if (input.blueprintId !== undefined) {
|
||||
if (typeof input.blueprintId !== 'string') throw new Error('Invalid blueprint.');
|
||||
({ recipe, blueprintName } = blueprintRecipe(input.blueprintId));
|
||||
} else if (input.deploymentId !== undefined) {
|
||||
if (typeof input.deploymentId !== 'string') throw new Error('Invalid deployment.');
|
||||
const source = recipeForDeployment(input.deploymentId);
|
||||
if (!source) throw new Error('This deployment has no recorded launch recipe.');
|
||||
({ recipe, blueprintName } = source);
|
||||
} else {
|
||||
if ((input.game !== 't5' && input.game !== 't6') || typeof input.mapSlug !== 'string') throw new Error('Choose a supported game and map.');
|
||||
recipe = captureRecipe(input.game, input.mapSlug);
|
||||
}
|
||||
const lobby = nextLobbyNumber(`${recipe.game}_zombie_${recipe.mapSlug}`);
|
||||
const name = lobby === null ? null : `${recipe.game}_zombie_${recipe.mapSlug}_${lobby}`;
|
||||
const port = nextAvailablePort();
|
||||
const configured = !!(config.komodo.address && config.komodo.apiKey && config.komodo.apiSecret && config.komodo.targetServer);
|
||||
const compatible = recipe.configFile === configFileForGame(recipe.game);
|
||||
const checks: LaunchPreview['checks'] = [
|
||||
{ label: 'Komodo settings', state: configured ? 'pass' : 'blocked', detail: configured ? 'Connection settings and target host are configured.' : 'Configure Komodo credentials and a target host before launching.' },
|
||||
{ label: 'Health verification', state: config.rcon.password ? 'pass' : 'blocked', detail: config.rcon.password ? 'RCON is configured. Container, RCON, and map checks run after launch.' : 'Configure RCON_PASSWORD so the new server can be verified.' },
|
||||
{ label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All 20 lobbies for this map are occupied or reserved.' },
|
||||
{ label: 'Game port', state: port === null ? 'blocked' : 'pass', detail: port === null ? 'No free game port. Free capacity before launching.' : `${port} / UDP and TCP. Availability is checked again at launch.` },
|
||||
{ label: 'Configuration layout', state: compatible ? 'pass' : 'blocked', detail: compatible ? recipe.configSource : 'The configured filename has changed since this recipe was saved. Create a fresh recipe.' },
|
||||
{ label: 'Game files and host readiness', state: 'info', detail: 'The host must have the selected game and DLC files. This preview does not probe volumes, images, credentials, or network access.' }
|
||||
];
|
||||
const id = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
db().prepare('DELETE FROM server_launch_previews WHERE expires_at < ? AND deployment_id IS NULL').run(new Date().toISOString());
|
||||
db().prepare('INSERT INTO server_launch_previews (id, expires_at, server, port, recipe, blueprint_name) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(id, expiresAt, name, port, JSON.stringify(recipe), blueprintName);
|
||||
return { id, expiresAt, name, port, hasSavedKey: !!name && !!getServerKey(name), recipe: summarizeRecipe(recipe), blueprintName,
|
||||
checks, ready: checks.every(check => check.state !== 'blocked') };
|
||||
}
|
||||
|
||||
export function queueCreateServer(input: CreateServerInput): CreateServerResult {
|
||||
return db().transaction((): CreateServerResult => {
|
||||
const preview = input.previewId ? db().prepare('SELECT * FROM server_launch_previews WHERE id = ?').get(input.previewId) as PreviewRow | undefined : undefined;
|
||||
if (input.previewId && !preview) return { ok: false, kind: 'bad-request', message: 'Launch preview expired or was not found. Review a fresh preview.' };
|
||||
const recipe: LaunchRecipe | undefined = preview ? JSON.parse(preview.recipe) : undefined;
|
||||
if (recipe && (recipe.game !== (input.game ?? 't5') || recipe.mapSlug !== input.mapSlug || input.gameMode !== 'zombie')) {
|
||||
return { ok: false, kind: 'bad-request', message: 'The selection does not match the reviewed launch recipe.' };
|
||||
}
|
||||
// A repeated submit after a lost response returns the same operation.
|
||||
if (preview?.deployment_id) return { ok: true, name: preview.server!, port: preview.port!, deploymentId: preview.deployment_id };
|
||||
if (preview && preview.expires_at <= new Date().toISOString()) return { ok: false, kind: 'bad-request', message: 'Launch preview expired. Review a fresh preview.' };
|
||||
if (preview && (!preview.server || !preview.port || preview.server !== nextName(input) || preview.port !== nextAvailablePort())) {
|
||||
return { ok: false, kind: 'no-slot', message: 'Available lobby or port changed. Refresh the preview and review the new server key.' };
|
||||
}
|
||||
if (recipe && (recipe.configFile !== configFileForGame(recipe.game) || !config.rcon.password)) {
|
||||
return { ok: false, kind: 'unconfigured', message: 'Configuration or health-check settings changed. Refresh the preview.' };
|
||||
}
|
||||
const prepared = prepareCreation(input);
|
||||
if (!prepared.ok) return prepared;
|
||||
const frozenRecipe = recipe ?? captureRecipe(input.game ?? 't5', prepared.mapEntry.slug);
|
||||
const id = beginDeployment(prepared.name, 'create', input.createdBy);
|
||||
db().prepare('INSERT INTO server_launch_recipes (deployment_id, recipe, blueprint_name) VALUES (?, ?, ?)')
|
||||
.run(id, JSON.stringify(frozenRecipe), preview?.blueprint_name ?? null);
|
||||
if (preview) db().prepare('UPDATE server_launch_previews SET deployment_id = ? WHERE id = ?').run(id, preview.id);
|
||||
db().prepare("UPDATE server_deployments SET state = 'queued' WHERE id = ?").run(id);
|
||||
db().prepare('INSERT INTO server_creation_jobs (deployment_id, server, port, game_mode, map_slug, expected_map) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(id, prepared.name, prepared.port, input.gameMode, prepared.mapEntry.slug, prepared.mapEntry.canonical);
|
||||
@@ -159,11 +226,19 @@ export function queueCreateServer(input: CreateServerInput): CreateServerResult
|
||||
})();
|
||||
}
|
||||
|
||||
function nextName(input: CreateServerInput): string | null {
|
||||
const base = `${input.game ?? 't5'}_${input.gameMode}_${input.mapSlug}`;
|
||||
const lobby = nextLobbyNumber(base);
|
||||
return lobby === null ? null : `${base}_${lobby}`;
|
||||
}
|
||||
|
||||
// Await provisioning for internal callers; completion still requires worker health verification.
|
||||
export async function createServer(input: CreateServerInput): Promise<CreateServerResult> {
|
||||
if (creating || destroying.size > 0) return { ok: false, kind: 'no-slot', message: 'Another server operation is in progress. Please retry shortly.' };
|
||||
const queued = queueCreateServer(input);
|
||||
if (!queued.ok) return queued;
|
||||
const existing = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(queued.deploymentId) as { state: string };
|
||||
if (existing.state !== 'queued') return queued;
|
||||
return await provisionCreation(queued.deploymentId);
|
||||
}
|
||||
|
||||
@@ -218,49 +293,36 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
|
||||
async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> {
|
||||
const { name, port, plutoKey, mapEntry } = prepared;
|
||||
const game = gameOfServer(name);
|
||||
const configFile = configFileForGame(game);
|
||||
const recipe = recipeForDeployment(operation)?.recipe ?? captureRecipe(game, mapEntry.slug);
|
||||
const configFile = recipe.configFile;
|
||||
if (configFile !== configFileForGame(game)) return { ok: false, kind: 'bad-request', message: 'Server config filename changed after this launch was queued.' };
|
||||
deploymentStage(operation, 'Preparing server files');
|
||||
const hostDir = serverDir(name);
|
||||
const initialConfig = getPromotedServerConfig(game, mapEntry.canonical, mapEntry.slug);
|
||||
const mapConfig = getServerMapConfig(game, mapEntry.canonical, mapEntry.slug)!;
|
||||
const initialArtifacts = promotedArtifacts(game, mapEntry.canonical);
|
||||
|
||||
// Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly
|
||||
// after — plutainer expects to be able to write to /home/plutainer/app.
|
||||
try {
|
||||
await mkdir(hostDir, { recursive: true, mode: 0o777 });
|
||||
await chmod(hostDir, 0o777);
|
||||
for (const { kind, script, version } of initialArtifacts) {
|
||||
const dest = destOfKind(name, kind);
|
||||
const target = join(dest, script.target_rel_path);
|
||||
for (const file of recipe.files) {
|
||||
const dest = destOfKind(name, file.kind);
|
||||
const target = join(dest, file.path);
|
||||
await mkdir(dirname(target), { recursive: true, mode: 0o777 });
|
||||
// Make every newly created ancestor writable by Plutainer, even
|
||||
// when the manager's umask removed write permissions at mkdir.
|
||||
for (let dir = dirname(target); dir !== join(hostDir) && dir !== dirname(dir); dir = dirname(dir)) {
|
||||
await chmod(dir, 0o777);
|
||||
}
|
||||
await writeFile(target, version.content);
|
||||
await writeFile(target, Buffer.from(file.content, 'base64'));
|
||||
await chmod(target, 0o666);
|
||||
}
|
||||
const mapConfigPath = join(destOfKind(name, 'config'), MAP_CONFIG_FILE);
|
||||
await mkdir(dirname(mapConfigPath), { recursive: true, mode: 0o777 });
|
||||
await chmod(dirname(mapConfigPath), 0o777);
|
||||
await writeFile(mapConfigPath, mapConfig);
|
||||
await chmod(mapConfigPath, 0o666);
|
||||
if (initialConfig) {
|
||||
const configPath = join(destOfKind(name, 'config'), configFile);
|
||||
await mkdir(dirname(configPath), { recursive: true, mode: 0o777 });
|
||||
await chmod(dirname(configPath), 0o777);
|
||||
await writeFile(configPath, initialConfig);
|
||||
await chmod(configPath, 0o666);
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, kind: 'fs', message: `failed to create ${hostDir}: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
// Step 2: Komodo CreateDeployment.
|
||||
deploymentStage(operation, 'Creating Komodo deployment');
|
||||
const spec = buildSpec({ game, name, port, hostDir, plutoKey });
|
||||
const spec = buildSpec({ game, name, port, hostDir, plutoKey, recipe });
|
||||
const createRes = await komodoCreateDeployment(spec);
|
||||
if (!createRes.ok) {
|
||||
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
|
||||
@@ -283,15 +345,12 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
|
||||
).run(
|
||||
name, name, game, mapEntry.canonical,
|
||||
input.gameMode, mapEntry.slug, hostDir,
|
||||
configFile, game === 't6' ? 't6zm' : 't5sp', config.komodo.defaultImage,
|
||||
configFile, game === 't6' ? 't6zm' : 't5sp', recipe.image,
|
||||
port, input.createdBy ?? 'ui'
|
||||
);
|
||||
if (initialConfig) {
|
||||
recordScript(name, configFile, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create');
|
||||
}
|
||||
recordScript(name, MAP_CONFIG_FILE, null, sha256Buffer(mapConfig), mapConfig.byteLength, 'create-map');
|
||||
for (const { script, version } of initialArtifacts) {
|
||||
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, 'create');
|
||||
for (const file of recipe.files) {
|
||||
const versionId = file.versionId && getVersion(file.versionId) ? file.versionId : null;
|
||||
recordScript(name, file.path, versionId, file.sha256, file.size, file.path === MAP_CONFIG_FILE ? 'create-map' : 'create');
|
||||
}
|
||||
})();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { recipeForDeployment, summarizeRecipe } from './launch-recipes.js';
|
||||
|
||||
export function beginDeployment(server: string, action: 'create' | 'destroy', triggeredBy = 'ui'): string {
|
||||
const id = randomUUID();
|
||||
@@ -25,7 +26,29 @@ export function getDeployment(id: string) {
|
||||
FROM server_deployments d LEFT JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE d.id = ?`).get(id);
|
||||
if (!deployment) return null;
|
||||
const events = db().prepare('SELECT id, created_at, message FROM server_deployment_events WHERE deployment_id = ? ORDER BY id').all(id);
|
||||
return { deployment, events };
|
||||
const launch = recipeForDeployment(id);
|
||||
return { deployment, events, recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null,
|
||||
canResumeVerification: canResumeVerification(id) };
|
||||
}
|
||||
|
||||
export function canResumeVerification(id: string): boolean {
|
||||
return !!db().prepare(`SELECT d.id FROM server_deployments d
|
||||
JOIN server_creation_jobs j ON j.deployment_id = d.id
|
||||
JOIN servers s ON s.name = j.server AND s.game_port = j.port
|
||||
WHERE d.id = ? AND d.action = 'create' AND d.state IN ('failed', 'interrupted')
|
||||
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`)
|
||||
.get(id);
|
||||
}
|
||||
|
||||
/** Resume observation only. Never repeat a container creation or restart. */
|
||||
export function resumeVerification(id: string): boolean {
|
||||
return db().transaction(() => {
|
||||
if (!canResumeVerification(id)) return false;
|
||||
db().prepare("UPDATE server_deployments SET state = 'awaiting-health', finished_at = NULL, error = NULL WHERE id = ?").run(id);
|
||||
recordDeploymentEvent(id, 'Resumed verification of the existing server');
|
||||
deploymentStage(id, 'Waiting for Komodo health');
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
export function finishDeployment(id: string, result: { ok: boolean; message?: string; warnings?: string[] }): void {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,302 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Field from './primitives/Field.svelte';
|
||||
import Input from './primitives/Input.svelte';
|
||||
import Select from './primitives/Select.svelte';
|
||||
|
||||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||||
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.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 game = $state<ZombieGame>('t5');
|
||||
let mapSlug = $state('');
|
||||
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
|
||||
let blueprints = $state<BlueprintSummary[]>([]);
|
||||
let blueprintId = $state('');
|
||||
let maps = $derived(catalogs[game]);
|
||||
let mapsError = $state<string | null>(null);
|
||||
let mapsLoaded = $state(false);
|
||||
|
||||
let gameMode = $state<'zombie'>('zombie');
|
||||
let mapSlug = $state<string>('');
|
||||
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
|
||||
let preview = $state<LaunchPreview | null>(null);
|
||||
let plutoKey = $state('');
|
||||
let savedIds = $state<string[]>([]);
|
||||
let deployedIds = $state<string[]>([]);
|
||||
let keysLoaded = $state(false);
|
||||
let deploying = $state(false);
|
||||
let flash = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
let loaded = $state(false);
|
||||
let error = $state('');
|
||||
let blueprintName = $state('');
|
||||
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(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !wasOpen) {
|
||||
reset();
|
||||
dialog.showModal();
|
||||
} else if (!open && wasOpen) {
|
||||
if (dialog.open) dialog.close();
|
||||
}
|
||||
wasOpen = open;
|
||||
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); }
|
||||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
||||
});
|
||||
|
||||
function reset(): void {
|
||||
plutoKey = '';
|
||||
void loadKeys();
|
||||
flash = null;
|
||||
deploying = false;
|
||||
if (maps.length > 0 && !maps.some((m) => m.slug === mapSlug)) mapSlug = maps[0].slug;
|
||||
}
|
||||
async function loadKeys() {
|
||||
keysLoaded = false;
|
||||
async function initialize() {
|
||||
const current = ++session;
|
||||
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false;
|
||||
blueprintName = ''; description = ''; savedName = ''; saving = false;
|
||||
blueprintId = initialBlueprintId;
|
||||
try {
|
||||
const [keysRes, serversRes] = await Promise.all([fetch('/api/server-keys'), fetch('/api/servers')]);
|
||||
if (!keysRes.ok || !serversRes.ok) throw new Error('Could not load saved keys and server slots. Reopen this dialog to retry.');
|
||||
savedIds = (await keysRes.json()).keys.map((k: { server_id: string }) => k.server_id);
|
||||
const fleet = await serversRes.json();
|
||||
deployedIds = [...fleet.servers.map((s: { name: string }) => s.name), ...(fleet.reservedNames ?? [])];
|
||||
keysLoaded = true;
|
||||
} catch (err) { flash = (err as Error).message; }
|
||||
}
|
||||
function close(): void { open = false; }
|
||||
|
||||
function onDialogClick(e: MouseEvent): void {
|
||||
if (e.target === dialog) close();
|
||||
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
|
||||
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and blueprints. Try again.');
|
||||
const [mapBody, blueprintBody] = await Promise.all([mapRes.json(), blueprintRes.json()]);
|
||||
if (current !== session) return;
|
||||
catalogs = { t5: mapBody.t5Zombie, t6: mapBody.t6Zombie };
|
||||
blueprints = blueprintBody.blueprints;
|
||||
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.');
|
||||
loaded = true;
|
||||
if (sourceDeploymentId) await review();
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
}
|
||||
|
||||
let lobby = $derived(Array.from({ length: 20 }, (_, i) => i + 1)
|
||||
.find(n => !deployedIds.includes(`${game}_${gameMode}_${mapSlug}_${n}`)) ?? null);
|
||||
let selectedMap = $derived(maps.find((m) => m.slug === mapSlug));
|
||||
let previewName = $derived(selectedMap && keysLoaded && lobby !== null ? `${game}_${gameMode}_${selectedMap.slug}_${lobby}` : '');
|
||||
|
||||
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;
|
||||
async function review() {
|
||||
if (busy) return;
|
||||
const current = session;
|
||||
busy = true; error = ''; plutoKey = ''; preview = null; savedName = '';
|
||||
try {
|
||||
const res = await fetch('/api/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ game, gameMode, mapSlug: selectedMap.slug, expectedName: previewName, plutoKey: plutoKey.trim() || undefined })
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; name?: string; port?: number; deploymentId?: string; error?: string; kind?: string };
|
||||
if (!res.ok || !body.ok || !body.deploymentId) {
|
||||
flash = body.error ?? 'Deploy failed';
|
||||
if (body.kind === 'no-slot') await loadKeys();
|
||||
deploying = false;
|
||||
return;
|
||||
const res = await fetch('/api/deployments/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : blueprintId ? { blueprintId } : { game, mapSlug }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
|
||||
if (current === session) preview = body;
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
finally { if (current === session) busy = false; }
|
||||
}
|
||||
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)}`);
|
||||
} catch (err) {
|
||||
flash = (err as Error).message;
|
||||
deploying = false;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { busy = 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>
|
||||
|
||||
<dialog bind:this={dialog} onclick={onDialogClick} onclose={() => (open = false)}>
|
||||
<form class="dlg" onsubmit={submit}>
|
||||
<header class="dlg-head">
|
||||
<h3>Deploy new server</h3>
|
||||
<button type="button" class="close" onclick={close} aria-label="Close">×</button>
|
||||
</header>
|
||||
|
||||
{#if mapsError}
|
||||
<div class="alert alert-danger">Failed to load map catalog: {mapsError}</div>
|
||||
{:else if !mapsLoaded}
|
||||
<div class="hint">Loading map catalog…</div>
|
||||
{:else}
|
||||
<div class="fields">
|
||||
<Field label="Game">
|
||||
<Select bind:value={game} options={[{ value: 't5', label: 'Black Ops 1 (T5)' }, { value: 't6', label: 'Black Ops II (T6)' }]} disabled={deploying} />
|
||||
</Field>
|
||||
|
||||
<Field label="Mode">
|
||||
<Select bind:value={gameMode} options={modeOptions} disabled={deploying} />
|
||||
</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}
|
||||
<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}>
|
||||
<div class="composer">
|
||||
<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>
|
||||
<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>
|
||||
{#if error}<div class="error" role="alert">{error}</div>{/if}
|
||||
{#if !loaded}
|
||||
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and blueprints…'}</p>
|
||||
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||||
{:else if preview}
|
||||
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From blueprint: {preview.blueprintName}</p>{/if}</section>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<style>
|
||||
dialog {
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0;
|
||||
max-width: 540px;
|
||||
width: calc(100vw - 40px);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
dialog[open] { animation: dlg-in var(--dur-base) var(--ease-standard); }
|
||||
@keyframes dlg-in {
|
||||
from { opacity: 0; transform: translateY(6px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
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; }
|
||||
}
|
||||
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); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
|
||||
.composer { display: grid; gap: 24px; padding: 30px; } header, .section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
||||
.eyebrow { font-size: 10px; color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
|
||||
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
|
||||
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
|
||||
.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); }
|
||||
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; }
|
||||
.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); }
|
||||
.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); }
|
||||
.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; }
|
||||
.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); }
|
||||
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; } }
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,9 @@ export async function POST({ request }: { request: Request }) {
|
||||
mapSlug?: string;
|
||||
plutoKey?: string;
|
||||
expectedName?: string;
|
||||
previewId?: string;
|
||||
};
|
||||
if (!body || (body.game !== undefined && body.game !== 't5' && body.game !== 't6') || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string')) {
|
||||
if (!body || (body.game !== undefined && body.game !== 't5' && body.game !== 't6') || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string') || (body.previewId !== undefined && (typeof body.previewId !== 'string' || !body.previewId))) {
|
||||
return json(
|
||||
{ ok: false, error: 'game must be t5 or t6; gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings', kind: 'bad-request' },
|
||||
{ status: 400 }
|
||||
@@ -70,6 +71,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
mapSlug: body.mapSlug,
|
||||
plutoKey: body.plutoKey,
|
||||
expectedName: body.expectedName,
|
||||
previewId: body.previewId,
|
||||
createdBy: 'ui'
|
||||
});
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||
import type { BlueprintSummary } from '$lib/deployment-plan.js';
|
||||
|
||||
interface Deployment {
|
||||
id: string; server: string; action: 'create' | 'destroy';
|
||||
@@ -16,6 +17,12 @@
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let open = $state(false);
|
||||
let blueprintId = $state('');
|
||||
let blueprints = $state<BlueprintSummary[]>([]);
|
||||
let blueprintsLoading = $state(true);
|
||||
let blueprintError = $state('');
|
||||
let removing = $state('');
|
||||
let confirmRemove = $state('');
|
||||
let action = $state('all');
|
||||
let search = $state('');
|
||||
let refreshing = false;
|
||||
@@ -35,9 +42,28 @@
|
||||
}
|
||||
onMount(() => {
|
||||
void load();
|
||||
void loadBlueprints();
|
||||
const timer = setInterval(load, 2000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
async function loadBlueprints() {
|
||||
try {
|
||||
const res = await fetch('/api/blueprints');
|
||||
if (!res.ok) throw new Error('Could not load your blueprints.');
|
||||
blueprints = (await res.json()).blueprints;
|
||||
blueprintError = '';
|
||||
} catch (err) { blueprintError = (err as Error).message; }
|
||||
finally { blueprintsLoading = false; }
|
||||
}
|
||||
async function removeBlueprint(id: string) {
|
||||
removing = id; blueprintError = '';
|
||||
try {
|
||||
const res = await fetch('/api/blueprints/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Could not remove blueprint.');
|
||||
blueprints = blueprints.filter(b => b.id !== id); confirmRemove = '';
|
||||
} catch (err) { blueprintError = (err as Error).message; }
|
||||
finally { removing = ''; }
|
||||
}
|
||||
function fmt(value: string) { return new Date(value).toLocaleString(); }
|
||||
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
||||
function status(d: Deployment) {
|
||||
@@ -47,11 +73,43 @@
|
||||
|
||||
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Deployments" subtitle="Server creations and destructions, from work in flight to operation history." />
|
||||
<div class="toolbar">
|
||||
<Button variant="primary" onclick={() => open = true}>Deploy new server</Button>
|
||||
<SectionHeader title="Deployments" subtitle="Your next game starts here. Save a setup, preview a launch, and follow it all the way online." />
|
||||
<div class="launch-banner">
|
||||
<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>
|
||||
<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}
|
||||
<section>
|
||||
<h2>In flight <span>{active.length}</span></h2>
|
||||
@@ -87,8 +145,13 @@
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: 28px; }
|
||||
.toolbar, .row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.toolbar, .row-head { justify-content: space-between; }
|
||||
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.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; }
|
||||
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
|
||||
p { margin: 8px 0; } a { color: var(--accent); }
|
||||
|
||||
@@ -4,16 +4,23 @@
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.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 {
|
||||
id: string; expected_map?: string; server: string; action: 'create' | 'destroy'; state: string;
|
||||
stage: string; started_at: string; finished_at: string | null;
|
||||
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 error = $state('');
|
||||
let refreshing = false;
|
||||
let now = $state(Date.now());
|
||||
let open = $state(false);
|
||||
let resuming = $state(false);
|
||||
let deployment = $derived(detail?.deployment);
|
||||
let active = $derived(['queued', 'awaiting-health', 'waiting', 'running'].includes(deployment?.state ?? ''));
|
||||
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'];
|
||||
if (deployment.state === 'waiting') return 1;
|
||||
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)));
|
||||
});
|
||||
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) {
|
||||
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(); });
|
||||
onMount(() => {
|
||||
const timer = setInterval(load, 2000);
|
||||
const timer = setInterval(() => { now = Date.now(); void load(); }, 2000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
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}
|
||||
</div>
|
||||
<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}
|
||||
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
|
||||
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
|
||||
@@ -75,8 +118,9 @@
|
||||
</section>
|
||||
<section>
|
||||
<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>
|
||||
{#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>
|
||||
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
|
||||
{#if detail && detail.events.length > 0}
|
||||
@@ -89,7 +133,8 @@
|
||||
<style>
|
||||
.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); }
|
||||
.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); }
|
||||
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; }
|
||||
@@ -98,6 +143,7 @@
|
||||
.flow li { border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.flow li.reached { color: var(--text-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 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); }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user