Add deployment blueprints and reviewed launch recipes

This commit is contained in:
2026-09-17 23:43:12 -04:00
parent edd74f09de
commit 782c7c0bda
17 changed files with 975 additions and 319 deletions
+95
View File
@@ -0,0 +1,95 @@
import { randomUUID } from 'node:crypto';
import type { BlueprintSummary, LaunchRecipeSummary, LaunchFileSummary } from '$lib/deployment-plan.js';
import { config, configFileForGame } from '../config.js';
import { db } from '../db/index.js';
import { zombieMapBySlug, type ZombieGame } from './map-aliases.js';
import { promotedArtifacts } from './promoted-artifacts.js';
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js';
import { sha256Buffer } from './files.js';
export interface LaunchRecipe {
format: 1;
game: ZombieGame;
mapSlug: string;
mapName: string;
expectedMap: string;
capturedAt: string;
image: string;
configFile: string;
configSource: string;
files: (LaunchFileSummary & { content: string })[];
}
export function captureRecipe(game: ZombieGame, mapSlug: string): LaunchRecipe {
const map = zombieMapBySlug(game, mapSlug);
if (!map) throw new Error('Choose a supported game and map.');
const files: LaunchRecipe['files'] = promotedArtifacts(game, map.canonical).map(({ kind, script, version }) => ({
name: script.name, kind, path: script.target_rel_path, versionId: version.id,
version: version.version, sha256: version.sha256, size: version.size, content: version.content.toString('base64')
}));
const addConfig = (name: string, path: string, content: Buffer) => files.push({
name, path, kind: 'config', versionId: null, version: null,
sha256: sha256Buffer(content), size: content.length, content: content.toString('base64')
});
const composed = getPromotedServerConfig(game, map.canonical, map.slug);
let configSource = 'Plutainer defaults, seeded on first boot';
if (composed) {
const baseline = getBaselineForScope('game', game)!;
const layers = [baseline, ...listPromotedOverlaysForScope('game', game), ...listPromotedOverlaysForScope('map', map.canonical)];
configSource = layers.map(layer => `${layer.name} v${getVersion(layer.current_version_id!)!.version}`).join(' + ');
addConfig('Composed server configuration', configFileForGame(game), composed);
}
addConfig('Selected map', MAP_CONFIG_FILE, getServerMapConfig(game, map.canonical, map.slug)!);
return { format: 1, game, mapSlug, mapName: map.friendly, expectedMap: map.canonical,
capturedAt: new Date().toISOString(), image: config.komodo.defaultImage,
configFile: configFileForGame(game), configSource, files };
}
export function summarizeRecipe(recipe: LaunchRecipe): LaunchRecipeSummary {
const { format: _format, files, ...summary } = recipe;
return { ...summary, files: files.map(({ content: _content, ...file }) => file),
fingerprint: sha256Buffer(Buffer.from(JSON.stringify(recipe))) };
}
export function recipeForDeployment(id: string): { recipe: LaunchRecipe; blueprintName: string | null } | null {
const row = db().prepare('SELECT recipe, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?')
.get(id) as { recipe: string; blueprint_name: string | null } | undefined;
return row ? { recipe: JSON.parse(row.recipe), blueprintName: row.blueprint_name } : null;
}
export function blueprintRecipe(id: string): { recipe: LaunchRecipe; blueprintName: string } {
const row = db().prepare('SELECT recipe, name FROM server_blueprints WHERE id = ?').get(id) as { recipe: string; name: string } | undefined;
if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.');
return { recipe: JSON.parse(row.recipe), blueprintName: row.name };
}
export function listBlueprints(): BlueprintSummary[] {
return (db().prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE').all() as {
id: string; name: string; description: string; created_at: string; recipe: string;
}[]).map(row => ({ id: row.id, name: row.name, description: row.description,
createdAt: row.created_at, recipe: summarizeRecipe(JSON.parse(row.recipe)) }));
}
export function saveBlueprint(input: { name: unknown; description?: unknown; previewId?: unknown; deploymentId?: unknown }): BlueprintSummary {
if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 80) throw new Error('Give the blueprint a name of 1–80 characters.');
if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 500)) throw new Error('Keep the description to 500 characters.');
if ((typeof input.previewId === 'string') === (typeof input.deploymentId === 'string')) throw new Error('Choose exactly one launch preview or recorded deployment.');
let recipe: LaunchRecipe | undefined;
if (typeof input.previewId === 'string') {
const row = db().prepare('SELECT recipe FROM server_launch_previews WHERE id = ? AND expires_at > ?')
.get(input.previewId, new Date().toISOString()) as { recipe: string } | undefined;
if (row) recipe = JSON.parse(row.recipe);
} else if (typeof input.deploymentId === 'string') recipe = recipeForDeployment(input.deploymentId)?.recipe;
if (!recipe) throw new Error('The launch recipe is unavailable or the preview expired. Generate a fresh preview.');
if (db().prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE').get(input.name.trim())) throw new Error('A blueprint with this name already exists. Choose a new name.');
const id = randomUUID();
const createdAt = new Date().toISOString();
db().prepare('INSERT INTO server_blueprints (id, name, description, created_at, recipe) VALUES (?, ?, ?, ?, ?)')
.run(id, input.name.trim(), input.description ?? '', createdAt, JSON.stringify(recipe));
return { id, name: input.name.trim(), description: (input.description as string) ?? '', createdAt, recipe: summarizeRecipe(recipe) };
}
export function deleteBlueprint(id: string): boolean {
return db().prepare('DELETE FROM server_blueprints WHERE id = ?').run(id).changes > 0;
}