Add configurable beta test plans and staged rollout
This commit is contained in:
@@ -1,3 +1,14 @@
|
||||
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
|
||||
import { storedPlan } from './beta-tests.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';
|
||||
@@ -229,7 +240,7 @@ export function findRunningBetaForScript(scriptId: number): BetaRow | undefined
|
||||
.prepare(
|
||||
`SELECT b.* FROM betas b
|
||||
JOIN beta_scripts bs ON bs.beta_id = b.id
|
||||
WHERE bs.script_id = ? AND b.state = 'running'
|
||||
WHERE bs.script_id = ? AND b.state IN ('running','ready-promote')
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(scriptId) as BetaRow | undefined;
|
||||
@@ -245,6 +256,7 @@ export interface OpenBetaScript {
|
||||
export interface OpenBetaInput {
|
||||
scripts: OpenBetaScript[];
|
||||
servers: string[];
|
||||
testPlan?: BetaPlan;
|
||||
waitForHealth: boolean;
|
||||
waitForMatch: boolean;
|
||||
notes?: string;
|
||||
@@ -257,6 +269,9 @@ export interface OpenBetaResult {
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -319,6 +334,11 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
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) {
|
||||
@@ -387,11 +407,16 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
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(
|
||||
@@ -405,6 +430,30 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
}
|
||||
})();
|
||||
|
||||
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));
|
||||
@@ -434,7 +483,9 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
WHERE beta_id = ? AND server = ?`
|
||||
);
|
||||
|
||||
for (const name of input.servers) {
|
||||
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
|
||||
@@ -456,7 +507,9 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
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) {
|
||||
@@ -494,6 +547,7 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
);
|
||||
}
|
||||
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,
|
||||
@@ -519,18 +573,6 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
}
|
||||
}
|
||||
|
||||
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 }))
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- rollback ----------
|
||||
@@ -543,6 +585,9 @@ export interface RollbackResult {
|
||||
}
|
||||
|
||||
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') {
|
||||
@@ -570,8 +615,10 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
@@ -601,7 +648,7 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
||||
}
|
||||
|
||||
// Collapsing config layer: single restore for the shared cfg file.
|
||||
if (hasCollapsing) {
|
||||
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);
|
||||
@@ -681,6 +728,15 @@ function promoteDeploymentId(betaId: number): string {
|
||||
}
|
||||
|
||||
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
if (storedPlan(betaId)) {
|
||||
const { plannedBetaTick } = await import('../pipeline/beta-plan-runner.js');
|
||||
await plannedBetaTick();
|
||||
}
|
||||
return exclusive(betaId, () => promoteBetaInternal(betaId));
|
||||
}
|
||||
async function promoteBetaInternal(betaId: number): 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') {
|
||||
|
||||
Reference in New Issue
Block a user