Initial commit — SvelteKit fleet manager for T5 Plutonium servers
Ports push_scripts.sh (1497 lines of bash) to a SvelteKit + SQLite web app that manages a fleet of T5 Plutonium servers running on Komodo. Same push and beta workflow as the shell script, with a live SSE pipeline view, a !rt-token-gated auth modal that hard-pauses deployments on stale tokens, per-file audit diffs, and a Komodo-container deployment target. - Push/beta engines port the exact filter, held-by-beta, conflict, and stale-backup rules from the shell script - SQLite (better-sqlite3) replaces script_metadata.json; a one-shot importer moves legacy state over on first boot - 16 vitest tests cover push add/update/skip, beta open→rollback, open→promote, conflict guards, sweep frozen/manual branches, and IW4M login/status parsing - docker/ + komodo/stack.toml deploy as a managed container on the same host as the game servers, /root/zombies bind-mounted at the same path the shell script used
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, posix } from 'node:path';
|
||||
import { betaSrc, config, destOf } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import {
|
||||
betaFile,
|
||||
findTargets,
|
||||
listSourceFiles,
|
||||
oldFile,
|
||||
sha256,
|
||||
slugify,
|
||||
stagedFiles
|
||||
} from './files.js';
|
||||
import { allServers, ensureDest, matchServers, ownerOf } from './servers.js';
|
||||
import { doPush } from './push.js';
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
|
||||
// -- shared row shapes --
|
||||
|
||||
interface BetaRow {
|
||||
slug: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
opened_at: string;
|
||||
updated_at: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
interface BetaScriptRow {
|
||||
id: number;
|
||||
beta_slug: string;
|
||||
staged_as: string;
|
||||
target: string;
|
||||
beta_file: string;
|
||||
backup: string;
|
||||
sha256: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
interface BetaDeployRow {
|
||||
beta_script_id: number;
|
||||
server: string;
|
||||
had_original: number;
|
||||
target: string;
|
||||
beta_file: string;
|
||||
backup: string;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function getBeta(slug: string): BetaRow | undefined {
|
||||
return db().prepare('SELECT * FROM betas WHERE slug = ?').get(slug) as BetaRow | undefined;
|
||||
}
|
||||
|
||||
export function listBetas(): BetaRow[] {
|
||||
return db().prepare('SELECT * FROM betas ORDER BY opened_at').all() as BetaRow[];
|
||||
}
|
||||
|
||||
export function listBetaScripts(slug: string): BetaScriptRow[] {
|
||||
return db()
|
||||
.prepare('SELECT * FROM beta_scripts WHERE beta_slug = ? ORDER BY id')
|
||||
.all(slug) as BetaScriptRow[];
|
||||
}
|
||||
|
||||
export function listBetaDeploys(slug: string): BetaDeployRow[] {
|
||||
return db()
|
||||
.prepare(
|
||||
`SELECT bd.beta_script_id, bd.server, bd.had_original,
|
||||
bs.target, bs.beta_file, bs.backup
|
||||
FROM beta_deploys bd
|
||||
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
|
||||
WHERE bs.beta_slug = ?`
|
||||
)
|
||||
.all(slug) as BetaDeployRow[];
|
||||
}
|
||||
|
||||
export function listBetaServers(slug: string): string[] {
|
||||
return (
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT DISTINCT bd.server AS server
|
||||
FROM beta_deploys bd
|
||||
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
|
||||
WHERE bs.beta_slug = ?
|
||||
ORDER BY bd.server`
|
||||
)
|
||||
.all(slug) as Array<{ server: string }>
|
||||
).map((r) => r.server);
|
||||
}
|
||||
|
||||
// -- open a beta (do_beta, push_scripts.sh:983) --
|
||||
|
||||
export interface OpenBetaOptions {
|
||||
name: string;
|
||||
patterns?: string[];
|
||||
dryRun?: boolean;
|
||||
create?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenBetaResult {
|
||||
slug: string;
|
||||
name: string;
|
||||
servers: string[];
|
||||
scripts: Array<{ stagedAs: string; target: string }>;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export class BetaError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly kind:
|
||||
| 'name-empty'
|
||||
| 'already-open'
|
||||
| 'stale-beta-dir'
|
||||
| 'no-staging-dir'
|
||||
| 'nothing-staged'
|
||||
| 'ambiguous'
|
||||
| 'no-servers'
|
||||
| 'conflict'
|
||||
| 'blocked-backup'
|
||||
| 'empty-plan'
|
||||
| 'missing-staged'
|
||||
| 'no-such-beta'
|
||||
| 'no-match',
|
||||
public readonly details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openBeta(opts: OpenBetaOptions): Promise<OpenBetaResult> {
|
||||
const { name, patterns = ['*'], dryRun = false, create = false } = opts;
|
||||
const slug = slugify(name);
|
||||
if (!slug) throw new BetaError('beta name needs at least one letter or number', 'name-empty');
|
||||
|
||||
const betaDir = `${betaSrc()}/${slug}`;
|
||||
|
||||
if (getBeta(slug)) throw new BetaError(`a beta called '${name}' is already open`, 'already-open');
|
||||
if (existsSync(betaDir)) {
|
||||
throw new BetaError(
|
||||
`${betaDir} already exists but no beta is open for it — move or delete it first`,
|
||||
'stale-beta-dir'
|
||||
);
|
||||
}
|
||||
if (!existsSync(betaSrc())) {
|
||||
throw new BetaError(
|
||||
`no staging folder at ${betaSrc()} — create it and drop the scripts you want to test`,
|
||||
'no-staging-dir'
|
||||
);
|
||||
}
|
||||
|
||||
const staged = await stagedFiles();
|
||||
if (staged.length === 0) {
|
||||
throw new BetaError(`nothing staged in ${betaSrc()}`, 'nothing-staged');
|
||||
}
|
||||
|
||||
const targets: Array<{ base: string; target: string }> = [];
|
||||
const ambiguous: Array<{ base: string; hits: string[] }> = [];
|
||||
for (const base of staged) {
|
||||
const hits = await findTargets(base);
|
||||
if (hits.length === 0) targets.push({ base, target: base });
|
||||
else if (hits.length === 1) targets.push({ base, target: hits[0] });
|
||||
else ambiguous.push({ base, hits });
|
||||
}
|
||||
if (ambiguous.length > 0) {
|
||||
throw new BetaError('more than one script matches — cannot tell which to replace', 'ambiguous', ambiguous);
|
||||
}
|
||||
|
||||
const servers = matchServers(await allServers(), patterns);
|
||||
if (servers.length === 0) throw new BetaError('no servers matched', 'no-servers');
|
||||
|
||||
// build the plan (server × target) while checking conflicts and stale backups
|
||||
const plan: Array<{ server: string; base: string; target: string; had: boolean }> = [];
|
||||
const conflicts: Array<{ server: string; target: string; owner: string }> = [];
|
||||
const blockedBackups: Array<{ server: string; ofile: string }> = [];
|
||||
|
||||
for (const server of servers) {
|
||||
const dest = await ensureDest(server, create);
|
||||
if (!dest.ok) continue;
|
||||
for (const { base, target } of targets) {
|
||||
const owner = ownerOf(server, target);
|
||||
if (owner) {
|
||||
conflicts.push({ server, target, owner });
|
||||
continue;
|
||||
}
|
||||
const had = existsSync(join(dest.path, target));
|
||||
const ofile = oldFile(target);
|
||||
if (had && existsSync(join(dest.path, ofile))) {
|
||||
blockedBackups.push({ server, ofile });
|
||||
continue;
|
||||
}
|
||||
plan.push({ server, base, target, had });
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0)
|
||||
throw new BetaError('script already belongs to another beta', 'conflict', conflicts);
|
||||
if (blockedBackups.length > 0)
|
||||
throw new BetaError('stale .OLD backups in the way', 'blocked-backup', blockedBackups);
|
||||
if (plan.length === 0) throw new BetaError('no usable servers — nothing was changed', 'empty-plan');
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
slug,
|
||||
name,
|
||||
servers: [...new Set(plan.map((p) => p.server))],
|
||||
scripts: targets.map((t) => ({ stagedAs: t.base, target: t.target })),
|
||||
dryRun: true
|
||||
};
|
||||
}
|
||||
|
||||
// ---- commit ----
|
||||
|
||||
const opened = nowIso();
|
||||
const insertBeta = db().prepare(
|
||||
`INSERT INTO betas (slug, name, folder, opened_at, updated_at, revision)
|
||||
VALUES (?, ?, ?, ?, ?, 1)`
|
||||
);
|
||||
const insertScript = db().prepare(
|
||||
`INSERT INTO beta_scripts (beta_slug, staged_as, target, beta_file, backup, sha256, revision)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)`
|
||||
);
|
||||
const insertDeploy = db().prepare(
|
||||
`INSERT INTO beta_deploys (beta_script_id, server, had_original) VALUES (?, ?, ?)`
|
||||
);
|
||||
|
||||
const scriptIdByTarget = new Map<string, number>();
|
||||
const tx = db().transaction(() => {
|
||||
insertBeta.run(slug, name, `betas/${slug}`, opened, opened);
|
||||
for (const { base, target } of targets) {
|
||||
const bfile = betaFile(target);
|
||||
const backup = oldFile(target);
|
||||
const sha = 'pending'; // filled after we compute below
|
||||
const res = insertScript.run(slug, base, target, bfile, backup, sha);
|
||||
scriptIdByTarget.set(target, Number(res.lastInsertRowid));
|
||||
}
|
||||
for (const { server, target, had } of plan) {
|
||||
insertDeploy.run(scriptIdByTarget.get(target)!, server, had ? 1 : 0);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
// Compute shas outside tx (I/O), then patch
|
||||
for (const { base, target } of targets) {
|
||||
const sha = await sha256(`${betaSrc()}/${base}`);
|
||||
db().prepare('UPDATE beta_scripts SET sha256 = ? WHERE beta_slug = ? AND target = ?')
|
||||
.run(sha, slug, target);
|
||||
}
|
||||
|
||||
// Move files on disk
|
||||
for (const { server, base, target, had } of plan) {
|
||||
const dest = destOf(server);
|
||||
const bfile = betaFile(target);
|
||||
const ofile = oldFile(target);
|
||||
await mkdir(join(dest, dirname(target)), { recursive: true });
|
||||
if (had) {
|
||||
await rename(join(dest, target), join(dest, ofile));
|
||||
}
|
||||
await copyFile(join(betaSrc(), base), join(dest, bfile));
|
||||
}
|
||||
|
||||
// Move staged files into betas/<slug>/ under their beta_file name
|
||||
await mkdir(betaDir, { recursive: true });
|
||||
for (const { base, target } of targets) {
|
||||
const bfile = betaFile(target);
|
||||
await rename(join(betaSrc(), base), join(betaDir, posix.basename(bfile)));
|
||||
}
|
||||
|
||||
emit({
|
||||
type: 'deployment-opened',
|
||||
deploymentId: `beta:${slug}`,
|
||||
action: `beta:${slug}`,
|
||||
servers: [...new Set(plan.map((p) => p.server))]
|
||||
});
|
||||
|
||||
return {
|
||||
slug,
|
||||
name,
|
||||
servers: [...new Set(plan.map((p) => p.server))],
|
||||
scripts: targets.map((t) => ({ stagedAs: t.base, target: t.target })),
|
||||
dryRun: false
|
||||
};
|
||||
}
|
||||
|
||||
// -- update an open beta (do_update_beta, push_scripts.sh:1153) --
|
||||
|
||||
export interface UpdateBetaResult {
|
||||
slug: string;
|
||||
replaced: string[];
|
||||
skipped: string[];
|
||||
revision: number;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export async function updateBeta(name: string, dryRun = false): Promise<UpdateBetaResult> {
|
||||
const slug = slugify(name);
|
||||
const beta = getBeta(slug);
|
||||
if (!beta) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta');
|
||||
|
||||
const staged = await stagedFiles();
|
||||
if (staged.length === 0) throw new BetaError('nothing staged', 'nothing-staged');
|
||||
|
||||
const known = listBetaScripts(slug);
|
||||
const matched: Array<{ base: string; target: string; betaFile: string }> = [];
|
||||
const skipped: string[] = [];
|
||||
for (const base of staged) {
|
||||
const hit = known.find((k) => k.staged_as === base || posix.basename(k.target) === base);
|
||||
if (hit) matched.push({ base, target: hit.target, betaFile: hit.beta_file });
|
||||
else skipped.push(base);
|
||||
}
|
||||
|
||||
if (matched.length === 0) throw new BetaError('none of the staged files belong to this beta', 'no-match');
|
||||
|
||||
if (dryRun) {
|
||||
return { slug, replaced: matched.map((m) => m.target), skipped, revision: beta.revision, dryRun: true };
|
||||
}
|
||||
|
||||
const betaDir = `${betaSrc()}/${slug}`;
|
||||
const now = nowIso();
|
||||
|
||||
// For each matched target, copy staged file to every deployed server,
|
||||
// move staged file into betas/<slug>/, bump revision.
|
||||
for (const { base, target, betaFile: bfile } of matched) {
|
||||
const deploys = listBetaDeploys(slug).filter((d) => d.target === target);
|
||||
for (const d of deploys) {
|
||||
const dest = destOf(d.server);
|
||||
if (!existsSync(dest)) continue;
|
||||
await mkdir(join(dest, dirname(target)), { recursive: true });
|
||||
await copyFile(join(betaSrc(), base), join(dest, bfile));
|
||||
}
|
||||
await rename(join(betaSrc(), base), join(betaDir, posix.basename(bfile)));
|
||||
const sha = await sha256(join(betaDir, posix.basename(bfile)));
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE beta_scripts
|
||||
SET sha256 = ?, revision = revision + 1
|
||||
WHERE beta_slug = ? AND target = ?`
|
||||
)
|
||||
.run(sha, slug, target);
|
||||
}
|
||||
db()
|
||||
.prepare('UPDATE betas SET revision = revision + 1, updated_at = ? WHERE slug = ?')
|
||||
.run(now, slug);
|
||||
|
||||
const updated = getBeta(slug)!;
|
||||
return { slug, replaced: matched.map((m) => m.target), skipped, revision: updated.revision, dryRun: false };
|
||||
}
|
||||
|
||||
// -- rollback (do_rollback, push_scripts.sh:1253) --
|
||||
|
||||
export interface RollbackResult {
|
||||
slug: string;
|
||||
restored: string[];
|
||||
dropped: string[];
|
||||
warnings: string[];
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export async function rollbackBeta(name: string, dryRun = false): Promise<RollbackResult> {
|
||||
const slug = slugify(name);
|
||||
if (!getBeta(slug)) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta');
|
||||
|
||||
const betaDir = `${betaSrc()}/${slug}`;
|
||||
const deploys = listBetaDeploys(slug);
|
||||
const restored: string[] = [];
|
||||
const dropped: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (dryRun) {
|
||||
for (const d of deploys) {
|
||||
if (d.had_original) restored.push(d.target);
|
||||
else dropped.push(d.target);
|
||||
}
|
||||
return { slug, restored, dropped, warnings, dryRun: true };
|
||||
}
|
||||
|
||||
for (const d of deploys) {
|
||||
const dest = destOf(d.server);
|
||||
const bpath = join(dest, d.beta_file);
|
||||
const opath = join(dest, d.backup);
|
||||
const tpath = join(dest, d.target);
|
||||
if (existsSync(bpath)) await rm(bpath, { force: true });
|
||||
if (d.had_original) {
|
||||
if (existsSync(opath)) {
|
||||
await rename(opath, tpath);
|
||||
restored.push(d.target);
|
||||
} else {
|
||||
warnings.push(`${d.server}: no ${d.backup} to restore`);
|
||||
}
|
||||
} else {
|
||||
dropped.push(d.target);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true });
|
||||
db().prepare('DELETE FROM betas WHERE slug = ?').run(slug); // cascades to scripts/deploys
|
||||
|
||||
return { slug, restored, dropped, warnings, dryRun: false };
|
||||
}
|
||||
|
||||
// -- promote (do_promote, push_scripts.sh:1325) --
|
||||
|
||||
export interface PromoteResult {
|
||||
slug: string;
|
||||
landed: string[];
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export async function promoteBeta(name: string, dryRun = false): Promise<PromoteResult> {
|
||||
const slug = slugify(name);
|
||||
const beta = getBeta(slug);
|
||||
if (!beta) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta');
|
||||
|
||||
const betaDir = `${betaSrc()}/${slug}`;
|
||||
const scripts = listBetaScripts(slug);
|
||||
|
||||
// Sanity: every beta_file must exist in the beta dir on disk
|
||||
const lost: string[] = [];
|
||||
for (const s of scripts) {
|
||||
const p = join(betaDir, posix.basename(s.beta_file));
|
||||
if (!existsSync(p)) lost.push(posix.basename(s.beta_file));
|
||||
}
|
||||
if (lost.length > 0)
|
||||
throw new BetaError('scripts missing from betas dir — cannot promote', 'missing-staged', lost);
|
||||
|
||||
const deploys = listBetaDeploys(slug);
|
||||
|
||||
if (dryRun) {
|
||||
return { slug, landed: scripts.map((s) => s.target), dryRun: true };
|
||||
}
|
||||
|
||||
// Clear beta files + backups off every server
|
||||
for (const d of deploys) {
|
||||
const dest = destOf(d.server);
|
||||
await rm(join(dest, d.beta_file), { force: true });
|
||||
if (d.had_original) await rm(join(dest, d.backup), { force: true });
|
||||
}
|
||||
|
||||
// Move staged betas/<slug>/*.gsc back into shared/scripts under real name
|
||||
for (const s of scripts) {
|
||||
await mkdir(join(config.sharedScripts, dirname(s.target)), { recursive: true });
|
||||
await rename(join(betaDir, posix.basename(s.beta_file)), join(config.sharedScripts, s.target));
|
||||
}
|
||||
|
||||
if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true });
|
||||
db().prepare('DELETE FROM betas WHERE slug = ?').run(slug);
|
||||
|
||||
// Then push to every server — same behavior as the shell script's promote (line 1391)
|
||||
await doPush({ patterns: ['*'], action: `promote:${slug}`, create: false });
|
||||
|
||||
return { slug, landed: scripts.map((s) => s.target), dryRun: false };
|
||||
}
|
||||
Reference in New Issue
Block a user