Defer server file changes until guarded restart activation
Persist beta, promotion, drift, and rollback payloads until occupancy and restart-capacity checks pass. Preserve rollback snapshots and campaign ordering, and allow held rollback campaigns to resume safely. Fixes #28 Validation: 805 tests passed, Svelte checks clean, production build passed.
This commit is contained in:
+150
-249
@@ -12,11 +12,11 @@ async function exclusive<T>(id: number, action: () => Promise<T>): Promise<T> {
|
||||
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, posix } from 'node:path';
|
||||
import { join, posix } from 'node:path';
|
||||
import { betaArchiveOf, configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { sha256Buffer } from './files.js';
|
||||
import { ensureDest, getServer, heldByBeta } from './servers.js';
|
||||
import { getServer, heldByBeta } from './servers.js';
|
||||
import {
|
||||
getBaselineForScope,
|
||||
getScript,
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
type ScriptVersionRow
|
||||
} from './scripts.js';
|
||||
import { composeServerCfg } from './config-compose.js';
|
||||
import { recordScript, removeScript, getServerScript } from './inventory.js';
|
||||
import { getServerScript, removeScript } from './inventory.js';
|
||||
import { cancelStagedFiles, isFileApplied, queueFileChange } from './staged-files.js';
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
import type { BetaStage } from '../pipeline/bus.js';
|
||||
|
||||
@@ -207,6 +208,19 @@ function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function unresolvedCampaignWrites(server: string): Array<{ server: string; deploymentId: string; state: string; kind: ArtifactKind; rel_path: string }> {
|
||||
return db().prepare(`SELECT DISTINCT f.server, f.source_id AS deploymentId, f.state, f.kind, f.rel_path
|
||||
FROM staged_file_changes f
|
||||
JOIN deployment_servers ds ON ds.deployment_id = f.source_id AND ds.server = f.server
|
||||
LEFT JOIN server_deployments operation ON operation.id = ds.provisioning_id
|
||||
WHERE f.source_kind = 'campaign' AND f.server = ? AND ds.server_deleted_at IS NULL
|
||||
AND ds.restart_state IN ('pending','failed','held','manual')
|
||||
AND (f.state = 'applying' OR (f.state = 'pending'
|
||||
AND (ds.restart_state IN ('held','manual') OR operation.id IS NULL
|
||||
OR operation.state IN ('queued','waiting','running','awaiting-health'))))`)
|
||||
.all(server) as Array<{ server: string; deploymentId: string; state: string; kind: ArtifactKind; rel_path: string }>;
|
||||
}
|
||||
|
||||
// ---------- Reads ----------
|
||||
|
||||
export function getBeta(id: number): BetaRow | undefined {
|
||||
@@ -370,6 +384,17 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope);
|
||||
}
|
||||
|
||||
// A beta outranks campaigns during activation. Do not let it overtake older
|
||||
// staged writes which would otherwise overwrite the tested version on closure.
|
||||
// Legacy campaigns have no staged payload and retain their restart-only priority.
|
||||
const queuedCampaigns = input.servers.flatMap(unresolvedCampaignWrites);
|
||||
if (queuedCampaigns.length) {
|
||||
const interrupted = queuedCampaigns.some(row => row.state === 'applying');
|
||||
throw new BetaError(interrupted
|
||||
? 'A campaign file write needs inspection. Resolve or roll back that campaign before opening a beta on this server.'
|
||||
: 'Finish or resume queued file campaigns before opening a beta on these servers.', 'conflict', queuedCampaigns);
|
||||
}
|
||||
|
||||
// Per-(server, script) conflict check. heldByBeta covers "another running
|
||||
// beta already claimed this target"; the previous stale-.OLD check is no
|
||||
// longer relevant now that snapshots live in the DB.
|
||||
@@ -383,14 +408,6 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
}
|
||||
if (conflicts.length > 0) throw new BetaError('target already held by another beta', 'conflict', conflicts);
|
||||
|
||||
// Ensure destinations exist. The `ensureDest` helper covers only the
|
||||
// script destination today; config/plugin destinations are created
|
||||
// on-demand via mkdir during the write loop.
|
||||
for (const name of input.servers) {
|
||||
const ok = await ensureDest(name, true);
|
||||
if (!ok.ok) throw new BetaError(`dest not usable for ${name}: ${ok.reason}`, 'dest-fail');
|
||||
}
|
||||
|
||||
// DB writes in one transaction.
|
||||
const primary = resolved[0]; // populates deprecated betas.* NOT NULL cols
|
||||
const insertBeta = db().prepare(
|
||||
@@ -430,13 +447,12 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
db().prepare('INSERT INTO beta_test_coverage (beta_id, server, game, map) VALUES (?, ?, ?, ?)').run(betaId, name, selected.game, selected.map);
|
||||
}
|
||||
for (const r of resolved) {
|
||||
const prior = getServerScript(name, r.script.target_rel_path);
|
||||
insertBetaServerScript.run(
|
||||
betaId,
|
||||
name,
|
||||
r.script.id,
|
||||
prior ? 1 : 0,
|
||||
prior?.script_version_id ?? null
|
||||
0,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -484,133 +500,61 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
|
||||
await writeFile(join(archive, base), r.version.content);
|
||||
}
|
||||
|
||||
const insertSnapshot = db().prepare(
|
||||
`INSERT INTO beta_file_snapshots (beta_id, server, rel_path, prior_content, prior_sha256, prior_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
// On-disk deploy per server. If any write fails on a server, mark the
|
||||
// whole server 'failed' — we can't restart a partially deployed server
|
||||
// safely. Remaining artifacts on that server are skipped.
|
||||
const updateStage = db().prepare(
|
||||
`UPDATE beta_servers
|
||||
SET stage = ?, error = ?, deployed_at = ?, updated_at = ?
|
||||
WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL`
|
||||
);
|
||||
|
||||
// Persist the requested bytes without touching any live destination. Provisioning
|
||||
// captures the original file and inventory immediately before guarded activation.
|
||||
for (const name of names) {
|
||||
if (!isActiveBetaServer(betaId, name)) continue;
|
||||
db().prepare("UPDATE beta_servers SET stage = 'deploying', deployed_at = ? WHERE beta_id = ? AND server = ?")
|
||||
.run(nowIso(), betaId, name);
|
||||
const serverRow = serverRows.find((r) => r.name === name)!;
|
||||
let firstErr: string | null = null;
|
||||
// 1. Non-collapsing artifacts: script/plugin — write directly to the
|
||||
// target filename. Pre-beta bytes go into beta_file_snapshots so
|
||||
// rollback can restore them.
|
||||
for (const { script, version } of nonCollapsing) {
|
||||
if (firstErr) break;
|
||||
const dest = destOfKind(name, script.kind);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
try {
|
||||
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
|
||||
if (existsSync(targetPath)) {
|
||||
const priorBytes = await readFile(targetPath);
|
||||
if (!isActiveBetaServer(betaId, name)) break;
|
||||
insertSnapshot.run(
|
||||
betaId,
|
||||
name,
|
||||
script.target_rel_path,
|
||||
priorBytes,
|
||||
sha256Buffer(priorBytes),
|
||||
priorBytes.byteLength
|
||||
);
|
||||
db().prepare('UPDATE beta_server_scripts SET had_original = 1 WHERE beta_id = ? AND server = ? AND script_id = ?').run(betaId, name, script.id);
|
||||
}
|
||||
db().prepare('INSERT OR IGNORE INTO beta_file_changes (beta_id, server, rel_path) VALUES (?, ?, ?)').run(betaId, name, script.target_rel_path);
|
||||
if (!isActiveBetaServer(betaId, name)) break;
|
||||
await writeFile(targetPath, version.content);
|
||||
if (!isActiveBetaServer(betaId, name)) break;
|
||||
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, `beta:${betaId}`);
|
||||
} catch (err) {
|
||||
firstErr = `${script.name}: ${(err as Error).message}`;
|
||||
}
|
||||
}
|
||||
// 2. Collapsing layer: compose the effective dedicated_sp.cfg from
|
||||
// baseline + all applicable overlays, write in place. Pre-beta cfg
|
||||
// bytes go into beta_file_snapshots keyed on the target rel_path.
|
||||
const hasCollapsing = collapsingBaseline || collapsingOverlays.length > 0;
|
||||
if (!firstErr && hasCollapsing) {
|
||||
try {
|
||||
try {
|
||||
const changes: Parameters<typeof queueFileChange>[0][] = nonCollapsing.map(({ script, version }) => ({
|
||||
sourceKind: 'beta' as const, sourceId: String(betaId), server: name,
|
||||
kind: script.kind, relPath: script.target_rel_path, content: version.content,
|
||||
scriptVersionId: version.id, inventorySource: `beta:${betaId}`
|
||||
}));
|
||||
if (collapsingBaseline || collapsingOverlays.length > 0) {
|
||||
const composedResult = composeCfgForServer(name, serverRow.game, serverRow.map, {
|
||||
inBetaBaseline: collapsingBaseline?.version,
|
||||
inBetaOverlays: collapsingOverlays
|
||||
});
|
||||
if (!composedResult) {
|
||||
throw new Error(
|
||||
`no baseline available for ${name} (game=${serverRow.game ?? '?'}) — upload one before running config overlays`
|
||||
);
|
||||
}
|
||||
const dest = destOfKind(name, 'config');
|
||||
await mkdir(dest, { recursive: true });
|
||||
const targetRel = configFileOf(name);
|
||||
const targetPath = join(dest, targetRel);
|
||||
if (existsSync(targetPath)) {
|
||||
const priorBytes = await readFile(targetPath);
|
||||
if (!isActiveBetaServer(betaId, name)) continue;
|
||||
insertSnapshot.run(
|
||||
betaId,
|
||||
name,
|
||||
targetRel,
|
||||
priorBytes,
|
||||
sha256Buffer(priorBytes),
|
||||
priorBytes.byteLength
|
||||
);
|
||||
throw new Error(`no baseline available for ${name} (game=${serverRow.game ?? '?'}) — upload one before running config overlays`);
|
||||
}
|
||||
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
||||
db().prepare('INSERT OR IGNORE INTO beta_file_changes (beta_id, server, rel_path) VALUES (?, ?, ?)').run(betaId, name, targetRel);
|
||||
if (!isActiveBetaServer(betaId, name)) continue;
|
||||
await writeFile(targetPath, composedBuf);
|
||||
if (!isActiveBetaServer(betaId, name)) continue;
|
||||
recordScript(
|
||||
name,
|
||||
targetRel,
|
||||
null,
|
||||
sha256Buffer(composedBuf),
|
||||
composedBuf.byteLength,
|
||||
`beta:${betaId}`
|
||||
);
|
||||
// Archive the composed preview for operator visibility.
|
||||
changes.push({ sourceKind: 'beta', sourceId: String(betaId), server: name,
|
||||
kind: 'config', relPath: configFileOf(name), content: composedBuf,
|
||||
scriptVersionId: null, inventorySource: `beta:${betaId}` });
|
||||
await writeFile(join(archive, `${name}.${configFileOf(name)}`), composedBuf);
|
||||
} catch (err) {
|
||||
firstErr = `configs: ${(err as Error).message}`;
|
||||
}
|
||||
}
|
||||
const t = nowIso();
|
||||
if (!isActiveBetaServer(betaId, name)) continue;
|
||||
if (firstErr) {
|
||||
updateStage.run('failed', `deploy: ${firstErr}`, t, t, betaId, name);
|
||||
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'failed', error: `deploy: ${firstErr}` });
|
||||
} else {
|
||||
updateStage.run('awaiting-empty', null, t, t, betaId, name);
|
||||
if (!isActiveBetaServer(betaId, name)) continue;
|
||||
db().transaction(() => {
|
||||
for (const change of changes) queueFileChange(change);
|
||||
db().prepare("UPDATE beta_servers SET stage = 'awaiting-empty', error = NULL, deployed_at = NULL, updated_at = ? WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL")
|
||||
.run(nowIso(), betaId, name);
|
||||
})();
|
||||
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'awaiting-empty', error: null });
|
||||
} catch (error) {
|
||||
const message = `deploy: ${(error as Error).message}`;
|
||||
db().prepare("UPDATE beta_servers SET stage = 'failed', error = ?, updated_at = ? WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL")
|
||||
.run(message, nowIso(), betaId, name);
|
||||
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'failed', error: message });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ---------- rollback ----------
|
||||
|
||||
export interface RollbackResult {
|
||||
betaId: number;
|
||||
deploymentId: string | null;
|
||||
restored: string[];
|
||||
dropped: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
||||
return exclusive(betaId, () => rollbackBetaInternal(betaId));
|
||||
export async function rollbackBeta(betaId: number, options: { restart?: 'manual' | 'when-empty' } = {}): Promise<RollbackResult> {
|
||||
return exclusive(betaId, () => rollbackBetaInternal(betaId, options));
|
||||
}
|
||||
async function rollbackBetaInternal(betaId: number): Promise<RollbackResult> {
|
||||
async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual' | 'when-empty' }): Promise<RollbackResult> {
|
||||
const beta = getBeta(betaId);
|
||||
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
|
||||
if (beta.state !== 'running' && beta.state !== 'ready-promote') {
|
||||
@@ -637,86 +581,77 @@ async function rollbackBetaInternal(betaId: number): Promise<RollbackResult> {
|
||||
WHERE beta_id = ? AND server = ? AND rel_path = ?`
|
||||
);
|
||||
|
||||
const restorations: Array<{ server: string; kind: ArtifactKind; relPath: string; content: Buffer | null; versionId: number | null }> = [];
|
||||
const changed = db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?');
|
||||
const applied = (bs: BetaServerRow, kind: ArtifactKind, path: string) => {
|
||||
const staged = isFileApplied('beta', String(betaId), bs.server, kind, path);
|
||||
if (staged !== null) return staged;
|
||||
// Existing betas predating staging wrote their files before deployed_at.
|
||||
return !!changed.get(betaId, bs.server, path) || !!bs.deployed_at;
|
||||
};
|
||||
for (const bs of servers) {
|
||||
if (!isActiveBetaServer(betaId, bs.server)) {
|
||||
warnings.push(`${bs.server}: original server was deleted; retained history was skipped`);
|
||||
continue;
|
||||
}
|
||||
if (!bs.deployed_at && bs.stage === 'queued') continue;
|
||||
for (const { script } of bundle) {
|
||||
if (!isActiveBetaServer(betaId, bs.server)) break;
|
||||
if (isCollapsingKind(script.kind)) continue; // handled once per server below
|
||||
if (storedPlan(betaId) && !db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?').get(betaId, bs.server, script.target_rel_path)) continue;
|
||||
const dest = destOfKind(bs.server, script.kind);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
|
||||
if (isCollapsingKind(script.kind) || !applied(bs, script.kind, script.target_rel_path)) continue;
|
||||
const bss = getBetaServerScript(betaId, bs.server, script.id);
|
||||
if (bss?.had_original) {
|
||||
const snap = getSnapshot.get(betaId, bs.server, script.target_rel_path) as
|
||||
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
||||
| undefined;
|
||||
if (snap) {
|
||||
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
|
||||
if (!isActiveBetaServer(betaId, bs.server)) break;
|
||||
await writeFile(targetPath, snap.prior_content);
|
||||
if (!isActiveBetaServer(betaId, bs.server)) break;
|
||||
if (bss.prior_version_id) {
|
||||
recordScript(bs.server, script.target_rel_path, bss.prior_version_id, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
|
||||
} else {
|
||||
recordScript(bs.server, script.target_rel_path, null, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
|
||||
}
|
||||
restored.add(bs.server);
|
||||
} else {
|
||||
warnings.push(`${bs.server}/${script.name}: snapshot missing — cannot restore`);
|
||||
}
|
||||
} else {
|
||||
// No prior file — the beta wrote a fresh file. Drop it.
|
||||
if (existsSync(targetPath)) await rm(targetPath, { force: true });
|
||||
if (!isActiveBetaServer(betaId, bs.server)) break;
|
||||
removeScript(bs.server, script.target_rel_path);
|
||||
dropped.add(bs.server);
|
||||
const snap = getSnapshot.get(betaId, bs.server, script.target_rel_path) as
|
||||
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
||||
| undefined;
|
||||
if (bss?.had_original && !snap) {
|
||||
warnings.push(`${bs.server}/${script.name}: snapshot missing — cannot restore`);
|
||||
continue;
|
||||
}
|
||||
restorations.push({ server: bs.server, kind: script.kind, relPath: script.target_rel_path,
|
||||
content: snap?.prior_content ?? null, versionId: bss?.prior_version_id ?? null });
|
||||
(snap ? restored : dropped).add(bs.server);
|
||||
}
|
||||
|
||||
// Collapsing config layer: single restore for the shared cfg file.
|
||||
if (!isActiveBetaServer(betaId, bs.server)) continue;
|
||||
if (hasCollapsing && (!storedPlan(betaId) || db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?').get(betaId, bs.server, configFileOf(bs.server)))) {
|
||||
const dest = destOfKind(bs.server, 'config');
|
||||
const targetRel = configFileOf(bs.server);
|
||||
const targetPath = join(dest, targetRel);
|
||||
const targetRel = configFileOf(bs.server);
|
||||
if (hasCollapsing && applied(bs, 'config', targetRel)) {
|
||||
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
|
||||
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
||||
| undefined;
|
||||
if (snap) {
|
||||
await mkdir(dest, { recursive: true });
|
||||
if (!isActiveBetaServer(betaId, bs.server)) continue;
|
||||
await writeFile(targetPath, snap.prior_content);
|
||||
if (!isActiveBetaServer(betaId, bs.server)) continue;
|
||||
recordScript(bs.server, targetRel, null, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
|
||||
restored.add(bs.server);
|
||||
} else if (existsSync(targetPath)) {
|
||||
// No snapshot ⇒ the beta wrote the cfg into a server that had
|
||||
// no pre-existing file. Remove the beta-written cfg to leave
|
||||
// the server clean.
|
||||
await rm(targetPath, { force: true });
|
||||
if (!isActiveBetaServer(betaId, bs.server)) continue;
|
||||
removeScript(bs.server, targetRel);
|
||||
dropped.add(bs.server);
|
||||
}
|
||||
restorations.push({ server: bs.server, kind: 'config', relPath: targetRel,
|
||||
content: snap?.prior_content ?? null, versionId: null });
|
||||
(snap ? restored : dropped).add(bs.server);
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshots are single-use — safe to drop now that the disk is restored.
|
||||
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
|
||||
const deploymentId = restorations.length ? `dep-beta-rollback-${betaId}-${Date.now()}` : null;
|
||||
db().transaction(() => {
|
||||
cancelStagedFiles('beta', String(betaId));
|
||||
if (deploymentId) {
|
||||
db().prepare("INSERT INTO deployments (id, action, started_at, triggered_by, beta_id) VALUES (?, 'rollback', ?, ?, ?)")
|
||||
.run(deploymentId, nowIso(), storedPlan(betaId) ? 'beta-test-plan' : 'ui', betaId);
|
||||
for (const server of new Set(restorations.map(change => change.server))) {
|
||||
db().prepare('INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES (?, ?, ?, ?)')
|
||||
.run(deploymentId, server, restorations.filter(change => change.server === server).length,
|
||||
options.restart === 'manual' ? 'held' : 'pending');
|
||||
}
|
||||
for (const change of restorations) {
|
||||
const file = db().prepare(`INSERT INTO deployment_files
|
||||
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
|
||||
VALUES (?, ?, ?, ?, NULL, ?, NULL, ?)`).run(deploymentId, change.server,
|
||||
change.content === null ? 'removed' : 'updated', change.relPath,
|
||||
change.content === null ? null : sha256Buffer(change.content), change.content);
|
||||
queueFileChange({ sourceKind: 'campaign', sourceId: deploymentId, server: change.server,
|
||||
kind: change.kind, relPath: change.relPath, content: change.content,
|
||||
scriptVersionId: change.versionId, inventorySource: `rollback:${betaId}`,
|
||||
deploymentFileId: Number(file.lastInsertRowid) });
|
||||
}
|
||||
}
|
||||
// The queued restoration owns a durable copy before snapshots are released.
|
||||
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
|
||||
db().prepare("UPDATE betas SET state = 'rolled-back', closed_at = ? WHERE id = ?").run(nowIso(), betaId);
|
||||
})();
|
||||
if (deploymentId) emit({ type: 'deployment-opened', deploymentId, action: 'rollback', servers: [...new Set(restorations.map(change => change.server))] });
|
||||
|
||||
// Wipe archive + close out beta.
|
||||
const archive = betaArchiveOf(betaId);
|
||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||
|
||||
db()
|
||||
.prepare(`UPDATE betas SET state = 'rolled-back', closed_at = ? WHERE id = ?`)
|
||||
.run(nowIso(), betaId);
|
||||
|
||||
// Per-version test-failed transition. Skip a version if (a) another beta
|
||||
// still gates it at ready-promote/promoted, or (b) it's the currently-
|
||||
// promoted version of its script (already-live code isn't "failed" by
|
||||
@@ -742,6 +677,7 @@ async function rollbackBetaInternal(betaId: number): Promise<RollbackResult> {
|
||||
|
||||
return {
|
||||
betaId,
|
||||
deploymentId,
|
||||
restored: [...restored].sort(),
|
||||
dropped: [...dropped].sort(),
|
||||
warnings
|
||||
@@ -915,8 +851,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
}
|
||||
}
|
||||
|
||||
// Write files. Track per-plan success and per-server first-error.
|
||||
const planWritten = new Map<Plan, boolean>();
|
||||
// Build durable activation work; live inventory changes only at activation.
|
||||
const serverErr = new Map<string, string>();
|
||||
|
||||
// Collapsing config layer: one composed cfg per server. Union of beta
|
||||
@@ -928,6 +863,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
composedContent: Buffer;
|
||||
composedSha: string;
|
||||
hadFile: boolean;
|
||||
requiresActivation: boolean;
|
||||
}
|
||||
const cfgPlans: CfgPlan[] = [];
|
||||
if (collapsing.length > 0) {
|
||||
@@ -951,7 +887,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
let priorContent: Buffer | null = null;
|
||||
let priorSha: string | null = null;
|
||||
let hadFile = false;
|
||||
// Beta server: prior cfg bytes were snapshotted at openBeta.
|
||||
// Beta server: preserve its original bytes captured at activation.
|
||||
if (betaServerSet.has(name)) {
|
||||
const snap = getSnapshotForPromote.get(betaId, name, targetRel) as
|
||||
| { prior_content: Buffer; prior_sha256: string }
|
||||
@@ -962,59 +898,32 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
hadFile = true;
|
||||
}
|
||||
}
|
||||
if (!hadFile && existsSync(targetPath)) {
|
||||
// Scope-expansion server (or beta server with no snapshot):
|
||||
// current on-disk cfg is the "before".
|
||||
if (!betaServerSet.has(name) && existsSync(targetPath)) {
|
||||
// Capture an initial preview; activation records the actual before image.
|
||||
priorContent = await readFile(targetPath);
|
||||
priorSha = sha256Buffer(priorContent);
|
||||
hadFile = true;
|
||||
}
|
||||
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile });
|
||||
const requiresActivation = !betaServerSet.has(name) || !existsSync(targetPath)
|
||||
|| sha256Buffer(await readFile(targetPath)) !== composedSha;
|
||||
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation });
|
||||
}
|
||||
}
|
||||
|
||||
// Recheck after asynchronous snapshot reads, before any mutation. Execution uses
|
||||
// the captured target lists/content so later membership changes never expand it.
|
||||
checkReview();
|
||||
for (const p of plans) {
|
||||
checkReview();
|
||||
const dest = destOfKind(p.server, p.script.kind);
|
||||
try {
|
||||
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
|
||||
checkReview();
|
||||
await writeFile(join(dest, p.script.target_rel_path), p.version.content);
|
||||
checkReview();
|
||||
planWritten.set(p, true);
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) throw err;
|
||||
planWritten.set(p, false);
|
||||
if (!serverErr.has(p.server)) serverErr.set(p.server, `${p.script.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
// Write composed cfg to each target.
|
||||
const cfgWritten = new Map<CfgPlan, boolean>();
|
||||
// A scope expansion from another promotion may have been queued while this
|
||||
// beta was active. Reassert the tested bytes after that older campaign, even
|
||||
// when they already match the live file, so it cannot overwrite this release.
|
||||
const queuedByServer = new Map(reviewed.preview.servers.map(server => [server.name, unresolvedCampaignWrites(server.name)]));
|
||||
const plansToActivate = new Set(plans.filter(p => !betaServerSet.has(p.server)
|
||||
|| queuedByServer.get(p.server)?.some(row => row.kind === p.script.kind && row.rel_path === p.script.target_rel_path)));
|
||||
for (const cp of cfgPlans) {
|
||||
checkReview();
|
||||
try {
|
||||
const dest = destOfKind(cp.server, 'config');
|
||||
await mkdir(dest, { recursive: true });
|
||||
checkReview();
|
||||
const targetRel = configFileOf(cp.server);
|
||||
const targetPath = join(dest, targetRel);
|
||||
// Beta server already has composed content at targetPath; overwrite
|
||||
// idempotently. Scope-expansion server: write in place.
|
||||
await writeFile(targetPath, cp.composedContent);
|
||||
checkReview();
|
||||
cfgWritten.set(cp, true);
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) throw err;
|
||||
cfgWritten.set(cp, false);
|
||||
const msg = `configs: ${(err as Error).message}`;
|
||||
if (!serverErr.has(cp.server)) serverErr.set(cp.server, msg);
|
||||
console.error(`[promote] ${cp.server}/configs failed:`, err);
|
||||
}
|
||||
if (queuedByServer.get(cp.server)?.some(row => row.kind === 'config' && row.rel_path === configFileOf(cp.server))) cp.requiresActivation = true;
|
||||
}
|
||||
|
||||
const activationServers = new Set([...Array.from(plansToActivate, plan => plan.server),
|
||||
...cfgPlans.filter(plan => plan.requiresActivation).map(plan => plan.server)]);
|
||||
// Build the aggregate deployment.
|
||||
const depId = promoteDeploymentId(betaId);
|
||||
const startedAt = nowIso();
|
||||
@@ -1049,13 +958,11 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
for (const { script, version } of bundle) {
|
||||
insertDepScript.run(depId, script.id, version.id, script.current_version_id, script.kind);
|
||||
}
|
||||
// deployment_files — one row per successful (script, server) write for
|
||||
// non-collapsing artifacts, plus one row per (server, dedicated_sp.cfg)
|
||||
// for the composed config layer.
|
||||
// Record the reviewed change. Scope expansion gets its actual before
|
||||
// image when activated; tested servers keep their pre-beta history.
|
||||
for (const p of plans) {
|
||||
if (!planWritten.get(p)) continue;
|
||||
const verb = p.hadFile ? 'updated' : 'added';
|
||||
insertDepFile.run(
|
||||
const file = insertDepFile.run(
|
||||
depId,
|
||||
p.server,
|
||||
verb,
|
||||
@@ -1065,19 +972,16 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
p.priorContent,
|
||||
p.version.content
|
||||
);
|
||||
recordScript(
|
||||
p.server,
|
||||
p.script.target_rel_path,
|
||||
p.version.id,
|
||||
p.version.sha256,
|
||||
p.version.size,
|
||||
`promote:${depId}`
|
||||
);
|
||||
if (plansToActivate.has(p)) {
|
||||
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: p.server,
|
||||
kind: p.script.kind, relPath: p.script.target_rel_path, content: p.version.content,
|
||||
scriptVersionId: p.version.id, inventorySource: `promote:${depId}`,
|
||||
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(p.server) });
|
||||
}
|
||||
}
|
||||
for (const cp of cfgPlans) {
|
||||
if (!cfgWritten.get(cp)) continue;
|
||||
const verb = cp.hadFile ? 'updated' : 'added';
|
||||
insertDepFile.run(
|
||||
const file = insertDepFile.run(
|
||||
depId,
|
||||
cp.server,
|
||||
verb,
|
||||
@@ -1087,14 +991,12 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
cp.priorContent,
|
||||
cp.composedContent
|
||||
);
|
||||
recordScript(
|
||||
cp.server,
|
||||
configFileOf(cp.server),
|
||||
null,
|
||||
cp.composedSha,
|
||||
cp.composedContent.byteLength,
|
||||
`promote:${depId}`
|
||||
);
|
||||
if (cp.requiresActivation) {
|
||||
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: cp.server,
|
||||
kind: 'config', relPath: configFileOf(cp.server), content: cp.composedContent,
|
||||
scriptVersionId: null, inventorySource: `promote:${depId}`,
|
||||
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(cp.server) });
|
||||
}
|
||||
}
|
||||
// deployment_servers — one row per unique server. Beta servers are
|
||||
// already restarted; scope-only servers still need the sweep to drain
|
||||
@@ -1106,9 +1008,9 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
continue;
|
||||
}
|
||||
const filesChanged =
|
||||
plans.filter((p) => p.server === name && planWritten.get(p)).length +
|
||||
cfgPlans.filter((c) => c.server === name && cfgWritten.get(c)).length;
|
||||
if (betaServerSet.has(name)) {
|
||||
plans.filter((p) => p.server === name).length +
|
||||
cfgPlans.filter((c) => c.server === name).length;
|
||||
if (!activationServers.has(name)) {
|
||||
const bsRestartedAt = db()
|
||||
.prepare(`SELECT restarted_at FROM beta_servers WHERE beta_id = ? AND server = ?`)
|
||||
.get(betaId, name) as { restarted_at: string | null } | undefined;
|
||||
@@ -1124,16 +1026,15 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
})();
|
||||
|
||||
db().prepare(`UPDATE betas SET state = 'promoted', closed_at = ? WHERE id = ?`).run(nowIso(), betaId);
|
||||
// Snapshots are single-use — the promoted content is live on disk, so the
|
||||
// pre-beta bytes are no longer needed.
|
||||
// The deployment history owns the pre-beta bytes for future rollback.
|
||||
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
|
||||
|
||||
const archive = betaArchiveOf(betaId);
|
||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||
|
||||
const landed = Array.from(new Set([
|
||||
...plans.filter((p) => planWritten.get(p)).map((p) => p.server),
|
||||
...cfgPlans.filter((c) => cfgWritten.get(c)).map((c) => c.server)
|
||||
...plans.map((p) => p.server),
|
||||
...cfgPlans.map((c) => c.server)
|
||||
])).sort();
|
||||
|
||||
emit({ type: 'beta-state', betaId, state: 'promoted' });
|
||||
@@ -1144,7 +1045,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
deploymentId: depId,
|
||||
landed,
|
||||
scriptIds: bundle.map((b) => b.script.id),
|
||||
awaitingActivation: reviewed.preview.servers.filter(server => !server.tested && !serverErr.has(server.name)).length,
|
||||
awaitingActivation: [...activationServers].filter(name => !serverErr.has(name)).length,
|
||||
failedServers: [...serverErr.keys()]
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user