config and plugin
This commit is contained in:
+361
-35
@@ -1,22 +1,121 @@
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, posix } from 'node:path';
|
||||
import { betaArchiveOf, destOf } from '../config.js';
|
||||
import { betaArchiveOf, config, destOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { betaFile, oldFile, sha256Buffer } from './files.js';
|
||||
import { ensureDest, getServer, heldByBeta } from './servers.js';
|
||||
import {
|
||||
getBaselineForScope,
|
||||
getScript,
|
||||
getVersion,
|
||||
listPromotedOverlaysForScope,
|
||||
listVersions,
|
||||
serversMatchingScope,
|
||||
type ScopeType,
|
||||
type ScriptRow,
|
||||
type ScriptVersionRow
|
||||
} from './scripts.js';
|
||||
import { composeCfg } from './config-compose.js';
|
||||
import { recordScript, removeScript, getServerScript } from './inventory.js';
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
import type { BetaStage } from '../pipeline/bus.js';
|
||||
|
||||
// Whether an artifact writes a dedicated on-disk file (script/plugin) or
|
||||
// contributes to a composed shared file (config/baseline).
|
||||
function isCollapsingKind(kind: ArtifactKind): boolean {
|
||||
return kind === 'config' || kind === 'baseline';
|
||||
}
|
||||
|
||||
// The path we should write to for the composed cfg on this server.
|
||||
function composedCfgPath(server: string): string {
|
||||
return join(destOfKind(server, 'config'), config.configTargetFile);
|
||||
}
|
||||
|
||||
// Compose the effective dedicated_sp.cfg for a given beta+server, taking
|
||||
// into account:
|
||||
// • baseline: from the beta if present, else the currently-promoted
|
||||
// baseline for the server's scope
|
||||
// • promoted overlays for the server's scope (excluding scripts being
|
||||
// replaced by this beta)
|
||||
// • this beta's overlays whose scope matches the server
|
||||
//
|
||||
// Returns null if no baseline is available (nothing to compose against).
|
||||
interface ComposeInputs {
|
||||
inBetaBaseline?: ScriptVersionRow;
|
||||
inBetaOverlays: Array<{ script: ScriptRow; version: ScriptVersionRow }>;
|
||||
}
|
||||
|
||||
function scopeAppliesToServer(
|
||||
scope: { scope_type: string | null; scope_value: string | null },
|
||||
server: { game: string | null; map: string | null }
|
||||
): boolean {
|
||||
if (!scope.scope_type || !scope.scope_value) return false;
|
||||
const field = scope.scope_type === 'game' ? server.game : server.map;
|
||||
return field === scope.scope_value;
|
||||
}
|
||||
|
||||
function composeCfgForServer(
|
||||
serverName: string,
|
||||
serverGame: string | null,
|
||||
serverMap: string | null,
|
||||
inputs: ComposeInputs
|
||||
): { text: string; baselineScriptId: number | null } | null {
|
||||
// Baseline: prefer the beta's baseline if its scope matches this server.
|
||||
let baselineText: string | null = null;
|
||||
let baselineScriptId: number | null = null;
|
||||
if (inputs.inBetaBaseline) {
|
||||
const s = getScript(inputs.inBetaBaseline.script_id);
|
||||
if (s && scopeAppliesToServer(s, { game: serverGame, map: serverMap })) {
|
||||
baselineText = inputs.inBetaBaseline.content.toString('utf8');
|
||||
baselineScriptId = s.id;
|
||||
}
|
||||
}
|
||||
// Fall back to the currently-promoted baseline for the server's game.
|
||||
if (baselineText === null && serverGame) {
|
||||
const promoted = getBaselineForScope('game', serverGame);
|
||||
if (promoted?.current_version_id) {
|
||||
const ver = getVersion(promoted.current_version_id);
|
||||
if (ver) {
|
||||
baselineText = ver.content.toString('utf8');
|
||||
baselineScriptId = promoted.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (baselineText === null) return null;
|
||||
|
||||
// In-beta script ids that are replacing promoted overlays — those get
|
||||
// skipped from the promoted overlay list to avoid double-applying an
|
||||
// older version of the same script.
|
||||
const inBetaScriptIds = new Set(inputs.inBetaOverlays.map((o) => o.script.id));
|
||||
|
||||
// Collect promoted overlays scoped to this server's game AND map.
|
||||
const promotedOverlays: Array<{ id: number; text: string }> = [];
|
||||
const promotedIdsSeen = new Set<number>();
|
||||
const pushIfScoped = (rows: ScriptRow[]) => {
|
||||
for (const s of rows) {
|
||||
if (promotedIdsSeen.has(s.id)) continue;
|
||||
if (inBetaScriptIds.has(s.id)) continue;
|
||||
if (!scopeAppliesToServer(s, { game: serverGame, map: serverMap })) continue;
|
||||
if (!s.current_version_id) continue;
|
||||
const ver = getVersion(s.current_version_id);
|
||||
if (!ver) continue;
|
||||
promotedOverlays.push({ id: s.id, text: ver.content.toString('utf8') });
|
||||
promotedIdsSeen.add(s.id);
|
||||
}
|
||||
};
|
||||
if (serverGame) pushIfScoped(listPromotedOverlaysForScope('game', serverGame));
|
||||
if (serverMap) pushIfScoped(listPromotedOverlaysForScope('map', serverMap));
|
||||
|
||||
// In-beta overlays scoped to this server.
|
||||
const inBetaScopedOverlays = inputs.inBetaOverlays
|
||||
.filter(({ script }) => scopeAppliesToServer(script, { game: serverGame, map: serverMap }))
|
||||
.map(({ script, version }) => ({ id: script.id, text: version.content.toString('utf8') }));
|
||||
|
||||
const composed = composeCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays]);
|
||||
return { text: composed, baselineScriptId };
|
||||
}
|
||||
|
||||
export interface BetaRow {
|
||||
id: number;
|
||||
// Deprecated: the columns are still populated (NOT NULL on legacy schemas)
|
||||
@@ -51,6 +150,7 @@ export interface BetaScriptRow {
|
||||
beta_id: number;
|
||||
script_id: number;
|
||||
script_version_id: number;
|
||||
kind: ArtifactKind;
|
||||
}
|
||||
|
||||
export interface BetaServerScriptRow {
|
||||
@@ -168,7 +268,12 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
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');
|
||||
@@ -184,15 +289,25 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
'version-script-mismatch'
|
||||
);
|
||||
}
|
||||
// Two scripts writing to the same target rel_path in one beta is an
|
||||
// internal race — reject up front.
|
||||
if (seenTargets.has(script.target_rel_path)) {
|
||||
throw new BetaError(
|
||||
`scripts ${seenTargets.get(script.target_rel_path)} and ${script.id} share target ${script.target_rel_path}`,
|
||||
'duplicate-target'
|
||||
);
|
||||
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 };
|
||||
}
|
||||
seenTargets.set(script.target_rel_path, script.id);
|
||||
resolved.push({ script, version });
|
||||
}
|
||||
|
||||
@@ -231,7 +346,7 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
for (const { script } of resolved) {
|
||||
const owner = held.get(script.target_rel_path);
|
||||
if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner });
|
||||
const dest = destOf(name);
|
||||
const dest = destOfKind(name, script.kind);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
const backupPath = join(dest, oldFile(script.target_rel_path));
|
||||
if (existsSync(targetPath) && existsSync(backupPath)) {
|
||||
@@ -242,7 +357,9 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
if (conflicts.length > 0) throw new BetaError('target already held by another beta', 'conflict', conflicts);
|
||||
if (blocked.length > 0) throw new BetaError('stale .OLD backups in the way', 'blocked-backup', blocked);
|
||||
|
||||
// Ensure destinations exist (create=true so a fresh server picks up cleanly).
|
||||
// Ensure destinations exist. The `ensureDest` helper covers only the
|
||||
// script destination today; config/plugin destinations are created
|
||||
// on-demand via mkdir during the write loop.
|
||||
for (const name of input.servers) {
|
||||
const ok = await ensureDest(name, true);
|
||||
if (!ok.ok) throw new BetaError(`dest not usable for ${name}: ${ok.reason}`, 'dest-fail');
|
||||
@@ -255,7 +372,7 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
VALUES (?, ?, 'bundle', ?, ?, 'running', ?)`
|
||||
);
|
||||
const insertBetaScript = db().prepare(
|
||||
`INSERT INTO beta_scripts (beta_id, script_id, script_version_id) VALUES (?, ?, ?)`
|
||||
`INSERT INTO beta_scripts (beta_id, script_id, script_version_id, kind) VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
const insertBetaServer = db().prepare(
|
||||
`INSERT INTO beta_servers (beta_id, server, had_original, prior_version_id, stage, updated_at)
|
||||
@@ -277,7 +394,7 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
);
|
||||
betaId = Number(res.lastInsertRowid);
|
||||
for (const r of resolved) {
|
||||
insertBetaScript.run(betaId, r.script.id, r.version.id);
|
||||
insertBetaScript.run(betaId, r.script.id, r.version.id, r.script.kind);
|
||||
}
|
||||
for (const name of input.servers) {
|
||||
insertBetaServer.run(betaId, name, nowIso());
|
||||
@@ -294,17 +411,24 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
}
|
||||
})();
|
||||
|
||||
// Archive: one -beta.gsc per script.
|
||||
// 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 -beta.gsc per non-collapsing artifact, plus one composed
|
||||
// preview per beta server for the config layer.
|
||||
const archive = betaArchiveOf(betaId);
|
||||
await mkdir(archive, { recursive: true });
|
||||
for (const r of resolved) {
|
||||
for (const r of nonCollapsing) {
|
||||
const bfile = betaFile(r.script.target_rel_path);
|
||||
await writeFile(join(archive, posix.basename(bfile)), r.version.content);
|
||||
}
|
||||
|
||||
// On-disk deploy per (server, script). If any script write fails on a
|
||||
// server, mark the whole server 'failed' — we can't restart a partially
|
||||
// deployed server safely. Remaining scripts on that server are skipped.
|
||||
// On-disk deploy per server. If any write fails on a server, mark the
|
||||
// whole server 'failed' — we can't restart a partially deployed server
|
||||
// safely. Remaining artifacts on that server are skipped.
|
||||
const updateStage = db().prepare(
|
||||
`UPDATE beta_servers
|
||||
SET stage = ?, error = ?, deployed_at = ?, updated_at = ?
|
||||
@@ -312,10 +436,12 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
);
|
||||
|
||||
for (const name of input.servers) {
|
||||
const dest = destOf(name);
|
||||
const serverRow = serverRows.find((r) => r.name === name)!;
|
||||
let firstErr: string | null = null;
|
||||
for (const { script, version } of resolved) {
|
||||
// 1. Non-collapsing artifacts: script/plugin — one file each.
|
||||
for (const { script, version } of nonCollapsing) {
|
||||
if (firstErr) break;
|
||||
const dest = destOfKind(name, script.kind);
|
||||
const bfile = betaFile(script.target_rel_path);
|
||||
const ofile = oldFile(script.target_rel_path);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
@@ -337,6 +463,51 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
firstErr = `${script.name}: ${(err as Error).message}`;
|
||||
}
|
||||
}
|
||||
// 2. Collapsing layer: compose the effective dedicated_sp.cfg from
|
||||
// baseline + all applicable overlays, write in place (no -beta
|
||||
// suffix — the game reads the exact filename), backup the prior
|
||||
// file as .OLD once.
|
||||
const hasCollapsing = collapsingBaseline || collapsingOverlays.length > 0;
|
||||
if (!firstErr && hasCollapsing) {
|
||||
try {
|
||||
const composedResult = composeCfgForServer(name, serverRow.game, serverRow.map, {
|
||||
inBetaBaseline: collapsingBaseline?.version,
|
||||
inBetaOverlays: collapsingOverlays
|
||||
});
|
||||
if (!composedResult) {
|
||||
throw new Error(
|
||||
`no baseline available for ${name} (game=${serverRow.game ?? '?'}) — upload one before running config overlays`
|
||||
);
|
||||
}
|
||||
const dest = destOfKind(name, 'config');
|
||||
await mkdir(dest, { recursive: true });
|
||||
const targetRel = config.configTargetFile;
|
||||
const targetPath = join(dest, targetRel);
|
||||
const backupPath = join(dest, oldFile(targetRel));
|
||||
if (existsSync(targetPath)) {
|
||||
await rename(targetPath, backupPath);
|
||||
const prior = getServerScript(name, targetRel);
|
||||
removeScript(name, targetRel);
|
||||
if (prior) {
|
||||
recordScript(name, oldFile(targetRel), prior.script_version_id, prior.sha256, prior.size, `beta:${betaId}`);
|
||||
}
|
||||
}
|
||||
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
||||
await writeFile(targetPath, composedBuf);
|
||||
recordScript(
|
||||
name,
|
||||
targetRel,
|
||||
null,
|
||||
sha256Buffer(composedBuf),
|
||||
composedBuf.byteLength,
|
||||
`beta:${betaId}`
|
||||
);
|
||||
// Archive the composed preview for operator visibility.
|
||||
await writeFile(join(archive, `${name}.${config.configTargetFile}`), composedBuf);
|
||||
} catch (err) {
|
||||
firstErr = `configs: ${(err as Error).message}`;
|
||||
}
|
||||
}
|
||||
const t = nowIso();
|
||||
if (firstErr) {
|
||||
updateStage.run('failed', `deploy: ${firstErr}`, t, t, betaId, name);
|
||||
@@ -390,16 +561,19 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
||||
const dropped = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
|
||||
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
|
||||
|
||||
for (const bs of servers) {
|
||||
const dest = destOf(bs.server);
|
||||
for (const { script } of bundle) {
|
||||
if (isCollapsingKind(script.kind)) continue; // handled once per server below
|
||||
const dest = destOfKind(bs.server, script.kind);
|
||||
const bfile = betaFile(script.target_rel_path);
|
||||
const ofile = oldFile(script.target_rel_path);
|
||||
const betaPath = join(dest, bfile);
|
||||
const backupPath = join(dest, ofile);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
|
||||
// Remove -beta.gsc regardless of stage.
|
||||
// Remove -beta.<ext> regardless of stage.
|
||||
if (existsSync(betaPath)) await rm(betaPath, { force: true });
|
||||
removeScript(bs.server, bfile);
|
||||
|
||||
@@ -430,6 +604,36 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
||||
dropped.add(bs.server);
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsing config layer: single restore for the shared cfg file.
|
||||
if (hasCollapsing) {
|
||||
const dest = destOfKind(bs.server, 'config');
|
||||
const targetRel = config.configTargetFile;
|
||||
const backupPath = join(dest, oldFile(targetRel));
|
||||
const targetPath = join(dest, targetRel);
|
||||
if (existsSync(backupPath)) {
|
||||
if (existsSync(targetPath)) await rm(targetPath, { force: true });
|
||||
await rename(backupPath, targetPath);
|
||||
removeScript(bs.server, oldFile(targetRel));
|
||||
const buf = await readFile(targetPath);
|
||||
recordScript(
|
||||
bs.server,
|
||||
targetRel,
|
||||
null,
|
||||
sha256Buffer(buf),
|
||||
buf.byteLength,
|
||||
`rollback:${betaId}`
|
||||
);
|
||||
restored.add(bs.server);
|
||||
} else if (existsSync(targetPath)) {
|
||||
// No .OLD ⇒ the beta wrote the cfg into a server that had
|
||||
// no pre-existing file. Remove the beta-written cfg to leave
|
||||
// the server clean.
|
||||
await rm(targetPath, { force: true });
|
||||
removeScript(bs.server, targetRel);
|
||||
dropped.add(bs.server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wipe archive + close out beta.
|
||||
@@ -500,7 +704,13 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
|
||||
const betaServerSet = new Set(listBetaServers(betaId).map((r) => r.server));
|
||||
|
||||
// Build a per-(script, server) plan: prior snapshot + intended write.
|
||||
// Split bundle into non-collapsing (per-file) and collapsing (composed cfg).
|
||||
const nonCollapsing = bundle.filter((b) => !isCollapsingKind(b.script.kind));
|
||||
const collapsing = bundle.filter((b) => isCollapsingKind(b.script.kind));
|
||||
const collapsingBaseline = collapsing.find((b) => b.script.kind === 'baseline');
|
||||
const collapsingOverlays = collapsing.filter((b) => b.script.kind === 'config');
|
||||
|
||||
// Build a per-(script, server) plan for non-collapsing artifacts.
|
||||
interface Plan {
|
||||
script: ScriptRow;
|
||||
version: ScriptVersionRow;
|
||||
@@ -512,18 +722,18 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
}
|
||||
const plans: Plan[] = [];
|
||||
|
||||
for (const { script, version } of bundle) {
|
||||
for (const { script, version } of nonCollapsing) {
|
||||
const scopeServers = script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type, script.scope_value)
|
||||
: [];
|
||||
const targets = new Set<string>([...betaServerSet, ...scopeServers]);
|
||||
|
||||
for (const name of targets) {
|
||||
const dest = destOfKind(name, script.kind);
|
||||
let plan: Plan;
|
||||
if (betaServerSet.has(name)) {
|
||||
const bss = getBetaServerScript(betaId, name, script.id);
|
||||
if (bss?.had_original) {
|
||||
const dest = destOf(name);
|
||||
const backupPath = join(dest, oldFile(script.target_rel_path));
|
||||
let priorContent: Buffer | null = null;
|
||||
let priorSha: string | null = null;
|
||||
@@ -544,7 +754,6 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
} else {
|
||||
// Non-beta matching-scope server: current on-disk file (if any)
|
||||
// is the "before" state for a promote-then-rollback round trip.
|
||||
const dest = destOf(name);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
if (existsSync(targetPath)) {
|
||||
const priorContent = await readFile(targetPath);
|
||||
@@ -567,7 +776,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
const planWritten = new Map<Plan, boolean>();
|
||||
const serverErr = new Map<string, string>();
|
||||
for (const p of plans) {
|
||||
const dest = destOf(p.server);
|
||||
const dest = destOfKind(p.server, p.script.kind);
|
||||
const bfile = betaFile(p.script.target_rel_path);
|
||||
const ofile = oldFile(p.script.target_rel_path);
|
||||
const targetPath = join(dest, p.script.target_rel_path);
|
||||
@@ -587,6 +796,91 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsing config layer: one composed cfg per server. Union of beta
|
||||
// servers + scope-matching servers for every collapsing artifact.
|
||||
interface CfgPlan {
|
||||
server: string;
|
||||
priorContent: Buffer | null;
|
||||
priorSha: string | null;
|
||||
composedContent: Buffer;
|
||||
composedSha: string;
|
||||
hadFile: boolean;
|
||||
}
|
||||
const cfgPlans: CfgPlan[] = [];
|
||||
if (collapsing.length > 0) {
|
||||
const cfgTargets = new Set<string>(betaServerSet);
|
||||
for (const { script } of collapsing) {
|
||||
if (script.scope_type && script.scope_value) {
|
||||
for (const s of serversMatchingScope(script.scope_type, script.scope_value)) {
|
||||
cfgTargets.add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of cfgTargets) {
|
||||
const srv = getServer(name);
|
||||
if (!srv) {
|
||||
serverErr.set(name, 'unknown server during config promote');
|
||||
continue;
|
||||
}
|
||||
const composedResult = composeCfgForServer(name, srv.game, srv.map, {
|
||||
inBetaBaseline: collapsingBaseline?.version,
|
||||
inBetaOverlays: collapsingOverlays
|
||||
});
|
||||
if (!composedResult) {
|
||||
serverErr.set(name, `no baseline available for ${name} (game=${srv.game ?? '?'})`);
|
||||
continue;
|
||||
}
|
||||
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
||||
const composedSha = sha256Buffer(composedBuf);
|
||||
const dest = destOfKind(name, 'config');
|
||||
const targetRel = config.configTargetFile;
|
||||
const targetPath = join(dest, targetRel);
|
||||
const backupPath = join(dest, oldFile(targetRel));
|
||||
|
||||
let priorContent: Buffer | null = null;
|
||||
let priorSha: string | null = null;
|
||||
let hadFile = false;
|
||||
// Beta server: prior was the pre-beta content saved as .OLD.
|
||||
if (betaServerSet.has(name) && existsSync(backupPath)) {
|
||||
priorContent = await readFile(backupPath);
|
||||
priorSha = sha256Buffer(priorContent);
|
||||
hadFile = true;
|
||||
} else if (existsSync(targetPath)) {
|
||||
// Scope-expansion server: current on-disk cfg is the "before".
|
||||
priorContent = await readFile(targetPath);
|
||||
priorSha = sha256Buffer(priorContent);
|
||||
hadFile = true;
|
||||
}
|
||||
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile });
|
||||
}
|
||||
}
|
||||
|
||||
// Write composed cfg to each target.
|
||||
const cfgWritten = new Map<CfgPlan, boolean>();
|
||||
for (const cp of cfgPlans) {
|
||||
try {
|
||||
const dest = destOfKind(cp.server, 'config');
|
||||
await mkdir(dest, { recursive: true });
|
||||
const targetRel = config.configTargetFile;
|
||||
const targetPath = join(dest, targetRel);
|
||||
const backupPath = join(dest, oldFile(targetRel));
|
||||
// Beta server already has composed content at targetPath; overwrite
|
||||
// idempotently. Scope-expansion server: write in place. Either way
|
||||
// clear any lingering .OLD from the beta stage.
|
||||
await writeFile(targetPath, cp.composedContent);
|
||||
if (existsSync(backupPath)) {
|
||||
await rm(backupPath, { force: true });
|
||||
removeScript(cp.server, oldFile(targetRel));
|
||||
}
|
||||
cfgWritten.set(cp, true);
|
||||
} catch (err) {
|
||||
cfgWritten.set(cp, false);
|
||||
const msg = `configs: ${(err as Error).message}`;
|
||||
if (!serverErr.has(cp.server)) serverErr.set(cp.server, msg);
|
||||
console.error(`[promote] ${cp.server}/configs failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the aggregate deployment.
|
||||
const depId = promoteDeploymentId(betaId);
|
||||
const startedAt = nowIso();
|
||||
@@ -597,8 +891,8 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
VALUES (?, 'promote', ?, 'ui', NULL, NULL, NULL, ?)`
|
||||
);
|
||||
const insertDepScript = db().prepare(
|
||||
`INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
`INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id, kind)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
);
|
||||
const insertDepServer = db().prepare(
|
||||
`INSERT INTO deployment_servers
|
||||
@@ -611,14 +905,19 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
const uniqueServers = Array.from(new Set(plans.map((p) => p.server)));
|
||||
const uniqueServers = Array.from(new Set([
|
||||
...plans.map((p) => p.server),
|
||||
...cfgPlans.map((c) => c.server)
|
||||
]));
|
||||
|
||||
db().transaction(() => {
|
||||
insertDep.run(depId, startedAt, betaId);
|
||||
for (const { script, version } of bundle) {
|
||||
insertDepScript.run(depId, script.id, version.id, script.current_version_id);
|
||||
insertDepScript.run(depId, script.id, version.id, script.current_version_id, script.kind);
|
||||
}
|
||||
// deployment_files — one row per successful (script, server) write.
|
||||
// deployment_files — one row per successful (script, server) write for
|
||||
// non-collapsing artifacts, plus one row per (server, dedicated_sp.cfg)
|
||||
// for the composed config layer.
|
||||
for (const p of plans) {
|
||||
if (!planWritten.get(p)) continue;
|
||||
const verb = p.hadFile ? 'updated' : 'added';
|
||||
@@ -641,6 +940,28 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
`promote:${depId}`
|
||||
);
|
||||
}
|
||||
for (const cp of cfgPlans) {
|
||||
if (!cfgWritten.get(cp)) continue;
|
||||
const verb = cp.hadFile ? 'updated' : 'added';
|
||||
insertDepFile.run(
|
||||
depId,
|
||||
cp.server,
|
||||
verb,
|
||||
config.configTargetFile,
|
||||
cp.priorSha,
|
||||
cp.composedSha,
|
||||
cp.priorContent,
|
||||
cp.composedContent
|
||||
);
|
||||
recordScript(
|
||||
cp.server,
|
||||
config.configTargetFile,
|
||||
null,
|
||||
cp.composedSha,
|
||||
cp.composedContent.byteLength,
|
||||
`promote:${depId}`
|
||||
);
|
||||
}
|
||||
// deployment_servers — one row per unique server. Beta servers are
|
||||
// already restarted; scope-only servers still need the sweep to drain
|
||||
// and restart them.
|
||||
@@ -650,7 +971,9 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
insertDepServer.run(depId, name, 0, 'failed', null, err);
|
||||
continue;
|
||||
}
|
||||
const filesChanged = plans.filter((p) => p.server === name && planWritten.get(p)).length;
|
||||
const filesChanged =
|
||||
plans.filter((p) => p.server === name && planWritten.get(p)).length +
|
||||
cfgPlans.filter((c) => c.server === name && cfgWritten.get(c)).length;
|
||||
if (betaServerSet.has(name)) {
|
||||
const bsRestartedAt = db()
|
||||
.prepare(`SELECT restarted_at FROM beta_servers WHERE beta_id = ? AND server = ?`)
|
||||
@@ -671,7 +994,10 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
const archive = betaArchiveOf(betaId);
|
||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||
|
||||
const landed = Array.from(new Set(plans.filter((p) => planWritten.get(p)).map((p) => p.server))).sort();
|
||||
const landed = Array.from(new Set([
|
||||
...plans.filter((p) => planWritten.get(p)).map((p) => p.server),
|
||||
...cfgPlans.filter((c) => cfgWritten.get(c)).map((c) => c.server)
|
||||
])).sort();
|
||||
|
||||
emit({ type: 'beta-state', betaId, state: 'promoted' });
|
||||
emit({ type: 'deployment-opened', deploymentId: depId, action: 'promote', servers: landed });
|
||||
@@ -706,7 +1032,7 @@ export async function unsetScopeAndCleanup(scriptId: number): Promise<string[]>
|
||||
const matched = serversMatchingScope(script.scope_type, script.scope_value);
|
||||
const cleaned: string[] = [];
|
||||
for (const name of matched) {
|
||||
const dest = destOf(name);
|
||||
const dest = destOfKind(name, script.kind);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
if (existsSync(targetPath)) {
|
||||
await rm(targetPath, { force: true });
|
||||
|
||||
Reference in New Issue
Block a user