Fix blocking collection matching with large plugins

This commit is contained in:
2026-10-05 10:15:48 -04:00
parent 10663333db
commit dbd2940d29
14 changed files with 319 additions and 39 deletions
+7
View File
@@ -369,6 +369,13 @@ export function db(): Database.Database {
backfillLeaderboard(handle);
initializeFleetSettings(handle);
migrateNativeInstances(handle);
// Lifecycle history is retained indefinitely. Admission and status reads
// must narrow by server or active state instead of scanning that history.
// Install after migrations that rebuild this table or add server_deleted_at.
handle.exec(`CREATE INDEX IF NOT EXISTS idx_server_deployments_server
ON server_deployments(server, server_deleted_at);
CREATE INDEX IF NOT EXISTS idx_server_deployments_state
ON server_deployments(state, action, server_deleted_at);`);
installReportRevisions(handle);
} catch (err) {
handle.close();
+3 -2
View File
@@ -516,8 +516,9 @@ CREATE TABLE IF NOT EXISTS server_launch_previews (
deployment_id TEXT REFERENCES server_deployments(id) ON DELETE CASCADE
);
-- Historical launch files survive library deletion. New launches resolve current
-- promotions instead of replaying these snapshots; credentials stay in their stores.
-- Queued plans contain metadata only. Attempted launch files survive library
-- deletion. New launches resolve current promotions instead of replaying these
-- snapshots; credentials stay in their stores.
CREATE TABLE IF NOT EXISTS server_launch_recipes (
deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id) ON DELETE CASCADE,
recipe TEXT NOT NULL,
+3
View File
@@ -160,6 +160,9 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
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);
// Older previews embedded plugin bytes. Child previews need only the
// reviewed metadata; provisioning captures the current files at dispatch.
for (const entry of entries) entry.recipe.files = entry.recipe.files.map(({ content: _content, ...file }) => file);
const reviewed: CollectionPreview['servers'] = JSON.parse(preview.servers);
if (preview.mode === 'exact' && preview.fleet_snapshot !== JSON.stringify(fleetSnapshot())) {
throw new Error('The fleet changed. Nothing was queued. Refresh the preview and review the servers to keep and remove.');
+33 -14
View File
@@ -3,12 +3,12 @@ import type { BlueprintSummary, LaunchRecipeSummary, LaunchFileSummary } from '$
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 { promotedArtifactMetadata, promotedArtifacts } from './promoted-artifacts.js';
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js';
import { getBaselineForScope, getVersionMetadata, listPromotedOverlaysForScope } from './scripts.js';
import { sha256Buffer } from './files.js';
export interface LaunchRecipe {
export interface LaunchRecipePlan {
native?: import('../../hosts.js').NativePlacement;
format: 1;
game: ZombieGame;
@@ -19,10 +19,15 @@ export interface LaunchRecipe {
image: string;
configFile: string;
configSource: string;
files: (LaunchFileSummary & { content?: string })[];
}
/** Attempted launches retain their exact bytes; queued plans need only metadata. */
export interface LaunchRecipe extends LaunchRecipePlan {
files: (LaunchFileSummary & { content: string })[];
}
export interface BlueprintEntry { count: number; recipe: LaunchRecipe }
export interface BlueprintEntry { count: number; recipe: LaunchRecipePlan }
interface BlueprintSelection { count: number; game: ZombieGame; mapSlug: string }
interface BlueprintRow { id: string; name: string; description: string; created_at: string; recipe: string }
@@ -45,7 +50,7 @@ function summarizeBlueprint(row: BlueprintRow): BlueprintSummary {
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 collection is no longer available. Choose another starting point.');
const entries = readEntries(row.recipe).map(({ count, game, mapSlug }) => ({ count, recipe: captureRecipe(game, mapSlug) }));
const entries = readEntries(row.recipe).map(({ count, game, mapSlug }) => ({ count, recipe: captureRecipePlan(game, mapSlug) }));
return { entries, blueprintName: row.name };
}
@@ -66,22 +71,36 @@ function buildEntries(input: unknown): BlueprintSelection[] {
}
export function captureRecipe(game: ZombieGame, mapSlug: string): LaunchRecipe {
return buildRecipe(game, mapSlug, true);
}
/** Preview and admission keep file references; execution captures the current bytes. */
export function captureRecipePlan(game: ZombieGame, mapSlug: string): LaunchRecipePlan {
return buildRecipe(game, mapSlug, false);
}
function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: true): LaunchRecipe;
function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: false): LaunchRecipePlan;
function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: boolean): LaunchRecipePlan {
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 }) => ({
const metadata = ({ kind, script, version }: ReturnType<typeof promotedArtifactMetadata>[number]): LaunchFileSummary => ({
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')
}));
version: version.version, sha256: version.sha256, size: version.size
});
const files: LaunchRecipePlan['files'] = includeContent
? promotedArtifacts(game, map.canonical).map(artifact => ({ ...metadata(artifact), content: artifact.version.content.toString('base64') }))
: promotedArtifactMetadata(game, map.canonical).map(metadata);
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')
sha256: sha256Buffer(content), size: content.length, ...(includeContent ? { 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(' + ');
configSource = layers.map(layer => `${layer.name} v${getVersionMetadata(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)!);
@@ -90,19 +109,19 @@ export function captureRecipe(game: ZombieGame, mapSlug: string): LaunchRecipe {
configFile: configFileForGame(game), configSource, files };
}
export function summarizeRecipe(recipe: LaunchRecipe): LaunchRecipeSummary {
export function summarizeRecipe(recipe: LaunchRecipePlan): 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 {
export function recipeForDeployment(id: string): { recipe: LaunchRecipePlan; 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 } {
export function blueprintRecipe(id: string): { recipe: LaunchRecipePlan; blueprintName: string } {
const { entries, blueprintName } = blueprintEntries(id);
if (entries.length !== 1 || entries[0].count !== 1) throw new Error('This collection launches multiple servers. Use the collection preview.');
return { recipe: entries[0].recipe, blueprintName };
@@ -122,7 +141,7 @@ function blueprintName(input: { name: unknown; description?: unknown }, exceptId
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;
let recipe: LaunchRecipePlan | 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;
+13 -4
View File
@@ -1,14 +1,23 @@
import { getVersion, listScripts } from './scripts.js';
import { getVersion, getVersionMetadata, listScripts } from './scripts.js';
/** Published file artifacts shared by provisioning and drift reconciliation. */
export function promotedArtifacts(game: string | null, map: string | null) {
function selectedArtifacts<Version>(game: string | null, map: string | null, readVersion: (id: number) => Version | undefined) {
return (['script', 'plugin'] as const).flatMap((kind) =>
listScripts(kind).flatMap((script) => {
const scopeMatches = script.scope_type === 'game' ? script.scope_value === game
: script.scope_type === 'map' ? script.scope_value === map : false;
if (!scopeMatches || !script.current_version_id) return [];
const version = getVersion(script.current_version_id);
const version = readVersion(script.current_version_id);
return version ? [{ kind, script, version }] : [];
})
);
}
/** Published file artifacts shared by provisioning and drift reconciliation. */
export function promotedArtifacts(game: string | null, map: string | null) {
return selectedArtifacts(game, map, getVersion);
}
/** Metadata uses the same promotion and scope rules without reading file content. */
export function promotedArtifactMetadata(game: string | null, map: string | null) {
return selectedArtifacts(game, map, getVersionMetadata);
}
+9
View File
@@ -149,6 +149,15 @@ export function getVersion(id: number): ScriptVersionRow | undefined {
| undefined;
}
export type ScriptVersionMetadata = Pick<ScriptVersionRow, 'id' | 'version' | 'sha256' | 'size'>;
/** Planning a launch never needs to load potentially large script or plugin bytes. */
export function getVersionMetadata(id: number): ScriptVersionMetadata | undefined {
return db().prepare('SELECT id, version, sha256, size FROM script_versions WHERE id = ?').get(id) as
| ScriptVersionMetadata
| undefined;
}
// Latest version is 'max(version)' — used everywhere we want the freshest
// upload, whether or not it has been promoted (current_version_id).
export function getLatestVersion(scriptId: number): ScriptVersionRow | undefined {
+8 -8
View File
@@ -4,7 +4,7 @@ import { probePlayerEvidence, probeServerStatus } from '../fleet/status-evidence
import { beginDeployment, deploymentStage, finishDeployment, recordDeploymentEvent, REMOVING_SERVER, resumeCleanup, resumeVerification, claimRemoval } from './history.js';
import { randomUUID } from 'node:crypto';
import type { LaunchPreview } from '$lib/deployment-plan.js';
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js';
import { blueprintRecipe, captureRecipe, captureRecipePlan, recipeForDeployment, summarizeRecipe, type LaunchRecipe, type LaunchRecipePlan } from '../fleet/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';
@@ -179,7 +179,7 @@ interface PreviewRow {
export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blueprintId?: unknown; deploymentId?: unknown; hostId?: unknown; libraryId?: unknown }): LaunchPreview {
if (input.hostId !== undefined || !config.komodo.address || listHosts().length > 0) return previewNativeCreation(input);
if (input.blueprintId !== undefined && input.deploymentId !== undefined) throw new Error('Choose one starting point.');
let recipe: LaunchRecipe;
let recipe: LaunchRecipePlan;
let blueprintName: string | null = null;
if (input.blueprintId !== undefined) {
if (typeof input.blueprintId !== 'string') throw new Error('Invalid blueprint.');
@@ -190,10 +190,10 @@ export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blue
if (!source) throw new Error('This deployment has no recorded launch recipe.');
blueprintName = source.blueprintName;
// History describes the previous launch; only its map selection is reusable.
recipe = captureRecipe(source.recipe.game, source.recipe.mapSlug);
recipe = captureRecipePlan(source.recipe.game, source.recipe.mapSlug);
} 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);
recipe = captureRecipePlan(input.game, input.mapSlug);
}
const lobby = nextLobbyNumber(`${recipe.game}_zombie_${recipe.mapSlug}`);
const name = lobby === null ? null : `${recipe.game}_zombie_${recipe.mapSlug}_${lobby}`;
@@ -223,7 +223,7 @@ export function queueCreateServer(input: CreateServerInput, releasingServers: st
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;
const recipe: LaunchRecipePlan | 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.' };
}
@@ -239,7 +239,7 @@ export function queueCreateServer(input: CreateServerInput, releasingServers: st
const prepared = prepareCreation(input, releasingServers);
if (!prepared.ok) return prepared;
// A preview records choices, not version pins. Promotions remain authoritative.
const currentRecipe = captureRecipe(input.game ?? 't5', prepared.mapEntry.slug);
const currentRecipe = captureRecipePlan(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(currentRecipe), preview?.blueprint_name ?? null);
@@ -268,7 +268,7 @@ export function queueRebuildServer(name: string, triggeredBy = 'ui'): CreateServ
if (!config.komodo.targetServer || (!config.rcon.password && !triggeredBy.startsWith('watchdog:'))) return { ok: false, kind: 'unconfigured', message: 'Configure the Komodo target host and RCON before rebuilding.' };
if (!getServerKey(name)) return { ok: false, kind: 'bad-request', message: `Save a server key for ${name} in Keys before rebuilding.` };
// Validate that the replacement setup can be composed before destruction.
const recipe = captureRecipe(game, map.slug);
const recipe = captureRecipePlan(game, map.slug);
return db().transaction(() => {
const id = beginDeployment(name, 'rebuild', triggeredBy);
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
@@ -304,7 +304,7 @@ export async function rebuildServer(id: string): Promise<void> {
}
// Recheck prerequisites while the original instance still exists.
if (!getServerKey(job.server)) { finishDeployment(id, { ok: false, message: 'Saved server key was removed; rebuild did not begin.' }); return; }
try { captureRecipe(gameOfServer(job.server), job.map_slug); }
try { captureRecipePlan(gameOfServer(job.server), job.map_slug); }
catch (err) { finishDeployment(id, { ok: false, message: (err as Error).message }); return; }
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ? AND state = 'waiting'").run(id);
destroying.add(job.server);
+7 -7
View File
@@ -7,7 +7,7 @@ import { config, relDestForKind, serverDir } from '../config.js';
import { nativeInstance, nativeRequest, type NativeInstance } from '../backends/native.js';
import { getHost, getHostOperation, listHosts, queueHostOperation, releaseHostPorts, reserveHostPort } from '../hosts/index.js';
import { newestReadyLibraryId, assertReadyLibrary } from '../game-files/index.js';
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js';
import { blueprintRecipe, captureRecipe, captureRecipePlan, recipeForDeployment, summarizeRecipe, type LaunchRecipe, type LaunchRecipePlan } from '../fleet/launch-recipes.js';
import { getServerKey, saveServerKey } from '../fleet/server-keys.js';
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
import { nextLobbyNumber, type CreateServerInput, type CreateServerResult } from './instances.js';
@@ -27,7 +27,7 @@ import { hostAgentUpdatesEnabled } from '../../hosts.js';
import { cancelCreation, finishCreationCancellation } from './cancel-creation.js';
export type NativeSelection = import('../../hosts.js').NativePlacement;
type NativeRecipe = LaunchRecipe & { native?: NativeSelection };
type NativeRecipe = LaunchRecipePlan & { native?: NativeSelection };
interface Job { id: string; server: string; action: ProvisioningAction; state: string; submitted_at: string | null; stage: string;
instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; files_json: string | null; applied_at: string | null }
interface FilePlan { id: number; path: string; contentBase64: string | null; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null }
@@ -60,12 +60,12 @@ export function previewNativeCreation(input: Record<string, unknown>): LaunchPre
if (typeof input.deploymentId === 'string') {
const previous = recipeForDeployment(input.deploymentId);
if (!previous) throw new Error('The recorded deployment is unavailable.');
recipe = captureRecipe(previous.recipe.game, previous.recipe.mapSlug);
recipe = captureRecipePlan(previous.recipe.game, previous.recipe.mapSlug);
blueprintName = previous.blueprintName;
} else if (typeof input.blueprintId === 'string') ({ recipe, blueprintName } = blueprintRecipe(input.blueprintId));
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);
recipe = captureRecipePlan(input.game, input.mapSlug);
}
const selection = nativeSelection(input, recipe.game);
recipe.native = selection; recipe.image = selection.image!;
@@ -105,7 +105,7 @@ export function queueNativeCreation(input: CreateServerInput): CreateServerResul
const game = input.game ?? 't5';
if (input.gameMode !== 'zombie' || (saved && (saved.game !== game || saved.mapSlug !== input.mapSlug))) throw new Error('Selection changed. Review a fresh launch preview.');
const selection = nativeSelection((saved?.native ?? input) as unknown as Record<string, unknown>, game);
const recipe: NativeRecipe = { ...captureRecipe(game, input.mapSlug), native: selection, image: selection.image! };
const recipe: NativeRecipe = { ...captureRecipePlan(game, input.mapSlug), native: selection, image: selection.image! };
const lobby = nextLobbyNumber(`${game}_zombie_${input.mapSlug}`);
if (lobby === null) throw new Error('All lobby IDs are reserved.');
const name = `${game}_zombie_${input.mapSlug}_${lobby}`;
@@ -157,7 +157,7 @@ export function queueNativeRebuild(name: string, triggeredBy: string): CreateSer
if (blocker) throw new Error(blocker);
if (!server?.map_slug || !getServerKey(name)) throw new Error('A saved map and server key are required before rebuilding.');
assertReadyLibrary(instance.host_id, instance.library_id);
const recipe: NativeRecipe = { ...captureRecipe(server.game as 't5' | 't6', server.map_slug), image: instance.image,
const recipe: NativeRecipe = { ...captureRecipePlan(server.game as 't5' | 't6', server.map_slug), image: instance.image,
native: { hostId: instance.host_id, libraryId: instance.library_id, image: instance.image, bindAddress: instance.bind_address } };
return db().transaction(() => {
const id = beginDeployment(name, 'rebuild', triggeredBy);
@@ -478,7 +478,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
deploymentStage(job.id, 'Preparing launch files for the host agent', { phase: 'prepare', recipeStatus: 'attempted',
checkpoint: { key: 'startup-queue', label: 'Startup capacity available' } });
const prior = recipeForDeployment(job.id)!.recipe as NativeRecipe;
const recipe: NativeRecipe = { ...captureRecipe(prior.game, prior.mapSlug), native: prior.native, image: instance!.image };
const recipe = { ...captureRecipe(prior.game, prior.mapSlug), native: prior.native, image: instance!.image };
payload = createPayload(instance!, recipe);
if (job.action === 'rebuild') payload.nextGeneration = instance!.generation + 1;
db().prepare('UPDATE server_launch_recipes SET recipe=? WHERE deployment_id=?').run(JSON.stringify(recipe), job.id);
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import { listCollections, queueCollection } from '$lib/server/fleet/collections.js';
import { kick } from '$lib/server/pipeline/worker.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
export function GET() { return json({ collections: listCollections() }); }
@@ -9,7 +9,11 @@ export async function POST({ request }: { request: Request }) {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection preview and its server keys.');
const result = queueCollection(body);
if (result.collectionId) void kick().catch(err => console.error('[collection] worker kick failed:', err));
// The durable queue is committed. Let the response and pending I/O run
// before provisioning loads launch files or starts checking removals.
if (result.collectionId) setImmediate(() => {
void kickProvisioning().catch(err => console.error('[collection] worker kick failed:', err));
});
return json(result, { status: result.collectionId ? 202 : 200 });
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
}