diff --git a/README.md b/README.md index 8d4c022..b76aa17 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,13 @@ 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. +Deployments includes a blueprint builder for whole server collections: choose +multiple T5/T6 maps, set a lobby count for each, and launch the lineup in one action. +The collection preview shows every server ID, port, and saved-key requirement. +All jobs and capacity reservations are committed together before provisioning; +one collection page tracks each server's independent progress and failures. +Existing single-server blueprints can be expanded with **Edit**. Single-server +launches and launching again from recorded recipes remain available. 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, diff --git a/docs/deployment-blueprints.md b/docs/deployment-blueprints.md index 0cef436..70f7c09 100644 --- a/docs/deployment-blueprints.md +++ b/docs/deployment-blueprints.md @@ -1,12 +1,49 @@ # 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. +The Deployments page is the starting point for reusable server collections. A +blueprint describes a whole lineup: for example, two Kino lobbies, one Moon lobby, +and two Town lobbies, launched together from one preview. + +## Build and launch a collection + +1. Choose **Create blueprint** on Deployments and name the lineup. +2. Add maps from Black Ops I or II and set the number of lobbies for each. +3. Choose **Save & preview** to review every new server ID and game port. +4. Enter each lobby's Plutonium key, or leave its field blank to reuse the key + saved for that exact server ID. +5. Choose **Launch all N servers** and follow the collection progress page. + +Blueprints support up to 40 distinct game/map selections, 1–20 lobbies per map, +and 100 servers total. The host's configured port range and existing fleet may +allow fewer. A blueprint can be saved even when there is insufficient capacity +to launch it. Each map captures its promoted managed files when added. + +Use **Edit** on a blueprint card to change its name, description, maps, or lobby +counts. Existing map recipes are retained when their counts change; newly added +maps capture their current promoted setup. Already reviewed launches keep their +original lineup and file contents, including after blueprint edits or removal. +Blueprints saved by the earlier single-server release load as one-server lineups +and can be expanded directly. + +A collection preview does not reserve capacity. At submission, all lobby IDs, +ports, and keys are validated again. The entire collection is then queued in one +database transaction. If any server lacks a valid key or capacity has changed, +no jobs, reservations, or key changes from that submission are committed. Refreshing +the preview clears typed keys so they cannot be applied to changed server IDs. + +Submitting the same preview again returns the same collection, including after +a lost response or an application restart. Reusing the blueprint with a new +preview deliberately creates another collection with fresh available capacity. + +The worker provisions servers sequentially; health verification and failures are +recorded separately for each. One failure does not undo servers already created +or prevent the rest of the queued collection from being attempted. The collection +page summarizes verified, active, and failed operations and links to every +server's timeline and recovery options. Each child links back to its collection. ## Create and review a launch -1. Choose **Create a server** on Deployments (or the deploy action on the fleet). +1. Choose **Single 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. @@ -31,12 +68,11 @@ 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 +Each map in a blueprint retains 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. +bytes. 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 +Use **Launch N servers** on a blueprint card to review fresh capacity. Remove a blueprint from the library when it is no longer needed; its existing servers, reviewed previews, and deployment history remain intact. @@ -89,10 +125,20 @@ require inspection rather than replaying provisioning automatically. - `GET /api/blueprints`: list summaries. - `POST /api/blueprints`: save `{ name, description?, previewId }` or - `{ name, description?, deploymentId }`. + `{ name, description?, deploymentId }` for a single-server recipe, or + `{ name, description?, entries: [{ game, mapSlug, count }] }` for a collection. +- `PUT /api/blueprints/:id`: edit `{ name, description?, entries }`. Unchanged + game/map selections keep their saved recipes. - `DELETE /api/blueprints/:id`: remove a library entry. +- `POST /api/deployment-collections/preview`: preview `{ blueprintId }`. +- `POST /api/deployment-collections`: atomically queue `{ previewId, keys? }`, + where `keys` maps exact preview server IDs to optional replacement keys. +- `GET /api/deployment-collections`: list collection progress summaries. +- `GET /api/deployment-collections/:id`: collection and per-server progress. - `POST /api/deployments/preview`: preview `{ game, mapSlug }`, `{ blueprintId }`, - or `{ deploymentId }`. Returns an ID, expiry, recipe summary, capacity, key + or `{ deploymentId }` for a single-server recipe. Multi-server blueprints must + use the collection endpoint; they cannot silently launch only one entry. + 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. @@ -100,5 +146,5 @@ require inspection rather than replaying provisioning automatically. `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. +Scheduled game nights and beta-gated expansion remain future layers. Collection +launches begin processing immediately after all requests are queued. diff --git a/src/lib/deployment-plan.ts b/src/lib/deployment-plan.ts index b9b47c0..c812b5e 100644 --- a/src/lib/deployment-plan.ts +++ b/src/lib/deployment-plan.ts @@ -29,6 +29,34 @@ export interface BlueprintSummary { description: string; createdAt: string; recipe: LaunchRecipeSummary; + entries: { count: number; recipe: LaunchRecipeSummary }[]; + totalServers: number; +} + +export interface CollectionPreview { + id: string; + expiresAt: string; + blueprintName: string; + entries: { count: number; recipe: LaunchRecipeSummary }[]; + servers: { entryIndex: number; name: string | null; port: number | null; hasSavedKey: boolean }[]; + blockers: string[]; + ready: boolean; +} + +export interface CollectionSummary { + id: string; + name: string; + created_at: string; + total: number; + done: number; + failed: number; + active: number; +} + +export interface CollectionDetail { + collection: CollectionSummary; + servers: { id: string; server: string; port: number; map_slug: string; state: string; stage: string; + started_at: string; finished_at: string | null; error: string | null; server_exists: number }[]; } export interface LaunchPreview { diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index c723dd7..d8db5cf 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -361,6 +361,28 @@ CREATE TABLE IF NOT EXISTS server_launch_recipes ( blueprint_name TEXT ); +CREATE TABLE IF NOT EXISTS server_launch_collections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS server_collection_members ( + collection_id TEXT NOT NULL REFERENCES server_launch_collections(id) ON DELETE CASCADE, + deployment_id TEXT NOT NULL UNIQUE REFERENCES server_deployments(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + PRIMARY KEY (collection_id, position) +); + +CREATE TABLE IF NOT EXISTS server_collection_previews ( + id TEXT PRIMARY KEY, + expires_at TEXT NOT NULL, + blueprint_name TEXT NOT NULL, + entries TEXT NOT NULL, + servers TEXT NOT NULL, + collection_id TEXT REFERENCES server_launch_collections(id) ON DELETE CASCADE +); + -- Persist state transitions atomically, including changes made by background workers. -- Polls with unchanged status/error do not produce duplicate activity entries. CREATE TABLE IF NOT EXISTS beta_events ( diff --git a/src/lib/server/fleet/collections.ts b/src/lib/server/fleet/collections.ts new file mode 100644 index 0000000..b275bde --- /dev/null +++ b/src/lib/server/fleet/collections.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto'; +import type { CollectionDetail, CollectionPreview, CollectionSummary } from '$lib/deployment-plan.js'; +import { db } from '../db/index.js'; +import { config, configFileForGame, komodoConfigured, rconConfigured } from '../config.js'; +import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js'; +import { nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js'; +import { getServerKey } from './server-keys.js'; + +function allocate(entries: BlueprintEntry[]): CollectionPreview['servers'] { + const names: string[] = []; + const ports: number[] = []; + return entries.flatMap((entry, entryIndex) => Array.from({ length: entry.count }, () => { + const base = `${entry.recipe.game}_zombie_${entry.recipe.mapSlug}`; + const lobby = nextLobbyNumber(base, names); + const name = lobby === null ? null : `${base}_${lobby}`; + const port = nextAvailablePort(ports); + if (name) names.push(name); + if (port !== null) ports.push(port); + return { entryIndex, name, port, hasSavedKey: !!name && !!getServerKey(name) }; + })); +} + +function blockersFor(entries: BlueprintEntry[], servers: CollectionPreview['servers']): string[] { + const blockers: string[] = []; + if (!komodoConfigured() || !config.komodo.targetServer) blockers.push('Configure Komodo credentials and a target host before launching.'); + if (!rconConfigured()) blockers.push('Configure RCON_PASSWORD so every new server can be verified.'); + if (servers.some(server => !server.name)) blockers.push('There are not enough free lobbies for one or more maps (20 per map). Reduce their counts or free capacity.'); + if (servers.some(server => server.port === null)) blockers.push(`There are not enough free game ports in ${config.portRange.min}–${config.portRange.max} for the whole collection.`); + if (entries.some(entry => entry.recipe.configFile !== configFileForGame(entry.recipe.game))) blockers.push('A saved config filename no longer matches the host settings. Create a new blueprint with the current setup.'); + return blockers; +} + +export function previewCollection(blueprintId: unknown): CollectionPreview { + if (typeof blueprintId !== 'string' || !blueprintId) throw new Error('Choose a blueprint to launch.'); + return db().transaction(() => { + const { entries, blueprintName } = blueprintEntries(blueprintId); + const servers = allocate(entries); + const blockers = blockersFor(entries, servers); + const id = randomUUID(); + const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString(); + db().prepare('DELETE FROM server_collection_previews WHERE expires_at < ? AND collection_id IS NULL').run(new Date().toISOString()); + db().prepare('INSERT INTO server_collection_previews (id, expires_at, blueprint_name, entries, servers) VALUES (?, ?, ?, ?, ?)') + .run(id, expiresAt, blueprintName, JSON.stringify(entries), JSON.stringify(servers)); + return { id, expiresAt, blueprintName, servers, blockers, ready: blockers.length === 0, + entries: entries.map(entry => ({ count: entry.count, recipe: summarizeRecipe(entry.recipe) })) }; + })(); +} + +/** Reserve and enqueue the entire collection atomically. No external operations here. */ +export function queueCollection(input: { previewId?: unknown; keys?: unknown }): { ok: true; collectionId: string } { + if (typeof input.previewId !== 'string' || !input.previewId) throw new Error('Review a collection preview before launching.'); + if (input.keys !== undefined && (!input.keys || typeof input.keys !== 'object' || Array.isArray(input.keys))) throw new Error('Provide keys indexed by the exact server ID.'); + const keys = (input.keys ?? {}) as Record; + return db().transaction(() => { + const preview = db().prepare('SELECT * FROM server_collection_previews WHERE id = ?').get(input.previewId) as { + id: string; expires_at: string; blueprint_name: string; entries: string; servers: string; collection_id: string | null; + } | undefined; + if (!preview) throw new Error('Collection preview expired or was not found. Refresh the preview.'); + if (preview.collection_id) return { ok: true as const, collectionId: preview.collection_id }; + if (preview.expires_at <= new Date().toISOString()) throw new Error('Collection preview expired. Refresh it before launching.'); + const entries: BlueprintEntry[] = JSON.parse(preview.entries); + const reviewed: CollectionPreview['servers'] = JSON.parse(preview.servers); + const servers = allocate(entries); + const blockers = blockersFor(entries, servers); + if (blockers.length) throw new Error(blockers.join(' ')); + if (servers.some((server, i) => server.name !== reviewed[i]?.name || server.port !== reviewed[i]?.port)) { + throw new Error('Lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.'); + } + const expected = new Set(servers.map(server => server.name)); + for (const [name, value] of Object.entries(keys)) { + if (!expected.has(name) || typeof value !== 'string') throw new Error('Keys must be strings for server IDs shown in this preview.'); + } + const collectionId = randomUUID(); + db().prepare('INSERT INTO server_launch_collections (id, name, created_at) VALUES (?, ?, ?)') + .run(collectionId, preview.blueprint_name, new Date().toISOString()); + servers.forEach((server, index) => { + const recipe = entries[server.entryIndex].recipe; + const childPreviewId = randomUUID(); + db().prepare('INSERT INTO server_launch_previews (id, expires_at, server, port, recipe, blueprint_name) VALUES (?, ?, ?, ?, ?, ?)') + .run(childPreviewId, preview.expires_at, server.name, server.port, JSON.stringify(recipe), preview.blueprint_name); + const result = queueCreateServer({ game: recipe.game, gameMode: 'zombie', mapSlug: recipe.mapSlug, + expectedName: server.name!, previewId: childPreviewId, plutoKey: keys[server.name!] as string | undefined, createdBy: 'ui' }); + // Throw so the outer transaction also reverses all earlier keys and children. + if (!result.ok) throw new Error(`${server.name}: ${result.message} Nothing was queued.`); + db().prepare('INSERT INTO server_collection_members (collection_id, deployment_id, position) VALUES (?, ?, ?)') + .run(collectionId, result.deploymentId, index); + }); + db().prepare('UPDATE server_collection_previews SET collection_id = ? WHERE id = ?').run(collectionId, preview.id); + return { ok: true as const, collectionId }; + })(); +} + +const summarySql = `SELECT c.id, c.name, c.created_at, COUNT(d.id) AS total, + COALESCE(SUM(d.state IN ('done', 'warning')), 0) AS done, + COALESCE(SUM(d.state IN ('failed', 'interrupted')), 0) AS failed, + COALESCE(SUM(d.state IN ('queued', 'running', 'awaiting-health', 'waiting')), 0) AS active + FROM server_launch_collections c + LEFT JOIN server_collection_members m ON m.collection_id = c.id + LEFT JOIN server_deployments d ON d.id = m.deployment_id`; + +export function listCollections(): CollectionSummary[] { + return db().prepare(summarySql + ' GROUP BY c.id ORDER BY c.created_at DESC, c.rowid DESC').all() as CollectionSummary[]; +} + +export function getCollection(id: string): CollectionDetail | null { + const collection = db().prepare(summarySql + ' WHERE c.id = ? GROUP BY c.id').get(id) as CollectionSummary | undefined; + if (!collection) return null; + const servers = db().prepare(`SELECT d.id, d.server, d.state, d.stage, d.started_at, d.finished_at, d.error, j.port, j.map_slug, + EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists + FROM server_collection_members m JOIN server_deployments d ON d.id = m.deployment_id + JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE m.collection_id = ? ORDER BY m.position`).all(id) as CollectionDetail['servers']; + return { collection, servers }; +} diff --git a/src/lib/server/fleet/create.ts b/src/lib/server/fleet/create.ts index 8f9a4b7..2f3e1d1 100644 --- a/src/lib/server/fleet/create.ts +++ b/src/lib/server/fleet/create.ts @@ -59,7 +59,7 @@ export interface CreateServerInput { * the servers table. Caps at 20 to stay inside the port range — with 20 total * ports across the whole fleet, no one map can hold more than that. */ -function nextLobbyNumber(baseName: string): number | null { +export function nextLobbyNumber(baseName: string, additionalNames: string[] = []): number | null { const rows = db() .prepare(`SELECT name FROM servers WHERE name LIKE ? UNION SELECT j.server AS name FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id @@ -67,7 +67,7 @@ function nextLobbyNumber(baseName: string): number | null { .all(`${baseName}_%`) as Array<{ name: string }>; const taken = new Set(); const prefix = `${baseName}_`; - for (const r of rows) { + for (const r of [...rows, ...additionalNames.map(name => ({ name }))]) { if (!r.name.startsWith(prefix)) continue; const tail = r.name.slice(prefix.length); if (/^\d+$/.test(tail)) taken.add(Number.parseInt(tail, 10)); @@ -83,13 +83,13 @@ function nextLobbyNumber(baseName: string): number | null { * from the DB (which EC controls end-to-end under the new naming scheme, so * the DB is authoritative — no need to ask Komodo). */ -function nextAvailablePort(): number | null { +export function nextAvailablePort(additionalPorts: number[] = []): number | null { const rows = db() .prepare(`SELECT game_port FROM servers WHERE game_port IS NOT NULL UNION SELECT j.port AS game_port FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.state IN ('queued', 'running', 'awaiting-health')`) .all() as Array<{ game_port: number }>; - const taken = new Set(rows.map((r) => r.game_port)); + const taken = new Set([...rows.map((r) => r.game_port), ...additionalPorts]); for (let p = config.portRange.min; p <= config.portRange.max; p++) { if (!taken.has(p)) return p; } diff --git a/src/lib/server/fleet/deployments.ts b/src/lib/server/fleet/deployments.ts index 3053f58..d7f2be1 100644 --- a/src/lib/server/fleet/deployments.ts +++ b/src/lib/server/fleet/deployments.ts @@ -27,8 +27,10 @@ export function getDeployment(id: string) { if (!deployment) return null; const events = db().prepare('SELECT id, created_at, message FROM server_deployment_events WHERE deployment_id = ? ORDER BY id').all(id); const launch = recipeForDeployment(id); + const collection = db().prepare(`SELECT c.id, c.name FROM server_collection_members m + JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ?`).get(id) ?? null; return { deployment, events, recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null, - canResumeVerification: canResumeVerification(id) }; + canResumeVerification: canResumeVerification(id), collection }; } export function canResumeVerification(id: string): boolean { diff --git a/src/lib/server/fleet/launch-recipes.ts b/src/lib/server/fleet/launch-recipes.ts index a8b8b8c..2b4a196 100644 --- a/src/lib/server/fleet/launch-recipes.ts +++ b/src/lib/server/fleet/launch-recipes.ts @@ -21,6 +21,44 @@ export interface LaunchRecipe { files: (LaunchFileSummary & { content: string })[]; } +export interface BlueprintEntry { count: number; recipe: LaunchRecipe } +interface BlueprintRow { id: string; name: string; description: string; created_at: string; recipe: string } + +/** Existing one-server blueprints remain usable without a destructive migration. */ +function readEntries(raw: string): BlueprintEntry[] { + const saved = JSON.parse(raw) as LaunchRecipe | { format: 2; entries: BlueprintEntry[] }; + return saved.format === 2 ? saved.entries : [{ count: 1, recipe: saved }]; +} + +function summarizeBlueprint(row: BlueprintRow): BlueprintSummary { + const entries = readEntries(row.recipe).map(entry => ({ count: entry.count, recipe: summarizeRecipe(entry.recipe) })); + return { id: row.id, name: row.name, description: row.description, createdAt: row.created_at, + recipe: entries[0].recipe, entries, totalServers: entries.reduce((total, entry) => total + entry.count, 0) }; +} + +export function blueprintEntries(id: string): { entries: BlueprintEntry[]; blueprintName: string } { + const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined; + if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.'); + return { entries: readEntries(row.recipe), blueprintName: row.name }; +} + +function buildEntries(input: unknown, prior: BlueprintEntry[] = []): BlueprintEntry[] { + if (!Array.isArray(input) || input.length < 1 || input.length > 40) throw new Error('Add between 1 and 40 map selections.'); + const seen = new Set(); + const entries = input.map(entry => { + if (!entry || (entry.game !== 't5' && entry.game !== 't6') || typeof entry.mapSlug !== 'string' + || !zombieMapBySlug(entry.game, entry.mapSlug)) throw new Error('Each selection needs a supported game and map.'); + if (!Number.isSafeInteger(entry.count) || entry.count < 1 || entry.count > 20) throw new Error('Choose 1–20 lobbies for each map.'); + const key = `${entry.game}:${entry.mapSlug}`; + if (seen.has(key)) throw new Error('Each game/map can appear once. Increase its lobby count instead.'); + seen.add(key); + const previous = prior.find(item => item.recipe.game === entry.game && item.recipe.mapSlug === entry.mapSlug); + return { count: entry.count as number, recipe: previous?.recipe ?? captureRecipe(entry.game, entry.mapSlug) }; + }); + if (entries.reduce((total, entry) => total + entry.count, 0) > 100) throw new Error('A blueprint can launch at most 100 servers.'); + return entries; +} + export function captureRecipe(game: ZombieGame, mapSlug: string): LaunchRecipe { const map = zombieMapBySlug(game, mapSlug); if (!map) throw new Error('Choose a supported game and map.'); @@ -59,35 +97,52 @@ export function recipeForDeployment(id: string): { recipe: LaunchRecipe; bluepri } 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 }; + const { entries, blueprintName } = blueprintEntries(id); + if (entries.length !== 1 || entries[0].count !== 1) throw new Error('This blueprint launches multiple servers. Use the collection preview.'); + return { recipe: entries[0].recipe, blueprintName }; } 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)) })); + return (db().prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE').all() as BlueprintRow[]).map(summarizeBlueprint); } -export function saveBlueprint(input: { name: unknown; description?: unknown; previewId?: unknown; deploymentId?: unknown }): BlueprintSummary { +function blueprintName(input: { name: unknown; description?: unknown }, exceptId = ''): string { 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.'); + if (db().prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE AND id != ?').get(input.name.trim(), exceptId)) throw new Error('A blueprint with this name already exists. Choose a new name.'); + return input.name.trim(); +} + +export function saveBlueprint(input: { name: unknown; description?: unknown; previewId?: unknown; deploymentId?: unknown; entries?: unknown }): BlueprintSummary { + const name = blueprintName(input); + if ([input.previewId, input.deploymentId, input.entries].filter(value => value !== undefined).length !== 1) throw new Error('Choose exactly one launch preview, recorded deployment, or set of maps.'); 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.'); + if (!recipe && input.entries === undefined) throw new Error('The launch recipe is unavailable or the preview expired. Generate a fresh preview.'); + const entries = input.entries !== undefined ? buildEntries(input.entries) : [{ count: 1, recipe: recipe! }]; const id = randomUUID(); const createdAt = new Date().toISOString(); + const saved = JSON.stringify({ format: 2, entries }); 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) }; + .run(id, name, input.description ?? '', createdAt, saved); + return summarizeBlueprint({ id, name, description: (input.description as string) ?? '', created_at: createdAt, recipe: saved }); +} + +export function updateBlueprint(id: string, input: { name: unknown; description?: unknown; entries?: unknown }): BlueprintSummary { + return db().transaction(() => { + const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined; + if (!row) throw new Error('Blueprint not found.'); + const name = blueprintName(input, id); + const entries = buildEntries(input.entries, readEntries(row.recipe)); + const recipe = JSON.stringify({ format: 2, entries }); + const description = (input.description as string) ?? ''; + db().prepare('UPDATE server_blueprints SET name = ?, description = ?, recipe = ? WHERE id = ?').run(name, description, recipe, id); + return summarizeBlueprint({ ...row, name, description, recipe }); + })(); } export function deleteBlueprint(id: string): boolean { diff --git a/src/lib/ui/components/BlueprintBuilder.svelte b/src/lib/ui/components/BlueprintBuilder.svelte new file mode 100644 index 0000000..e6f3a3c --- /dev/null +++ b/src/lib/ui/components/BlueprintBuilder.svelte @@ -0,0 +1,103 @@ + + + { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}> +
+
One blueprint. Your whole lineup.

