Replace -beta sidecar and .OLD backup with DB snapshots
Beta deploys used to rename foo.gsc -> foo.OLD and write beta bytes to a foo-beta.gsc sidecar, forcing the mod loader to know the naming convention. Now the beta content lives at the actual target filename (same as configs already did) and the pre-beta bytes are captured in a new beta_file_snapshots table for rollback. - schema.sql: new beta_file_snapshots(beta_id, server, rel_path, prior_content BLOB, prior_sha256, prior_size). Handles scripts, plugins, and the composed dedicated_sp.cfg uniformly. - db/index.ts: assertSnapshotSchemeSafe boot guard refuses to start if a legacy running/ready-promote beta is present on first upgrade. A settings sentinel (snapshots_v1) makes it one-shot. - beta.ts openBeta: read prior bytes, insert snapshot, overwrite target directly. - beta.ts rollbackBeta: restore from snapshot if had_original, else delete the fresh file. Drop snapshot rows after restore. - beta.ts promoteBeta: no more rm -beta / rm .OLD calls, pull prior content from the snapshot table for the deployment audit, drop snapshots after promote. - files.ts: delete betaFile() and oldFile(). - drift.ts: docstring updated; no special-casing needed since beta bytes and inventory now agree on the target rel_path. - servers/[name]/+page.svelte: drop dead -beta/.OLD row filters. - tests: plugin write asserts the target filename; unused betaFile/oldFile tests removed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -149,6 +149,30 @@ function dropLegacyBetaTables(handle: Database.Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-shot guard for the -beta/.OLD → DB-snapshot cutover. Legacy running/
|
||||||
|
// ready-promote betas from the old code have -beta.<ext> and .OLD files on
|
||||||
|
// disk with no corresponding beta_file_snapshots rows — restoring them under
|
||||||
|
// the new rollback path would delete-instead-of-restore. If a legacy active
|
||||||
|
// beta is detected on the first boot after upgrade, refuse to start so the
|
||||||
|
// operator can rollback/promote under the old code. A settings sentinel marks
|
||||||
|
// the cutover complete so subsequent boots with (legitimately new-scheme)
|
||||||
|
// active betas aren't blocked.
|
||||||
|
function assertSnapshotSchemeSafe(handle: Database.Database): void {
|
||||||
|
const done = handle.prepare("SELECT value FROM settings WHERE key = 'snapshots_v1'").get() as
|
||||||
|
| { value: string }
|
||||||
|
| undefined;
|
||||||
|
if (done) return;
|
||||||
|
const active = handle
|
||||||
|
.prepare("SELECT id FROM betas WHERE state IN ('running','ready-promote') LIMIT 1")
|
||||||
|
.get() as { id: number } | undefined;
|
||||||
|
if (active) {
|
||||||
|
throw new Error(
|
||||||
|
`close active beta ${active.id} before upgrading — snapshot scheme is incompatible with legacy -beta/.OLD state`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
handle.prepare("INSERT INTO settings (key, value) VALUES ('snapshots_v1', '1')").run();
|
||||||
|
}
|
||||||
|
|
||||||
export function db(): Database.Database {
|
export function db(): Database.Database {
|
||||||
if (_db) return _db;
|
if (_db) return _db;
|
||||||
|
|
||||||
@@ -179,6 +203,7 @@ export function db(): Database.Database {
|
|||||||
);
|
);
|
||||||
handle.exec(`CREATE INDEX IF NOT EXISTS idx_scripts_kind ON scripts(kind)`);
|
handle.exec(`CREATE INDEX IF NOT EXISTS idx_scripts_kind ON scripts(kind)`);
|
||||||
backfillMultiScriptJoins(handle);
|
backfillMultiScriptJoins(handle);
|
||||||
|
assertSnapshotSchemeSafe(handle);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handle.close();
|
handle.close();
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -123,8 +123,9 @@ CREATE INDEX IF NOT EXISTS idx_beta_scripts_beta ON beta_scripts(beta_id);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_beta_scripts_script ON beta_scripts(script_id);
|
CREATE INDEX IF NOT EXISTS idx_beta_scripts_script ON beta_scripts(script_id);
|
||||||
|
|
||||||
-- Per (beta, server, script) prior-file state. A server row in beta_servers
|
-- Per (beta, server, script) prior-file state. A server row in beta_servers
|
||||||
-- covers restart progress; this table covers what each script's target
|
-- covers restart progress; this table covers whether each script's target
|
||||||
-- looked like on that server before the beta wrote its -beta.gsc.
|
-- existed on that server before the beta overwrote it — snapshot content
|
||||||
|
-- lives in beta_file_snapshots.
|
||||||
CREATE TABLE IF NOT EXISTS beta_server_scripts (
|
CREATE TABLE IF NOT EXISTS beta_server_scripts (
|
||||||
beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE,
|
beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE,
|
||||||
server TEXT NOT NULL REFERENCES servers(name),
|
server TEXT NOT NULL REFERENCES servers(name),
|
||||||
@@ -135,6 +136,21 @@ CREATE TABLE IF NOT EXISTS beta_server_scripts (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_beta_server_scripts_beta ON beta_server_scripts(beta_id);
|
CREATE INDEX IF NOT EXISTS idx_beta_server_scripts_beta ON beta_server_scripts(beta_id);
|
||||||
|
|
||||||
|
-- Pre-beta bytes of every target file the beta overwrote on each server.
|
||||||
|
-- Absence of a row for (beta, server, rel_path) means the file did not exist
|
||||||
|
-- before the beta wrote it — rollback deletes the file rather than restoring.
|
||||||
|
-- Handles scripts, plugins, and the composed dedicated_sp.cfg uniformly.
|
||||||
|
CREATE TABLE IF NOT EXISTS beta_file_snapshots (
|
||||||
|
beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE,
|
||||||
|
server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
|
||||||
|
rel_path TEXT NOT NULL,
|
||||||
|
prior_content BLOB NOT NULL,
|
||||||
|
prior_sha256 TEXT NOT NULL,
|
||||||
|
prior_size INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (beta_id, server, rel_path)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_beta_file_snapshots_beta ON beta_file_snapshots(beta_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS deployments (
|
CREATE TABLE IF NOT EXISTS deployments (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
action TEXT NOT NULL, -- 'drift' | 'promote' | 'rollback'
|
action TEXT NOT NULL, -- 'drift' | 'promote' | 'rollback'
|
||||||
|
|||||||
+100
-102
@@ -1,9 +1,9 @@
|
|||||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { dirname, join, posix } from 'node:path';
|
import { dirname, join, posix } from 'node:path';
|
||||||
import { betaArchiveOf, config, destOf, destOfKind, type ArtifactKind } from '../config.js';
|
import { betaArchiveOf, config, destOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { betaFile, oldFile, sha256Buffer } from './files.js';
|
import { sha256Buffer } from './files.js';
|
||||||
import { ensureDest, getServer, heldByBeta } from './servers.js';
|
import { ensureDest, getServer, heldByBeta } from './servers.js';
|
||||||
import {
|
import {
|
||||||
getBaselineForScope,
|
getBaselineForScope,
|
||||||
@@ -338,24 +338,18 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|||||||
throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope);
|
throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-(server, script) conflict + stale-.OLD check.
|
// Per-(server, script) conflict check. heldByBeta covers "another running
|
||||||
|
// 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 }> = [];
|
const conflicts: Array<{ server: string; scriptId: number; heldByBetaId: number }> = [];
|
||||||
const blocked: Array<{ server: string; ofile: string }> = [];
|
|
||||||
for (const name of input.servers) {
|
for (const name of input.servers) {
|
||||||
const held = heldByBeta(name);
|
const held = heldByBeta(name);
|
||||||
for (const { script } of resolved) {
|
for (const { script } of resolved) {
|
||||||
const owner = held.get(script.target_rel_path);
|
const owner = held.get(script.target_rel_path);
|
||||||
if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner });
|
if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner });
|
||||||
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)) {
|
|
||||||
blocked.push({ server: name, ofile: oldFile(script.target_rel_path) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (conflicts.length > 0) throw new BetaError('target already held by another beta', 'conflict', conflicts);
|
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. The `ensureDest` helper covers only the
|
// Ensure destinations exist. The `ensureDest` helper covers only the
|
||||||
// script destination today; config/plugin destinations are created
|
// script destination today; config/plugin destinations are created
|
||||||
@@ -417,15 +411,20 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|||||||
const collapsingOverlays = resolved.filter((r) => r.script.kind === 'config');
|
const collapsingOverlays = resolved.filter((r) => r.script.kind === 'config');
|
||||||
const collapsingBaseline = resolved.find((r) => r.script.kind === 'baseline');
|
const collapsingBaseline = resolved.find((r) => r.script.kind === 'baseline');
|
||||||
|
|
||||||
// Archive: one -beta.gsc per non-collapsing artifact, plus one composed
|
// Archive: one copy per non-collapsing artifact under its target basename,
|
||||||
// preview per beta server for the config layer.
|
// plus one composed preview per beta server for the config layer.
|
||||||
const archive = betaArchiveOf(betaId);
|
const archive = betaArchiveOf(betaId);
|
||||||
await mkdir(archive, { recursive: true });
|
await mkdir(archive, { recursive: true });
|
||||||
for (const r of nonCollapsing) {
|
for (const r of nonCollapsing) {
|
||||||
const bfile = betaFile(r.script.target_rel_path);
|
const base = posix.basename(r.script.target_rel_path);
|
||||||
await writeFile(join(archive, posix.basename(bfile)), r.version.content);
|
await writeFile(join(archive, base), r.version.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const insertSnapshot = db().prepare(
|
||||||
|
`INSERT INTO beta_file_snapshots (beta_id, server, rel_path, prior_content, prior_sha256, prior_size)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
|
||||||
// On-disk deploy per server. If any write fails on a server, mark the
|
// 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
|
// whole server 'failed' — we can't restart a partially deployed server
|
||||||
// safely. Remaining artifacts on that server are skipped.
|
// safely. Remaining artifacts on that server are skipped.
|
||||||
@@ -438,35 +437,35 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|||||||
for (const name of input.servers) {
|
for (const name of input.servers) {
|
||||||
const serverRow = serverRows.find((r) => r.name === name)!;
|
const serverRow = serverRows.find((r) => r.name === name)!;
|
||||||
let firstErr: string | null = null;
|
let firstErr: string | null = null;
|
||||||
// 1. Non-collapsing artifacts: script/plugin — one file each.
|
// 1. Non-collapsing artifacts: script/plugin — write directly to the
|
||||||
|
// target filename. Pre-beta bytes go into beta_file_snapshots so
|
||||||
|
// rollback can restore them.
|
||||||
for (const { script, version } of nonCollapsing) {
|
for (const { script, version } of nonCollapsing) {
|
||||||
if (firstErr) break;
|
if (firstErr) break;
|
||||||
const dest = destOfKind(name, script.kind);
|
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);
|
const targetPath = join(dest, script.target_rel_path);
|
||||||
const backupPath = join(dest, ofile);
|
|
||||||
const betaPath = join(dest, bfile);
|
|
||||||
try {
|
try {
|
||||||
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
|
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
|
||||||
if (existsSync(targetPath)) {
|
if (existsSync(targetPath)) {
|
||||||
await rename(targetPath, backupPath);
|
const priorBytes = await readFile(targetPath);
|
||||||
const prior = getServerScript(name, script.target_rel_path);
|
insertSnapshot.run(
|
||||||
removeScript(name, script.target_rel_path);
|
betaId,
|
||||||
if (prior) {
|
name,
|
||||||
recordScript(name, ofile, prior.script_version_id, prior.sha256, prior.size, `beta:${betaId}`);
|
script.target_rel_path,
|
||||||
}
|
priorBytes,
|
||||||
|
sha256Buffer(priorBytes),
|
||||||
|
priorBytes.byteLength
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await writeFile(betaPath, version.content);
|
await writeFile(targetPath, version.content);
|
||||||
recordScript(name, bfile, version.id, version.sha256, version.size, `beta:${betaId}`);
|
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, `beta:${betaId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
firstErr = `${script.name}: ${(err as Error).message}`;
|
firstErr = `${script.name}: ${(err as Error).message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2. Collapsing layer: compose the effective dedicated_sp.cfg from
|
// 2. Collapsing layer: compose the effective dedicated_sp.cfg from
|
||||||
// baseline + all applicable overlays, write in place (no -beta
|
// baseline + all applicable overlays, write in place. Pre-beta cfg
|
||||||
// suffix — the game reads the exact filename), backup the prior
|
// bytes go into beta_file_snapshots keyed on the target rel_path.
|
||||||
// file as .OLD once.
|
|
||||||
const hasCollapsing = collapsingBaseline || collapsingOverlays.length > 0;
|
const hasCollapsing = collapsingBaseline || collapsingOverlays.length > 0;
|
||||||
if (!firstErr && hasCollapsing) {
|
if (!firstErr && hasCollapsing) {
|
||||||
try {
|
try {
|
||||||
@@ -483,14 +482,16 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|||||||
await mkdir(dest, { recursive: true });
|
await mkdir(dest, { recursive: true });
|
||||||
const targetRel = config.configTargetFile;
|
const targetRel = config.configTargetFile;
|
||||||
const targetPath = join(dest, targetRel);
|
const targetPath = join(dest, targetRel);
|
||||||
const backupPath = join(dest, oldFile(targetRel));
|
|
||||||
if (existsSync(targetPath)) {
|
if (existsSync(targetPath)) {
|
||||||
await rename(targetPath, backupPath);
|
const priorBytes = await readFile(targetPath);
|
||||||
const prior = getServerScript(name, targetRel);
|
insertSnapshot.run(
|
||||||
removeScript(name, targetRel);
|
betaId,
|
||||||
if (prior) {
|
name,
|
||||||
recordScript(name, oldFile(targetRel), prior.script_version_id, prior.sha256, prior.size, `beta:${betaId}`);
|
targetRel,
|
||||||
}
|
priorBytes,
|
||||||
|
sha256Buffer(priorBytes),
|
||||||
|
priorBytes.byteLength
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
||||||
await writeFile(targetPath, composedBuf);
|
await writeFile(targetPath, composedBuf);
|
||||||
@@ -563,44 +564,38 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
|||||||
|
|
||||||
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
|
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
|
||||||
|
|
||||||
|
const getSnapshot = db().prepare(
|
||||||
|
`SELECT prior_content, prior_sha256, prior_size FROM beta_file_snapshots
|
||||||
|
WHERE beta_id = ? AND server = ? AND rel_path = ?`
|
||||||
|
);
|
||||||
|
|
||||||
for (const bs of servers) {
|
for (const bs of servers) {
|
||||||
for (const { script } of bundle) {
|
for (const { script } of bundle) {
|
||||||
if (isCollapsingKind(script.kind)) continue; // handled once per server below
|
if (isCollapsingKind(script.kind)) continue; // handled once per server below
|
||||||
const dest = destOfKind(bs.server, script.kind);
|
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);
|
const targetPath = join(dest, script.target_rel_path);
|
||||||
|
|
||||||
// Remove -beta.<ext> regardless of stage.
|
|
||||||
if (existsSync(betaPath)) await rm(betaPath, { force: true });
|
|
||||||
removeScript(bs.server, bfile);
|
|
||||||
|
|
||||||
const bss = getBetaServerScript(betaId, bs.server, script.id);
|
const bss = getBetaServerScript(betaId, bs.server, script.id);
|
||||||
if (bss?.had_original) {
|
if (bss?.had_original) {
|
||||||
if (existsSync(backupPath)) {
|
const snap = getSnapshot.get(betaId, bs.server, script.target_rel_path) as
|
||||||
await rename(backupPath, targetPath);
|
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
||||||
removeScript(bs.server, ofile);
|
| undefined;
|
||||||
const prior = bss.prior_version_id ? getVersion(bss.prior_version_id) : null;
|
if (snap) {
|
||||||
if (prior) {
|
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
|
||||||
recordScript(bs.server, script.target_rel_path, prior.id, prior.sha256, prior.size, `rollback:${betaId}`);
|
await writeFile(targetPath, snap.prior_content);
|
||||||
|
if (bss.prior_version_id) {
|
||||||
|
recordScript(bs.server, script.target_rel_path, bss.prior_version_id, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
|
||||||
} else {
|
} else {
|
||||||
const buf = await readFile(targetPath);
|
recordScript(bs.server, script.target_rel_path, null, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
|
||||||
recordScript(
|
|
||||||
bs.server,
|
|
||||||
script.target_rel_path,
|
|
||||||
null,
|
|
||||||
sha256Buffer(buf),
|
|
||||||
buf.byteLength,
|
|
||||||
`rollback:${betaId}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
restored.add(bs.server);
|
restored.add(bs.server);
|
||||||
} else {
|
} else {
|
||||||
warnings.push(`${bs.server}/${script.name}: no ${ofile} to restore`);
|
warnings.push(`${bs.server}/${script.name}: snapshot missing — cannot restore`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// No prior file — the beta wrote a fresh file. Drop it.
|
||||||
|
if (existsSync(targetPath)) await rm(targetPath, { force: true });
|
||||||
|
removeScript(bs.server, script.target_rel_path);
|
||||||
dropped.add(bs.server);
|
dropped.add(bs.server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -609,24 +604,17 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
|||||||
if (hasCollapsing) {
|
if (hasCollapsing) {
|
||||||
const dest = destOfKind(bs.server, 'config');
|
const dest = destOfKind(bs.server, 'config');
|
||||||
const targetRel = config.configTargetFile;
|
const targetRel = config.configTargetFile;
|
||||||
const backupPath = join(dest, oldFile(targetRel));
|
|
||||||
const targetPath = join(dest, targetRel);
|
const targetPath = join(dest, targetRel);
|
||||||
if (existsSync(backupPath)) {
|
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
|
||||||
if (existsSync(targetPath)) await rm(targetPath, { force: true });
|
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
||||||
await rename(backupPath, targetPath);
|
| undefined;
|
||||||
removeScript(bs.server, oldFile(targetRel));
|
if (snap) {
|
||||||
const buf = await readFile(targetPath);
|
await mkdir(dest, { recursive: true });
|
||||||
recordScript(
|
await writeFile(targetPath, snap.prior_content);
|
||||||
bs.server,
|
recordScript(bs.server, targetRel, null, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
|
||||||
targetRel,
|
|
||||||
null,
|
|
||||||
sha256Buffer(buf),
|
|
||||||
buf.byteLength,
|
|
||||||
`rollback:${betaId}`
|
|
||||||
);
|
|
||||||
restored.add(bs.server);
|
restored.add(bs.server);
|
||||||
} else if (existsSync(targetPath)) {
|
} else if (existsSync(targetPath)) {
|
||||||
// No .OLD ⇒ the beta wrote the cfg into a server that had
|
// No snapshot ⇒ the beta wrote the cfg into a server that had
|
||||||
// no pre-existing file. Remove the beta-written cfg to leave
|
// no pre-existing file. Remove the beta-written cfg to leave
|
||||||
// the server clean.
|
// the server clean.
|
||||||
await rm(targetPath, { force: true });
|
await rm(targetPath, { force: true });
|
||||||
@@ -636,6 +624,9 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshots are single-use — safe to drop now that the disk is restored.
|
||||||
|
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
|
||||||
|
|
||||||
// Wipe archive + close out beta.
|
// Wipe archive + close out beta.
|
||||||
const archive = betaArchiveOf(betaId);
|
const archive = betaArchiveOf(betaId);
|
||||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||||
@@ -722,6 +713,11 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
|||||||
}
|
}
|
||||||
const plans: Plan[] = [];
|
const plans: Plan[] = [];
|
||||||
|
|
||||||
|
const getSnapshotForPromote = db().prepare(
|
||||||
|
`SELECT prior_content, prior_sha256 FROM beta_file_snapshots
|
||||||
|
WHERE beta_id = ? AND server = ? AND rel_path = ?`
|
||||||
|
);
|
||||||
|
|
||||||
for (const { script, version } of nonCollapsing) {
|
for (const { script, version } of nonCollapsing) {
|
||||||
const scopeServers = script.scope_type && script.scope_value
|
const scopeServers = script.scope_type && script.scope_value
|
||||||
? serversMatchingScope(script.scope_type, script.scope_value)
|
? serversMatchingScope(script.scope_type, script.scope_value)
|
||||||
@@ -734,12 +730,14 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
|||||||
if (betaServerSet.has(name)) {
|
if (betaServerSet.has(name)) {
|
||||||
const bss = getBetaServerScript(betaId, name, script.id);
|
const bss = getBetaServerScript(betaId, name, script.id);
|
||||||
if (bss?.had_original) {
|
if (bss?.had_original) {
|
||||||
const backupPath = join(dest, oldFile(script.target_rel_path));
|
const snap = getSnapshotForPromote.get(betaId, name, script.target_rel_path) as
|
||||||
|
| { prior_content: Buffer; prior_sha256: string }
|
||||||
|
| undefined;
|
||||||
let priorContent: Buffer | null = null;
|
let priorContent: Buffer | null = null;
|
||||||
let priorSha: string | null = null;
|
let priorSha: string | null = null;
|
||||||
if (existsSync(backupPath)) {
|
if (snap) {
|
||||||
priorContent = await readFile(backupPath);
|
priorContent = snap.prior_content;
|
||||||
priorSha = sha256Buffer(priorContent);
|
priorSha = snap.prior_sha256;
|
||||||
} else if (bss.prior_version_id) {
|
} else if (bss.prior_version_id) {
|
||||||
const pv = getVersion(bss.prior_version_id);
|
const pv = getVersion(bss.prior_version_id);
|
||||||
if (pv) { priorContent = pv.content; priorSha = pv.sha256; }
|
if (pv) { priorContent = pv.content; priorSha = pv.sha256; }
|
||||||
@@ -777,15 +775,12 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
|||||||
const serverErr = new Map<string, string>();
|
const serverErr = new Map<string, string>();
|
||||||
for (const p of plans) {
|
for (const p of plans) {
|
||||||
const dest = destOfKind(p.server, p.script.kind);
|
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);
|
const targetPath = join(dest, p.script.target_rel_path);
|
||||||
const betaPath = join(dest, bfile);
|
|
||||||
const backupPath = join(dest, ofile);
|
|
||||||
try {
|
try {
|
||||||
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
|
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
|
||||||
if (existsSync(betaPath)) { await rm(betaPath, { force: true }); removeScript(p.server, bfile); }
|
// Beta servers already have the version bytes at targetPath from
|
||||||
if (existsSync(backupPath)) { await rm(backupPath, { force: true }); removeScript(p.server, ofile); }
|
// openBeta; scope-expansion servers need the write. Idempotent
|
||||||
|
// either way — we just overwrite with the promoted content.
|
||||||
await writeFile(targetPath, p.version.content);
|
await writeFile(targetPath, p.version.content);
|
||||||
planWritten.set(p, true);
|
planWritten.set(p, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -835,18 +830,24 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
|||||||
const dest = destOfKind(name, 'config');
|
const dest = destOfKind(name, 'config');
|
||||||
const targetRel = config.configTargetFile;
|
const targetRel = config.configTargetFile;
|
||||||
const targetPath = join(dest, targetRel);
|
const targetPath = join(dest, targetRel);
|
||||||
const backupPath = join(dest, oldFile(targetRel));
|
|
||||||
|
|
||||||
let priorContent: Buffer | null = null;
|
let priorContent: Buffer | null = null;
|
||||||
let priorSha: string | null = null;
|
let priorSha: string | null = null;
|
||||||
let hadFile = false;
|
let hadFile = false;
|
||||||
// Beta server: prior was the pre-beta content saved as .OLD.
|
// Beta server: prior cfg bytes were snapshotted at openBeta.
|
||||||
if (betaServerSet.has(name) && existsSync(backupPath)) {
|
if (betaServerSet.has(name)) {
|
||||||
priorContent = await readFile(backupPath);
|
const snap = getSnapshotForPromote.get(betaId, name, targetRel) as
|
||||||
priorSha = sha256Buffer(priorContent);
|
| { prior_content: Buffer; prior_sha256: string }
|
||||||
hadFile = true;
|
| undefined;
|
||||||
} else if (existsSync(targetPath)) {
|
if (snap) {
|
||||||
// Scope-expansion server: current on-disk cfg is the "before".
|
priorContent = snap.prior_content;
|
||||||
|
priorSha = snap.prior_sha256;
|
||||||
|
hadFile = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hadFile && existsSync(targetPath)) {
|
||||||
|
// Scope-expansion server (or beta server with no snapshot):
|
||||||
|
// current on-disk cfg is the "before".
|
||||||
priorContent = await readFile(targetPath);
|
priorContent = await readFile(targetPath);
|
||||||
priorSha = sha256Buffer(priorContent);
|
priorSha = sha256Buffer(priorContent);
|
||||||
hadFile = true;
|
hadFile = true;
|
||||||
@@ -863,15 +864,9 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
|||||||
await mkdir(dest, { recursive: true });
|
await mkdir(dest, { recursive: true });
|
||||||
const targetRel = config.configTargetFile;
|
const targetRel = config.configTargetFile;
|
||||||
const targetPath = join(dest, targetRel);
|
const targetPath = join(dest, targetRel);
|
||||||
const backupPath = join(dest, oldFile(targetRel));
|
|
||||||
// Beta server already has composed content at targetPath; overwrite
|
// Beta server already has composed content at targetPath; overwrite
|
||||||
// idempotently. Scope-expansion server: write in place. Either way
|
// idempotently. Scope-expansion server: write in place.
|
||||||
// clear any lingering .OLD from the beta stage.
|
|
||||||
await writeFile(targetPath, cp.composedContent);
|
await writeFile(targetPath, cp.composedContent);
|
||||||
if (existsSync(backupPath)) {
|
|
||||||
await rm(backupPath, { force: true });
|
|
||||||
removeScript(cp.server, oldFile(targetRel));
|
|
||||||
}
|
|
||||||
cfgWritten.set(cp, true);
|
cfgWritten.set(cp, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cfgWritten.set(cp, false);
|
cfgWritten.set(cp, false);
|
||||||
@@ -990,6 +985,9 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
db().prepare(`UPDATE betas SET state = 'promoted', closed_at = ? WHERE id = ?`).run(nowIso(), betaId);
|
db().prepare(`UPDATE betas SET state = 'promoted', closed_at = ? WHERE id = ?`).run(nowIso(), betaId);
|
||||||
|
// Snapshots are single-use — the promoted content is live on disk, so the
|
||||||
|
// pre-beta bytes are no longer needed.
|
||||||
|
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
|
||||||
|
|
||||||
const archive = betaArchiveOf(betaId);
|
const archive = betaArchiveOf(betaId);
|
||||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -78,9 +78,11 @@ async function walkFiles(root: string): Promise<string[]> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan every managed destination tree on a server (scripts, configs, plugins)
|
* Scan every managed destination tree on a server (scripts, configs, plugins)
|
||||||
* and return every file that isn't tracked in server_scripts. Beta artifacts
|
* and return every file that isn't tracked in server_scripts. During a beta,
|
||||||
* (-beta.gsc, .OLD, -beta.dll, composed dedicated_sp.cfg) are tracked while
|
* the beta bytes live at the actual target filename and the inventory row is
|
||||||
* a beta is active so they don't appear as drift.
|
* updated to reflect them, so beta files register as tracked with no
|
||||||
|
* special-casing (pre-beta bytes are snapshotted to beta_file_snapshots for
|
||||||
|
* rollback rather than left on disk as .OLD).
|
||||||
*
|
*
|
||||||
* The inventory table (server_scripts) keys on (server, rel_path) with no
|
* The inventory table (server_scripts) keys on (server, rel_path) with no
|
||||||
* destination discriminator. Rel_paths across destinations don't collide in
|
* destination discriminator. Rel_paths across destinations don't collide in
|
||||||
|
|||||||
@@ -1,35 +1,5 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { readFile, stat } from 'node:fs/promises';
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
import { posix } from 'node:path';
|
|
||||||
|
|
||||||
// Naming rules for beta artifacts, preserved from the legacy shell script.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `foo/bar.gsc` → `foo/bar-beta.gsc`. Used to name the beta copy that lives
|
|
||||||
* next to (or in place of) the target file on each server during a beta.
|
|
||||||
*/
|
|
||||||
export function betaFile(rel: string): string {
|
|
||||||
const dir = posix.dirname(rel);
|
|
||||||
const base = posix.basename(rel);
|
|
||||||
const dot = base.lastIndexOf('.');
|
|
||||||
const stem = dot === -1 ? base : base.slice(0, dot);
|
|
||||||
const ext = dot === -1 ? '' : base.slice(dot);
|
|
||||||
const named = `${stem}-beta${ext}`;
|
|
||||||
return dir === '.' ? named : `${dir}/${named}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `foo/bar.gsc` → `foo/bar.OLD`. Used to back up the previous target when a
|
|
||||||
* beta lands on top of it, so rollback can restore.
|
|
||||||
*/
|
|
||||||
export function oldFile(rel: string): string {
|
|
||||||
const dir = posix.dirname(rel);
|
|
||||||
const base = posix.basename(rel);
|
|
||||||
const dot = base.lastIndexOf('.');
|
|
||||||
const stem = dot === -1 ? base : base.slice(0, dot);
|
|
||||||
const named = `${stem}.OLD`;
|
|
||||||
return dir === '.' ? named : `${dir}/${named}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sha256(path: string): Promise<string> {
|
export async function sha256(path: string): Promise<string> {
|
||||||
const buf = await readFile(path);
|
const buf = await readFile(path);
|
||||||
|
|||||||
@@ -466,13 +466,13 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Scripts on this server ({inventory.filter((f) => !f.relPath.endsWith('.OLD') && !/-beta\./.test(f.relPath)).length})</h2>
|
<h2>Scripts on this server ({inventory.length})</h2>
|
||||||
{#if inventory.length === 0}
|
{#if inventory.length === 0}
|
||||||
<p class="empty">no scripts tracked — upload one from the Scripts page and open a beta to deploy it here</p>
|
<p class="empty">no scripts tracked — upload one from the Scripts page and open a beta to deploy it here</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="inv-list">
|
<div class="inv-list">
|
||||||
{#each inventory as f (f.relPath)}
|
{#each inventory as f (f.relPath)}
|
||||||
<div class="inv-row inv-{f.relPath.endsWith('.OLD') ? 'old' : /-beta\./.test(f.relPath) ? 'beta' : 'live'}">
|
<div class="inv-row">
|
||||||
<span class="rel mono">{f.relPath}</span>
|
<span class="rel mono">{f.relPath}</span>
|
||||||
<span class="size mono">{f.size}B</span>
|
<span class="size mono">{f.size}B</span>
|
||||||
<span class="src mono">{f.source ?? '—'}</span>
|
<span class="src mono">{f.source ?? '—'}</span>
|
||||||
@@ -910,9 +910,7 @@
|
|||||||
font-size: var(--text-mono-sm);
|
font-size: var(--text-mono-sm);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.inv-row.inv-old { opacity: 0.55; border-left: 3px solid var(--ink-500); }
|
.inv-row { border-left: 3px solid var(--spark-500); }
|
||||||
.inv-row.inv-beta { border-left: 3px solid var(--amber-500); }
|
|
||||||
.inv-row.inv-live { border-left: 3px solid var(--spark-500); }
|
|
||||||
.inv-row .rel { color: var(--cream); word-break: break-all; }
|
.inv-row .rel { color: var(--cream); word-break: break-all; }
|
||||||
.inv-row .size, .inv-row .src, .inv-row .sha { color: var(--ink-500); }
|
.inv-row .size, .inv-row .src, .inv-row .sha { color: var(--ink-500); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function seedFleet(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('config baseline + overlay flow', () => {
|
describe('config baseline + overlay flow', () => {
|
||||||
it('composes baseline + overlay and writes dedicated_sp.cfg on beta open, restores .OLD on rollback', async () => {
|
it('composes baseline + overlay and writes dedicated_sp.cfg on beta open, restores pre-beta state on rollback', async () => {
|
||||||
await seedFleet();
|
await seedFleet();
|
||||||
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
|
const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
@@ -120,7 +120,7 @@ describe('config baseline + overlay flow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('plugin kind writes to plugin destination', () => {
|
describe('plugin kind writes to plugin destination', () => {
|
||||||
it('deploys a plugin .dll under runtime/plutonium/plugins with -beta suffix', async () => {
|
it('deploys a plugin .dll directly to its target filename', async () => {
|
||||||
await seedFleet();
|
await seedFleet();
|
||||||
// Pre-create the plugins dir so the write can succeed.
|
// Pre-create the plugins dir so the write can succeed.
|
||||||
await mkdir(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins'), { recursive: true });
|
await mkdir(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins'), { recursive: true });
|
||||||
@@ -144,7 +144,7 @@ describe('plugin kind writes to plugin destination', () => {
|
|||||||
waitForMatch: false
|
waitForMatch: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const pluginPath = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins', 'demo-beta.dll');
|
const pluginPath = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins', 'demo.dll');
|
||||||
expect(existsSync(pluginPath)).toBe(true);
|
expect(existsSync(pluginPath)).toBe(true);
|
||||||
const bytes = await readFile(pluginPath);
|
const bytes = await readFile(pluginPath);
|
||||||
expect(bytes.equals(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe(true);
|
expect(bytes.equals(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe(true);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { betaFile, oldFile } from '../../src/lib/server/fleet/files.js';
|
|
||||||
import { slugify } from '../../src/lib/server/fleet/scripts.js';
|
import { slugify } from '../../src/lib/server/fleet/scripts.js';
|
||||||
|
|
||||||
describe('slugify', () => {
|
describe('slugify', () => {
|
||||||
@@ -9,20 +8,3 @@ describe('slugify', () => {
|
|||||||
expect(slugify('t5_kino_1')).toBe('t5-kino-1');
|
expect(slugify('t5_kino_1')).toBe('t5-kino-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('betaFile', () => {
|
|
||||||
it('inserts -beta before the extension', () => {
|
|
||||||
expect(betaFile('hud.gsc')).toBe('hud-beta.gsc');
|
|
||||||
expect(betaFile('perks/zom.gsc')).toBe('perks/zom-beta.gsc');
|
|
||||||
});
|
|
||||||
it('handles files without extension', () => {
|
|
||||||
expect(betaFile('README')).toBe('README-beta');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('oldFile', () => {
|
|
||||||
it('replaces extension with .OLD', () => {
|
|
||||||
expect(oldFile('hud.gsc')).toBe('hud.OLD');
|
|
||||||
expect(oldFile('perks/zom.gsc')).toBe('perks/zom.OLD');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user