import { putFileBlob, blobContent } from '../db/file-blobs.js'; import type { ArtifactKind } from '../config.js'; import { db } from '../db/index.js'; import { emit } from '../pipeline/bus.js'; import { getVersion } from './scripts.js'; import { cancelStagedFiles, isFileApplied, queueFileChange, stagedFileChanges } from './staged-files.js'; // Roll a promoted deployment back to whatever versions were current before // the promote landed. Reads deployment_scripts as the source of truth for // which scripts were involved (and their prior versions); falls back to the // deprecated deployments.script_id/version_id/prior_version_id columns for // pre-multi-script legacy rows. export class RollbackError extends Error { constructor( message: string, public readonly kind: | 'history-expired' | 'no-such-deployment' | 'not-promote' | 'already-rolled-back' | 'snapshot-pending' ) { super(message); } } interface DeploymentRow { id: string; action: string; script_id: number | null; script_version_id: number | null; prior_version_id: number | null; rolled_back_by: string | null; beta_id: number | null; } interface DepScriptRow { deployment_id: string; script_id: number; script_version_id: number; prior_version_id: number | null; kind: ArtifactKind; } interface FileRow { id: number; server: string; verb: 'added' | 'updated' | 'removed'; rel_path: string; sha256_before: string | null; sha256_after: string | null; content_before: Buffer | null; content_after: Buffer | null; snapshot_pending: number; } interface ScriptTargetRow { id: number; target_rel_path: string; kind: ArtifactKind; } function nowIso(): string { return new Date().toISOString(); } function rollbackDeploymentId(promoteId: string): string { const stamp = new Date() .toISOString() .replace(/[-:]/g, '') .replace(/\.\d+Z$/, 'Z'); return `dep-${stamp}-rollback-${promoteId}`; } export interface RollbackDeploymentResult { deploymentId: string; // the new rollback deployment reverses: string; // the promote deployment id scriptIds: number[]; // scripts whose current_version_id got reverted affectedServers: string[]; } export async function rollbackPromoteDeployment( promoteId: string ): Promise { const dep = db() .prepare( `SELECT id, action, script_id, script_version_id, prior_version_id, rolled_back_by, beta_id FROM deployments WHERE id = ?` ) .get(promoteId) as DeploymentRow | undefined; if (!dep) throw new RollbackError(`no such deployment ${promoteId}`, 'no-such-deployment'); if ( db() .prepare( "SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?" ) .get(promoteId) ) { throw new RollbackError( 'Rollback file contents have expired under the retention policy.', 'history-expired' ); } if (dep.action !== 'promote') { throw new RollbackError( `deployment ${promoteId} is action='${dep.action}', not 'promote'`, 'not-promote' ); } if (dep.rolled_back_by) { throw new RollbackError( `deployment ${promoteId} already rolled back by ${dep.rolled_back_by}`, 'already-rolled-back' ); } // Source of truth for which scripts (and prior versions) this promote // touched. Prefer deployment_scripts; fall back to the deprecated // singular columns for legacy single-script deployments. let scripts = db() .prepare(`SELECT * FROM deployment_scripts WHERE deployment_id = ? ORDER BY script_id`) .all(promoteId) as DepScriptRow[]; if (scripts.length === 0 && dep.script_id !== null && dep.script_version_id !== null) { scripts = [ { deployment_id: promoteId, script_id: dep.script_id, script_version_id: dep.script_version_id, prior_version_id: dep.prior_version_id, kind: 'script' } ]; } const fileRows = db() .prepare( `SELECT f.id, f.server, f.verb, f.rel_path, f.sha256_before, f.sha256_after, ${blobContent('f.content_before_hash')} AS content_before, ${blobContent('f.content_after_hash')} AS content_after, f.snapshot_pending FROM deployment_files f JOIN deployment_servers ds ON ds.deployment_id = f.deployment_id AND ds.server = f.server JOIN servers s ON s.name = f.server WHERE f.deployment_id = ? AND ds.server_deleted_at IS NULL AND s.destroyed_at IS NULL ORDER BY f.server, f.rel_path` ) .all(promoteId) as FileRow[]; // Different games may promote distinct scripts with the same target path. // Retain all candidates and resolve ownership from the per-server history. const scriptsByTarget = new Map(); const kindByScriptId = new Map(); if (scripts.length > 0) { const placeholders = scripts.map(() => '?').join(','); const targetRows = db() .prepare(`SELECT id, target_rel_path, kind FROM scripts WHERE id IN (${placeholders})`) .all(...scripts.map((s) => s.script_id)) as ScriptTargetRow[]; const targetById = new Map(); for (const t of targetRows) { targetById.set(t.id, t.target_rel_path); kindByScriptId.set(t.id, t.kind); } for (const s of scripts) { const t = targetById.get(s.script_id); if (t) scriptsByTarget.set(t, [...(scriptsByTarget.get(t) ?? []), s]); } } const staged = stagedFileChanges('campaign', promoteId); const scriptForFile = (file: FileRow): DepScriptRow | undefined => { const candidates = scriptsByTarget.get(file.rel_path) ?? []; const versionId = staged.find( (row) => row.deployment_file_id === file.id )?.script_version_id; if (versionId) return candidates.find((script) => script.script_version_id === versionId); if (dep.beta_id) { const participant = candidates.find((script) => db() .prepare( `SELECT 1 FROM beta_server_scripts WHERE beta_id = ? AND server = ? AND script_id = ?` ) .get(dep.beta_id, file.server, script.script_id) ); if (participant) return participant; } return ( candidates.find( (script) => getVersion(script.script_version_id)?.sha256 === file.sha256_after ) ?? candidates[0] ); }; const kindForFile = (file: FileRow): ArtifactKind => { const stagedKind = staged.find((row) => row.deployment_file_id === file.id)?.kind; if (stagedKind) return stagedKind; const owner = scriptForFile(file); if (owner) return owner.kind ?? kindByScriptId.get(owner.script_id) ?? 'script'; // Config/baseline aggregate rows have no per-artifact target_rel_path // mapping — the deployment_files row is keyed on the shared cfg name. // Any script/baseline in this deployment tagged as config/baseline // means the rollback for that path lives under the config destination. for (const s of scripts) if (s.kind === 'config' || s.kind === 'baseline') return 'config'; return 'script'; }; const newDepId = rollbackDeploymentId(promoteId); const startedAt = nowIso(); // Group files by server so each server gets exactly one deployment_servers row. const perServer = new Map(); for (const f of fileRows) { // A promotion can still be waiting for empty servers. Withdrawing it // must not restore or remove files that it never installed. Rows from // older releases have no staging record and retain their audit meaning. const kind = kindForFile(f); const preservesBetaSnapshot = staged.some( (row) => row.server === f.server && row.kind === kind && row.rel_path === f.rel_path && row.preserve_before_image ); if ( !preservesBetaSnapshot && isFileApplied('campaign', promoteId, f.server, kind, f.rel_path) === false ) continue; if (f.snapshot_pending) { throw new RollbackError( `${f.server}: rollback snapshot for ${f.rel_path} is awaiting activation confirmation. Reconcile the provisioning operation before rolling back.`, 'snapshot-pending' ); } const arr = perServer.get(f.server); if (arr) arr.push(f); else perServer.set(f.server, [f]); } const affectedServers: string[] = []; const active = (server: string) => !!db() .prepare( `SELECT 1 FROM deployment_servers ds JOIN servers s ON s.name = ds.server WHERE ds.deployment_id = ? AND ds.server = ? AND ds.server_deleted_at IS NULL AND s.destroyed_at IS NULL` ) .get(promoteId, server); // Write the new rollback deployment + mark the original. const insertDep = db().prepare( `INSERT INTO deployments (id, action, started_at, triggered_by, script_id, script_version_id, prior_version_id, beta_id) VALUES (?, 'rollback', ?, 'ui', NULL, NULL, NULL, NULL)` ); const insertDepScript = db().prepare( `INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id, kind) VALUES (?, ?, ?, ?, ?)` ); const insertDepServer = db().prepare( `INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error) VALUES (?, ?, ?, ?, NULL, NULL, NULL, 0, ?)` ); const insertDepFile = db().prepare( `INSERT INTO deployment_files (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ); db().transaction(() => { cancelStagedFiles('campaign', promoteId); // Withdrawing this source leaves a shared restart available to other // campaigns. Provisioning cancels an operation with no eligible sources. db() .prepare( `UPDATE deployment_servers SET restart_state = 'skipped', error = NULL WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state IN ('pending','failed','held','manual')` ) .run(promoteId); insertDep.run(newDepId, startedAt); // Mirror the promote's script list — swapped: the "current" version // after rollback is the promote's prior; the "prior" of the rollback // is what was current up until this call (i.e. the promote's version). for (const s of scripts) { insertDepScript.run( newDepId, s.script_id, s.prior_version_id ?? s.script_version_id, s.script_version_id, s.kind ?? 'script' ); } for (const [server, files] of perServer) { if (!active(server)) continue; if (files.some((file) => file.verb !== 'added' && file.content_before === null)) { insertDepServer.run( newDepId, server, 0, 'failed', 'Original file snapshot is missing; cannot restore safely' ); continue; } insertDepServer.run(newDepId, server, files.length, 'pending', null); for (const f of files) { const invertedVerb = f.verb === 'added' ? 'removed' : f.verb === 'removed' ? 'added' : 'updated'; const inserted = insertDepFile.run( newDepId, server, invertedVerb, f.rel_path, f.sha256_after, f.sha256_before, putFileBlob(f.content_after), putFileBlob(f.content_before) ); const owningScript = scriptForFile(f); const betaPrior = dep.beta_id && owningScript ? (db() .prepare( `SELECT prior_version_id FROM beta_server_scripts WHERE beta_id = ? AND server = ? AND script_id = ?` ) .get(dep.beta_id, server, owningScript.script_id) as { prior_version_id: number | null } | undefined) : undefined; const priorVersionId = betaPrior ? betaPrior.prior_version_id : owningScript?.prior_version_id; const priorVersion = priorVersionId ? getVersion(priorVersionId) : null; queueFileChange({ sourceKind: 'campaign', sourceId: newDepId, server, kind: kindForFile(f), relPath: f.rel_path, content: f.verb === 'added' ? null : f.content_before, scriptVersionId: f.verb === 'updated' ? (priorVersion?.id ?? null) : null, inventorySource: `rollback:${newDepId}`, deploymentFileId: Number(inserted.lastInsertRowid) }); } affectedServers.push(server); } db() .prepare(`UPDATE deployments SET rolled_back_by = ? WHERE id = ?`) .run(newDepId, promoteId); // Restore each affected script's current_version_id. for (const s of scripts) { db() .prepare(`UPDATE scripts SET current_version_id = ? WHERE id = ?`) .run(s.prior_version_id, s.script_id); } })(); emit({ type: 'deployment-opened', deploymentId: newDepId, action: 'rollback', servers: affectedServers }); return { deploymentId: newDepId, reverses: promoteId, scriptIds: scripts.map((s) => s.script_id), affectedServers }; }