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,44 @@
|
||||
import { env } from 'node:process';
|
||||
|
||||
function req(name: string, fallback?: string): string {
|
||||
const v = env[name] ?? fallback;
|
||||
if (v === undefined) throw new Error(`missing env: ${name}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function num(name: string, fallback: number): number {
|
||||
const raw = env[name];
|
||||
if (!raw) return fallback;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) throw new Error(`env ${name}=${raw} is not a number`);
|
||||
return n;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
sharedScripts: req('SHARED_SCRIPTS', '/root/zombies/shared/scripts'),
|
||||
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
|
||||
relDest: req('REL_DEST', 'runtime/plutonium/storage/t5/raw/scripts/sp'),
|
||||
dbPath: req('DB_PATH', './data/cherry.db'),
|
||||
deployKeepDays: num('DEPLOY_KEEP_DAYS', 7),
|
||||
httpTimeoutMs: num('HTTP_TIMEOUT_MS', 10_000),
|
||||
komodo: {
|
||||
address: env.KOMODO_ADDRESS ?? '',
|
||||
apiKey: env.KOMODO_API_KEY ?? '',
|
||||
apiSecret: env.KOMODO_API_SECRET ?? ''
|
||||
},
|
||||
iw4m: {
|
||||
address: env.IW4M_ADDRESS ?? '',
|
||||
clientId: env.IW4M_CLIENT_ID ?? ''
|
||||
}
|
||||
} as const;
|
||||
|
||||
export function komodoConfigured(): boolean {
|
||||
return !!(config.komodo.address && config.komodo.apiKey && config.komodo.apiSecret);
|
||||
}
|
||||
|
||||
export function iw4mConfigured(): boolean {
|
||||
return !!(config.iw4m.address && config.iw4m.clientId);
|
||||
}
|
||||
|
||||
export const betaSrc = () => `${config.sharedScripts}/betas`;
|
||||
export const destOf = (server: string) => `${config.serversRoot}/${server}/${config.relDest}`;
|
||||
@@ -0,0 +1,26 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
import schemaSql from './schema.sql?raw';
|
||||
|
||||
let _db: Database.Database | null = null;
|
||||
|
||||
export function db(): Database.Database {
|
||||
if (_db) return _db;
|
||||
|
||||
mkdirSync(dirname(resolve(config.dbPath)), { recursive: true });
|
||||
_db = new Database(config.dbPath);
|
||||
_db.pragma('journal_mode = WAL');
|
||||
_db.pragma('foreign_keys = ON');
|
||||
_db.exec(schemaSql);
|
||||
|
||||
return _db;
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (_db) {
|
||||
_db.close();
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { db } from './index.js';
|
||||
|
||||
// The heavy lifting is idempotent DDL inside schema.sql, which db() runs on
|
||||
// first handle. This module exists as an explicit hook for future migrations
|
||||
// (numbered .sql files applied via a schema_version row) and to be called
|
||||
// from hooks.server.ts so boot-time failures surface early.
|
||||
export function migrate(): void {
|
||||
db().prepare('SELECT 1').get();
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.sql?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
-- Electric Cherry schema. Idempotent — safe to run at every boot via migrate.ts.
|
||||
-- Mirrors the shapes tracked in the legacy script_metadata.json but in real SQL.
|
||||
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
name TEXT PRIMARY KEY,
|
||||
komodo_type TEXT NOT NULL DEFAULT 'deployment', -- deployment|stack|container
|
||||
komodo_name TEXT NOT NULL,
|
||||
komodo_server TEXT,
|
||||
iw4m_match TEXT, -- port|id|name
|
||||
iw4m_value TEXT,
|
||||
last_players INTEGER,
|
||||
last_checked_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS betas (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
opened_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS beta_scripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
beta_slug TEXT NOT NULL REFERENCES betas(slug) ON DELETE CASCADE,
|
||||
staged_as TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
beta_file TEXT NOT NULL,
|
||||
backup TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_beta_scripts_slug ON beta_scripts(beta_slug);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS beta_deploys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
beta_script_id INTEGER NOT NULL REFERENCES beta_scripts(id) ON DELETE CASCADE,
|
||||
server TEXT NOT NULL REFERENCES servers(name),
|
||||
had_original INTEGER NOT NULL,
|
||||
UNIQUE(beta_script_id, server)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_beta_deploys_server ON beta_deploys(server);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deployments (
|
||||
id TEXT PRIMARY KEY,
|
||||
action TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
triggered_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deployments_started ON deployments(started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deployment_servers (
|
||||
deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE,
|
||||
server TEXT NOT NULL REFERENCES servers(name),
|
||||
files_changed INTEGER NOT NULL,
|
||||
restart_state TEXT NOT NULL, -- pending | done | failed | manual | held
|
||||
players INTEGER,
|
||||
checked_at TEXT,
|
||||
restarted_at TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
PRIMARY KEY (deployment_id, server)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deployment_servers_state ON deployment_servers(restart_state);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deployment_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE,
|
||||
server TEXT NOT NULL,
|
||||
verb TEXT NOT NULL, -- added | updated | removed | held
|
||||
rel_path TEXT NOT NULL,
|
||||
sha256_before TEXT,
|
||||
sha256_after TEXT,
|
||||
content_before BLOB,
|
||||
content_after BLOB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deployment_files_dep ON deployment_files(deployment_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, posix, relative, sep } from 'node:path';
|
||||
import { betaSrc, config } from '../config.js';
|
||||
|
||||
// Ported from push_scripts.sh:414-464. These are the exact filter rules —
|
||||
// change one and the parity check against the shell script will fail.
|
||||
|
||||
const EXCLUDE_NAMES_EXACT = new Set(['.git']);
|
||||
const EXCLUDE_SUFFIXES = ['.swp', '.OLD'];
|
||||
const EXCLUDE_TRAILING = ['~'];
|
||||
|
||||
function isExcluded(name: string): boolean {
|
||||
if (EXCLUDE_NAMES_EXACT.has(name)) return true;
|
||||
if (name.startsWith('.')) return false; // dotfiles allowed except .git
|
||||
for (const s of EXCLUDE_SUFFIXES) if (name.endsWith(s)) return true;
|
||||
for (const t of EXCLUDE_TRAILING) if (name.endsWith(t)) return true;
|
||||
// *-beta.* — script line 417 excludes any file with "-beta." in its name
|
||||
if (/-beta\./.test(name)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function toPosix(p: string): string {
|
||||
return p.split(sep).join(posix.sep);
|
||||
}
|
||||
|
||||
async function walk(root: string, prune: (rel: string) => boolean): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
async function recur(dir: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const abs = join(dir, e.name);
|
||||
const rel = toPosix(relative(root, abs));
|
||||
if (prune(rel)) continue;
|
||||
if (e.isDirectory()) {
|
||||
await recur(abs);
|
||||
} else if (e.isFile()) {
|
||||
if (isExcluded(e.name)) continue;
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
await recur(root);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file under SHARED_SCRIPTS except the betas/ tree.
|
||||
* Mirrors `list_source_files` in push_scripts.sh:414.
|
||||
*/
|
||||
export async function listSourceFiles(): Promise<string[]> {
|
||||
return walk(config.sharedScripts, (rel) => rel === 'betas' || rel.startsWith('betas/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file directly in betas/ (one level deep, ignore subfolders — those
|
||||
* are open betas). Mirrors `staged_files` in push_scripts.sh:420.
|
||||
*/
|
||||
export async function stagedFiles(): Promise<string[]> {
|
||||
if (!existsSync(betaSrc())) return [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(betaSrc(), { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.filter((e) => e.isFile() && !isExcluded(e.name) && !e.name.startsWith('.'))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every source file whose basename matches `base`.
|
||||
* Mirrors `find_targets` in push_scripts.sh:426.
|
||||
*/
|
||||
export async function findTargets(base: string): Promise<string[]> {
|
||||
const all = await listSourceFiles();
|
||||
return all.filter((rel) => posix.basename(rel) === base);
|
||||
}
|
||||
|
||||
/**
|
||||
* `foo/bar.gsc` -> `foo/bar-beta.gsc`
|
||||
* Mirrors `beta_file` in push_scripts.sh:450.
|
||||
*/
|
||||
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`
|
||||
* Mirrors `old_file` in push_scripts.sh:458.
|
||||
*/
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugify a beta name the same way the shell script does.
|
||||
* Mirrors `slugify` in push_scripts.sh:399.
|
||||
*/
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export async function sha256(path: string): Promise<string> {
|
||||
const buf = await readFile(path);
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
export async function sha256OrNull(path: string): Promise<string | null> {
|
||||
try {
|
||||
await stat(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return sha256(path);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { copyFile, mkdir, readdir, readFile, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { config, destOf } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { listSourceFiles, sha256, sha256OrNull } from './files.js';
|
||||
import { allServers, ensureDest, heldByBeta, matchServers } from './servers.js';
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
|
||||
export type FileVerb = 'added' | 'updated' | 'removed' | 'held';
|
||||
|
||||
export interface FileChange {
|
||||
verb: FileVerb;
|
||||
relPath: string;
|
||||
sha256Before: string | null;
|
||||
sha256After: string | null;
|
||||
// held rows carry the beta slug in `error` for the audit log
|
||||
heldBy?: string;
|
||||
}
|
||||
|
||||
export interface ServerPlan {
|
||||
server: string;
|
||||
dest: string;
|
||||
changes: FileChange[];
|
||||
heldTargets: string[];
|
||||
skipped?: string;
|
||||
}
|
||||
|
||||
export interface PushOptions {
|
||||
patterns?: string[]; // server-name globs; default ['*']
|
||||
dryRun?: boolean;
|
||||
delete?: boolean; // remove dest files not in source
|
||||
create?: boolean; // create sp/ if missing
|
||||
triggeredBy?: 'ui' | 'sweep' | 'cron';
|
||||
action?: string; // 'push' | 'beta:<slug>' | ...
|
||||
}
|
||||
|
||||
export interface PushResult {
|
||||
deploymentId: string | null; // null when dryRun or nothing changed
|
||||
plans: ServerPlan[];
|
||||
totalServers: number;
|
||||
totalChangedServers: number;
|
||||
totalFiles: number;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString().replace(/\.\d+Z$/, (m) => m.replace(/\d{3}Z$/, 'Z'));
|
||||
}
|
||||
|
||||
let _depSeq = 0;
|
||||
function deploymentId(): string {
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||
// Seconds-precision timestamp + monotonic counter — two pushes in the
|
||||
// same second (e.g. a beta open followed by its post-promote push) must
|
||||
// still get distinct IDs. Same shape as `dep-<utc>-<n>` in the plan.
|
||||
return `dep-${stamp}-${(++_depSeq).toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the change set for a single server without touching the disk.
|
||||
* Splitting plan-from-apply is what lets dry-run share code with commit.
|
||||
*/
|
||||
async function planServer(
|
||||
server: string,
|
||||
sourceFiles: string[],
|
||||
opts: { deleteExtras: boolean; create: boolean }
|
||||
): Promise<ServerPlan | null> {
|
||||
const dest = destOf(server);
|
||||
const ok = await ensureDest(server, opts.create);
|
||||
if (!ok.ok) return { server, dest, changes: [], heldTargets: [], skipped: ok.reason };
|
||||
|
||||
const held = heldByBeta(server);
|
||||
const heldTargets = [...held.keys()];
|
||||
const sourceSet = new Set(sourceFiles);
|
||||
const changes: FileChange[] = [];
|
||||
|
||||
for (const rel of sourceFiles) {
|
||||
if (held.has(rel)) continue;
|
||||
const src = join(config.sharedScripts, rel);
|
||||
const dst = join(dest, rel);
|
||||
const dstSha = await sha256OrNull(dst);
|
||||
const srcSha = await sha256(src);
|
||||
if (dstSha === null) {
|
||||
changes.push({ verb: 'added', relPath: rel, sha256Before: null, sha256After: srcSha });
|
||||
} else if (dstSha !== srcSha) {
|
||||
changes.push({ verb: 'updated', relPath: rel, sha256Before: dstSha, sha256After: srcSha });
|
||||
}
|
||||
// matching sha → skip
|
||||
}
|
||||
|
||||
if (opts.deleteExtras) {
|
||||
// walk dest and remove files that aren't in the source set (and
|
||||
// aren't held-by-beta artifacts, which include -beta.gsc and .OLD)
|
||||
const destFiles = await walkDest(dest);
|
||||
for (const rel of destFiles) {
|
||||
if (rel.endsWith('.OLD')) continue;
|
||||
if (/-beta\./.test(rel)) continue;
|
||||
if (sourceSet.has(rel)) continue;
|
||||
if (held.has(rel)) continue;
|
||||
const before = await sha256OrNull(join(dest, rel));
|
||||
changes.push({ verb: 'removed', relPath: rel, sha256Before: before, sha256After: null });
|
||||
}
|
||||
}
|
||||
|
||||
return { server, dest, changes, heldTargets };
|
||||
}
|
||||
|
||||
async function walkDest(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
async function recur(dir: string, prefix: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) await recur(join(dir, e.name), rel);
|
||||
else if (e.isFile()) out.push(rel);
|
||||
}
|
||||
}
|
||||
await recur(root, '');
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a plan: write files, capture content blobs for the audit log.
|
||||
*/
|
||||
async function applyPlan(plan: ServerPlan): Promise<Array<FileChange & { contentBefore: Buffer | null; contentAfter: Buffer | null }>> {
|
||||
const applied: Array<FileChange & { contentBefore: Buffer | null; contentAfter: Buffer | null }> = [];
|
||||
for (const change of plan.changes) {
|
||||
const dst = join(plan.dest, change.relPath);
|
||||
if (change.verb === 'added' || change.verb === 'updated') {
|
||||
const src = join(config.sharedScripts, change.relPath);
|
||||
const contentBefore = change.verb === 'updated' ? await readFile(dst) : null;
|
||||
const contentAfter = await readFile(src);
|
||||
await mkdir(dirname(dst), { recursive: true });
|
||||
await copyFile(src, dst);
|
||||
applied.push({ ...change, contentBefore, contentAfter });
|
||||
} else if (change.verb === 'removed') {
|
||||
const contentBefore = existsSync(dst) ? await readFile(dst) : null;
|
||||
await rm(dst, { force: true });
|
||||
applied.push({ ...change, contentBefore, contentAfter: null });
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main push entry point. Ports `do_push` in push_scripts.sh:897.
|
||||
* Uses node:fs (not shelling out to rsync) but matches the same
|
||||
* added/updated/removed classification and held-by-beta exclusion rules.
|
||||
*/
|
||||
export async function doPush(opts: PushOptions = {}): Promise<PushResult> {
|
||||
const { patterns = ['*'], dryRun = false, delete: deleteExtras = false, create = false } = opts;
|
||||
|
||||
const sourceFiles = await listSourceFiles();
|
||||
const servers = matchServers(await allServers(), patterns);
|
||||
|
||||
const plans: ServerPlan[] = [];
|
||||
let totalServers = 0;
|
||||
let totalChangedServers = 0;
|
||||
let totalFiles = 0;
|
||||
|
||||
for (const server of servers) {
|
||||
const plan = await planServer(server, sourceFiles, { deleteExtras, create });
|
||||
if (!plan) continue;
|
||||
totalServers++;
|
||||
if (!plan.skipped && plan.changes.length > 0) {
|
||||
totalChangedServers++;
|
||||
totalFiles += plan.changes.length;
|
||||
}
|
||||
plans.push(plan);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return { deploymentId: null, plans, totalServers, totalChangedServers, totalFiles };
|
||||
}
|
||||
|
||||
// No changes → no deployment row. Same behavior as `deploy_finish` line 806.
|
||||
const changedPlans = plans.filter((p) => !p.skipped && p.changes.length > 0);
|
||||
if (changedPlans.length === 0) {
|
||||
return { deploymentId: null, plans, totalServers, totalChangedServers, totalFiles };
|
||||
}
|
||||
|
||||
const depId = deploymentId();
|
||||
const action = opts.action ?? 'push';
|
||||
const started = nowIso();
|
||||
|
||||
db().prepare('INSERT INTO deployments (id, action, started_at, triggered_by) VALUES (?, ?, ?, ?)')
|
||||
.run(depId, action, started, opts.triggeredBy ?? 'ui');
|
||||
|
||||
const insServer = db().prepare(
|
||||
`INSERT INTO deployment_servers
|
||||
(deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error)
|
||||
VALUES (?, ?, ?, 'pending', NULL, NULL, NULL, 0, NULL)`
|
||||
);
|
||||
const insFile = db().prepare(
|
||||
`INSERT INTO deployment_files
|
||||
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
for (const plan of changedPlans) {
|
||||
const applied = await applyPlan(plan);
|
||||
const tx = db().transaction(() => {
|
||||
insServer.run(depId, plan.server, plan.changes.length);
|
||||
for (const a of applied) {
|
||||
insFile.run(
|
||||
depId,
|
||||
plan.server,
|
||||
a.verb,
|
||||
a.relPath,
|
||||
a.sha256Before,
|
||||
a.sha256After,
|
||||
a.contentBefore,
|
||||
a.contentAfter
|
||||
);
|
||||
}
|
||||
for (const heldRel of plan.heldTargets) {
|
||||
insFile.run(depId, plan.server, 'held', heldRel, null, null, null, null);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
emit({ type: 'server-changed', deploymentId: depId, server: plan.server, files: plan.changes.length });
|
||||
}
|
||||
|
||||
emit({ type: 'deployment-opened', deploymentId: depId, action, servers: changedPlans.map((p) => p.server) });
|
||||
|
||||
return {
|
||||
deploymentId: depId,
|
||||
plans,
|
||||
totalServers,
|
||||
totalChangedServers,
|
||||
totalFiles
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { readdir, mkdir, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { config, destOf } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
/**
|
||||
* All server folders under SERVERS_ROOT. Mirrors `all_servers` in
|
||||
* push_scripts.sh:430.
|
||||
*/
|
||||
export async function allServers(): Promise<string[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(config.serversRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a server name against a list of glob-like patterns (supports `*` and
|
||||
* `?` and comma-separated lists). Ports `match_servers` in
|
||||
* push_scripts.sh:438.
|
||||
*/
|
||||
export function matchServers(all: string[], patterns: string[]): string[] {
|
||||
if (patterns.length === 0 || (patterns.length === 1 && patterns[0] === '*')) {
|
||||
return [...all];
|
||||
}
|
||||
const regexes = patterns
|
||||
.flatMap((p) => p.split(',').map((s) => s.trim()).filter(Boolean))
|
||||
.map((p) => new RegExp('^' + p.replace(/[.+^$()|{}\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'));
|
||||
return all.filter((name) => regexes.some((r) => r.test(name)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a server has an sp/ destination. Mirrors `ensure_dest` in
|
||||
* push_scripts.sh:466. Returns `{ ok: true }` when the dir exists (or was
|
||||
* just created with `create: true`), or `{ ok: false, reason }` when it can't
|
||||
* be used.
|
||||
*/
|
||||
export async function ensureDest(
|
||||
server: string,
|
||||
create: boolean
|
||||
): Promise<{ ok: true; path: string } | { ok: false; reason: string }> {
|
||||
const dest = destOf(server);
|
||||
if (existsSync(dest)) {
|
||||
try {
|
||||
const s = await stat(dest);
|
||||
if (!s.isDirectory()) return { ok: false, reason: 'dest is not a directory' };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `stat failed: ${(err as Error).message}` };
|
||||
}
|
||||
return { ok: true, path: dest };
|
||||
}
|
||||
if (!create) return { ok: false, reason: 'no sp folder — use create=true' };
|
||||
await mkdir(dest, { recursive: true });
|
||||
return { ok: true, path: dest };
|
||||
}
|
||||
|
||||
// -- server registry (was .servers map in script_metadata.json) --
|
||||
|
||||
export interface ServerRow {
|
||||
name: string;
|
||||
komodo_type: 'deployment' | 'stack' | 'container';
|
||||
komodo_name: string;
|
||||
komodo_server: string | null;
|
||||
iw4m_match: 'port' | 'id' | 'name' | null;
|
||||
iw4m_value: string | null;
|
||||
last_players: number | null;
|
||||
last_checked_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the servers table with rows for any folder under SERVERS_ROOT that
|
||||
* isn't yet tracked. Uses folder-name-with-dashes as the default komodo_name,
|
||||
* mirroring `server_reg_seed` in push_scripts.sh:564.
|
||||
*/
|
||||
export async function seedServerRegistry(): Promise<number> {
|
||||
const rows = await allServers();
|
||||
const insert = db().prepare(
|
||||
'INSERT OR IGNORE INTO servers (name, komodo_type, komodo_name) VALUES (?, ?, ?)'
|
||||
);
|
||||
let n = 0;
|
||||
const tx = db().transaction((names: string[]) => {
|
||||
for (const name of names) {
|
||||
const res = insert.run(name, 'deployment', name.replace(/_/g, '-'));
|
||||
if (res.changes > 0) n++;
|
||||
}
|
||||
});
|
||||
tx(rows);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function getServer(name: string): ServerRow | undefined {
|
||||
return db().prepare('SELECT * FROM servers WHERE name = ?').get(name) as ServerRow | undefined;
|
||||
}
|
||||
|
||||
export function listServers(): ServerRow[] {
|
||||
return db().prepare('SELECT * FROM servers ORDER BY name').all() as ServerRow[];
|
||||
}
|
||||
|
||||
export function setServerField(
|
||||
name: string,
|
||||
field: keyof ServerRow,
|
||||
value: string | number | null
|
||||
): void {
|
||||
// whitelist columns — never interpolate arbitrary strings into SQL
|
||||
const allowed: Array<keyof ServerRow> = [
|
||||
'komodo_type',
|
||||
'komodo_name',
|
||||
'komodo_server',
|
||||
'iw4m_match',
|
||||
'iw4m_value',
|
||||
'last_players',
|
||||
'last_checked_at'
|
||||
];
|
||||
if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`);
|
||||
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which relative-path targets are currently held by an open beta on this
|
||||
* server? Returns `Map<relPath, betaSlug>`. Mirrors `held_by_beta` in
|
||||
* push_scripts.sh:378.
|
||||
*/
|
||||
export function heldByBeta(server: string): Map<string, string> {
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT bs.target AS target, bs.beta_slug AS slug
|
||||
FROM beta_deploys bd
|
||||
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
|
||||
WHERE bd.server = ?`
|
||||
)
|
||||
.all(server) as Array<{ target: string; slug: string }>;
|
||||
const map = new Map<string, string>();
|
||||
for (const r of rows) map.set(r.target, r.slug);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which beta (if any) owns this target on this server?
|
||||
* Mirrors `owner_of` in push_scripts.sh:366.
|
||||
*/
|
||||
export function ownerOf(server: string, target: string): string | null {
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT bs.beta_slug AS slug
|
||||
FROM beta_deploys bd
|
||||
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
|
||||
WHERE bd.server = ? AND bs.target = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(server, target) as { slug: string } | undefined;
|
||||
return row?.slug ?? null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
|
||||
export type AuthState =
|
||||
| { kind: 'ready'; token: string; cookie: string; expiresAt: number }
|
||||
| { kind: 'stale'; reason: 'expired' | 'rejected' | 'unreachable' }
|
||||
| { kind: 'missing' };
|
||||
|
||||
// Two minutes minus 5s slack — the !rt token really is single-use and dies
|
||||
// fast. Better to preemptively mark stale than to fire a call with a dead
|
||||
// session cookie mid-sweep.
|
||||
export const TOKEN_TTL_MS = 115_000;
|
||||
// How long before expiry the UI should re-prompt so the user has time to
|
||||
// generate a new one in-game.
|
||||
export const TOKEN_WARN_MS = 20_000;
|
||||
|
||||
let state: AuthState = { kind: 'missing' };
|
||||
|
||||
export function getAuthState(): AuthState {
|
||||
// Auto-transition from ready → stale when we cross the expiry boundary
|
||||
if (state.kind === 'ready' && Date.now() >= state.expiresAt) {
|
||||
state = { kind: 'stale', reason: 'expired' };
|
||||
emit({ type: 'auth-state', state });
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function setAuthState(next: AuthState): void {
|
||||
state = next;
|
||||
const payload: Parameters<typeof emit>[0] =
|
||||
next.kind === 'ready'
|
||||
? { type: 'auth-state', state: next, msRemaining: Math.max(0, next.expiresAt - Date.now()) }
|
||||
: { type: 'auth-state', state: next };
|
||||
emit(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Web-app-side check used by the pipeline worker's tick loop. Returns true
|
||||
* only when the token is ready AND has more than a couple seconds left. Any
|
||||
* other state hard-freezes the pipeline.
|
||||
*/
|
||||
export function pipelineReady(): boolean {
|
||||
const s = getAuthState();
|
||||
if (s.kind !== 'ready') return false;
|
||||
return s.expiresAt - Date.now() > 3000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The UI shows a warning modal once we're within TOKEN_WARN_MS of expiry.
|
||||
*/
|
||||
export function tokenNearExpiry(): boolean {
|
||||
const s = getAuthState();
|
||||
if (s.kind !== 'ready') return false;
|
||||
return s.expiresAt - Date.now() <= TOKEN_WARN_MS;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { config, iw4mConfigured } from '../config.js';
|
||||
import { getServer, setServerField } from '../fleet/servers.js';
|
||||
import { getAuthState, setAuthState, TOKEN_TTL_MS } from './auth-state.js';
|
||||
|
||||
// Ports iw4m_login/fetch_status/players/autolink from push_scripts.sh:497-620.
|
||||
// The one big difference: the script keeps a temp cookie-jar file, this holds
|
||||
// the session cookie in memory (we're a single Node process, not a shell).
|
||||
|
||||
export interface StatusEntry {
|
||||
id?: number;
|
||||
port?: number;
|
||||
name?: string;
|
||||
currentPlayers?: number;
|
||||
}
|
||||
|
||||
let statusCache: { entries: StatusEntry[]; at: number } | null = null;
|
||||
const STATUS_TTL_MS = 5_000;
|
||||
|
||||
function abortableFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config.httpTimeoutMs);
|
||||
return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the 4-char !rt token for a session cookie. Called by the
|
||||
* `/api/auth/token` route when the user pastes the modal input.
|
||||
*/
|
||||
export async function loginWithToken(
|
||||
token: string
|
||||
): Promise<{ ok: true; cookie: string; expiresAt: number } | { ok: false; reason: 'rejected' | 'unreachable' | 'unconfigured'; message: string }> {
|
||||
if (!iw4mConfigured()) return { ok: false, reason: 'unconfigured', message: 'iw4m not configured' };
|
||||
|
||||
const url = `${config.iw4m.address.replace(/\/+$/, '')}/api/client/${config.iw4m.clientId}/login`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await abortableFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: token })
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, reason: 'unreachable', message: (err as Error).message };
|
||||
}
|
||||
|
||||
if (res.status === 401 || res.status === 403) return { ok: false, reason: 'rejected', message: 'iw4m rejected the token' };
|
||||
if (!res.ok) return { ok: false, reason: 'unreachable', message: `HTTP ${res.status}` };
|
||||
|
||||
// Grab every Set-Cookie the server handed us and re-send them intact.
|
||||
// `getSetCookie` is Node 20+; on older Node use `res.headers.raw()['set-cookie']`.
|
||||
let setCookies: string[] = [];
|
||||
if (typeof (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie === 'function') {
|
||||
setCookies = (res.headers as unknown as { getSetCookie: () => string[] }).getSetCookie();
|
||||
} else {
|
||||
const single = res.headers.get('set-cookie');
|
||||
if (single) setCookies = [single];
|
||||
}
|
||||
const cookie = setCookies
|
||||
.map((c) => c.split(';')[0])
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
if (!cookie) return { ok: false, reason: 'rejected', message: 'iw4m returned no session cookie' };
|
||||
|
||||
return { ok: true, cookie, expiresAt: Date.now() + TOKEN_TTL_MS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch /api/status, using the current session cookie if we have one. Falls
|
||||
* back to anonymous read — that's what the script does at line 542 — but the
|
||||
* pipeline worker gates on the token being present anyway, so this is used
|
||||
* mainly for the dashboard's non-blocking status refresh.
|
||||
*/
|
||||
export async function fetchStatus(): Promise<{ ok: true; entries: StatusEntry[] } | { ok: false; message: string }> {
|
||||
if (!iw4mConfigured()) return { ok: false, message: 'iw4m not configured' };
|
||||
|
||||
if (statusCache && Date.now() - statusCache.at < STATUS_TTL_MS) {
|
||||
return { ok: true, entries: statusCache.entries };
|
||||
}
|
||||
|
||||
const url = `${config.iw4m.address.replace(/\/+$/, '')}/api/status`;
|
||||
const s = getAuthState();
|
||||
const headers: Record<string, string> = {};
|
||||
if (s.kind === 'ready') headers.cookie = s.cookie;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await abortableFetch(url, { headers });
|
||||
} catch (err) {
|
||||
return { ok: false, message: (err as Error).message };
|
||||
}
|
||||
if (!res.ok) {
|
||||
if ((res.status === 401 || res.status === 403) && s.kind === 'ready') {
|
||||
// Cookie went sour — force a re-prompt.
|
||||
setAuthState({ kind: 'stale', reason: 'rejected' });
|
||||
}
|
||||
return { ok: false, message: `HTTP ${res.status}` };
|
||||
}
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch (err) {
|
||||
return { ok: false, message: `bad JSON: ${(err as Error).message}` };
|
||||
}
|
||||
if (!Array.isArray(json)) return { ok: false, message: 'status was not an array' };
|
||||
|
||||
// Normalize keys — the script does the same case-insensitive dance
|
||||
// (line 589) to work across IW4MAdmin versions.
|
||||
const entries: StatusEntry[] = json.map((e) => {
|
||||
const o = e as Record<string, unknown>;
|
||||
return {
|
||||
id: (o.id ?? o.Id) as number | undefined,
|
||||
port: (o.port ?? o.Port) as number | undefined,
|
||||
name: (o.name ?? o.Name ?? '') as string,
|
||||
currentPlayers: (o.currentPlayers ?? o.CurrentPlayers) as number | undefined
|
||||
};
|
||||
});
|
||||
statusCache = { entries, at: Date.now() };
|
||||
return { ok: true, entries };
|
||||
}
|
||||
|
||||
function normalizeName(s: string): string {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* How many players are on this server right now, or null if we can't tell.
|
||||
* Ports `iw4m_players` in push_scripts.sh:579.
|
||||
*/
|
||||
export async function playersOn(server: string): Promise<number | null> {
|
||||
const row = getServer(server);
|
||||
if (!row) return null;
|
||||
const status = await fetchStatus();
|
||||
if (!status.ok) return null;
|
||||
|
||||
let match = row.iw4m_match;
|
||||
let value = row.iw4m_value;
|
||||
if (!match || !value) {
|
||||
const auto = autolink(server, status.entries);
|
||||
if (!auto) return null;
|
||||
setServerField(server, 'iw4m_match', auto.match);
|
||||
setServerField(server, 'iw4m_value', auto.value);
|
||||
match = auto.match;
|
||||
value = auto.value;
|
||||
}
|
||||
|
||||
const hits = status.entries.filter((e) => {
|
||||
if (match === 'port') return String(e.port ?? '') === value;
|
||||
if (match === 'id') return String(e.id ?? '') === value;
|
||||
return normalizeName(e.name ?? '') === normalizeName(value!);
|
||||
});
|
||||
if (hits.length !== 1) return null;
|
||||
const players = hits[0].currentPlayers;
|
||||
return typeof players === 'number' ? players : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run guess: pick the first non-generic token in the server folder
|
||||
* name and look for a matching IW4M server by name. Ports `iw4m_autolink`
|
||||
* in push_scripts.sh:605.
|
||||
*/
|
||||
function autolink(server: string, entries: StatusEntry[]): { match: 'port'; value: string } | null {
|
||||
const skip = new Set(['t4', 't5', 't6', 'iw5', 'iw4']);
|
||||
const parts = server
|
||||
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.split(' ')
|
||||
.filter((p) => p && !skip.has(p.toLowerCase()) && !/^\d+$/.test(p));
|
||||
if (parts.length === 0) return null;
|
||||
const token = parts[0].toLowerCase();
|
||||
|
||||
const hits = entries.filter((e) => normalizeName(e.name ?? '').includes(token));
|
||||
if (hits.length !== 1) return null;
|
||||
const port = hits[0].port;
|
||||
if (typeof port !== 'number') return null;
|
||||
return { match: 'port', value: String(port) };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { config, komodoConfigured } from '../config.js';
|
||||
import { getServer } from '../fleet/servers.js';
|
||||
|
||||
// Ports `komodo_restart` in push_scripts.sh:622. Three shapes depending on
|
||||
// the tracked komodo_type: deployment | container | stack.
|
||||
|
||||
export type KomodoRestartResult =
|
||||
| { ok: true }
|
||||
| { ok: false; kind: 'unconfigured' | 'no-name' | 'http-error' | 'unreachable'; message: string };
|
||||
|
||||
async function komodoCall(path: string, body: unknown): Promise<Response> {
|
||||
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config.httpTimeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': config.komodo.apiKey,
|
||||
'X-Api-Secret': config.komodo.apiSecret
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function komodoRestart(serverName: string): Promise<KomodoRestartResult> {
|
||||
if (!komodoConfigured()) {
|
||||
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
|
||||
}
|
||||
const row = getServer(serverName);
|
||||
if (!row?.komodo_name) return { ok: false, kind: 'no-name', message: 'no komodo_name set for server' };
|
||||
|
||||
const type = row.komodo_type ?? 'deployment';
|
||||
let path: string;
|
||||
let body: Record<string, string>;
|
||||
|
||||
switch (type) {
|
||||
case 'stack':
|
||||
path = 'execute/RestartStack';
|
||||
body = { stack: row.komodo_name };
|
||||
break;
|
||||
case 'container':
|
||||
if (!row.komodo_server) return { ok: false, kind: 'no-name', message: 'container type needs komodo_server' };
|
||||
path = 'execute/RestartContainer';
|
||||
body = { server: row.komodo_server, container: row.komodo_name };
|
||||
break;
|
||||
case 'deployment':
|
||||
default:
|
||||
path = 'execute/RestartDeployment';
|
||||
body = { deployment: row.komodo_name };
|
||||
break;
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await komodoCall(path, body);
|
||||
} catch (err) {
|
||||
return { ok: false, kind: 'unreachable', message: (err as Error).message };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, kind: 'http-error', message: `HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
// The script also checks the response body: if it's an object with
|
||||
// .success === false, treat as failure. If .success is absent, trust the
|
||||
// HTTP status (line 646).
|
||||
try {
|
||||
const json = await res.json();
|
||||
if (json && typeof json === 'object' && 'success' in json && json.success === false) {
|
||||
return { ok: false, kind: 'http-error', message: 'komodo returned success=false' };
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body is fine — HTTP 2xx already indicates success.
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// One in-process event bus, multiplexed by all three SSE endpoints. Kept
|
||||
// separate from any specific subscriber so push/beta/sweep can emit without
|
||||
// pulling the whole worker module in.
|
||||
|
||||
export type PipelineEvent =
|
||||
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
|
||||
| { type: 'server-changed'; deploymentId: string; server: string; files: number }
|
||||
| {
|
||||
type: 'server-state';
|
||||
deploymentId: string;
|
||||
server: string;
|
||||
state: 'pending' | 'done' | 'failed' | 'held' | 'manual';
|
||||
players: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
| { type: 'sweep-tick'; frozen: boolean; reason?: string }
|
||||
| { type: 'auth-state'; state: import('../integrations/auth-state.js').AuthState; msRemaining?: number }
|
||||
| { type: 'server-status'; entries: Array<{ server: string; players: number | null; name?: string }> };
|
||||
|
||||
class Bus extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
// Deployments and sweep can produce a lot of events during a big push.
|
||||
this.setMaxListeners(64);
|
||||
}
|
||||
}
|
||||
|
||||
const bus = new Bus();
|
||||
|
||||
export function emit(event: PipelineEvent): void {
|
||||
bus.emit('event', event);
|
||||
}
|
||||
|
||||
export function subscribe(listener: (event: PipelineEvent) => void): () => void {
|
||||
bus.on('event', listener);
|
||||
return () => bus.off('event', listener);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { config, iw4mConfigured, komodoConfigured } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { emit } from './bus.js';
|
||||
import { fetchStatus, playersOn } from '../integrations/iw4m.js';
|
||||
import { komodoRestart } from '../integrations/komodo.js';
|
||||
import { pipelineReady } from '../integrations/auth-state.js';
|
||||
|
||||
interface PendingRow {
|
||||
deployment_id: string;
|
||||
server: string;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function integrationsOn(): boolean {
|
||||
return komodoConfigured() && iw4mConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check every pending/failed deployment_server row, restart the ones with
|
||||
* zero players, mark the busy ones held. Same-server dedup is per-sweep, so
|
||||
* one restart resolves every pending row for that server. Ports `deploy_sweep`
|
||||
* in push_scripts.sh:705.
|
||||
*/
|
||||
export async function sweep(): Promise<{ checked: number; restarted: number; held: number; failed: number; frozen: boolean }> {
|
||||
if (!pipelineReady()) {
|
||||
emit({ type: 'sweep-tick', frozen: true, reason: 'iw4m token stale' });
|
||||
return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: true };
|
||||
}
|
||||
|
||||
if (!integrationsOn()) {
|
||||
// Same behavior as line 714 — mark rows manual so they don't hang
|
||||
// forever if integrations were never configured.
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE deployment_servers
|
||||
SET restart_state = 'manual', checked_at = ?, error = 'integrations not configured'
|
||||
WHERE restart_state IN ('pending','failed')`
|
||||
)
|
||||
.run(nowIso());
|
||||
return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: false };
|
||||
}
|
||||
|
||||
const pending = db()
|
||||
.prepare(
|
||||
`SELECT deployment_id, server, attempts
|
||||
FROM deployment_servers
|
||||
WHERE restart_state IN ('pending','failed')`
|
||||
)
|
||||
.all() as PendingRow[];
|
||||
if (pending.length === 0) return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: false };
|
||||
|
||||
// Warm the /api/status cache so per-server calls just hit memory
|
||||
const status = await fetchStatus();
|
||||
if (!status.ok) {
|
||||
// Same as line 742 — mark held with the iw4m error
|
||||
const stmt = db().prepare(
|
||||
`UPDATE deployment_servers
|
||||
SET checked_at = ?, error = ?, attempts = attempts + 1
|
||||
WHERE restart_state IN ('pending','failed')`
|
||||
);
|
||||
stmt.run(nowIso(), `iw4m: ${status.message}`);
|
||||
return { checked: 0, restarted: 0, held: pending.length, failed: 0, frozen: false };
|
||||
}
|
||||
|
||||
// Dedup across deployments: if we resolved server X once this tick,
|
||||
// reuse the outcome for every other pending row on X.
|
||||
type Outcome = { state: 'done' | 'failed' | 'pending'; players: number | null; error: string | null };
|
||||
const seen = new Map<string, Outcome>();
|
||||
|
||||
let checked = 0;
|
||||
let restarted = 0;
|
||||
let held = 0;
|
||||
let failed = 0;
|
||||
|
||||
const applyOutcome = db().prepare(
|
||||
`UPDATE deployment_servers
|
||||
SET restart_state = ?, checked_at = ?, players = ?, error = ?, attempts = attempts + 1,
|
||||
restarted_at = CASE WHEN ? = 'done' THEN ? ELSE restarted_at END
|
||||
WHERE deployment_id = ? AND server = ?`
|
||||
);
|
||||
|
||||
for (const row of pending) {
|
||||
let outcome = seen.get(row.server);
|
||||
if (!outcome) {
|
||||
checked++;
|
||||
const players = await playersOn(row.server);
|
||||
if (players === null) {
|
||||
outcome = { state: 'pending', players: null, error: 'no iw4m match' };
|
||||
held++;
|
||||
} else if (players > 0) {
|
||||
outcome = { state: 'pending', players, error: null };
|
||||
held++;
|
||||
} else {
|
||||
const r = await komodoRestart(row.server);
|
||||
if (r.ok) {
|
||||
outcome = { state: 'done', players: 0, error: null };
|
||||
restarted++;
|
||||
} else if (r.kind === 'no-name') {
|
||||
outcome = { state: 'failed', players: 0, error: r.message };
|
||||
failed++;
|
||||
} else {
|
||||
outcome = { state: 'failed', players: 0, error: `komodo: ${r.message}` };
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
seen.set(row.server, outcome);
|
||||
}
|
||||
const now = nowIso();
|
||||
applyOutcome.run(
|
||||
outcome.state,
|
||||
now,
|
||||
outcome.players,
|
||||
outcome.error,
|
||||
outcome.state,
|
||||
now,
|
||||
row.deployment_id,
|
||||
row.server
|
||||
);
|
||||
emit({
|
||||
type: 'server-state',
|
||||
deploymentId: row.deployment_id,
|
||||
server: row.server,
|
||||
state: outcome.state,
|
||||
players: outcome.players,
|
||||
error: outcome.error
|
||||
});
|
||||
}
|
||||
|
||||
// Finish deployments where every server is now non-pending
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE deployments
|
||||
SET finished_at = ?
|
||||
WHERE finished_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM deployment_servers
|
||||
WHERE deployment_id = deployments.id
|
||||
AND restart_state IN ('pending','failed')
|
||||
)`
|
||||
)
|
||||
.run(nowIso());
|
||||
|
||||
// Prune rows older than DEPLOY_KEEP_DAYS that have no pending/failed servers
|
||||
const cutoff = new Date(Date.now() - config.deployKeepDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
db()
|
||||
.prepare(
|
||||
`DELETE FROM deployments
|
||||
WHERE started_at < ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM deployment_servers
|
||||
WHERE deployment_id = deployments.id
|
||||
AND restart_state IN ('pending','failed')
|
||||
)`
|
||||
)
|
||||
.run(cutoff);
|
||||
|
||||
emit({ type: 'sweep-tick', frozen: false });
|
||||
return { checked, restarted, held, failed, frozen: false };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { sweep } from './sweep.js';
|
||||
import { emit } from './bus.js';
|
||||
import { fetchStatus } from '../integrations/iw4m.js';
|
||||
import { listServers, seedServerRegistry } from '../fleet/servers.js';
|
||||
import { getAuthState } from '../integrations/auth-state.js';
|
||||
|
||||
const TICK_MS = 15_000;
|
||||
const STATUS_REFRESH_MS = 30_000;
|
||||
|
||||
let started = false;
|
||||
let tickTimer: NodeJS.Timeout | null = null;
|
||||
let statusTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
try {
|
||||
await sweep();
|
||||
} catch (err) {
|
||||
console.error('[worker] sweep error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function pushServerStatus(): Promise<void> {
|
||||
const st = await fetchStatus();
|
||||
if (!st.ok) return;
|
||||
const rows = listServers();
|
||||
const byName = new Map(rows.map((r) => [r.name, r]));
|
||||
// For the dashboard: emit a lightweight snapshot. Actual per-server
|
||||
// resolution is handled by playersOn() during a sweep, which caches
|
||||
// iw4m_match/value into the servers table.
|
||||
emit({
|
||||
type: 'server-status',
|
||||
entries: st.entries.map((e) => ({
|
||||
server: e.name ?? String(e.port ?? ''),
|
||||
players: e.currentPlayers ?? null,
|
||||
name: e.name
|
||||
}))
|
||||
});
|
||||
// Also fold current auth ttl into the auth channel so subscribers get a
|
||||
// heartbeat and a countdown without waiting for a transition.
|
||||
const auth = getAuthState();
|
||||
emit({
|
||||
type: 'auth-state',
|
||||
state: auth,
|
||||
msRemaining: auth.kind === 'ready' ? Math.max(0, auth.expiresAt - Date.now()) : undefined
|
||||
});
|
||||
void byName; // reserved: future join between /api/status and server registry
|
||||
}
|
||||
|
||||
export function startWorker(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
// Seed the servers table from the fs so first-boot has rows to reference.
|
||||
seedServerRegistry().catch((err) => console.error('[worker] seed error:', err));
|
||||
|
||||
void tick();
|
||||
void pushServerStatus();
|
||||
tickTimer = setInterval(tick, TICK_MS);
|
||||
statusTimer = setInterval(pushServerStatus, STATUS_REFRESH_MS);
|
||||
|
||||
console.log('[worker] started — sweep every', TICK_MS, 'ms, status every', STATUS_REFRESH_MS, 'ms');
|
||||
}
|
||||
|
||||
export function stopWorker(): void {
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
if (statusTimer) clearInterval(statusTimer);
|
||||
tickTimer = null;
|
||||
statusTimer = null;
|
||||
started = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* External trigger — called from `/api/push` and beta endpoints so a sweep
|
||||
* fires immediately after a state change instead of waiting for the next tick.
|
||||
*/
|
||||
export async function kick(): Promise<void> {
|
||||
await tick();
|
||||
}
|
||||
Reference in New Issue
Block a user