feat: match fleet to blueprint targets exactly

Add an optional exact blueprint mode with keep/create/remove previews, atomic queueing, cancellation of unstarted excess creations, and persistent dependencies for safe port reuse. Track removals alongside creations and preserve fill mode as the default.

Validation: type check, production build, and 103 deployment tests passed. The full suite has one telemetry assertion failure also reproduced on the previous commit.

Fixes #42
This commit is contained in:
2026-09-27 13:36:08 -04:00
parent 1aa7f15371
commit 0b0e5b5bdb
13 changed files with 517 additions and 83 deletions
+32 -11
View File
@@ -83,13 +83,13 @@ export function nextLobbyNumber(baseName: string, additionalNames: string[] = []
* from the DB (which EC controls end-to-end under the new naming scheme, so
* the DB is authoritative — no need to ask Komodo).
*/
export function nextAvailablePort(additionalPorts: number[] = []): number | null {
export function nextAvailablePort(additionalPorts: number[] = [], releasingServers: string[] = []): 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
.prepare(`SELECT name, game_port FROM servers WHERE game_port IS NOT NULL UNION
SELECT j.server AS name, 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), ...additionalPorts]);
.all() as Array<{ name: string; game_port: number }>;
const taken = new Set([...rows.filter(row => !releasingServers.includes(row.name)).map((r) => r.game_port), ...additionalPorts]);
for (let p = config.portRange.min; p <= config.portRange.max; p++) {
if (!taken.has(p)) return p;
}
@@ -195,7 +195,8 @@ export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blue
checks, ready: checks.every(check => check.state !== 'blocked') };
}
export function queueCreateServer(input: CreateServerInput): CreateServerResult {
// releasingServers is internal to atomic collection queueing; HTTP callers cannot set it.
export function queueCreateServer(input: CreateServerInput, releasingServers: string[] = []): 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.' };
@@ -206,13 +207,13 @@ export function queueCreateServer(input: CreateServerInput): CreateServerResult
// 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())) {
if (preview && (!preview.server || !preview.port || preview.server !== nextName(input) || preview.port !== nextAvailablePort([], releasingServers))) {
return { ok: false, kind: 'no-slot', message: 'Available lobby or port changed. Refresh the preview and review the new server key.' };
}
if (recipe && !config.rcon.password) {
return { ok: false, kind: 'unconfigured', message: 'Health-check settings changed. Refresh the preview.' };
}
const prepared = prepareCreation(input);
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);
@@ -247,7 +248,7 @@ export async function createServer(input: CreateServerInput): Promise<CreateServ
return outcome ?? queued;
}
function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<CreateServerResult, { ok: false }> {
function prepareCreation(input: CreateServerInput, releasingServers: string[] = []): PreparedCreation | Extract<CreateServerResult, { ok: false }> {
const game = input.game ?? 't5';
if (game !== 't5' && game !== 't6') {
return { ok: false, kind: 'bad-request', message: 'Supported games are t5 and t6' };
@@ -279,7 +280,7 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
const plutoKey = input.plutoKey?.trim() || getServerKey(name);
if (!plutoKey) return { ok: false, kind: 'bad-request', message: `No saved key for ${name}. Enter a Plutonium server key.` };
const port = nextAvailablePort();
const port = nextAvailablePort([], releasingServers);
if (port === null) {
return {
ok: false,
@@ -640,6 +641,16 @@ export function creationQueue() {
return { active: jobs.filter(job => job.state !== 'queued'), queued: jobs.filter(job => job.state === 'queued') };
}
function creationBlocker(id: string): string | null {
const dependency = db().prepare(`SELECT d.server, d.state FROM server_creation_dependencies dep
JOIN server_deployments d ON d.id = dep.removal_id
WHERE dep.deployment_id = ? AND d.state NOT IN ('done', 'warning') LIMIT 1`).get(id) as { server: string; state: string } | undefined;
if (dependency) return `Waiting for removal of ${dependency.server} (${dependency.state}) before reusing its port`;
const occupied = db().prepare(`SELECT s.name FROM servers s JOIN server_creation_jobs j ON s.game_port = j.port
WHERE j.deployment_id = ? LIMIT 1`).get(id) as { name: string } | undefined;
return occupied ? `Waiting for port to be released by ${occupied.name}` : null;
}
async function runCreations(): Promise<CreationOutcomes> {
const outcomes: CreationOutcomes = new Map();
if (creating || destroying.size > 0) return outcomes;
@@ -696,12 +707,22 @@ async function runCreations(): Promise<CreationOutcomes> {
// worker pass into an unbounded stream of expensive container startups.
const queue = creationQueue();
const available = Math.max(0, config.createConcurrency - queue.active.length);
for (const { id } of queue.queued.slice(0, available)) {
const eligible = queue.queued.filter(({ id }) => {
const blocker = creationBlocker(id);
if (blocker) deploymentStage(id, blocker);
return !blocker;
});
for (const { id } of eligible.slice(0, available)) {
if (creating || destroying.size > 0) break;
// API calls can cancel a selected job or resume an older startup while
// a previous provision awaits Komodo. Recheck without expanding this pass.
if (creationQueue().active.length >= config.createConcurrency) break;
if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND action = 'create' AND state = 'queued'").get(id)) continue;
const blocker = creationBlocker(id);
if (blocker) {
deploymentStage(id, blocker);
continue;
}
try { outcomes.set(id, await provisionCreation(id)); }
catch (err) {
const failure = err instanceof Error ? err : new Error(String(err));