Support multi-game betas with automatic server selection
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user