Support multi-game betas with automatic server selection
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 }[]) {
|
||||
|
||||
Reference in New Issue
Block a user