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

1154 lines
50 KiB
TypeScript

import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { storedPlan } from './beta-tests.js';
import type { BetaPromotionPreview } from '../../beta-promotion.js';
const operations = new Set<number>();
export const betaOperationInFlight = (id: number) => operations.has(id);
async function exclusive<T>(id: number, action: () => Promise<T>): Promise<T> {
if (operations.has(id)) throw new BetaError('A beta operation is already running', 'invalid-state');
operations.add(id);
try { return await action(); } finally { operations.delete(id); }
}
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, 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 {
getBaselineForScope,
getScript,
getVersion,
listPromotedOverlaysForScope,
listVersions,
serversMatchingScope,
setScope,
type ScopeType,
type ScriptRow,
type ScriptVersionRow
} from './scripts.js';
import { composeServerCfg } from './config-compose.js';
import { recordScript, removeScript, getServerScript } from './inventory.js';
import { emit } from '../pipeline/bus.js';
import type { BetaStage } from '../pipeline/bus.js';
// Whether an artifact writes a dedicated on-disk file (script/plugin) or
// contributes to a composed shared file (config/baseline).
function isCollapsingKind(kind: ArtifactKind): boolean {
return kind === 'config' || kind === 'baseline';
}
// The path we should write to for the composed cfg on this server.
function composedCfgPath(server: string): string {
return join(destOfKind(server, 'config'), configFileOf(server));
}
// Compose the effective dedicated_sp.cfg for a given beta+server, taking
// into account:
// • baseline: from the beta if present, else the currently-promoted
// baseline for the server's scope
// • promoted overlays for the server's scope (excluding scripts being
// replaced by this beta)
// • this beta's overlays whose scope matches the server
//
// Returns null if no baseline is available (nothing to compose against).
interface ComposeInputs {
inBetaBaseline?: ScriptVersionRow;
inBetaOverlays: Array<{ script: ScriptRow; version: ScriptVersionRow }>;
}
function scopeAppliesToServer(
scope: { scope_type: string | null; scope_value: string | null },
server: { game: string | null; map: string | null }
): boolean {
if (!scope.scope_type || !scope.scope_value) return false;
const field = scope.scope_type === 'game' ? server.game : server.map;
return field === scope.scope_value;
}
function composeCfgForServer(
serverName: string,
serverGame: string | null,
serverMap: string | null,
inputs: ComposeInputs
): { text: string; baselineScriptId: number | null } | null {
// Baseline: prefer the beta's baseline if its scope matches this server.
let baselineText: string | null = null;
let baselineScriptId: number | null = null;
if (inputs.inBetaBaseline) {
const s = getScript(inputs.inBetaBaseline.script_id);
if (s && scopeAppliesToServer(s, { game: serverGame, map: serverMap })) {
baselineText = inputs.inBetaBaseline.content.toString('utf8');
baselineScriptId = s.id;
}
}
// Fall back to the currently-promoted baseline for the server's game.
if (baselineText === null && serverGame) {
const promoted = getBaselineForScope('game', serverGame);
if (promoted?.current_version_id) {
const ver = getVersion(promoted.current_version_id);
if (ver) {
baselineText = ver.content.toString('utf8');
baselineScriptId = promoted.id;
}
}
}
if (baselineText === null) return null;
// In-beta script ids that are replacing promoted overlays — those get
// skipped from the promoted overlay list to avoid double-applying an
// older version of the same script.
const inBetaScriptIds = new Set(inputs.inBetaOverlays.map((o) => o.script.id));
// Collect promoted overlays scoped to this server's game AND map.
const promotedOverlays: Array<{ id: number; text: string }> = [];
const promotedIdsSeen = new Set<number>();
const pushIfScoped = (rows: ScriptRow[]) => {
for (const s of rows) {
if (promotedIdsSeen.has(s.id)) continue;
if (inBetaScriptIds.has(s.id)) continue;
if (!scopeAppliesToServer(s, { game: serverGame, map: serverMap })) continue;
if (!s.current_version_id) continue;
const ver = getVersion(s.current_version_id);
if (!ver) continue;
promotedOverlays.push({ id: s.id, text: ver.content.toString('utf8') });
promotedIdsSeen.add(s.id);
}
};
if (serverGame) pushIfScoped(listPromotedOverlaysForScope('game', serverGame));
if (serverMap) pushIfScoped(listPromotedOverlaysForScope('map', serverMap));
// In-beta overlays scoped to this server.
const inBetaScopedOverlays = inputs.inBetaOverlays
.filter(({ script }) => scopeAppliesToServer(script, { game: serverGame, map: serverMap }))
.map(({ script, version }) => ({ id: script.id, text: version.content.toString('utf8') }));
const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap, getServer(serverName)?.map_slug);
return { text: composed, baselineScriptId };
}
export interface BetaRow {
id: number;
// Deprecated: the columns are still populated (NOT NULL on legacy schemas)
// from the first bundled script's identity so existing data continues to
// roundtrip, but new code reads beta_scripts for the authoritative list.
script_id: number;
script_version_id: number;
trigger_kind: string;
wait_for_health: number;
wait_for_match: number;
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
opened_at: string;
closed_at: string | null;
notes: string | null;
}
export interface BetaServerRow {
beta_id: number;
server: string;
had_original: number; // deprecated; per-script data lives in beta_server_scripts
prior_version_id: number | null; // deprecated
stage: BetaStage;
error: string | null;
deployed_at: string | null;
restarted_at: string | null;
provisioning_id: string | null;
healthy_at: string | null;
match_seen_at: string | null;
updated_at: string;
}
export interface BetaScriptRow {
beta_id: number;
script_id: number;
script_version_id: number;
kind: ArtifactKind;
}
export interface BetaServerScriptRow {
beta_id: number;
server: string;
script_id: number;
had_original: number;
prior_version_id: number | null;
}
export class BetaError extends Error {
constructor(
message: string,
public readonly kind:
| 'no-such-script'
| 'no-such-version'
| 'version-script-mismatch'
| 'no-scripts'
| 'no-servers'
| 'unknown-server'
| 'out-of-scope'
| 'conflict'
| 'blocked-backup'
| 'no-such-beta'
| 'not-ready-promote'
| 'not-running'
| 'dest-fail'
| 'invalid-state'
| 'duplicate-script'
| 'preview-changed'
| 'duplicate-target',
public readonly details?: unknown
) {
super(message);
}
}
function nowIso(): string {
return new Date().toISOString();
}
// ---------- Reads ----------
export function getBeta(id: number): BetaRow | undefined {
return db().prepare('SELECT * FROM betas WHERE id = ?').get(id) as BetaRow | undefined;
}
export function listBetas(state?: BetaRow['state']): BetaRow[] {
if (state) {
return db().prepare('SELECT * FROM betas WHERE state = ? ORDER BY opened_at DESC').all(state) as BetaRow[];
}
return db().prepare('SELECT * FROM betas ORDER BY opened_at DESC').all() as BetaRow[];
}
export function listBetaServers(betaId: number): BetaServerRow[] {
return db()
.prepare('SELECT * FROM beta_servers WHERE beta_id = ? ORDER BY server')
.all(betaId) as BetaServerRow[];
}
export function listBetaScripts(betaId: number): BetaScriptRow[] {
return db()
.prepare('SELECT * FROM beta_scripts WHERE beta_id = ? ORDER BY script_id')
.all(betaId) as BetaScriptRow[];
}
export function getBetaServerScript(betaId: number, server: string, scriptId: number): BetaServerScriptRow | undefined {
return db()
.prepare('SELECT * FROM beta_server_scripts WHERE beta_id = ? AND server = ? AND script_id = ?')
.get(betaId, server, scriptId) as BetaServerScriptRow | undefined;
}
// A script is "in a running beta" if beta_scripts joins to a running beta row.
// Existing callers only care about the running-beta id, so return that shape.
export function findRunningBetaForScript(scriptId: number): BetaRow | undefined {
return db()
.prepare(
`SELECT b.* FROM betas b
JOIN beta_scripts bs ON bs.beta_id = b.id
WHERE bs.script_id = ? AND b.state IN ('running','ready-promote')
LIMIT 1`
)
.get(scriptId) as BetaRow | undefined;
}
// ---------- openBeta ----------
export interface OpenBetaScript {
scriptId: number;
scriptVersionId: number;
}
export interface OpenBetaInput {
scripts: OpenBetaScript[];
servers: string[];
testPlan?: BetaPlan;
waitForHealth: boolean;
waitForMatch: boolean;
notes?: string;
}
export interface OpenBetaResult {
betaId: number;
servers: string[];
scripts: OpenBetaScript[];
}
export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
const plan = input.testPlan ? parseBetaPlan(input.testPlan) : null;
if (new Set(input.servers).size !== input.servers.length) throw new BetaError("Select each server only once", "invalid-state");
if (plan?.scope === "quorum" && plan.quorum > input.servers.length) throw new BetaError("Quorum exceeds selected servers", "invalid-state");
if (!input.scripts || input.scripts.length === 0) {
throw new BetaError('at least one script is required', 'no-scripts');
}
if (!input.servers || input.servers.length === 0) {
throw new BetaError('at least one server is required', 'no-servers');
}
// Resolve every (script, version) pair and validate belonging.
interface Resolved { script: ScriptRow; version: ScriptVersionRow }
const resolved: Resolved[] = [];
const seenScriptIds = new Set<number>();
// For non-collapsing kinds (script/plugin), two artifacts targeting the
// same rel_path within one beta is an internal race — reject up front.
// Collapsing kinds (config/baseline) intentionally share a target file
// (dedicated_sp.cfg) and compose, so they skip this check.
const seenTargets = new Map<string, number>();
let baselineInBeta: Resolved | null = null;
for (const s of input.scripts) {
if (seenScriptIds.has(s.scriptId)) {
throw new BetaError(`script ${s.scriptId} listed twice in one beta`, 'duplicate-script');
}
seenScriptIds.add(s.scriptId);
const script = getScript(s.scriptId);
if (!script) throw new BetaError(`no such script (id=${s.scriptId})`, 'no-such-script');
const version = getVersion(s.scriptVersionId);
if (!version) throw new BetaError(`no such version (id=${s.scriptVersionId})`, 'no-such-version');
if (version.script_id !== script.id) {
throw new BetaError(
`version ${s.scriptVersionId} does not belong to script ${s.scriptId}`,
'version-script-mismatch'
);
}
if (!isCollapsingKind(script.kind)) {
const targetKey = `${script.kind}:${script.target_rel_path}`;
if (seenTargets.has(targetKey)) {
throw new BetaError(
`${script.kind}s ${seenTargets.get(targetKey)} and ${script.id} share target ${script.target_rel_path}`,
'duplicate-target'
);
}
seenTargets.set(targetKey, script.id);
}
if (script.kind === 'baseline') {
if (baselineInBeta) {
throw new BetaError(
`two baselines in one beta (${baselineInBeta.script.id}, ${script.id}) — betas support at most one baseline`,
'duplicate-target'
);
}
baselineInBeta = { script, version };
}
resolved.push({ script, version });
}
// Look up server rows once.
const serverRows: Array<{ name: string; game: string | null; map: string | null }> = [];
for (const name of input.servers) {
const row = getServer(name);
if (!row) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
serverRows.push({ name, game: row.game, map: row.map });
}
if (plan && ['map', 'game', 'map-game'].includes(plan.scope)) {
const missing = serverRows.some(s => (plan.scope !== 'map' && !s.game) || (plan.scope !== 'game' && !s.map));
if (missing) throw new BetaError('Coverage requires game/map metadata on every selected server', 'invalid-state');
}
// Per-script scope validation. Aggregate all violations into one error.
const outOfScope: Array<{ scriptId: number; scope: string; servers: string[] }> = [];
for (const { script } of resolved) {
if (!script.scope_type || !script.scope_value) continue;
const bad = serverRows.filter(
(r) => (script.scope_type === 'game' ? r.game : r.map) !== script.scope_value
);
if (bad.length > 0) {
outOfScope.push({
scriptId: script.id,
scope: `${script.scope_type}=${script.scope_value}`,
servers: bad.map((r) => r.name)
});
}
}
if (outOfScope.length > 0) {
throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope);
}
// 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.
const conflicts: Array<{ server: string; scriptId: number; heldByBetaId: number }> = [];
for (const name of input.servers) {
const held = heldByBeta(name);
for (const { script } of resolved) {
const owner = held.get(script.target_rel_path);
if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner });
}
}
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(
`INSERT INTO betas (script_id, script_version_id, trigger_kind, wait_for_health, wait_for_match, state, notes)
VALUES (?, ?, 'bundle', ?, ?, 'running', ?)`
);
const insertBetaScript = db().prepare(
`INSERT INTO beta_scripts (beta_id, script_id, script_version_id, kind) VALUES (?, ?, ?, ?)`
);
const insertBetaServer = db().prepare(
`INSERT INTO beta_servers (beta_id, server, had_original, prior_version_id, stage, updated_at)
VALUES (?, ?, 0, NULL, 'deploying', ?)`
);
const insertBetaServerScript = db().prepare(
`INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original, prior_version_id)
VALUES (?, ?, ?, ?, ?)`
);
let betaId!: number;
db().transaction(() => {
const res = insertBeta.run(
primary.script.id,
primary.version.id,
input.waitForHealth ? 1 : 0,
input.waitForMatch ? 1 : 0,
input.notes ?? null
);
betaId = Number(res.lastInsertRowid);
if (plan) db().prepare('INSERT INTO beta_test_plans (beta_id, plan) VALUES (?, ?)').run(betaId, JSON.stringify(plan));
for (const r of resolved) {
insertBetaScript.run(betaId, r.script.id, r.version.id, r.script.kind);
}
for (const name of input.servers) {
insertBetaServer.run(betaId, name, nowIso());
if (plan) {
const selected = serverRows.find(s => s.name === name)!;
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
);
}
}
})();
if (plan?.waveSize) db().prepare("UPDATE beta_servers SET stage = 'queued' WHERE beta_id = ?").run(betaId);
await deployBetaServers(betaId, plan?.waveSize ? input.servers.slice(0, plan.waveSize) : input.servers);
emit({
type: 'beta-opened',
betaId,
scriptIds: resolved.map((r) => r.script.id),
servers: input.servers
});
return {
betaId,
servers: input.servers,
scripts: resolved.map((r) => ({ scriptId: r.script.id, scriptVersionId: r.version.id }))
};
}
/** Apply a reserved wave only when its predecessors have passed. */
export async function deployBetaServers(betaId: number, names: string[]): Promise<void> {
return exclusive(betaId, () => deployBetaServersInternal(betaId, names));
}
async function deployBetaServersInternal(betaId: number, names: string[]): Promise<void> {
const resolved = listBetaScripts(betaId).map(r => ({ script: getScript(r.script_id)!, version: getVersion(r.script_version_id)! }));
const serverRows = names.map(name => getServer(name)!);
// Split resolved artifacts into non-collapsing (each writes its own file)
// and collapsing (config/baseline overlay onto one shared dedicated_sp.cfg).
const nonCollapsing = resolved.filter((r) => !isCollapsingKind(r.script.kind));
const collapsingOverlays = resolved.filter((r) => r.script.kind === 'config');
const collapsingBaseline = resolved.find((r) => r.script.kind === 'baseline');
// Archive: one copy per non-collapsing artifact under its target basename,
// plus one composed preview per beta server for the config layer.
const archive = betaArchiveOf(betaId);
await mkdir(archive, { recursive: true });
for (const r of nonCollapsing) {
const base = posix.basename(r.script.target_rel_path);
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 = ?`
);
for (const name of names) {
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);
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);
await writeFile(targetPath, version.content);
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 {
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);
insertSnapshot.run(
betaId,
name,
targetRel,
priorBytes,
sha256Buffer(priorBytes),
priorBytes.byteLength
);
}
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);
await writeFile(targetPath, composedBuf);
recordScript(
name,
targetRel,
null,
sha256Buffer(composedBuf),
composedBuf.byteLength,
`beta:${betaId}`
);
// Archive the composed preview for operator visibility.
await writeFile(join(archive, `${name}.${configFileOf(name)}`), composedBuf);
} catch (err) {
firstErr = `configs: ${(err as Error).message}`;
}
}
const t = nowIso();
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);
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'awaiting-empty', error: null });
}
}
}
// ---------- rollback ----------
export interface RollbackResult {
betaId: number;
restored: string[];
dropped: string[];
warnings: string[];
}
export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
return exclusive(betaId, () => rollbackBetaInternal(betaId));
}
async function rollbackBetaInternal(betaId: number): 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') {
throw new BetaError(`beta is ${beta.state}, cannot rollback`, 'invalid-state');
}
const bundleRows = listBetaScripts(betaId);
if (bundleRows.length === 0) {
throw new BetaError('beta has no scripts', 'no-scripts');
}
const bundle = bundleRows.map((br) => ({
script: getScript(br.script_id)!,
versionId: br.script_version_id
}));
const servers = listBetaServers(betaId);
const restored = new Set<string>();
const dropped = new Set<string>();
const warnings: string[] = [];
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
const getSnapshot = db().prepare(
`SELECT prior_content, prior_sha256, prior_size FROM beta_file_snapshots
WHERE beta_id = ? AND server = ? AND rel_path = ?`
);
for (const bs of servers) {
if (!bs.deployed_at && bs.stage === 'queued') continue;
for (const { script } of bundle) {
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);
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 });
await writeFile(targetPath, snap.prior_content);
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 });
removeScript(bs.server, script.target_rel_path);
dropped.add(bs.server);
}
}
// Collapsing config layer: single restore for the shared cfg file.
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 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 });
await writeFile(targetPath, snap.prior_content);
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 });
removeScript(bs.server, targetRel);
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);
// 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
// rejecting a redundant beta).
for (const { script, versionId } of bundle) {
const stillOk = db()
.prepare(
`SELECT 1 FROM beta_scripts bs
JOIN betas b ON b.id = bs.beta_id
WHERE bs.script_version_id = ?
AND b.id <> ?
AND b.state IN ('ready-promote','promoted')
LIMIT 1`
)
.get(versionId, betaId);
const isCurrent = script.current_version_id === versionId;
if (!stillOk && !isCurrent) {
db().prepare(`UPDATE script_versions SET state = 'test-failed' WHERE id = ?`).run(versionId);
}
}
emit({ type: 'beta-state', betaId, state: 'rolled-back' });
return {
betaId,
restored: [...restored].sort(),
dropped: [...dropped].sort(),
warnings
};
}
// ---------- promote ----------
export interface PromoteResult {
betaId: number;
deploymentId: string;
landed: string[]; // unique servers that received at least one file
scriptIds: number[];
awaitingActivation: number;
failedServers: string[];
}
/** Snapshot exactly the scope expansion and composed configuration reviewed by the operator. */
function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configContent: Map<string, Buffer> } {
const beta = getBeta(betaId);
if (!beta) throw new BetaError('This beta no longer exists.', 'no-such-beta');
const bundle = listBetaScripts(betaId).map(row => {
const script = getScript(row.script_id), version = getVersion(row.script_version_id);
if (!script || !version) throw new BetaError('A selected file version is no longer available.', 'no-such-version');
return { script, version };
});
if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts');
const tested = new Set(listBetaServers(betaId).map(row => row.server));
const targets = (script: ScriptRow) => [...new Set([...tested, ...(script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type, script.scope_value) : [])])].sort();
const files = bundle.map(({ script, version }) => ({ id: script.id, name: script.name, kind: script.kind,
path: script.target_rel_path, versionId: version.id, version: version.version,
release_version: version.release_version, sha256: version.sha256,
scope: script.scope_type ? `${script.scope_type}: ${script.scope_value}` : 'Selected test servers only', servers: targets(script) }));
const names = [...new Set(files.flatMap(file => file.servers))].sort();
const blockers: string[] = [];
const servers = names.map(name => {
const server = getServer(name);
if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`);
return { name, tested: tested.has(name), game: server?.game ?? null, map: server?.map ?? null };
});
const collapsing = bundle.filter(({ script }) => isCollapsingKind(script.kind));
const configContent = new Map<string, Buffer>();
const configWrites = [...new Set(collapsing.flatMap(({ script }) => targets(script)))].sort().map(server => {
const row = getServer(server);
const composed = row && composeCfgForServer(server, row.game, row.map, {
inBetaBaseline: collapsing.find(({ script }) => script.kind === 'baseline')?.version,
inBetaOverlays: collapsing.filter(({ script }) => script.kind === 'config')
});
const error = composed ? null : `${server}: no baseline is available to compose its configuration.`;
if (error) blockers.push(error);
const content = composed ? Buffer.from(composed.text, 'utf8') : null;
if (content) configContent.set(server, content);
return { server, path: configFileOf(server), sha256: content ? sha256Buffer(content) : null, error };
});
const impact = { betaId, files, servers, configWrites, blockers, restartPolicy: 'additional-when-empty' as const };
return { preview: { ...impact, token: sha256Buffer(Buffer.from(JSON.stringify(impact))), createdAt: nowIso() }, configContent };
}
export function previewBetaPromotion(betaId: number): BetaPromotionPreview {
return promotionPlan(betaId).preview;
}
function promoteDeploymentId(betaId: number): string {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
return `dep-${stamp}-promote-${betaId}`;
}
export async function promoteBeta(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
if (storedPlan(betaId)) {
const { plannedBetaTick } = await import('../pipeline/beta-plan-runner.js');
await plannedBetaTick();
}
return exclusive(betaId, () => promoteBetaInternal(betaId, reviewedToken));
}
async function promoteBetaInternal(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
const test = storedPlan(betaId);
if (test && test.outcome !== 'passed') throw new BetaError('Test plan has not passed', 'not-ready-promote');
const beta = getBeta(betaId);
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
if (beta.state !== 'ready-promote') {
throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-ready-promote');
}
const reviewed = promotionPlan(betaId);
const checkReview = () => {
const current = previewBetaPromotion(betaId);
if (current.token !== (reviewedToken ?? reviewed.preview.token)) {
throw new BetaError('Promotion targets or versions changed. Review the updated plan before promoting.', 'preview-changed', current);
}
if (current.blockers.length) throw new BetaError(current.blockers.join(' '), 'invalid-state');
};
checkReview();
const bundleRows = listBetaScripts(betaId);
if (bundleRows.length === 0) throw new BetaError('beta has no scripts', 'no-scripts');
const bundle = bundleRows.map((br) => ({
script: getScript(br.script_id)!,
version: getVersion(br.script_version_id)!
}));
const betaServerSet = new Set(listBetaServers(betaId).map((r) => r.server));
// Split bundle into non-collapsing (per-file) and collapsing (composed cfg).
const nonCollapsing = bundle.filter((b) => !isCollapsingKind(b.script.kind));
const collapsing = bundle.filter((b) => isCollapsingKind(b.script.kind));
// Build a per-(script, server) plan for non-collapsing artifacts.
interface Plan {
script: ScriptRow;
version: ScriptVersionRow;
server: string;
priorVersionId: number | null;
priorSha: string | null;
priorContent: Buffer | null;
hadFile: boolean;
}
const plans: Plan[] = [];
const getSnapshotForPromote = db().prepare(
`SELECT prior_content, prior_sha256 FROM beta_file_snapshots
WHERE beta_id = ? AND server = ? AND rel_path = ?`
);
for (const { script, version } of nonCollapsing) {
const targets = reviewed.preview.files.find(file => file.id === script.id)!.servers;
for (const name of targets) {
const dest = destOfKind(name, script.kind);
let plan: Plan;
if (betaServerSet.has(name)) {
const bss = getBetaServerScript(betaId, name, script.id);
if (bss?.had_original) {
const snap = getSnapshotForPromote.get(betaId, name, script.target_rel_path) as
| { prior_content: Buffer; prior_sha256: string }
| undefined;
let priorContent: Buffer | null = null;
let priorSha: string | null = null;
if (snap) {
priorContent = snap.prior_content;
priorSha = snap.prior_sha256;
} else if (bss.prior_version_id) {
const pv = getVersion(bss.prior_version_id);
if (pv) { priorContent = pv.content; priorSha = pv.sha256; }
}
plan = {
script, version, server: name,
priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true
};
} else {
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
}
} else {
// Non-beta matching-scope server: current on-disk file (if any)
// is the "before" state for a promote-then-rollback round trip.
const targetPath = join(dest, script.target_rel_path);
if (existsSync(targetPath)) {
const priorContent = await readFile(targetPath);
const inv = getServerScript(name, script.target_rel_path);
plan = {
script, version, server: name,
priorVersionId: inv?.script_version_id ?? null,
priorSha: sha256Buffer(priorContent),
priorContent, hadFile: true
};
} else {
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
}
}
plans.push(plan);
}
}
// Write files. Track per-plan success and per-server first-error.
const planWritten = new Map<Plan, boolean>();
const serverErr = new Map<string, string>();
// Collapsing config layer: one composed cfg per server. Union of beta
// servers + scope-matching servers for every collapsing artifact.
interface CfgPlan {
server: string;
priorContent: Buffer | null;
priorSha: string | null;
composedContent: Buffer;
composedSha: string;
hadFile: boolean;
}
const cfgPlans: CfgPlan[] = [];
if (collapsing.length > 0) {
const cfgTargets = reviewed.preview.configWrites.map(write => write.server);
for (const name of cfgTargets) {
const srv = getServer(name);
if (!srv) {
serverErr.set(name, 'unknown server during config promote');
continue;
}
const composedBuf = reviewed.configContent.get(name);
if (!composedBuf) {
serverErr.set(name, `no baseline available for ${name} (game=${srv.game ?? '?'})`);
continue;
}
const composedSha = sha256Buffer(composedBuf);
const dest = destOfKind(name, 'config');
const targetRel = configFileOf(name);
const targetPath = join(dest, targetRel);
let priorContent: Buffer | null = null;
let priorSha: string | null = null;
let hadFile = false;
// Beta server: prior cfg bytes were snapshotted at openBeta.
if (betaServerSet.has(name)) {
const snap = getSnapshotForPromote.get(betaId, name, targetRel) as
| { prior_content: Buffer; prior_sha256: string }
| undefined;
if (snap) {
priorContent = snap.prior_content;
priorSha = snap.prior_sha256;
hadFile = true;
}
}
if (!hadFile && existsSync(targetPath)) {
// Scope-expansion server (or beta server with no snapshot):
// current on-disk cfg is the "before".
priorContent = await readFile(targetPath);
priorSha = sha256Buffer(priorContent);
hadFile = true;
}
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile });
}
}
// 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) {
const dest = destOfKind(p.server, p.script.kind);
try {
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
await writeFile(join(dest, p.script.target_rel_path), p.version.content);
planWritten.set(p, true);
} catch (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>();
for (const cp of cfgPlans) {
try {
const dest = destOfKind(cp.server, 'config');
await mkdir(dest, { recursive: true });
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);
cfgWritten.set(cp, true);
} catch (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);
}
}
// Build the aggregate deployment.
const depId = promoteDeploymentId(betaId);
const startedAt = nowIso();
const insertDep = db().prepare(
`INSERT INTO deployments
(id, action, started_at, triggered_by, script_id, script_version_id, prior_version_id, beta_id)
VALUES (?, 'promote', ?, 'ui', 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, ?, 0, ?)`
);
const insertDepFile = db().prepare(
`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
const uniqueServers = Array.from(new Set([
...plans.map((p) => p.server),
...cfgPlans.map((c) => c.server)
]));
db().transaction(() => {
insertDep.run(depId, startedAt, betaId);
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.
for (const p of plans) {
if (!planWritten.get(p)) continue;
const verb = p.hadFile ? 'updated' : 'added';
insertDepFile.run(
depId,
p.server,
verb,
p.script.target_rel_path,
p.priorSha,
p.version.sha256,
p.priorContent,
p.version.content
);
recordScript(
p.server,
p.script.target_rel_path,
p.version.id,
p.version.sha256,
p.version.size,
`promote:${depId}`
);
}
for (const cp of cfgPlans) {
if (!cfgWritten.get(cp)) continue;
const verb = cp.hadFile ? 'updated' : 'added';
insertDepFile.run(
depId,
cp.server,
verb,
configFileOf(cp.server),
cp.priorSha,
cp.composedSha,
cp.priorContent,
cp.composedContent
);
recordScript(
cp.server,
configFileOf(cp.server),
null,
cp.composedSha,
cp.composedContent.byteLength,
`promote:${depId}`
);
}
// deployment_servers — one row per unique server. Beta servers are
// already restarted; scope-only servers still need the sweep to drain
// and restart them.
for (const name of uniqueServers) {
const err = serverErr.get(name);
if (err) {
insertDepServer.run(depId, name, 0, 'failed', null, err);
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)) {
const bsRestartedAt = db()
.prepare(`SELECT restarted_at FROM beta_servers WHERE beta_id = ? AND server = ?`)
.get(betaId, name) as { restarted_at: string | null } | undefined;
insertDepServer.run(depId, name, filesChanged, 'done', bsRestartedAt?.restarted_at ?? startedAt, null);
} else {
insertDepServer.run(depId, name, filesChanged, 'pending', null, null);
}
}
// Update each script's current_version_id in the same transaction.
for (const { script, version } of bundle) {
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
}
})();
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.
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)
])).sort();
emit({ type: 'beta-state', betaId, state: 'promoted' });
emit({ type: 'deployment-opened', deploymentId: depId, action: 'promote', servers: landed });
return {
betaId,
deploymentId: depId,
landed,
scriptIds: bundle.map((b) => b.script.id),
awaitingActivation: reviewed.preview.servers.filter(server => !server.tested && !serverErr.has(server.name)).length,
failedServers: [...serverErr.keys()]
};
}
// ---------- helpers used by API layer ----------
// Latest of the script's versions — useful for the "open beta with this
// version" default in the UI.
export function latestVersionForScript(script: ScriptRow): ScriptVersionRow | null {
const rows = listVersions(script.id);
return rows[0] ?? null;
}
/**
* Un-set a script's scope. Files are left on disk so drift picks up the
* change on the next /deployments rescan and the operator reconciles from
* there like any other drift — the same visible flow as manual drift.
*
* For scripts/plugins, we detach the inventory rows for this artifact's
* versions so drift Pass 1 flags the on-disk copies as untracked. For
* configs/baselines, no detach is needed: drift Pass 2 recomposes the
* expected dedicated_sp.cfg against the current promoted overlays, and
* scope=null naturally drops the overlay from the compose, surfacing the
* disparity. Returns the servers whose inventory was detached (empty for
* configs/baselines).
*/
export function unsetScopeAndDetach(scriptId: number): string[] {
const script = getScript(scriptId);
if (!script) throw new BetaError(`no such script (id=${scriptId})`, 'no-such-script');
setScope({ scriptId, scopeType: null, scopeValue: null });
const detached: string[] = [];
if (script.kind === 'script' || script.kind === 'plugin') {
const rows = db()
.prepare(
`SELECT server FROM server_scripts
WHERE rel_path = ?
AND script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?)`
)
.all(script.target_rel_path, scriptId) as Array<{ server: string }>;
for (const { server } of rows) {
removeScript(server, script.target_rel_path);
detached.push(server);
}
}
return detached;
}