Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -3,7 +3,12 @@ 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';
|
||||
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
|
||||
@@ -12,7 +17,15 @@ import { cancelStagedFiles, isFileApplied, queueFileChange, stagedFileChanges }
|
||||
// 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') {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly kind:
|
||||
| 'history-expired'
|
||||
| 'no-such-deployment'
|
||||
| 'not-promote'
|
||||
| 'already-rolled-back'
|
||||
| 'snapshot-pending'
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -58,31 +71,53 @@ function nowIso(): string {
|
||||
}
|
||||
|
||||
function rollbackDeploymentId(promoteId: string): string {
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||
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
|
||||
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> {
|
||||
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 = ?`)
|
||||
.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 (
|
||||
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');
|
||||
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');
|
||||
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
|
||||
@@ -92,13 +127,15 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
.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'
|
||||
}];
|
||||
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()
|
||||
@@ -132,17 +169,29 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
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);
|
||||
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));
|
||||
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];
|
||||
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;
|
||||
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';
|
||||
@@ -164,11 +213,23 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
// 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;
|
||||
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');
|
||||
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);
|
||||
@@ -176,8 +237,13 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
}
|
||||
|
||||
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);
|
||||
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(
|
||||
@@ -204,8 +270,12 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
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);
|
||||
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
|
||||
@@ -221,16 +291,20 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
}
|
||||
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');
|
||||
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';
|
||||
f.verb === 'added' ? 'removed' : f.verb === 'removed' ? 'added' : 'updated';
|
||||
const inserted = insertDepFile.run(
|
||||
newDepId,
|
||||
server,
|
||||
@@ -242,27 +316,51 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
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 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,
|
||||
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) });
|
||||
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);
|
||||
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);
|
||||
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 });
|
||||
emit({
|
||||
type: 'deployment-opened',
|
||||
deploymentId: newDepId,
|
||||
action: 'rollback',
|
||||
servers: affectedServers
|
||||
});
|
||||
|
||||
return {
|
||||
deploymentId: newDepId,
|
||||
|
||||
Reference in New Issue
Block a user