Files
electric-cherry/src/lib/server/fleet/rollback.ts
T

372 lines
14 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;
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<RollbackDeploymentResult> {
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<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) 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<string, FileRow[]>();
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
};
}