{blueprint ? 'Edit your blueprint' : 'Build a server collection'}

+

Choose your maps and how many lobbies to launch for each. Mix Black Ops I and II in the same blueprint.

+ {#if error}{/if} + {#if !loaded}

Loading maps…

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

Your lineup

{total} server{total === 1 ? '' : 's'}
+ {#each rows as row, index (row.key)} +
+ {String(index + 1).padStart(2, '0')} + + + + +
+ {/each} + +
+

{blueprint ? 'Changing lobby counts keeps the saved files for each map. Newly added maps use their current promoted setup.' : 'Each map saves its current promoted setup. Before launch, you’ll review every new server ID, port, and key.'}

+
+
+ {/if} +
+
+ + diff --git a/src/lib/ui/components/DeployCollectionDialog.svelte b/src/lib/ui/components/DeployCollectionDialog.svelte new file mode 100644 index 0000000..c6df5e0 --- /dev/null +++ b/src/lib/ui/components/DeployCollectionDialog.svelte @@ -0,0 +1,83 @@ + + + { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}> +
+
Your lineup, ready to launch

{preview?.blueprintName ?? 'Preparing your collection'}

+ {#if error}{/if} + {#if !preview}

{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}

{#if !busy}{/if} + {:else} +
{preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} · one launch
    {#each preview.entries as entry}
  • {entry.count}× {entry.recipe.mapName} {entry.recipe.game.toUpperCase()}
  • {/each}
+

Review every lobby

+ {#each preview.blockers as blocker}{/each} +

Each server needs its own key. Saved keys are reused for the exact IDs below. All slots are checked again before anything is queued.

+
{ event.preventDefault(); void launch(); }}> +
+ {#each preview.servers as server, index} +
+
{preview.entries[server.entryIndex].recipe.mapName} · {server.port === null ? 'No available port' : `Port ${server.port}`}{server.name ?? `No available lobby (${index + 1})`}
+ {#if server.name}{/if} +
+ {/each} +
+

The host must have the selected games and DLC. This preview checks settings and capacity; it does not probe game files, images, credentials, or network access.

+
+

Your collection is queued in one action. Servers provision in sequence and verify independently. Preview expires at {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.

+
+
Review saved files for each map{#each preview.entries as entry}
{/each}
+ {/if} +
+
+ + diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 5a9b42a..b111b89 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import Button from './primitives/Button.svelte'; import LaunchRecipe from './LaunchRecipe.svelte'; + import DeployCollectionDialog from './DeployCollectionDialog.svelte'; import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js'; import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js'; @@ -13,6 +14,7 @@ let catalogs = $state>({ t5: [], t6: [] }); let blueprints = $state([]); let blueprintId = $state(''); + let collectionOpen = $state(false); let maps = $derived(catalogs[game]); let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId)); let preview = $state(null); @@ -54,6 +56,9 @@ async function review() { if (busy) return; + if (!sourceDeploymentId && selectedBlueprint && selectedBlueprint.totalServers > 1) { + open = false; collectionOpen = true; return; + } const current = session; busy = true; error = ''; plutoKey = ''; preview = null; savedName = ''; try { @@ -125,8 +130,8 @@

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

{:else}
{ event.preventDefault(); void review(); }}> - - {#if selectedBlueprint}
{#if selectedBlueprint.description}

{selectedBlueprint.description}

{/if}
+ + {#if selectedBlueprint}
{#if selectedBlueprint.totalServers > 1}

{selectedBlueprint.totalServers} servers in this blueprint

{#each selectedBlueprint.entries as entry}

{entry.count}× {entry.recipe.mapName} · {entry.recipe.game.toUpperCase()}

{/each}

Continue to review all servers and their keys together.

{:else}{/if}{#if selectedBlueprint.description}

{selectedBlueprint.description}

{/if}
{:else}

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

{/if}
Know what you’re launching.

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

@@ -134,6 +139,7 @@ {/if} + diff --git a/tests/unit/deployment-collections.test.ts b/tests/unit/deployment-collections.test.ts new file mode 100644 index 0000000..05bcc57 --- /dev/null +++ b/tests/unit/deployment-collections.test.ts @@ -0,0 +1,225 @@ +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'; +import type { CollectionPreview } from '../../src/lib/deployment-plan.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 (server: string) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '', + status: { players: 0, map: server.startsWith('t6_') ? 'zm_transit' : server.includes('_moon_') ? 'zombie_moon' : 'zombie_theater', gametype: null, roster: [] } })) +})); +vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) })); + +let fixture: Fixture; +const entries = [ + { game: 't5', mapSlug: 'theater', count: 2 }, + { game: 't5', mapSlug: 'moon', count: 1 }, + { game: 't6', mapSlug: 'town', count: 2 } +]; +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'); + vi.stubEnv('SERVER_PORT_MIN', '28960'); + vi.stubEnv('SERVER_PORT_MAX', '28980'); +}); +afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); }); + +async function previewLineup() { + const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); + const { previewCollection } = await import('../../src/lib/server/fleet/collections.js'); + const blueprint = saveBlueprint({ name: 'Friday Zombies', entries }); + return { blueprint, preview: previewCollection(blueprint.id) }; +} +function keysFor(preview: CollectionPreview) { + return Object.fromEntries(preview.servers.filter(server => server.name).map(server => [server.name!, 'key-' + server.name])); +} + +describe('multi-server blueprints', () => { + it('saves a mixed-game lineup and previews unique slots and global ports without reserving them', async () => { + const { blueprint, preview } = await previewLineup(); + const { db } = await import('../../src/lib/server/db/index.js'); + const { previewCreation } = await import('../../src/lib/server/fleet/create.js'); + expect(blueprint.totalServers).toBe(5); + expect(blueprint.entries.map(entry => entry.count)).toEqual([2, 1, 2]); + expect(preview.ready).toBe(true); + expect(preview.servers.map(server => server.name)).toEqual(['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_moon_1', 't6_zombie_town_1', 't6_zombie_town_2']); + expect(preview.servers.map(server => server.port)).toEqual([28960, 28961, 28962, 28963, 28964]); + expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]); + expect(db().prepare('SELECT * FROM server_keys').all()).toEqual([]); + expect(JSON.stringify(preview)).not.toContain('private-'); + expect(preview.entries[0].recipe.files[0]).not.toHaveProperty('content'); + expect(() => previewCreation({ blueprintId: blueprint.id })).toThrow(/multiple servers/); + }); + + it('upgrades an existing one-server blueprint and preserves its saved bytes when counts change', async () => { + const { captureRecipe, listBlueprints, updateBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); + const { db, closeDb } = await import('../../src/lib/server/db/index.js'); + const recipe = captureRecipe('t5', 'theater'); + db().prepare('INSERT INTO server_blueprints (id,name,created_at,recipe) VALUES (?, ?, ?, ?)').run('legacy', 'Old favorite', new Date().toISOString(), JSON.stringify(recipe)); + closeDb(); + const old = listBlueprints()[0]; + expect(old.totalServers).toBe(1); + expect(old.entries).toEqual([{ count: 1, recipe: old.recipe }]); + const edited = updateBlueprint('legacy', { name: 'Whole lineup', entries }); + expect(edited.totalServers).toBe(5); + expect(edited.entries[0].recipe.fingerprint).toBe(old.recipe.fingerprint); + expect(listBlueprints()).toHaveLength(1); + }); + + it.each([[], [{ game: 't5', mapSlug: 'theater', count: 0 }], [{ game: 't5', mapSlug: 'theater', count: 21 }], + [{ game: 't5', mapSlug: 'theater', count: 1.5 }], [{ game: 't5', mapSlug: 'theater', count: '2' }], + [{ game: 't5', mapSlug: 'town', count: 1 }], [entries[0], entries[0]]].map(input => ({ input })))('rejects an invalid lineup: $input', async ({ input }) => { + const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); + expect(() => saveBlueprint({ name: 'Invalid', entries: input })).toThrow(); + }); + + it('reports insufficient capacity without preventing blueprint creation', async () => { + vi.stubEnv('SERVER_PORT_MAX', '28961'); + const { blueprint, preview } = await previewLineup(); + const { queueCollection } = await import('../../src/lib/server/fleet/collections.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + expect(blueprint.totalServers).toBe(5); + expect(preview.ready).toBe(false); + expect(preview.servers.filter(server => server.port === null)).toHaveLength(3); + expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/not enough free game ports/); + expect(db().prepare('SELECT * FROM server_launch_collections').all()).toEqual([]); + }); +}); + +describe('atomic collection launches', () => { + it('queues all five servers, persists one group, and completes each independently', async () => { + const { preview } = await previewLineup(); + const { queueCollection, getCollection, listCollections } = await import('../../src/lib/server/fleet/collections.js'); + const { processCreations } = await import('../../src/lib/server/fleet/create.js'); + const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js'); + const { closeDb } = await import('../../src/lib/server/db/index.js'); + const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js'); + const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) }); + expect(komodoCreateDeployment).not.toHaveBeenCalled(); + expect(getCollection(queued.collectionId)?.collection).toMatchObject({ total: 5, active: 5, done: 0, failed: 0 }); + closeDb(); + await processCreations(); + const detail = getCollection(queued.collectionId)!; + expect(detail.collection).toMatchObject({ total: 5, active: 0, done: 5, failed: 0 }); + expect(listCollections()).toHaveLength(1); + expect(komodoCreateDeployment).toHaveBeenCalledTimes(5); + expect(getDeployment(detail.servers[0].id)?.collection).toEqual({ id: queued.collectionId, name: 'Friday Zombies' }); + expect(await readFile(join(fixture.t6, 't6_zombie_town_2', 'configs/electric_cherry_map.cfg'), 'utf8')).toContain('execgts zm_standard_town.cfg map zm_transit'); + expect(JSON.stringify(detail)).not.toContain('key-t5'); + }); + + it('rolls back all earlier jobs and key changes if the final server has no valid key', async () => { + const { preview } = await previewLineup(); + const { queueCollection } = await import('../../src/lib/server/fleet/collections.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const { saveServerKey, getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); + saveServerKey(preview.servers[0].name!, 'original-key'); + const keys = keysFor(preview); + delete keys[preview.servers[4].name!]; + expect(() => queueCollection({ previewId: preview.id, keys })).toThrow(/No saved key/); + for (const table of ['server_launch_collections', 'server_collection_members', 'server_creation_jobs', 'server_deployments', 'server_launch_previews', 'server_launch_recipes']) { + expect(db().prepare(`SELECT * FROM ${table}`).all()).toEqual([]); + } + expect(getServerKey(preview.servers[0].name!)).toBe('original-key'); + expect(db().prepare('SELECT * FROM server_keys').all()).toHaveLength(1); + keys[preview.servers[4].name!] = 'invalid key'; + expect(() => queueCollection({ previewId: preview.id, keys })).toThrow(/without whitespace/); + expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]); + expect(queueCollection({ previewId: preview.id, keys: keysFor(preview) }).ok).toBe(true); + }); + + it('rejects changed capacity before saving keys or partially queueing the lineup', async () => { + const { preview } = await previewLineup(); + const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js'); + const { queueCollection } = await import('../../src/lib/server/fleet/collections.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + queueCreateServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', plutoKey: 'other-server-key' }); + expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/availability changed/); + expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(1); + expect(db().prepare('SELECT * FROM server_keys').all()).toHaveLength(1); + expect(db().prepare('SELECT * FROM server_launch_collections').all()).toEqual([]); + }); + + it('reuses saved keys and returns the same collection on resubmission after restart and expiry', async () => { + const { preview, blueprint } = await previewLineup(); + const { queueCollection, previewCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js'); + const { saveServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); + const { db, closeDb } = await import('../../src/lib/server/db/index.js'); + for (const server of preview.servers) saveServerKey(server.name!, `saved-${server.name}`); + const ready = previewCollection(blueprint.id); + expect(ready.servers.every(server => server.hasSavedKey)).toBe(true); + const queued = queueCollection({ previewId: ready.id }); + db().prepare("UPDATE server_collection_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(ready.id); + closeDb(); + expect(queueCollection({ previewId: ready.id })).toEqual(queued); + expect(getCollection(queued.collectionId)?.servers).toHaveLength(5); + expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(5); + const next = previewCollection(blueprint.id); + expect(next.servers.map(server => server.name)).toEqual(['t5_zombie_theater_3', 't5_zombie_theater_4', 't5_zombie_moon_2', 't6_zombie_town_3', 't6_zombie_town_4']); + expect(next.servers.map(server => server.port)).toEqual([28965, 28966, 28967, 28968, 28969]); + }); + + it('keeps the reviewed lineup and bytes after blueprint edits, deletion, and promotion', async () => { + const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const { script, version } = createScript({ name: 'Classic perks', kind: 'script', scopeType: 'game', scopeValue: 't5', targetRelPath: 'perks.gsc', content: Buffer.from('original') }); + db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id); + const { preview, blueprint } = await previewLineup(); + const { updateBlueprint, deleteBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); + const { queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js'); + const { processCreations } = await import('../../src/lib/server/fleet/create.js'); + const newer = uploadNewVersion(script.id, Buffer.from('new version')); + db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newer.id, script.id); + updateBlueprint(blueprint.id, { name: 'Changed', entries: [{ ...entries[0], count: 1 }] }); + deleteBlueprint(blueprint.id); + const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) }); + await processCreations(); + expect(getCollection(queued.collectionId)?.collection).toMatchObject({ name: 'Friday Zombies', total: 5, done: 5 }); + for (const name of ['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_moon_1']) { + expect(await readFile(join(fixture.t5, name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('original'); + } + }); + + it('keeps a provisioning failure separate while the other servers complete', async () => { + const { preview } = await previewLineup(); + const { queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js'); + const { processCreations } = await import('../../src/lib/server/fleet/create.js'); + const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js'); + vi.mocked(komodoCreateDeployment).mockResolvedValueOnce({ ok: false, kind: 'http-error', message: 'Host rejected this container' }); + const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) }); + await processCreations(); + const detail = getCollection(queued.collectionId)!; + expect(detail.collection).toMatchObject({ total: 5, active: 0, done: 4, failed: 1 }); + expect(detail.servers[0]).toMatchObject({ state: 'failed', error: expect.stringContaining('Host rejected') }); + expect(detail.servers.slice(1).every(server => server.state === 'done')).toBe(true); + }); + + it('validates collection API payloads and rejects an unused expired preview', async () => { + const { preview } = await previewLineup(); + const { db } = await import('../../src/lib/server/db/index.js'); + const { queueCollection } = await import('../../src/lib/server/fleet/collections.js'); + const { POST } = await import('../../src/routes/api/deployment-collections/+server.js'); + for (const body of [null, [], {}, { previewId: preview.id, keys: [] }, { previewId: preview.id, keys: { wrong: 'key' } }]) { + const response = await POST({ request: new Request('http://localhost/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) }); + expect(response.status).toBe(400); + } + db().prepare("UPDATE server_collection_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(preview.id); + expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/expired/); + expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]); + }); +});