259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
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;
|
|
}
|
|
|
|
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<RollbackDeploymentResult> {
|
|
const dep = db()
|
|
.prepare(`SELECT id, action, script_id, script_version_id, prior_version_id, rolled_back_by
|
|
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[];
|
|
|
|
// Map rel_path → scriptId (and prior_version_id) so we can restore the
|
|
// inventory row's script_version_id correctly during file restore.
|
|
const scriptByTarget = new Map<string, DepScriptRow>();
|
|
const kindByScriptId = new Map<number, ArtifactKind>();
|
|
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<number, string>();
|
|
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) scriptByTarget.set(t, s);
|
|
}
|
|
}
|
|
const kindForRelPath = (relPath: string): ArtifactKind => {
|
|
const owner = scriptByTarget.get(relPath);
|
|
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<string, FileRow[]>();
|
|
const staged = stagedFileChanges('campaign', promoteId);
|
|
// Composed baseline rows are physically staged as config files. Prefer the
|
|
// explicit link instead of inferring their destination from the library.
|
|
const kindForFile = (file: FileRow) => staged.find(row => row.deployment_file_id === file.id)?.kind ?? kindForRelPath(file.rel_path);
|
|
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 = scriptByTarget.get(f.rel_path);
|
|
const priorVersion = owningScript?.prior_version_id ? getVersion(owningScript.prior_version_id) : 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
|
|
};
|
|
}
|