Support multi-game betas with automatic server selection

This commit is contained in:
2026-10-07 09:41:17 -04:00
parent a6dee8c5e2
commit c6352d86b4
17 changed files with 881 additions and 173 deletions
+103
View File
@@ -0,0 +1,103 @@
import { db } from '../db/index.js';
import { listHosts } from '../hosts/index.js';
import { protectedWork } from '../autoscaling/snapshot.js';
import { freshStatusTimestamp } from '../../server-freshness.js';
import { listServers, type ServerRow } from './servers.js';
import { observationReader } from './status.js';
import type { ScriptRow } from './scripts.js';
const games = ['t5', 't6'] as const;
type Game = typeof games[number];
type Counts = Partial<Record<Game, number>>;
export function parseBetaInstanceCounts(value: unknown): Counts {
if (!value || typeof value !== 'object' || Array.isArray(value) || !Object.keys(value).length) {
throw new Error('instancesPerGame must contain at least one game and instance count');
}
const counts: Counts = {};
for (const [game, count] of Object.entries(value)) {
if (!games.includes(game as Game) || !Number.isSafeInteger(count) || count < 1 || count > 1000) {
throw new Error('Instance counts must be integers from 1 to 1000 for t5 or t6');
}
counts[game as Game] = count;
}
return counts;
}
function matches(script: ScriptRow, server: ServerRow): boolean {
return !script.scope_type || !script.scope_value
|| (script.scope_type === 'game' ? server.game : server.map) === script.scope_value;
}
/** Choose at submission time using the same fresh observations shown in Fleet. */
export function selectBetaServers(scripts: ScriptRow[], instancesPerGame: unknown): string[] {
const counts = parseBetaInstanceCounts(instancesPerGame), observe = observationReader(), now = Date.now();
const hosts = new Map(listHosts().map(host => [host.id, host]));
const busy = new Set((db().prepare(`SELECT server FROM server_deployments WHERE server_deleted_at IS NULL
AND (state IN ('queued','waiting','running','awaiting-health','interrupted')
OR (action IN ('destroy','rebuild') AND state = 'failed'
AND stage IN ('Stopping and removing container','Removing server files and registry')))`)
.all() as { server: string }[]).map(row => row.server));
const candidates = listServers().flatMap(server => {
if (!counts[server.game as Game] || server.desired_state === 'stopped' || busy.has(server.name)
|| !scripts.some(script => matches(script, server)) || protectedWork(server.name)) return [];
const host = server.host_id ? hosts.get(server.host_id) : null;
if (server.host_id && (!host?.connected || host.mode !== 'active' || host.decommission)) return [];
const observation = observe(server);
if (observation.health !== 'online' || observation.last_players === null
|| !freshStatusTimestamp(observation.last_seen_at, now)) return [];
// An occupied RCON observation must not rank as empty just because a
// competing telemetry report claims zero players.
const rconPlayers = freshStatusTimestamp(server.last_seen_at, now)
&& (!observation.telemetry_after || Date.parse(server.last_seen_at!) > Date.parse(observation.telemetry_after))
? server.last_players ?? 0 : 0;
return [{ server, players: Math.max(observation.last_players, rconPlayers) }];
}).sort((a, b) => a.players - b.players || a.server.name.localeCompare(b.server.name));
for (const game of games) {
const available = candidates.filter(candidate => candidate.server.game === game).length;
if ((counts[game] ?? 0) > available) {
throw new Error(`${game.toUpperCase()} needs ${counts[game]} available instances, but only ${available} have fresh status and no pending work. Reduce the count or retry when more servers are available.`);
}
}
for (const script of scripts) {
if (!candidates.some(candidate => matches(script, candidate.server))) {
throw new Error(`${script.name}: no available server in the requested games matches this file's scope.`);
}
}
// Map scopes need a representative even when the least populated servers
// all run the same map. Reserve enough remaining slots for every map.
const requiredMaps = new Set(scripts.filter(script => script.scope_type === 'map').map(script => script.scope_value!));
function canComplete(remaining: typeof candidates, slots: Counts, maps: Set<string>): boolean {
const options = new Map<string, Set<Game>>();
for (const game of games) {
if (remaining.filter(candidate => candidate.server.game === game).length < (slots[game] ?? 0)) return false;
}
for (const map of maps) {
const choices = new Set(remaining.filter(candidate => candidate.server.map === map && (slots[candidate.server.game as Game] ?? 0) > 0)
.map(candidate => candidate.server.game as Game));
if (!choices.size) return false;
options.set(map, choices);
}
// There are two supported games: single-game maps reserve that game's
// slots, and maps available in either game use any remaining slot.
return games.every(game => [...options.values()].filter(choices => choices.size === 1 && choices.has(game)).length <= (slots[game] ?? 0))
&& maps.size <= games.reduce((total, game) => total + (slots[game] ?? 0), 0);
}
if (!canComplete(candidates, counts, requiredMaps)) {
throw new Error('Increase the instance counts to cover every selected file\'s map scope, or choose servers in advanced options.');
}
const selected: string[] = [], slots = { ...counts };
let maps = requiredMaps;
for (let index = 0; index < candidates.length; index++) {
const { server } = candidates[index], game = server.game as Game;
if (!slots[game]) continue;
const nextSlots = { ...slots, [game]: slots[game]! - 1 }, nextMaps = new Set(maps);
if (server.map) nextMaps.delete(server.map);
if (!canComplete(candidates.slice(index + 1), nextSlots, nextMaps)) continue;
selected.push(server.name); slots[game] = nextSlots[game]; maps = nextMaps;
if (games.every(game => !slots[game])) break;
}
return selected;
}
+91 -113
View File
@@ -1,6 +1,7 @@
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { getVersionContent } from './scripts.js';
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { defaultPlan, parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { selectBetaServers } from './beta-selection.js';
import { storedPlan, testView, activity, setOutcome } from './beta-tests.js';
import { archiveBetaRevision, currentBetaRevision } from './beta-revisions.js';
import type { BetaPromotionPreview } from '../../beta-promotion.js';
@@ -74,7 +75,7 @@ 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;
if (!scope.scope_type || !scope.scope_value) return true;
const field = scope.scope_type === 'game' ? server.game : server.map;
return field === scope.scope_value;
}
@@ -265,10 +266,12 @@ export function isActiveBetaServer(betaId: number, server: string): boolean {
.get(betaId, server);
}
export function listBetaScripts(betaId: number): BetaScriptRow[] {
export function listBetaScripts(betaId: number, server?: string): BetaScriptRow[] {
return db()
.prepare('SELECT * FROM beta_scripts WHERE beta_id = ? ORDER BY script_id')
.all(betaId) as BetaScriptRow[];
.prepare(`SELECT bs.* FROM beta_scripts bs WHERE bs.beta_id = ?
${server === undefined ? '' : 'AND EXISTS (SELECT 1 FROM beta_server_scripts bss WHERE bss.beta_id = bs.beta_id AND bss.script_id = bs.script_id AND bss.server = ?)'}
ORDER BY bs.script_id`)
.all(betaId, ...(server === undefined ? [] : [server])) as BetaScriptRow[];
}
export function getBetaServerScript(betaId: number, server: string, scriptId: number): BetaServerScriptRow | undefined {
@@ -299,7 +302,8 @@ export interface OpenBetaScript {
export interface OpenBetaInput {
scripts: OpenBetaScript[];
servers: string[];
servers?: string[];
instancesPerGame?: Record<string, number>;
testPlan?: BetaPlan;
waitForHealth: boolean;
waitForMatch: boolean;
@@ -313,26 +317,18 @@ 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");
const automatic = input.instancesPerGame !== undefined;
if (automatic && input.servers !== undefined) throw new BetaError('Choose automatic instance counts or explicit servers, not both', 'invalid-state');
const plan = automatic ? parseBetaPlan({ ...(input.testPlan ?? defaultPlan()), scope: 'all' })
: input.testPlan ? parseBetaPlan(input.testPlan) : null;
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');
@@ -348,33 +344,23 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
'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 });
}
let selectedServers = input.servers;
if (automatic) {
try { selectedServers = selectBetaServers(resolved.map(row => row.script), input.instancesPerGame); }
catch (error) { throw new BetaError((error as Error).message, 'invalid-state'); }
}
if (!selectedServers?.length) throw new BetaError('at least one server is required', 'no-servers');
if (new Set(selectedServers).size !== selectedServers.length) throw new BetaError('Select each server only once', 'invalid-state');
if (plan?.scope === 'quorum' && plan.quorum > selectedServers.length) throw new BetaError('Quorum exceeds selected servers', 'invalid-state');
// Look up server rows once.
const serverRows: Array<{ name: string; game: string | null; map: string | null }> = [];
for (const name of input.servers) {
for (const name of selectedServers) {
const row = getServer(name);
if (!row) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
if (!row || row.destroyed_at) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
// Once teardown is queued or in flight, opening a beta cannot safely
// claim the instance. A failed remote request may still be running.
const removal = db().prepare(`SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL
@@ -391,29 +377,30 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
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)
});
// Each server receives only matching bundle members. Unscoped files apply
// to every selected server; every file must be exercised by at least one.
const perServer = new Map(serverRows.map(server => [server.name, resolved.filter(({ script }) => scopeAppliesToServer(script, server))]));
for (const [server, members] of perServer) {
if (!members.length) throw new BetaError(`${server}: no bundled files apply to this server`, 'out-of-scope');
const seenTargets = new Map<string, number>();
for (const { script } of members) {
if (script.kind === 'config') continue;
const target = script.kind === 'baseline' ? 'baseline' : `${script.kind}:${script.target_rel_path}`;
const prior = seenTargets.get(target);
if (prior !== undefined) throw new BetaError(`${server}: files ${prior} and ${script.id} share target ${script.target_rel_path}`, 'duplicate-target');
seenTargets.set(target, script.id);
}
}
if (outOfScope.length > 0) {
throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope);
for (const { script } of resolved) {
if (!serverRows.some(server => scopeAppliesToServer(script, server))) {
throw new BetaError(`${script.name}: select at least one server matching ${script.scope_type}=${script.scope_value}`, 'out-of-scope');
}
}
// 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);
const queuedCampaigns = selectedServers.flatMap(unresolvedCampaignWrites);
if (queuedCampaigns.length) {
const interrupted = queuedCampaigns.some(row => row.state === 'applying');
throw new BetaError(interrupted
@@ -425,9 +412,9 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
// 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) {
for (const name of selectedServers) {
const held = heldByBeta(name);
for (const { script } of resolved) {
for (const { script } of perServer.get(name)!) {
const owner = held.get(script.target_rel_path);
if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner });
}
@@ -466,13 +453,13 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
for (const r of resolved) {
insertBetaScript.run(betaId, r.script.id, r.version.id, r.script.kind);
}
for (const name of input.servers) {
for (const name of selectedServers) {
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) {
for (const r of perServer.get(name)!) {
insertBetaServerScript.run(
betaId,
name,
@@ -485,18 +472,18 @@ 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);
await deployBetaServers(betaId, plan?.waveSize ? selectedServers.slice(0, plan.waveSize) : selectedServers);
emit({
type: 'beta-opened',
betaId,
scriptIds: resolved.map((r) => r.script.id),
servers: input.servers
servers: selectedServers
});
return {
betaId,
servers: input.servers,
servers: selectedServers,
scripts: resolved.map((r) => ({ scriptId: r.script.id, scriptVersionId: r.version.id }))
};
}
@@ -602,7 +589,7 @@ export async function reviseBeta(betaId: number, expectedRevision: number, uploa
}
function revisionFileChanges(betaId: number, name: string): Parameters<typeof queueFileChange>[0][] {
const resolved = listBetaScripts(betaId).map(row => ({ script: getScript(row.script_id)!, version: getVersion(row.script_version_id)! }));
const resolved = listBetaScripts(betaId, name).map(row => ({ script: getScript(row.script_id)!, version: getVersion(row.script_version_id)! }));
const server = getServer(name)!;
const changes: Parameters<typeof queueFileChange>[0][] = resolved.filter(row => !isCollapsingKind(row.script.kind))
.map(({ script, version }) => ({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: script.kind,
@@ -612,7 +599,7 @@ function revisionFileChanges(betaId: number, name: string): Parameters<typeof qu
inBetaBaseline: resolved.find(row => row.script.kind === 'baseline')?.version,
inBetaOverlays: resolved.filter(row => row.script.kind === 'config')
});
if (!composed) throw new BetaError(`No baseline available for ${name}.`, 'invalid-state');
if (!composed) throw new BetaError(`no baseline available for ${name} — upload one before running config overlays`, 'invalid-state');
changes.push({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: 'config', relPath: configFileOf(name),
content: Buffer.from(composed.text), inventorySource: `beta:${betaId}` });
}
@@ -628,47 +615,24 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
if (!isActiveBetaServer(betaId, name)) throw new BetaError(`${name}: original beta server was deleted; start a new beta`, 'unknown-server');
}
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.
// Script IDs distinguish same-named files for different games in archives.
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), getVersionContent(r.version.id));
await writeFile(join(archive, `${r.script.id}.${base}`), getVersionContent(r.version.id));
}
// 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;
const serverRow = serverRows.find((r) => r.name === name)!;
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: getVersionContent(version.id),
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 composedBuf = Buffer.from(composedResult.text, 'utf8');
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);
}
const changes = revisionFileChanges(betaId, name);
const configChange = changes.find(change => change.kind === 'config');
if (configChange?.content) await writeFile(join(archive, `${name}.${configFileOf(name)}`), configChange.content);
if (!isActiveBetaServer(betaId, name)) continue;
db().transaction(() => {
for (const change of changes) queueFileChange(change);
@@ -732,8 +696,6 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
const dropped = new Set<string>();
const warnings: string[] = [];
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
const getSnapshot = db().prepare(
`SELECT ${blobContent('prior_content_hash')} AS prior_content, prior_sha256, prior_size FROM beta_file_snapshots
WHERE beta_id = ? AND server = ? AND rel_path = ?`
@@ -754,7 +716,8 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
warnings.push(`${bs.server}: original server was deleted; retained history was skipped`);
continue;
}
for (const { script } of bundle) {
const members = bundle.filter(({ script }) => getBetaServerScript(betaId, bs.server, script.id));
for (const { script } of members) {
if (isCollapsingKind(script.kind) || !applied(bs, script.kind, script.target_rel_path)) continue;
const bss = getBetaServerScript(betaId, bs.server, script.id);
const snap = getSnapshot.get(betaId, bs.server, script.target_rel_path) as
@@ -769,7 +732,7 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
(snap ? restored : dropped).add(bs.server);
}
const targetRel = configFileOf(bs.server);
if (hasCollapsing && applied(bs, 'config', targetRel)) {
if (members.some(({ script }) => isCollapsingKind(script.kind)) && applied(bs, 'config', targetRel)) {
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
| undefined;
@@ -878,8 +841,12 @@ function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configC
if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts');
const participants = listBetaServers(betaId);
const tested = new Set(participants.filter(row => isActiveBetaServer(betaId, row.server)).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 targetsByScript = new Map(bundle.map(({ script }) => {
const participants = [...tested].filter(server => getBetaServerScript(betaId, server, script.id));
const scopeServers = script.scope_type && script.scope_value ? serversMatchingScope(script.scope_type, script.scope_value) : [];
return [script.id, [...new Set([...participants, ...scopeServers])].sort()];
}));
const targets = (script: ScriptRow) => targetsByScript.get(script.id)!;
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,
@@ -890,15 +857,23 @@ function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configC
const servers = names.map(name => {
const server = getServer(name);
if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`);
const destinations = new Map<string, number>();
for (const file of files.filter(file => file.servers.includes(name) && file.kind !== 'config')) {
const target = file.kind === 'baseline' ? 'baseline' : `${file.kind}:${file.path}`;
const prior = destinations.get(target);
if (prior !== undefined) blockers.push(`${name}: files ${prior} and ${file.id} share target ${file.path}.`);
destinations.set(target, file.id);
}
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 matching = collapsing.filter(({ script }) => targets(script).includes(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')
inBetaBaseline: matching.find(({ script }) => script.kind === 'baseline')?.version,
inBetaOverlays: matching.filter(({ script }) => script.kind === 'config')
});
const error = composed ? null : `${server}: no baseline is available to compose its configuration.`;
if (error) blockers.push(error);
@@ -966,6 +941,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
priorSha: string | null;
priorContent: Buffer | null;
hadFile: boolean;
tested: boolean;
}
const plans: Plan[] = [];
@@ -979,8 +955,8 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
for (const name of targets) {
let plan: Plan;
if (betaServerSet.has(name)) {
const bss = getBetaServerScript(betaId, name, script.id);
const bss = getBetaServerScript(betaId, name, script.id);
if (bss && betaServerSet.has(name)) {
if (bss?.had_original) {
const snap = getSnapshotForPromote.get(betaId, name, script.target_rel_path) as
| { prior_content: Buffer; prior_sha256: string }
@@ -996,15 +972,15 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
}
plan = {
script, version, server: name,
priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true
priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true, tested: true
};
} else {
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false, tested: true };
}
} else {
// Campaign activation captures the actual before image just
// before replacing this file. Do not wait on remote reads here.
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false, tested: false };
}
plans.push(plan);
}
@@ -1023,6 +999,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
composedSha: string;
hadFile: boolean;
requiresActivation: boolean;
tested: boolean;
}
const cfgPlans: CfgPlan[] = [];
if (collapsing.length > 0) {
@@ -1046,8 +1023,9 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
let priorContent: Buffer | null = null;
let priorSha: string | null = null;
let hadFile = false;
const tested = betaServerSet.has(name) && collapsing.some(({ script }) => getBetaServerScript(betaId, name, script.id));
// Beta server: preserve its original bytes captured at activation.
if (betaServerSet.has(name)) {
if (tested) {
const snap = getSnapshotForPromote.get(betaId, name, targetRel) as
| { prior_content: Buffer; prior_sha256: string }
| undefined;
@@ -1057,9 +1035,9 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
hadFile = true;
}
}
const requiresActivation = !betaServerSet.has(name) || !await exists(targetPath)
const requiresActivation = !tested || !await exists(targetPath)
|| sha256Buffer(await readFile(targetPath)) !== composedSha;
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation });
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation, tested });
}
}
@@ -1070,7 +1048,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
// 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)
const plansToActivate = new Set(plans.filter(p => !p.tested
|| queuedByServer.get(p.server)?.some(row => row.kind === p.script.kind && row.rel_path === p.script.target_rel_path)));
for (const cp of cfgPlans) {
if (queuedByServer.get(cp.server)?.some(row => row.kind === 'config' && row.rel_path === configFileOf(cp.server))) cp.requiresActivation = true;
@@ -1124,13 +1102,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
p.version.sha256,
putFileBlob(p.priorContent),
putFileBlob(getVersionContent(p.version.id)),
betaServerSet.has(p.server) ? 0 : 1
p.tested ? 0 : 1
);
if (plansToActivate.has(p)) {
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: p.server,
kind: p.script.kind, relPath: p.script.target_rel_path, content: getVersionContent(p.version.id),
scriptVersionId: p.version.id, inventorySource: `promote:${depId}`,
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(p.server) });
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: p.tested });
}
}
for (const cp of cfgPlans) {
@@ -1144,13 +1122,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
cp.composedSha,
putFileBlob(cp.priorContent),
putFileBlob(cp.composedContent),
betaServerSet.has(cp.server) ? 0 : 1
cp.tested ? 0 : 1
);
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) });
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: cp.tested });
}
}
// deployment_servers — one row per unique server. Beta servers are
+28 -13
View File
@@ -24,6 +24,7 @@ interface DeploymentRow {
script_version_id: number | null;
prior_version_id: number | null;
rolled_back_by: string | null;
beta_id: number | null;
}
interface DepScriptRow {
@@ -70,7 +71,7 @@ export interface 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
.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');
@@ -109,9 +110,9 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
)
.all(promoteId) as FileRow[];
// Map rel_path → scriptId (and prior_version_id) so we can restore the
// inventory row's script_version_id correctly during file restore.
const scriptByTarget = new Map<string, DepScriptRow>();
// Different games may promote distinct scripts with the same target path.
// Retain all candidates and resolve ownership from the per-server history.
const scriptsByTarget = new Map<string, DepScriptRow[]>();
const kindByScriptId = new Map<number, ArtifactKind>();
if (scripts.length > 0) {
const placeholders = scripts.map(() => '?').join(',');
@@ -125,11 +126,25 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
}
for (const s of scripts) {
const t = targetById.get(s.script_id);
if (t) scriptByTarget.set(t, s);
if (t) scriptsByTarget.set(t, [...(scriptsByTarget.get(t) ?? []), s]);
}
}
const kindForRelPath = (relPath: string): ArtifactKind => {
const owner = scriptByTarget.get(relPath);
const staged = stagedFileChanges('campaign', promoteId);
const scriptForFile = (file: FileRow): DepScriptRow | undefined => {
const candidates = scriptsByTarget.get(file.rel_path) ?? [];
const versionId = staged.find(row => row.deployment_file_id === file.id)?.script_version_id;
if (versionId) return candidates.find(script => script.script_version_id === versionId);
if (dep.beta_id) {
const participant = candidates.find(script => db().prepare(`SELECT 1 FROM beta_server_scripts
WHERE beta_id = ? AND server = ? AND script_id = ?`).get(dep.beta_id, file.server, script.script_id));
if (participant) return participant;
}
return candidates.find(script => getVersion(script.script_version_id)?.sha256 === file.sha256_after) ?? candidates[0];
};
const kindForFile = (file: FileRow): ArtifactKind => {
const stagedKind = staged.find(row => row.deployment_file_id === file.id)?.kind;
if (stagedKind) return stagedKind;
const owner = scriptForFile(file);
if (owner) return owner.kind ?? kindByScriptId.get(owner.script_id) ?? 'script';
// Config/baseline aggregate rows have no per-artifact target_rel_path
// mapping — the deployment_files row is keyed on the shared cfg name.
@@ -144,10 +159,6 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
// Group files by server so each server gets exactly one deployment_servers row.
const perServer = new Map<string, FileRow[]>();
const staged = stagedFileChanges('campaign', promoteId);
// Composed baseline rows are physically staged as config files. Prefer the
// explicit link instead of inferring their destination from the library.
const kindForFile = (file: FileRow) => staged.find(row => row.deployment_file_id === file.id)?.kind ?? kindForRelPath(file.rel_path);
for (const f of fileRows) {
// A promotion can still be waiting for empty servers. Withdrawing it
// must not restore or remove files that it never installed. Rows from
@@ -230,8 +241,12 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
putFileBlob(f.content_after),
putFileBlob(f.content_before)
);
const owningScript = scriptByTarget.get(f.rel_path);
const priorVersion = owningScript?.prior_version_id ? getVersion(owningScript.prior_version_id) : null;
const owningScript = scriptForFile(f);
const betaPrior = dep.beta_id && owningScript ? db().prepare(`SELECT prior_version_id FROM beta_server_scripts
WHERE beta_id = ? AND server = ? AND script_id = ?`).get(dep.beta_id, server, owningScript.script_id) as
{ prior_version_id: number | null } | undefined : undefined;
const priorVersionId = betaPrior ? betaPrior.prior_version_id : owningScript?.prior_version_id;
const priorVersion = priorVersionId ? getVersion(priorVersionId) : null;
queueFileChange({ sourceKind: 'campaign', sourceId: newDepId, server,
kind: kindForFile(f), relPath: f.rel_path,
content: f.verb === 'added' ? null : f.content_before,
+2 -2
View File
@@ -228,7 +228,7 @@ export function heldByBeta(server: string): Map<string, number> {
`SELECT s.target_rel_path AS target, b.id AS beta_id
FROM beta_servers bsv
JOIN betas b ON b.id = bsv.beta_id
JOIN beta_scripts bs ON bs.beta_id = b.id
JOIN beta_server_scripts bs ON bs.beta_id = b.id AND bs.server = bsv.server
JOIN scripts s ON s.id = bs.script_id
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')`
)
@@ -247,7 +247,7 @@ export function ownerOf(server: string, target: string): number | null {
`SELECT b.id AS beta_id
FROM beta_servers bsv
JOIN betas b ON b.id = bsv.beta_id
JOIN beta_scripts bs ON bs.beta_id = b.id
JOIN beta_server_scripts bs ON bs.beta_id = b.id AND bs.server = bsv.server
JOIN scripts s ON s.id = bs.script_id
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND s.target_rel_path = ? AND b.state IN ('running','ready-promote')
LIMIT 1`
+2 -1
View File
@@ -17,7 +17,8 @@ export function fleetSnapshot(serverName?: string) {
.all(...bindings) as NativeInstance[]).map(instance => [instance.server, instance]));
const holds = new Map<string, { target: string; betaId: number }[]>();
for (const row of db().prepare(`SELECT bsv.server, s.target_rel_path AS target, b.id AS betaId
FROM beta_servers bsv JOIN betas b ON b.id=bsv.beta_id JOIN beta_scripts bs ON bs.beta_id=b.id
FROM beta_servers bsv JOIN betas b ON b.id=bsv.beta_id
JOIN beta_server_scripts bs ON bs.beta_id=b.id AND bs.server=bsv.server
JOIN scripts s ON s.id=bs.script_id WHERE bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')
${serverName === undefined ? '' : 'AND bsv.server = ?'}`).all(...bindings) as
{ server: string; target: string; betaId: number }[]) {