Rework as scripts-centric, upload-only, gated beta lifecycle
Scripts become first-class: named, versioned, uploaded via the UI, optionally scoped to a game or map. Any change that would land on scope-matched servers (marking-required or new version of a scoped script) must first pass a beta. The beta runs a per-server state machine — deploy -beta.gsc → wait empty → Komodo restart → wait healthy → optionally wait for a real match end (games_sp.log 'ExitLevel: executed') — before it's eligible for promotion. Rollback is always available and restores from .OLD. The shared/scripts mirror and its betas/ staging folder are gone. Every server now has a tracked script inventory (server_scripts table), populated only by explicit upload flows. New: /scripts, /scripts/[id], /betas/[id] UI; /api/scripts + /api/betas + /api/servers/[name]/scripts endpoints; scripts.ts, inventory.ts, beta.ts (rewritten), beta-runner.ts, match-log.ts, komodoInspectHealthy(). Removed: push.ts, /api/push, BetaPanel, legacy importer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -1,7 +1,8 @@
|
||||
# Filesystem paths — inside the Komodo container, these mirror the host layout
|
||||
SHARED_SCRIPTS=/root/zombies/shared/scripts
|
||||
SERVERS_ROOT=/root/zombies/t5
|
||||
REL_DEST=runtime/plutonium/storage/t5/raw/scripts/sp
|
||||
# On-disk archive of active beta files (cleared per-beta on promote/rollback)
|
||||
BETA_ARCHIVE_DIR=/root/zombies/beta-archive
|
||||
|
||||
# SQLite location
|
||||
DB_PATH=/data/cherry.db
|
||||
|
||||
+2
-2
@@ -10,5 +10,5 @@ build
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
data/
|
||||
test-fixtures/*/zombies/
|
||||
!test-fixtures/*/zombies/.gitkeep
|
||||
test-fixtures/
|
||||
.gstack/
|
||||
|
||||
+1
-2
@@ -17,11 +17,10 @@ services:
|
||||
- /root/zombies:/root/zombies:rw
|
||||
- /root/zombies/.electric-cherry:/data:rw
|
||||
environment:
|
||||
SHARED_SCRIPTS: /root/zombies/shared/scripts
|
||||
SERVERS_ROOT: /root/zombies/t5
|
||||
REL_DEST: runtime/plutonium/storage/t5/raw/scripts/sp
|
||||
BETA_ARCHIVE_DIR: /root/zombies/beta-archive
|
||||
DB_PATH: /data/cherry.db
|
||||
LEGACY_METADATA: /root/zombies/script_metadata.json
|
||||
DEPLOY_KEEP_DAYS: "7"
|
||||
KOMODO_ADDRESS: ${KOMODO_ADDRESS}
|
||||
KOMODO_API_KEY: ${KOMODO_API_KEY}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Legacy import: if /data/cherry.db is empty and script_metadata.json exists
|
||||
# in the mounted zombies tree, run a one-shot import before starting.
|
||||
if [ -n "${LEGACY_METADATA:-}" ] && [ -f "$LEGACY_METADATA" ]; then
|
||||
if [ ! -f "${DB_PATH:-/data/cherry.db}" ] || [ ! -s "${DB_PATH:-/data/cherry.db}" ]; then
|
||||
echo "[entrypoint] running legacy import from $LEGACY_METADATA"
|
||||
node build/scripts/import-legacy.js || echo "[entrypoint] legacy import failed — continuing"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[entrypoint] starting node server on ${HOST:-0.0.0.0}:${PORT:-3000}"
|
||||
exec node build/index.js
|
||||
|
||||
+4
-5
@@ -21,11 +21,10 @@ volumes = [
|
||||
extra_hosts = ["host.docker.internal:host-gateway"]
|
||||
|
||||
[stack.services.web.env]
|
||||
SHARED_SCRIPTS = "/root/zombies/shared/scripts"
|
||||
SERVERS_ROOT = "/root/zombies/t5"
|
||||
REL_DEST = "runtime/plutonium/storage/t5/raw/scripts/sp"
|
||||
DB_PATH = "/data/cherry.db"
|
||||
LEGACY_METADATA = "/root/zombies/script_metadata.json"
|
||||
SERVERS_ROOT = "/root/zombies/t5"
|
||||
REL_DEST = "runtime/plutonium/storage/t5/raw/scripts/sp"
|
||||
BETA_ARCHIVE_DIR = "/root/zombies/beta-archive"
|
||||
DB_PATH = "/data/cherry.db"
|
||||
DEPLOY_KEEP_DAYS = "7"
|
||||
|
||||
# secrets: bind via Komodo's env-file or secret store
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"import-legacy": "tsx scripts/import-legacy.ts",
|
||||
"seed:fixture": "tsx scripts/seed-fixture.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* One-shot importer: reads script_metadata.json (the format push_scripts.sh
|
||||
* writes) and populates the electric-cherry SQLite DB with the equivalent
|
||||
* rows. Idempotent — safe to re-run, will INSERT OR IGNORE on collisions.
|
||||
*
|
||||
* Trigger: docker/entrypoint.sh calls this when LEGACY_METADATA is set and
|
||||
* DB_PATH is empty.
|
||||
*/
|
||||
|
||||
import { readFile, rename, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { env, exit } from 'node:process';
|
||||
import { db } from '../src/lib/server/db/index.js';
|
||||
|
||||
interface LegacyServer {
|
||||
komodo_type?: string;
|
||||
komodo_name?: string;
|
||||
komodo_server?: string;
|
||||
iw4m_match?: string;
|
||||
iw4m_value?: string;
|
||||
}
|
||||
interface LegacyBetaScript {
|
||||
staged_as: string;
|
||||
target: string;
|
||||
beta_file: string;
|
||||
backup: string;
|
||||
sha256: string;
|
||||
revision?: number;
|
||||
deployed?: Array<{ server: string; had_original: boolean }>;
|
||||
}
|
||||
interface LegacyBeta {
|
||||
name: string;
|
||||
folder: string;
|
||||
opened: string;
|
||||
updated?: string;
|
||||
revision?: number;
|
||||
servers?: string[];
|
||||
scripts?: LegacyBetaScript[];
|
||||
}
|
||||
interface LegacyDeployServer {
|
||||
server: string;
|
||||
files: number;
|
||||
restart: string;
|
||||
players: number | null;
|
||||
checked: string | null;
|
||||
restarted: string | null;
|
||||
attempts: number;
|
||||
error: string | null;
|
||||
}
|
||||
interface LegacyDeployment {
|
||||
id: string;
|
||||
action: string;
|
||||
started: string;
|
||||
servers?: LegacyDeployServer[];
|
||||
}
|
||||
interface LegacyRoot {
|
||||
servers?: Record<string, LegacyServer>;
|
||||
betas?: Record<string, LegacyBeta>;
|
||||
deployments?: LegacyDeployment[];
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const src = env.LEGACY_METADATA;
|
||||
if (!src) {
|
||||
console.error('LEGACY_METADATA env var not set');
|
||||
exit(1);
|
||||
}
|
||||
if (!existsSync(src)) {
|
||||
console.log(`no legacy file at ${src} — nothing to import`);
|
||||
return;
|
||||
}
|
||||
const stats = await stat(src);
|
||||
if (stats.size === 0) {
|
||||
console.log(`legacy file at ${src} is empty — nothing to import`);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = await readFile(src, 'utf8');
|
||||
const data = JSON.parse(raw) as LegacyRoot;
|
||||
const handle = db();
|
||||
|
||||
let servers = 0;
|
||||
let betas = 0;
|
||||
let betaScripts = 0;
|
||||
let betaDeploys = 0;
|
||||
let deployments = 0;
|
||||
let deploymentServers = 0;
|
||||
|
||||
const tx = handle.transaction(() => {
|
||||
// servers
|
||||
const insServer = handle.prepare(
|
||||
`INSERT OR IGNORE INTO servers
|
||||
(name, komodo_type, komodo_name, komodo_server, iw4m_match, iw4m_value)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const [name, s] of Object.entries(data.servers ?? {})) {
|
||||
const r = insServer.run(
|
||||
name,
|
||||
s.komodo_type ?? 'deployment',
|
||||
s.komodo_name ?? name.replace(/_/g, '-'),
|
||||
s.komodo_server ?? null,
|
||||
s.iw4m_match ?? null,
|
||||
s.iw4m_value ?? null
|
||||
);
|
||||
if (r.changes > 0) servers++;
|
||||
}
|
||||
|
||||
// betas
|
||||
const insBeta = handle.prepare(
|
||||
`INSERT OR IGNORE INTO betas (slug, name, folder, opened_at, updated_at, revision)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const insScript = handle.prepare(
|
||||
`INSERT INTO beta_scripts (beta_slug, staged_as, target, beta_file, backup, sha256, revision)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const insDeploy = handle.prepare(
|
||||
`INSERT OR IGNORE INTO beta_deploys (beta_script_id, server, had_original)
|
||||
VALUES (?, ?, ?)`
|
||||
);
|
||||
|
||||
for (const [slug, b] of Object.entries(data.betas ?? {})) {
|
||||
const br = insBeta.run(slug, b.name, b.folder, b.opened, b.updated ?? b.opened, b.revision ?? 1);
|
||||
if (br.changes > 0) betas++;
|
||||
for (const s of b.scripts ?? []) {
|
||||
const sr = insScript.run(
|
||||
slug,
|
||||
s.staged_as,
|
||||
s.target,
|
||||
s.beta_file,
|
||||
s.backup,
|
||||
s.sha256,
|
||||
s.revision ?? 1
|
||||
);
|
||||
betaScripts++;
|
||||
const scriptId = Number(sr.lastInsertRowid);
|
||||
for (const d of s.deployed ?? []) {
|
||||
// Skip deploys whose server isn't tracked — matches the
|
||||
// FK constraint. Log so we don't silently lose rows.
|
||||
const exists = handle.prepare('SELECT 1 FROM servers WHERE name = ?').get(d.server);
|
||||
if (!exists) {
|
||||
console.warn(`skipping beta_deploy for unknown server ${d.server}`);
|
||||
continue;
|
||||
}
|
||||
const dr = insDeploy.run(scriptId, d.server, d.had_original ? 1 : 0);
|
||||
if (dr.changes > 0) betaDeploys++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deployments
|
||||
const insDep = handle.prepare(
|
||||
`INSERT OR IGNORE INTO deployments (id, action, started_at, triggered_by)
|
||||
VALUES (?, ?, ?, 'legacy')`
|
||||
);
|
||||
const insDepSrv = handle.prepare(
|
||||
`INSERT OR IGNORE INTO deployment_servers
|
||||
(deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const d of data.deployments ?? []) {
|
||||
const r = insDep.run(d.id, d.action, d.started);
|
||||
if (r.changes > 0) deployments++;
|
||||
for (const s of d.servers ?? []) {
|
||||
const exists = handle.prepare('SELECT 1 FROM servers WHERE name = ?').get(s.server);
|
||||
if (!exists) continue;
|
||||
const sr = insDepSrv.run(
|
||||
d.id,
|
||||
s.server,
|
||||
s.files ?? 0,
|
||||
s.restart ?? 'manual',
|
||||
s.players ?? null,
|
||||
s.checked ?? null,
|
||||
s.restarted ?? null,
|
||||
s.attempts ?? 0,
|
||||
s.error ?? null
|
||||
);
|
||||
if (sr.changes > 0) deploymentServers++;
|
||||
}
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
console.log(
|
||||
`imported: ${servers} servers, ${betas} betas (${betaScripts} scripts, ${betaDeploys} deploys), ` +
|
||||
`${deployments} deployments (${deploymentServers} server rows)`
|
||||
);
|
||||
|
||||
// Move the source out of the way so we don't re-import on future boots.
|
||||
const moved = `${src}.migrated`;
|
||||
await rename(src, moved);
|
||||
console.log(`renamed ${src} → ${moved}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('legacy import failed:', err);
|
||||
exit(2);
|
||||
});
|
||||
+11
-34
@@ -3,59 +3,36 @@
|
||||
* Seed a test-fixtures/zombies/ tree so the app has something to point at
|
||||
* during local dev on Windows/macOS (where /root/zombies doesn't exist).
|
||||
*
|
||||
* Since the upload-only rework, there's no shared/scripts source dir — the
|
||||
* seed just creates empty server folders. Upload scripts via the /scripts UI
|
||||
* to populate them.
|
||||
*
|
||||
* Usage:
|
||||
* npm run seed:fixture
|
||||
* SHARED_SCRIPTS=./test-fixtures/zombies/shared/scripts \
|
||||
* SERVERS_ROOT=./test-fixtures/zombies/t5 \
|
||||
* BETA_ARCHIVE_DIR=./test-fixtures/zombies/beta-archive \
|
||||
* npm run dev
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const root = process.env.FIXTURE_ROOT ?? './test-fixtures/zombies';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const shared = join(root, 'shared/scripts');
|
||||
const staging = join(shared, 'betas');
|
||||
const t5 = join(root, 't5');
|
||||
await mkdir(shared, { recursive: true });
|
||||
await mkdir(staging, { recursive: true });
|
||||
const betaArchive = join(root, 'beta-archive');
|
||||
await mkdir(betaArchive, { recursive: true });
|
||||
|
||||
// A handful of representative GSC files
|
||||
await writeFile(
|
||||
join(shared, 'hud.gsc'),
|
||||
'#include maps\\_utility;\n\ninit() { println("hud v1"); }\n'
|
||||
);
|
||||
await writeFile(
|
||||
join(shared, 'zom.gsc'),
|
||||
'#include maps\\_zombiemode;\n\ninit() { println("zom v1"); }\n'
|
||||
);
|
||||
await writeFile(
|
||||
join(shared, 'perks.gsc'),
|
||||
'#include maps\\_zombiemode_perks;\n\ninit() { println("perks v1"); }\n'
|
||||
);
|
||||
|
||||
// Three servers, one already up-to-date, one empty, one with old copies
|
||||
const rel = 'runtime/plutonium/storage/t5/raw/scripts/sp';
|
||||
for (const name of ['t5_kino_1', 't5_ascension_1', 't5_cotd_1']) {
|
||||
const dest = join(t5, name, rel);
|
||||
await mkdir(dest, { recursive: true });
|
||||
await mkdir(join(t5, name, rel), { recursive: true });
|
||||
await mkdir(join(t5, name, 'logs'), { recursive: true });
|
||||
}
|
||||
// t5_kino_1 is already up-to-date
|
||||
await writeFile(
|
||||
join(t5, 't5_kino_1', rel, 'hud.gsc'),
|
||||
'#include maps\\_utility;\n\ninit() { println("hud v1"); }\n'
|
||||
);
|
||||
// t5_cotd_1 has an older version of zom
|
||||
await writeFile(
|
||||
join(t5, 't5_cotd_1', rel, 'zom.gsc'),
|
||||
'#include maps\\_zombiemode;\n\ninit() { println("zom v0"); }\n'
|
||||
);
|
||||
|
||||
console.log(`seeded ${root}`);
|
||||
console.log(` set SHARED_SCRIPTS=${shared}`);
|
||||
console.log(` set SERVERS_ROOT=${t5}`);
|
||||
console.log(` set BETA_ARCHIVE_DIR=${betaArchive}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -15,9 +15,11 @@ function num(name: string, fallback: number): number {
|
||||
}
|
||||
|
||||
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'),
|
||||
// Where on-disk copies of active beta files live for operator visibility.
|
||||
// Cleared per-beta on promote/rollback.
|
||||
betaArchiveDir: req('BETA_ARCHIVE_DIR', '/root/zombies/beta-archive'),
|
||||
dbPath: req('DB_PATH', './data/cherry.db'),
|
||||
deployKeepDays: num('DEPLOY_KEEP_DAYS', 7),
|
||||
httpTimeoutMs: num('HTTP_TIMEOUT_MS', 10_000),
|
||||
@@ -46,5 +48,5 @@ export function rconConfigured(): boolean {
|
||||
return !!config.rcon.password;
|
||||
}
|
||||
|
||||
export const betaSrc = () => `${config.sharedScripts}/betas`;
|
||||
export const destOf = (server: string) => `${config.serversRoot}/${server}/${config.relDest}`;
|
||||
export const betaArchiveOf = (betaId: number) => `${config.betaArchiveDir}/beta-${betaId}`;
|
||||
|
||||
@@ -25,7 +25,10 @@ const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
||||
{ name: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' },
|
||||
{ name: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' },
|
||||
{ name: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' },
|
||||
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' }
|
||||
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' },
|
||||
// Script-scope columns — static per server, populated via server-edit UI.
|
||||
{ name: 'game', ddl: 'ALTER TABLE servers ADD COLUMN game TEXT' },
|
||||
{ name: 'map', ddl: 'ALTER TABLE servers ADD COLUMN map TEXT' }
|
||||
];
|
||||
|
||||
function migrateServers(handle: Database.Database): void {
|
||||
@@ -38,6 +41,39 @@ function migrateServers(handle: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
// The pre-script-model beta tables had entirely different shapes (slug PK,
|
||||
// beta_scripts, beta_deploys). schema.sql defines the new shape; CREATE TABLE
|
||||
// IF NOT EXISTS is a no-op if the old tables already exist, so we have to
|
||||
// detect and drop them first. Any outstanding rows abort the boot so the
|
||||
// operator can promote/rollback in the old app before upgrading.
|
||||
function dropLegacyBetaTables(handle: Database.Database): void {
|
||||
const legacy = handle
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('betas','beta_scripts','beta_deploys')")
|
||||
.all() as Array<{ name: string }>;
|
||||
if (legacy.length === 0) return;
|
||||
|
||||
const legacyNames = legacy.map((r) => r.name);
|
||||
// The old `betas` table had a `slug` column; the new one has `id` + `script_id`.
|
||||
// If we see `slug`, we know it's the old shape.
|
||||
const betasCols = handle.prepare("PRAGMA table_info(betas)").all() as Array<{ name: string }>;
|
||||
const isLegacyShape = betasCols.some((c) => c.name === 'slug');
|
||||
if (!isLegacyShape) return;
|
||||
|
||||
for (const table of ['beta_deploys', 'beta_scripts', 'betas']) {
|
||||
if (!legacyNames.includes(table)) continue;
|
||||
const row = handle.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number };
|
||||
if (row.n > 0) {
|
||||
throw new Error(
|
||||
`legacy ${table} still has ${row.n} rows — promote or rollback outstanding betas in the previous version before upgrading`
|
||||
);
|
||||
}
|
||||
}
|
||||
// Drop in FK-safe order.
|
||||
for (const table of ['beta_deploys', 'beta_scripts', 'betas']) {
|
||||
if (legacyNames.includes(table)) handle.exec(`DROP TABLE ${table}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function db(): Database.Database {
|
||||
if (_db) return _db;
|
||||
|
||||
@@ -45,6 +81,7 @@ export function db(): Database.Database {
|
||||
_db = new Database(config.dbPath);
|
||||
_db.pragma('journal_mode = WAL');
|
||||
_db.pragma('foreign_keys = ON');
|
||||
dropLegacyBetaTables(_db);
|
||||
_db.exec(schemaSql);
|
||||
migrateServers(_db);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
-- 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.
|
||||
-- The old betas/beta_scripts/beta_deploys tables are dropped in-code before this
|
||||
-- runs (see dropLegacyBetaTables in db/index.ts); the new script-centric model
|
||||
-- lives here.
|
||||
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -11,6 +13,8 @@ CREATE TABLE IF NOT EXISTS servers (
|
||||
komodo_server TEXT,
|
||||
game_port INTEGER, -- UDP RCON port; lazy-filled from Komodo
|
||||
rcon_host TEXT, -- optional per-server override of RCON_HOST
|
||||
game TEXT, -- 't5' etc.; static per-server (no map rotation)
|
||||
map TEXT, -- 'zombie_theater' etc.; static per-server
|
||||
last_players INTEGER,
|
||||
last_checked_at TEXT,
|
||||
last_map TEXT, -- from RCON getstatus mapname
|
||||
@@ -22,37 +26,74 @@ CREATE TABLE IF NOT EXISTS servers (
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
-- One row per script (a logical thing, e.g. "HUD tweaks"). Name is unique and
|
||||
-- doubles as the slug in URLs.
|
||||
CREATE TABLE IF NOT EXISTS scripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
target_rel_path TEXT NOT NULL, -- 'maps/mp_kino/hud.gsc' or 'hud.gsc'
|
||||
scope_type TEXT, -- NULL | 'game' | 'map'
|
||||
scope_value TEXT, -- NULL | 't5' | 'zombie_theater'
|
||||
current_version_id INTEGER, -- FK → script_versions(id) after first promote
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scripts_scope ON scripts(scope_type, scope_value);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS script_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
notes TEXT,
|
||||
uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(script_id, version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_script_versions_script ON script_versions(script_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_scripts (
|
||||
server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
|
||||
rel_path TEXT NOT NULL,
|
||||
script_version_id INTEGER REFERENCES script_versions(id) ON DELETE SET NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
source TEXT, -- 'beta:<id>' | 'promote:<id>' | 'rollback:<id>'
|
||||
PRIMARY KEY (server, rel_path)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_scripts_server ON server_scripts(server);
|
||||
|
||||
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
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||
script_version_id INTEGER NOT NULL REFERENCES script_versions(id) ON DELETE CASCADE,
|
||||
trigger_kind TEXT NOT NULL, -- 'mark-required' | 'version-update' | 'unscoped'
|
||||
wait_for_health INTEGER NOT NULL DEFAULT 1,
|
||||
wait_for_match INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL, -- 'running' | 'ready-promote' | 'promoted' | 'rolled-back'
|
||||
opened_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
closed_at TEXT,
|
||||
notes TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_betas_state ON betas(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_betas_script ON betas(script_id);
|
||||
|
||||
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 TABLE IF NOT EXISTS beta_servers (
|
||||
beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE,
|
||||
server TEXT NOT NULL REFERENCES servers(name),
|
||||
had_original INTEGER NOT NULL,
|
||||
prior_version_id INTEGER REFERENCES script_versions(id),
|
||||
stage TEXT NOT NULL, -- 'deploying'|'awaiting-empty'|'restarting'|'awaiting-health'|'awaiting-match'|'ready'|'failed'
|
||||
error TEXT,
|
||||
deployed_at TEXT,
|
||||
restarted_at TEXT,
|
||||
healthy_at TEXT,
|
||||
match_seen_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
PRIMARY KEY (beta_id, server)
|
||||
);
|
||||
|
||||
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 INDEX IF NOT EXISTS idx_beta_servers_stage ON beta_servers(stage);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deployments (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -83,7 +124,7 @@ 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
|
||||
verb TEXT NOT NULL, -- added | updated | removed
|
||||
rel_path TEXT NOT NULL,
|
||||
sha256_before TEXT,
|
||||
sha256_after TEXT,
|
||||
|
||||
+355
-373
@@ -1,455 +1,437 @@
|
||||
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises';
|
||||
import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, posix } from 'node:path';
|
||||
import { betaSrc, config, destOf } from '../config.js';
|
||||
import { betaArchiveOf, config, destOf } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { betaFile, oldFile, sha256Buffer } from './files.js';
|
||||
import { ensureDest, getServer, heldByBeta } from './servers.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';
|
||||
getScript,
|
||||
getVersion,
|
||||
listVersions,
|
||||
serversMatchingScope,
|
||||
type ScriptRow,
|
||||
type ScriptVersionRow
|
||||
} from './scripts.js';
|
||||
import { recordScript, removeScript, getServerScript } from './inventory.js';
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
import type { BetaStage } from '../pipeline/bus.js';
|
||||
|
||||
// -- shared row shapes --
|
||||
export type BetaTriggerKind = 'mark-required' | 'version-update' | 'unscoped';
|
||||
|
||||
interface BetaRow {
|
||||
slug: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
opened_at: string;
|
||||
updated_at: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
interface BetaScriptRow {
|
||||
export interface BetaRow {
|
||||
id: number;
|
||||
beta_slug: string;
|
||||
staged_as: string;
|
||||
target: string;
|
||||
beta_file: string;
|
||||
backup: string;
|
||||
sha256: string;
|
||||
revision: number;
|
||||
script_id: number;
|
||||
script_version_id: number;
|
||||
trigger_kind: BetaTriggerKind;
|
||||
wait_for_health: number;
|
||||
wait_for_match: number;
|
||||
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||
opened_at: string;
|
||||
closed_at: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface BetaDeployRow {
|
||||
beta_script_id: number;
|
||||
export interface BetaServerRow {
|
||||
beta_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;
|
||||
prior_version_id: number | null;
|
||||
stage: BetaStage;
|
||||
error: string | null;
|
||||
deployed_at: string | null;
|
||||
restarted_at: string | null;
|
||||
healthy_at: string | null;
|
||||
match_seen_at: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
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-such-script'
|
||||
| 'no-such-version'
|
||||
| 'version-script-mismatch'
|
||||
| 'no-servers'
|
||||
| 'unknown-server'
|
||||
| 'out-of-scope'
|
||||
| 'conflict'
|
||||
| 'blocked-backup'
|
||||
| 'empty-plan'
|
||||
| 'missing-staged'
|
||||
| 'no-such-beta'
|
||||
| 'no-match',
|
||||
| 'not-ready-promote'
|
||||
| 'not-running'
|
||||
| 'dest-fail'
|
||||
| 'invalid-state',
|
||||
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');
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
const betaDir = `${betaSrc()}/${slug}`;
|
||||
// ---------- Reads ----------
|
||||
|
||||
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'
|
||||
export function getBeta(id: number): BetaRow | undefined {
|
||||
return db().prepare('SELECT * FROM betas WHERE id = ?').get(id) as BetaRow | undefined;
|
||||
}
|
||||
|
||||
export function listBetas(state?: BetaRow['state']): BetaRow[] {
|
||||
if (state) {
|
||||
return db().prepare('SELECT * FROM betas WHERE state = ? ORDER BY opened_at DESC').all(state) as BetaRow[];
|
||||
}
|
||||
return db().prepare('SELECT * FROM betas ORDER BY opened_at DESC').all() as BetaRow[];
|
||||
}
|
||||
|
||||
export function listBetaServers(betaId: number): BetaServerRow[] {
|
||||
return db()
|
||||
.prepare('SELECT * FROM beta_servers WHERE beta_id = ? ORDER BY server')
|
||||
.all(betaId) as BetaServerRow[];
|
||||
}
|
||||
|
||||
export function findRunningBetaForScript(scriptId: number): BetaRow | undefined {
|
||||
return db()
|
||||
.prepare(`SELECT * FROM betas WHERE script_id = ? AND state = 'running' LIMIT 1`)
|
||||
.get(scriptId) as BetaRow | undefined;
|
||||
}
|
||||
|
||||
// ---------- openBeta ----------
|
||||
|
||||
export interface OpenBetaInput {
|
||||
scriptId: number;
|
||||
scriptVersionId: number;
|
||||
servers: string[];
|
||||
waitForHealth: boolean;
|
||||
waitForMatch: boolean;
|
||||
triggerKind: BetaTriggerKind;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface OpenBetaResult {
|
||||
betaId: number;
|
||||
servers: string[];
|
||||
}
|
||||
|
||||
export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
const script = getScript(input.scriptId);
|
||||
if (!script) throw new BetaError(`no such script (id=${input.scriptId})`, 'no-such-script');
|
||||
|
||||
const version = getVersion(input.scriptVersionId);
|
||||
if (!version) throw new BetaError(`no such version (id=${input.scriptVersionId})`, 'no-such-version');
|
||||
if (version.script_id !== script.id) {
|
||||
throw new BetaError('version does not belong to this script', 'version-script-mismatch');
|
||||
}
|
||||
|
||||
if (!input.servers || input.servers.length === 0) {
|
||||
throw new BetaError('at least one server is required', 'no-servers');
|
||||
}
|
||||
|
||||
// Look up server rows + validate scope up front.
|
||||
const rows: Array<{ name: string; game: string | null; map: string | null }> = [];
|
||||
for (const name of input.servers) {
|
||||
const row = getServer(name);
|
||||
if (!row) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
|
||||
rows.push({ name, game: row.game, map: row.map });
|
||||
}
|
||||
if (script.scope_type && script.scope_value) {
|
||||
const outOfScope = rows.filter(
|
||||
(r) => (script.scope_type === 'game' ? r.game : r.map) !== script.scope_value
|
||||
);
|
||||
}
|
||||
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 (outOfScope.length > 0) {
|
||||
throw new BetaError(
|
||||
`servers not in scope ${script.scope_type}=${script.scope_value}`,
|
||||
'out-of-scope',
|
||||
outOfScope.map((r) => r.name)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
// Per-server conflict check: is another running beta already holding this
|
||||
// target on this server?
|
||||
const conflicts: Array<{ server: string; betaId: number }> = [];
|
||||
const blocked: Array<{ server: string; ofile: string }> = [];
|
||||
for (const name of input.servers) {
|
||||
const held = heldByBeta(name);
|
||||
const ownerId = held.get(script.target_rel_path);
|
||||
if (ownerId) conflicts.push({ server: name, betaId: ownerId });
|
||||
|
||||
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
|
||||
};
|
||||
// If the target exists on-disk and .OLD already exists, refuse — that
|
||||
// .OLD is either from a crashed prior beta or manual editing.
|
||||
const dest = destOf(name);
|
||||
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 (blocked.length > 0) throw new BetaError('stale .OLD backups in the way', 'blocked-backup', blocked);
|
||||
|
||||
// Ensure destinations exist (create=true so a fresh server picks up cleanly).
|
||||
for (const name of input.servers) {
|
||||
const ok = await ensureDest(name, true);
|
||||
if (!ok.ok) throw new BetaError(`dest not usable for ${name}: ${ok.reason}`, 'dest-fail');
|
||||
}
|
||||
|
||||
// ---- commit ----
|
||||
|
||||
const opened = nowIso();
|
||||
// Insert rows in a transaction — disk work happens after commit so we can
|
||||
// await file I/O without holding the DB lock.
|
||||
const insertBeta = db().prepare(
|
||||
`INSERT INTO betas (slug, name, folder, opened_at, updated_at, revision)
|
||||
VALUES (?, ?, ?, ?, ?, 1)`
|
||||
`INSERT INTO betas (script_id, script_version_id, trigger_kind, wait_for_health, wait_for_match, state, notes)
|
||||
VALUES (?, ?, ?, ?, ?, 'running', ?)`
|
||||
);
|
||||
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 insertBetaServer = db().prepare(
|
||||
`INSERT INTO beta_servers (beta_id, server, had_original, prior_version_id, stage, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'deploying', ?)`
|
||||
);
|
||||
|
||||
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();
|
||||
let betaId!: number;
|
||||
db().transaction(() => {
|
||||
const res = insertBeta.run(
|
||||
script.id,
|
||||
version.id,
|
||||
input.triggerKind,
|
||||
input.waitForHealth ? 1 : 0,
|
||||
input.waitForMatch ? 1 : 0,
|
||||
input.notes ?? null
|
||||
);
|
||||
betaId = Number(res.lastInsertRowid);
|
||||
|
||||
// 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);
|
||||
for (const name of input.servers) {
|
||||
const prior = getServerScript(name, script.target_rel_path);
|
||||
insertBetaServer.run(
|
||||
betaId,
|
||||
name,
|
||||
prior ? 1 : 0,
|
||||
prior?.script_version_id ?? null,
|
||||
nowIso()
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
// On-disk deploy: for each server, backup target → .OLD (if any) and write
|
||||
// -beta.gsc from the version's content.
|
||||
const bfile = betaFile(script.target_rel_path);
|
||||
const ofile = oldFile(script.target_rel_path);
|
||||
const archive = betaArchiveOf(betaId);
|
||||
await mkdir(archive, { recursive: true });
|
||||
await writeFile(join(archive, posix.basename(bfile)), version.content);
|
||||
|
||||
const updateStage = db().prepare(
|
||||
`UPDATE beta_servers
|
||||
SET stage = ?, error = ?, deployed_at = ?, updated_at = ?
|
||||
WHERE beta_id = ? AND server = ?`
|
||||
);
|
||||
|
||||
for (const name of input.servers) {
|
||||
const dest = destOf(name);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
const backupPath = join(dest, ofile);
|
||||
const betaPath = join(dest, bfile);
|
||||
|
||||
try {
|
||||
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
|
||||
if (existsSync(targetPath)) {
|
||||
await rename(targetPath, backupPath);
|
||||
// Move inventory row: target → .OLD (keep prior sha/version).
|
||||
const prior = getServerScript(name, script.target_rel_path);
|
||||
removeScript(name, script.target_rel_path);
|
||||
if (prior) {
|
||||
recordScript(name, ofile, prior.script_version_id, prior.sha256, prior.size, `beta:${betaId}`);
|
||||
}
|
||||
}
|
||||
await writeFile(betaPath, version.content);
|
||||
recordScript(name, bfile, version.id, version.sha256, version.size, `beta:${betaId}`);
|
||||
|
||||
const t = nowIso();
|
||||
updateStage.run('awaiting-empty', null, t, t, betaId, name);
|
||||
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'awaiting-empty', error: null });
|
||||
} catch (err) {
|
||||
const t = nowIso();
|
||||
const msg = (err as Error).message;
|
||||
updateStage.run('failed', `deploy: ${msg}`, t, t, betaId, name);
|
||||
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'failed', error: `deploy: ${msg}` });
|
||||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
emit({ type: 'beta-opened', betaId, scriptId: script.id, servers: input.servers });
|
||||
|
||||
// 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
|
||||
};
|
||||
return { betaId, servers: input.servers };
|
||||
}
|
||||
|
||||
// -- 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) --
|
||||
// ---------- rollback ----------
|
||||
|
||||
export interface RollbackResult {
|
||||
slug: string;
|
||||
betaId: number;
|
||||
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');
|
||||
export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
||||
const beta = getBeta(betaId);
|
||||
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
|
||||
if (beta.state !== 'running' && beta.state !== 'ready-promote') {
|
||||
throw new BetaError(`beta is ${beta.state}, cannot rollback`, 'invalid-state');
|
||||
}
|
||||
const script = getScript(beta.script_id)!;
|
||||
const bfile = betaFile(script.target_rel_path);
|
||||
const ofile = oldFile(script.target_rel_path);
|
||||
|
||||
const betaDir = `${betaSrc()}/${slug}`;
|
||||
const deploys = listBetaDeploys(slug);
|
||||
const rows = listBetaServers(betaId);
|
||||
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 bs of rows) {
|
||||
const dest = destOf(bs.server);
|
||||
const betaPath = join(dest, bfile);
|
||||
const backupPath = join(dest, ofile);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
|
||||
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);
|
||||
// Remove -beta.gsc regardless of stage.
|
||||
if (existsSync(betaPath)) await rm(betaPath, { force: true });
|
||||
removeScript(bs.server, bfile);
|
||||
|
||||
if (bs.had_original) {
|
||||
if (existsSync(backupPath)) {
|
||||
await rename(backupPath, targetPath);
|
||||
removeScript(bs.server, ofile);
|
||||
// Restore inventory row for target with prior version bytes.
|
||||
const prior = bs.prior_version_id ? getVersion(bs.prior_version_id) : null;
|
||||
if (prior) {
|
||||
recordScript(bs.server, script.target_rel_path, prior.id, prior.sha256, prior.size, `rollback:${betaId}`);
|
||||
} else {
|
||||
// We had_original but lost the prior version reference — hash the file to record what's there.
|
||||
const buf = await readFile(targetPath);
|
||||
recordScript(
|
||||
bs.server,
|
||||
script.target_rel_path,
|
||||
null,
|
||||
sha256Buffer(buf),
|
||||
buf.byteLength,
|
||||
`rollback:${betaId}`
|
||||
);
|
||||
}
|
||||
restored.push(bs.server);
|
||||
} else {
|
||||
warnings.push(`${d.server}: no ${d.backup} to restore`);
|
||||
warnings.push(`${bs.server}: no ${ofile} to restore`);
|
||||
}
|
||||
} else {
|
||||
dropped.push(d.target);
|
||||
dropped.push(bs.server);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true });
|
||||
db().prepare('DELETE FROM betas WHERE slug = ?').run(slug); // cascades to scripts/deploys
|
||||
// Wipe archive + close out beta.
|
||||
const archive = betaArchiveOf(betaId);
|
||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||
|
||||
return { slug, restored, dropped, warnings, dryRun: false };
|
||||
db()
|
||||
.prepare(`UPDATE betas SET state = 'rolled-back', closed_at = ? WHERE id = ?`)
|
||||
.run(nowIso(), betaId);
|
||||
emit({ type: 'beta-state', betaId, state: 'rolled-back' });
|
||||
|
||||
return { betaId, restored, dropped, warnings };
|
||||
}
|
||||
|
||||
// -- promote (do_promote, push_scripts.sh:1325) --
|
||||
// ---------- promote ----------
|
||||
|
||||
export interface PromoteResult {
|
||||
slug: string;
|
||||
landed: string[];
|
||||
dryRun: boolean;
|
||||
betaId: number;
|
||||
landed: string[]; // servers we wrote the promoted target to
|
||||
versionId: number;
|
||||
}
|
||||
|
||||
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));
|
||||
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
const beta = getBeta(betaId);
|
||||
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
|
||||
if (beta.state !== 'ready-promote') {
|
||||
throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-ready-promote');
|
||||
}
|
||||
if (lost.length > 0)
|
||||
throw new BetaError('scripts missing from betas dir — cannot promote', 'missing-staged', lost);
|
||||
const script = getScript(beta.script_id)!;
|
||||
const version = getVersion(beta.script_version_id)!;
|
||||
const bfile = betaFile(script.target_rel_path);
|
||||
const ofile = oldFile(script.target_rel_path);
|
||||
|
||||
const deploys = listBetaDeploys(slug);
|
||||
const betaServers = listBetaServers(betaId).map((r) => r.server);
|
||||
// For scoped scripts, promotion applies to every matching server (whether
|
||||
// or not they were in the beta). For unscoped, just the beta servers.
|
||||
const scopeServers = script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type, script.scope_value)
|
||||
: [];
|
||||
const allTargets = Array.from(new Set([...betaServers, ...scopeServers]));
|
||||
|
||||
if (dryRun) {
|
||||
return { slug, landed: scripts.map((s) => s.target), dryRun: true };
|
||||
const landed: string[] = [];
|
||||
for (const name of allTargets) {
|
||||
const dest = destOf(name);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
const betaPath = join(dest, bfile);
|
||||
const backupPath = join(dest, ofile);
|
||||
|
||||
try {
|
||||
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
|
||||
if (existsSync(betaPath)) {
|
||||
await rm(betaPath, { force: true });
|
||||
removeScript(name, bfile);
|
||||
}
|
||||
if (existsSync(backupPath)) {
|
||||
await rm(backupPath, { force: true });
|
||||
removeScript(name, ofile);
|
||||
}
|
||||
await writeFile(targetPath, version.content);
|
||||
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, `promote:${betaId}`);
|
||||
landed.push(name);
|
||||
} catch (err) {
|
||||
// Best-effort continue; error will surface via the beta detail UI.
|
||||
console.error(`[promote] ${name} failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
// Update the script's canonical version and close out the beta + wipe archive.
|
||||
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
|
||||
db().prepare(`UPDATE betas SET state = 'promoted', closed_at = ? WHERE id = ?`).run(nowIso(), betaId);
|
||||
|
||||
// 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));
|
||||
}
|
||||
const archive = betaArchiveOf(betaId);
|
||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||
|
||||
if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true });
|
||||
db().prepare('DELETE FROM betas WHERE slug = ?').run(slug);
|
||||
emit({ type: 'beta-state', betaId, state: 'promoted' });
|
||||
|
||||
// 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 };
|
||||
return { betaId, landed, versionId: version.id };
|
||||
}
|
||||
|
||||
// ---------- helpers used by API layer ----------
|
||||
|
||||
// Latest of the script's versions — useful for the "open beta with this
|
||||
// version" default in the UI.
|
||||
export function latestVersionForScript(script: ScriptRow): ScriptVersionRow | null {
|
||||
const rows = listVersions(script.id);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-set a script's scope. When scope is being removed and matching servers
|
||||
* currently have the script, remove it from them for consistency (per plan:
|
||||
* "un-marking auto-removes from matching servers, no beta"). Returns the
|
||||
* server names that had files deleted.
|
||||
*/
|
||||
export async function unsetScopeAndCleanup(scriptId: number): Promise<string[]> {
|
||||
const script = getScript(scriptId);
|
||||
if (!script) throw new BetaError(`no such script (id=${scriptId})`, 'no-such-script');
|
||||
if (!script.scope_type || !script.scope_value) return [];
|
||||
|
||||
const matched = serversMatchingScope(script.scope_type, script.scope_value);
|
||||
const cleaned: string[] = [];
|
||||
for (const name of matched) {
|
||||
const dest = destOf(name);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
if (existsSync(targetPath)) {
|
||||
await rm(targetPath, { force: true });
|
||||
removeScript(name, script.target_rel_path);
|
||||
cleaned.push(name);
|
||||
}
|
||||
}
|
||||
db().prepare('UPDATE scripts SET scope_type = NULL, scope_value = NULL WHERE id = ?').run(scriptId);
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@@ -1,93 +1,12 @@
|
||||
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';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { posix } from 'node:path';
|
||||
|
||||
// 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();
|
||||
}
|
||||
// Naming rules for beta artifacts, preserved from the legacy shell script.
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `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);
|
||||
@@ -100,8 +19,8 @@ export function betaFile(rel: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* `foo/bar.gsc` -> `foo/bar.OLD`
|
||||
* Mirrors `old_file` in push_scripts.sh:458.
|
||||
* `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);
|
||||
@@ -112,17 +31,6 @@ export function oldFile(rel: string): string {
|
||||
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');
|
||||
@@ -136,3 +44,7 @@ export async function sha256OrNull(path: string): Promise<string | null> {
|
||||
}
|
||||
return sha256(path);
|
||||
}
|
||||
|
||||
export function sha256Buffer(buf: Buffer): string {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
export interface ServerScriptRow {
|
||||
server: string;
|
||||
rel_path: string;
|
||||
script_version_id: number | null;
|
||||
sha256: string;
|
||||
size: number;
|
||||
updated_at: string;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
// Upsert an inventory row for a file that just landed on a server. Called from
|
||||
// every writer (beta open, beta update, promote, rollback) so the DB tracks
|
||||
// exactly what's on-disk in each server's sp/ directory.
|
||||
export function recordScript(
|
||||
server: string,
|
||||
relPath: string,
|
||||
scriptVersionId: number | null,
|
||||
sha256: string,
|
||||
size: number,
|
||||
source: string
|
||||
): void {
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO server_scripts (server, rel_path, script_version_id, sha256, size, updated_at, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(server, rel_path) DO UPDATE SET
|
||||
script_version_id = excluded.script_version_id,
|
||||
sha256 = excluded.sha256,
|
||||
size = excluded.size,
|
||||
updated_at = excluded.updated_at,
|
||||
source = excluded.source`
|
||||
)
|
||||
.run(server, relPath, scriptVersionId, sha256, size, new Date().toISOString(), source);
|
||||
}
|
||||
|
||||
export function removeScript(server: string, relPath: string): void {
|
||||
db().prepare('DELETE FROM server_scripts WHERE server = ? AND rel_path = ?').run(server, relPath);
|
||||
}
|
||||
|
||||
export function getServerScript(server: string, relPath: string): ServerScriptRow | undefined {
|
||||
return db()
|
||||
.prepare('SELECT * FROM server_scripts WHERE server = ? AND rel_path = ?')
|
||||
.get(server, relPath) as ServerScriptRow | undefined;
|
||||
}
|
||||
|
||||
export function listServerScripts(server: string): ServerScriptRow[] {
|
||||
return db()
|
||||
.prepare('SELECT * FROM server_scripts WHERE server = ? ORDER BY rel_path')
|
||||
.all(server) as ServerScriptRow[];
|
||||
}
|
||||
|
||||
export function listAllScripts(): ServerScriptRow[] {
|
||||
return db()
|
||||
.prepare('SELECT * FROM server_scripts ORDER BY server, rel_path')
|
||||
.all() as ServerScriptRow[];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { resolveLogPath, tailLog } from './logs.js';
|
||||
|
||||
// Match markers for T5 Plutonium's `logs/games_sp.log`. Verified against a live
|
||||
// sample: each game session is bracketed by `InitGame:` and `ShutdownGame:`.
|
||||
// A REAL completed match always emits `ExitLevel: executed` right before
|
||||
// `ShutdownGame:`. Idle map cycles print `InitGame:` / `ShutdownGame:` at
|
||||
// 0:00 with no ExitLevel — those don't count as a played match.
|
||||
//
|
||||
// So the reliable discriminator is `ExitLevel: executed`: seeing that line
|
||||
// after `healthy_at` means a game actually ended normally (no crash — a crash
|
||||
// would kill the container before ExitLevel fires and take RCON with it).
|
||||
export const MATCH_START = /InitGame:/;
|
||||
export const MATCH_END = /ExitLevel:\s*executed/;
|
||||
|
||||
/**
|
||||
* Poll a server's games_sp.log tail for match markers. Returns whether a
|
||||
* completed match end has been observed since `sinceIso` (the server's
|
||||
* `healthy_at` timestamp).
|
||||
*/
|
||||
export async function scanForMatchEnd(
|
||||
serverName: string,
|
||||
sinceIso: string
|
||||
): Promise<{ ended: boolean; started: boolean; reason?: string }> {
|
||||
const path = resolveLogPath(serverName, 'games_sp');
|
||||
if (!path) return { ended: false, started: false, reason: 'log path resolution failed' };
|
||||
|
||||
const tail = await tailLog(path, 2000);
|
||||
if (!tail) return { ended: false, started: false, reason: 'log not written yet' };
|
||||
if (tail.mtime < sinceIso) return { ended: false, started: false, reason: 'no log activity since healthy' };
|
||||
|
||||
let started = false;
|
||||
let ended = false;
|
||||
for (const line of tail.lines) {
|
||||
if (MATCH_START.test(line)) started = true;
|
||||
if (MATCH_END.test(line)) ended = true;
|
||||
}
|
||||
return { ended, started };
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
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,235 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
export type ScopeType = 'game' | 'map';
|
||||
|
||||
export interface ScriptRow {
|
||||
id: number;
|
||||
name: string;
|
||||
target_rel_path: string;
|
||||
scope_type: ScopeType | null;
|
||||
scope_value: string | null;
|
||||
current_version_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ScriptVersionRow {
|
||||
id: number;
|
||||
script_id: number;
|
||||
version: number;
|
||||
sha256: string;
|
||||
size: number;
|
||||
content: Buffer;
|
||||
notes: string | null;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
// The script name is the primary user-facing identifier; slugify to derive a
|
||||
// URL-safe form. Same rules as the old beta slugify — lowercase, letters + digits.
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export class ScriptError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly kind:
|
||||
| 'name-empty'
|
||||
| 'name-taken'
|
||||
| 'path-empty'
|
||||
| 'no-such-script'
|
||||
| 'no-such-version'
|
||||
| 'scope-requires-beta'
|
||||
| 'invalid-scope'
|
||||
| 'in-use-current'
|
||||
| 'in-use-beta'
|
||||
| 'has-scope'
|
||||
| 'has-active-beta'
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(buf: Buffer): string {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
export function getScript(id: number): ScriptRow | undefined {
|
||||
return db().prepare('SELECT * FROM scripts WHERE id = ?').get(id) as ScriptRow | undefined;
|
||||
}
|
||||
|
||||
export function getScriptByName(name: string): ScriptRow | undefined {
|
||||
return db().prepare('SELECT * FROM scripts WHERE name = ?').get(name) as ScriptRow | undefined;
|
||||
}
|
||||
|
||||
export function listScripts(): ScriptRow[] {
|
||||
return db().prepare('SELECT * FROM scripts ORDER BY name').all() as ScriptRow[];
|
||||
}
|
||||
|
||||
export function listVersions(scriptId: number): ScriptVersionRow[] {
|
||||
return db()
|
||||
.prepare('SELECT * FROM script_versions WHERE script_id = ? ORDER BY version DESC')
|
||||
.all(scriptId) as ScriptVersionRow[];
|
||||
}
|
||||
|
||||
export function getVersion(id: number): ScriptVersionRow | undefined {
|
||||
return db().prepare('SELECT * FROM script_versions WHERE id = ?').get(id) as
|
||||
| ScriptVersionRow
|
||||
| undefined;
|
||||
}
|
||||
|
||||
// Latest version is 'max(version)' — used everywhere we want the freshest
|
||||
// upload, whether or not it has been promoted (current_version_id).
|
||||
export function getLatestVersion(scriptId: number): ScriptVersionRow | undefined {
|
||||
return db()
|
||||
.prepare('SELECT * FROM script_versions WHERE script_id = ? ORDER BY version DESC LIMIT 1')
|
||||
.get(scriptId) as ScriptVersionRow | undefined;
|
||||
}
|
||||
|
||||
export interface CreateScriptInput {
|
||||
name: string;
|
||||
targetRelPath: string;
|
||||
scopeType?: ScopeType | null;
|
||||
scopeValue?: string | null;
|
||||
content: Buffer;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Insert script + version 1 in one transaction. Scope may be set at creation
|
||||
// because the script has no current_version_id yet (nothing to gate).
|
||||
export function createScript(input: CreateScriptInput): { script: ScriptRow; version: ScriptVersionRow } {
|
||||
if (!input.name.trim()) throw new ScriptError('script name is required', 'name-empty');
|
||||
if (!input.targetRelPath.trim()) throw new ScriptError('target rel path is required', 'path-empty');
|
||||
validateScope(input.scopeType ?? null, input.scopeValue ?? null);
|
||||
if (getScriptByName(input.name)) throw new ScriptError(`script "${input.name}" already exists`, 'name-taken');
|
||||
|
||||
const sha = sha256(input.content);
|
||||
const size = input.content.byteLength;
|
||||
|
||||
const insertScript = db().prepare(
|
||||
`INSERT INTO scripts (name, target_rel_path, scope_type, scope_value)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
const insertVersion = db().prepare(
|
||||
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes)
|
||||
VALUES (?, 1, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
let scriptId!: number;
|
||||
let versionId!: number;
|
||||
db().transaction(() => {
|
||||
const s = insertScript.run(input.name, input.targetRelPath, input.scopeType ?? null, input.scopeValue ?? null);
|
||||
scriptId = Number(s.lastInsertRowid);
|
||||
const v = insertVersion.run(scriptId, sha, size, input.content, input.notes ?? null);
|
||||
versionId = Number(v.lastInsertRowid);
|
||||
})();
|
||||
|
||||
return {
|
||||
script: getScript(scriptId)!,
|
||||
version: getVersion(versionId)!
|
||||
};
|
||||
}
|
||||
|
||||
export function uploadNewVersion(scriptId: number, content: Buffer, notes?: string): ScriptVersionRow {
|
||||
const script = getScript(scriptId);
|
||||
if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script');
|
||||
|
||||
const latest = getLatestVersion(scriptId);
|
||||
const nextVersion = (latest?.version ?? 0) + 1;
|
||||
const sha = sha256(content);
|
||||
|
||||
const res = db()
|
||||
.prepare(
|
||||
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(scriptId, nextVersion, sha, content.byteLength, content, notes ?? null);
|
||||
return getVersion(Number(res.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
function validateScope(scopeType: ScopeType | null, scopeValue: string | null): void {
|
||||
if (scopeType === null && scopeValue === null) return;
|
||||
if (scopeType === 'game' || scopeType === 'map') {
|
||||
if (!scopeValue || !scopeValue.trim()) {
|
||||
throw new ScriptError(`scope_value required for scope_type=${scopeType}`, 'invalid-scope');
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new ScriptError(`unknown scope_type: ${scopeType}`, 'invalid-scope');
|
||||
}
|
||||
|
||||
export interface SetScopeInput {
|
||||
scriptId: number;
|
||||
scopeType: ScopeType | null;
|
||||
scopeValue: string | null;
|
||||
}
|
||||
|
||||
// Setting or changing a scope while the script already has a promoted version
|
||||
// requires a beta first (per plan §"Beta trigger"). Un-scoping is free.
|
||||
export function setScope(input: SetScopeInput): ScriptRow {
|
||||
const script = getScript(input.scriptId);
|
||||
if (!script) throw new ScriptError(`no such script (id=${input.scriptId})`, 'no-such-script');
|
||||
validateScope(input.scopeType, input.scopeValue);
|
||||
|
||||
const changed =
|
||||
input.scopeType !== script.scope_type || (input.scopeValue ?? null) !== (script.scope_value ?? null);
|
||||
const isSetOrChange = input.scopeType !== null && changed;
|
||||
|
||||
if (isSetOrChange && script.current_version_id !== null) {
|
||||
throw new ScriptError(
|
||||
'setting or changing scope requires opening a beta first',
|
||||
'scope-requires-beta'
|
||||
);
|
||||
}
|
||||
|
||||
db()
|
||||
.prepare('UPDATE scripts SET scope_type = ?, scope_value = ? WHERE id = ?')
|
||||
.run(input.scopeType, input.scopeValue, input.scriptId);
|
||||
return getScript(input.scriptId)!;
|
||||
}
|
||||
|
||||
// Version delete is metadata-only: it never touches server disks. But deleting
|
||||
// the current version or a version referenced by a running beta would break
|
||||
// downstream state, so those are blocked.
|
||||
export function deleteVersion(versionId: number): void {
|
||||
const version = getVersion(versionId);
|
||||
if (!version) throw new ScriptError(`no such version (id=${versionId})`, 'no-such-version');
|
||||
|
||||
const script = getScript(version.script_id)!;
|
||||
if (script.current_version_id === versionId) {
|
||||
throw new ScriptError('cannot delete the current version', 'in-use-current');
|
||||
}
|
||||
|
||||
const beta = db()
|
||||
.prepare(`SELECT id FROM betas WHERE script_version_id = ? AND state = 'running'`)
|
||||
.get(versionId) as { id: number } | undefined;
|
||||
if (beta) throw new ScriptError(`beta ${beta.id} is still running against this version`, 'in-use-beta');
|
||||
|
||||
db().prepare('DELETE FROM script_versions WHERE id = ?').run(versionId);
|
||||
}
|
||||
|
||||
export function deleteScript(scriptId: number): void {
|
||||
const script = getScript(scriptId);
|
||||
if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script');
|
||||
if (script.scope_type !== null) {
|
||||
throw new ScriptError('un-set scope before deleting a script', 'has-scope');
|
||||
}
|
||||
const beta = db()
|
||||
.prepare(`SELECT id FROM betas WHERE script_id = ? AND state = 'running'`)
|
||||
.get(scriptId) as { id: number } | undefined;
|
||||
if (beta) throw new ScriptError(`beta ${beta.id} is still running against this script`, 'has-active-beta');
|
||||
db().prepare('DELETE FROM scripts WHERE id = ?').run(scriptId);
|
||||
}
|
||||
|
||||
// Servers that match a script's scope. Empty for unscoped scripts — those are
|
||||
// only ever pushed to explicitly chosen beta targets.
|
||||
export function serversMatchingScope(scopeType: ScopeType | null, scopeValue: string | null): string[] {
|
||||
if (scopeType === null || scopeValue === null) return [];
|
||||
const rows = db()
|
||||
.prepare(`SELECT name FROM servers WHERE ${scopeType} = ? ORDER BY name`)
|
||||
.all(scopeValue) as Array<{ name: string }>;
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ 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.
|
||||
* All server folders under SERVERS_ROOT. Used by seedServerRegistry to backfill
|
||||
* the servers table for any on-disk server directory that isn't yet tracked.
|
||||
*/
|
||||
export async function allServers(): Promise<string[]> {
|
||||
let entries;
|
||||
@@ -22,8 +22,7 @@ export async function allServers(): Promise<string[]> {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `?` and comma-separated lists).
|
||||
*/
|
||||
export function matchServers(all: string[], patterns: string[]): string[] {
|
||||
if (patterns.length === 0 || (patterns.length === 1 && patterns[0] === '*')) {
|
||||
@@ -36,10 +35,7 @@ export function matchServers(all: string[], patterns: string[]): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Ensure a server has an sp/ destination. Creates it when create=true.
|
||||
*/
|
||||
export async function ensureDest(
|
||||
server: string,
|
||||
@@ -60,7 +56,7 @@ export async function ensureDest(
|
||||
return { ok: true, path: dest };
|
||||
}
|
||||
|
||||
// -- server registry (was .servers map in script_metadata.json) --
|
||||
// -- server registry --
|
||||
|
||||
export interface ServerRow {
|
||||
name: string;
|
||||
@@ -69,22 +65,19 @@ export interface ServerRow {
|
||||
komodo_server: string | null;
|
||||
game_port: number | null;
|
||||
rcon_host: string | null;
|
||||
game: string | null;
|
||||
map: string | null;
|
||||
last_players: number | null;
|
||||
last_checked_at: string | null;
|
||||
last_map: string | null;
|
||||
last_gametype: string | null;
|
||||
last_roster: string | null; // JSON array of RconPlayer, or null
|
||||
last_seen_at: string | null; // last successful RCON reply
|
||||
last_error: string | null; // cleared on success
|
||||
last_reply_snippet: string | null; // hex+ASCII of last bad-reply payload
|
||||
last_roster: string | null;
|
||||
last_seen_at: string | null;
|
||||
last_error: string | null;
|
||||
last_reply_snippet: 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(
|
||||
@@ -114,13 +107,14 @@ export function setServerField(
|
||||
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',
|
||||
'game_port',
|
||||
'rcon_host',
|
||||
'game',
|
||||
'map',
|
||||
'last_players',
|
||||
'last_checked_at',
|
||||
'last_map',
|
||||
@@ -135,37 +129,38 @@ export function setServerField(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Which target rel_paths on this server are currently held by a running beta?
|
||||
* Returns Map<relPath, betaId>. A target held by beta X blocks other betas AND
|
||||
* blocks unrelated writes to that same target on that server.
|
||||
*/
|
||||
export function heldByBeta(server: string): Map<string, string> {
|
||||
export function heldByBeta(server: string): Map<string, number> {
|
||||
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 = ?`
|
||||
`SELECT s.target_rel_path AS target, b.id AS beta_id
|
||||
FROM beta_servers bs
|
||||
JOIN betas b ON b.id = bs.beta_id
|
||||
JOIN scripts s ON s.id = b.script_id
|
||||
WHERE bs.server = ? AND b.state = 'running'`
|
||||
)
|
||||
.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);
|
||||
.all(server) as Array<{ target: string; beta_id: number }>;
|
||||
const map = new Map<string, number>();
|
||||
for (const r of rows) map.set(r.target, r.beta_id);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which beta (if any) owns this target on this server?
|
||||
* Mirrors `owner_of` in push_scripts.sh:366.
|
||||
* Which running beta (if any) owns a specific target rel_path on a server?
|
||||
*/
|
||||
export function ownerOf(server: string, target: string): string | null {
|
||||
export function ownerOf(server: string, target: string): number | 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 = ?
|
||||
`SELECT b.id AS beta_id
|
||||
FROM beta_servers bs
|
||||
JOIN betas b ON b.id = bs.beta_id
|
||||
JOIN scripts s ON s.id = b.script_id
|
||||
WHERE bs.server = ? AND s.target_rel_path = ? AND b.state = 'running'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(server, target) as { slug: string } | undefined;
|
||||
return row?.slug ?? null;
|
||||
.get(server, target) as { beta_id: number } | undefined;
|
||||
return row?.beta_id ?? null;
|
||||
}
|
||||
|
||||
@@ -251,6 +251,103 @@ export async function komodoResolveGamePort(serverName: string): Promise<number
|
||||
return result.ok ? result.port : null;
|
||||
}
|
||||
|
||||
// ---------- Health probe ----------
|
||||
|
||||
export type KomodoHealthResult =
|
||||
| { ok: true; state: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
// Walks a Komodo read response looking for a container state string, matching
|
||||
// the shapes we've seen: docker-inspect ({ State: { Status, Running } }),
|
||||
// GetDeployment ({ state } / { status: { state } }), and GetStack similar.
|
||||
function extractContainerState(payload: unknown): string | null {
|
||||
const seen = new Set<unknown>();
|
||||
let found: string | null = null;
|
||||
const walk = (v: unknown): void => {
|
||||
if (found || v === null || typeof v !== 'object' || seen.has(v)) return;
|
||||
seen.add(v);
|
||||
if (Array.isArray(v)) {
|
||||
for (const item of v) walk(item);
|
||||
return;
|
||||
}
|
||||
const obj = v as Record<string, unknown>;
|
||||
// Prefer the explicit docker inspect shape.
|
||||
const s = obj['State'];
|
||||
if (s && typeof s === 'object') {
|
||||
const status = (s as Record<string, unknown>)['Status'];
|
||||
const running = (s as Record<string, unknown>)['Running'];
|
||||
if (typeof status === 'string') {
|
||||
found = status;
|
||||
return;
|
||||
}
|
||||
if (running === true) {
|
||||
found = 'running';
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typeof obj['state'] === 'string') {
|
||||
found = obj['state'] as string;
|
||||
return;
|
||||
}
|
||||
for (const val of Object.values(obj)) walk(val);
|
||||
};
|
||||
walk(payload);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask Komodo whether the server's container is currently running. Used by the
|
||||
* beta runner during the `awaiting-health` stage after a restart. A truthy
|
||||
* `ok` doesn't imply the game is fully ready — the runner also gates on RCON
|
||||
* before advancing.
|
||||
*/
|
||||
export async function komodoInspectHealthy(serverName: string): Promise<KomodoHealthResult> {
|
||||
if (!komodoConfigured()) return { ok: false, reason: 'komodo not configured' };
|
||||
const row = getServer(serverName);
|
||||
if (!row?.komodo_name) return { ok: false, reason: 'no komodo_name for server' };
|
||||
|
||||
const type = row.komodo_type ?? 'deployment';
|
||||
let path: string;
|
||||
let body: Record<string, string>;
|
||||
switch (type) {
|
||||
case 'stack':
|
||||
path = 'read/GetStack';
|
||||
body = { stack: row.komodo_name };
|
||||
break;
|
||||
case 'container':
|
||||
if (!row.komodo_server) return { ok: false, reason: 'container type needs komodo_server' };
|
||||
path = 'read/InspectContainer';
|
||||
body = { server: row.komodo_server, container: row.komodo_name };
|
||||
break;
|
||||
case 'deployment':
|
||||
default:
|
||||
path = 'read/GetDeployment';
|
||||
body = { deployment: row.komodo_name };
|
||||
break;
|
||||
}
|
||||
|
||||
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await komodoCall(path, body);
|
||||
} catch (err) {
|
||||
return { ok: false, reason: describeFetchError(err, url) };
|
||||
}
|
||||
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `unparseable json: ${(err as Error).message}` };
|
||||
}
|
||||
const state = extractContainerState(json);
|
||||
if (!state) return { ok: false, reason: 'no container state found in response' };
|
||||
const healthy = state.toLowerCase() === 'running' || state.toLowerCase() === 'healthy';
|
||||
if (!healthy) return { ok: false, reason: `container state: ${state}` };
|
||||
return { ok: true, state };
|
||||
}
|
||||
|
||||
// ---------- Extractor ----------
|
||||
|
||||
// Property names Komodo/Docker use for the collection of port mappings.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { emit } from './bus.js';
|
||||
import type { BetaStage } from './bus.js';
|
||||
import { komodoConfigured, rconConfigured } from '../config.js';
|
||||
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
|
||||
import { komodoRestart, komodoInspectHealthy } from '../integrations/komodo.js';
|
||||
import { scanForMatchEnd } from '../fleet/match-log.js';
|
||||
|
||||
interface BetaServerActiveRow {
|
||||
beta_id: number;
|
||||
server: string;
|
||||
stage: BetaStage;
|
||||
healthy_at: string | null;
|
||||
wait_for_health: number;
|
||||
wait_for_match: number;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function integrationsOn(): boolean {
|
||||
return komodoConfigured() && rconConfigured();
|
||||
}
|
||||
|
||||
const setStage = () =>
|
||||
db().prepare(
|
||||
`UPDATE beta_servers
|
||||
SET stage = ?, error = ?, updated_at = ?,
|
||||
restarted_at = CASE WHEN ? = 'awaiting-health' OR ? = 'ready' THEN COALESCE(restarted_at, ?) ELSE restarted_at END,
|
||||
healthy_at = CASE WHEN ? = 'awaiting-match' OR (? = 'ready' AND healthy_at IS NULL) THEN COALESCE(healthy_at, ?) ELSE healthy_at END,
|
||||
match_seen_at = CASE WHEN ? = 'ready' AND match_seen_at IS NULL THEN ? ELSE match_seen_at END
|
||||
WHERE beta_id = ? AND server = ?`
|
||||
);
|
||||
|
||||
function transition(
|
||||
betaId: number,
|
||||
server: string,
|
||||
stage: BetaStage,
|
||||
error: string | null = null
|
||||
): void {
|
||||
const t = nowIso();
|
||||
setStage().run(
|
||||
stage,
|
||||
error,
|
||||
t,
|
||||
stage,
|
||||
stage,
|
||||
t,
|
||||
stage,
|
||||
stage,
|
||||
t,
|
||||
stage,
|
||||
t,
|
||||
betaId,
|
||||
server
|
||||
);
|
||||
emit({ type: 'beta-server-stage', betaId, server, stage, error });
|
||||
}
|
||||
|
||||
// Bump beta.state to ready-promote when every one of its beta_servers is ready.
|
||||
// A single failed row is enough to keep the beta in 'running' (operator retries).
|
||||
function reconcileBetaState(betaId: number): void {
|
||||
const rows = db()
|
||||
.prepare(`SELECT stage FROM beta_servers WHERE beta_id = ?`)
|
||||
.all(betaId) as Array<{ stage: BetaStage }>;
|
||||
if (rows.length === 0) return;
|
||||
if (rows.every((r) => r.stage === 'ready')) {
|
||||
db()
|
||||
.prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ? AND state = 'running'`)
|
||||
.run(betaId);
|
||||
emit({ type: 'beta-state', betaId, state: 'ready-promote' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function betaTick(): Promise<{ processed: number }> {
|
||||
if (!integrationsOn()) return { processed: 0 };
|
||||
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT bs.beta_id AS beta_id, bs.server AS server, bs.stage AS stage,
|
||||
bs.healthy_at AS healthy_at,
|
||||
b.wait_for_health AS wait_for_health, b.wait_for_match AS wait_for_match
|
||||
FROM beta_servers bs
|
||||
JOIN betas b ON b.id = bs.beta_id
|
||||
WHERE b.state = 'running'
|
||||
AND bs.stage IN ('awaiting-empty','restarting','awaiting-health','awaiting-match')`
|
||||
)
|
||||
.all() as BetaServerActiveRow[];
|
||||
|
||||
if (rows.length === 0) return { processed: 0 };
|
||||
|
||||
const touchedBetas = new Set<number>();
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await advance(row);
|
||||
} catch (err) {
|
||||
transition(row.beta_id, row.server, 'failed', `runner: ${(err as Error).message}`);
|
||||
}
|
||||
touchedBetas.add(row.beta_id);
|
||||
}
|
||||
for (const id of touchedBetas) reconcileBetaState(id);
|
||||
|
||||
return { processed: rows.length };
|
||||
}
|
||||
|
||||
async function advance(row: BetaServerActiveRow): Promise<void> {
|
||||
switch (row.stage) {
|
||||
case 'awaiting-empty': {
|
||||
const players = await rconGetPlayerCount(row.server);
|
||||
if (players === null) return; // hold — RCON unreachable, try next tick
|
||||
if (players > 0) return; // hold — still populated
|
||||
transition(row.beta_id, row.server, 'restarting');
|
||||
// Fall through to restart on the same tick for lower latency.
|
||||
await advance({ ...row, stage: 'restarting' });
|
||||
return;
|
||||
}
|
||||
case 'restarting': {
|
||||
const r = await komodoRestart(row.server);
|
||||
if (!r.ok) {
|
||||
transition(row.beta_id, row.server, 'failed', `komodo: ${r.message}`);
|
||||
return;
|
||||
}
|
||||
const next = row.wait_for_health ? 'awaiting-health' : row.wait_for_match ? 'awaiting-match' : 'ready';
|
||||
transition(row.beta_id, row.server, next);
|
||||
return;
|
||||
}
|
||||
case 'awaiting-health': {
|
||||
const h = await komodoInspectHealthy(row.server);
|
||||
if (!h.ok) return; // hold — try next tick
|
||||
const rcon = await rconStatusResult(row.server);
|
||||
if (!rcon.ok) return; // container reports up but game not answering yet
|
||||
const next = row.wait_for_match ? 'awaiting-match' : 'ready';
|
||||
transition(row.beta_id, row.server, next);
|
||||
return;
|
||||
}
|
||||
case 'awaiting-match': {
|
||||
const since = row.healthy_at ?? nowIso();
|
||||
const result = await scanForMatchEnd(row.server, since);
|
||||
if (!result.ended) return;
|
||||
transition(row.beta_id, row.server, 'ready');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,17 @@ export interface ServerStatusEntry {
|
||||
replySnippet: string | null;
|
||||
}
|
||||
|
||||
export type BetaStage =
|
||||
| 'deploying'
|
||||
| 'awaiting-empty'
|
||||
| 'restarting'
|
||||
| 'awaiting-health'
|
||||
| 'awaiting-match'
|
||||
| 'ready'
|
||||
| 'failed';
|
||||
|
||||
export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||
|
||||
export type PipelineEvent =
|
||||
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
|
||||
| { type: 'server-changed'; deploymentId: string; server: string; files: number }
|
||||
@@ -32,7 +43,16 @@ export type PipelineEvent =
|
||||
error: string | null;
|
||||
}
|
||||
| { type: 'sweep-tick'; frozen: boolean; reason?: string }
|
||||
| { type: 'server-status'; entries: ServerStatusEntry[] };
|
||||
| { type: 'server-status'; entries: ServerStatusEntry[] }
|
||||
| { type: 'beta-opened'; betaId: number; scriptId: number; servers: string[] }
|
||||
| {
|
||||
type: 'beta-server-stage';
|
||||
betaId: number;
|
||||
server: string;
|
||||
stage: BetaStage;
|
||||
error: string | null;
|
||||
}
|
||||
| { type: 'beta-state'; betaId: number; state: BetaState };
|
||||
|
||||
class Bus extends EventEmitter {
|
||||
constructor() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sweep } from './sweep.js';
|
||||
import { betaTick } from './beta-runner.js';
|
||||
import { emit } from './bus.js';
|
||||
import type { ServerStatusEntry } from './bus.js';
|
||||
import { rconStatusResult } from '../integrations/rcon.js';
|
||||
@@ -22,6 +23,11 @@ async function tick(): Promise<void> {
|
||||
} catch (err) {
|
||||
console.error('[worker] sweep error:', err);
|
||||
}
|
||||
try {
|
||||
await betaTick();
|
||||
} catch (err) {
|
||||
console.error('[worker] beta-runner error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface StagedHint {
|
||||
base: string;
|
||||
hits: string[];
|
||||
}
|
||||
interface BetaSummary {
|
||||
slug: string;
|
||||
name: string;
|
||||
opened_at: string;
|
||||
updated_at: string;
|
||||
revision: number;
|
||||
servers: string[];
|
||||
scripts: Array<{ staged_as: string; target: string; revision: number }>;
|
||||
}
|
||||
interface Props {
|
||||
staged: StagedHint[];
|
||||
betas: BetaSummary[];
|
||||
}
|
||||
let { staged, betas }: Props = $props();
|
||||
|
||||
let newBetaName = $state('');
|
||||
let newBetaPatterns = $state('*');
|
||||
let submitting = $state(false);
|
||||
let flash = $state<string | null>(null);
|
||||
|
||||
async function openNewBeta() {
|
||||
if (!newBetaName.trim() || submitting) return;
|
||||
submitting = true;
|
||||
flash = null;
|
||||
const res = await fetch('/api/betas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newBetaName.trim(),
|
||||
patterns: newBetaPatterns.split(',').map((p) => p.trim()).filter(Boolean),
|
||||
create: false
|
||||
})
|
||||
});
|
||||
submitting = false;
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
newBetaName = '';
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function act(slug: string, action: 'rollback' | 'promote' | 'update') {
|
||||
const path =
|
||||
action === 'update' ? '/api/betas' : `/api/betas/${slug}/${action}`;
|
||||
const method = action === 'update' ? 'PATCH' : 'POST';
|
||||
const body = action === 'update' ? JSON.stringify({ name: slug }) : '{}';
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
flash = err.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h3>Staged</h3>
|
||||
{#if staged.length === 0}
|
||||
<p class="empty">nothing staged</p>
|
||||
{:else}
|
||||
<ul class="staged">
|
||||
{#each staged as s (s.base)}
|
||||
<li>
|
||||
<code>{s.base}</code>
|
||||
{#if s.hits.length === 0}
|
||||
<span class="tag new">new</span>
|
||||
{:else if s.hits.length === 1}
|
||||
<span class="tag replaces">replaces {s.hits[0]}</span>
|
||||
{:else}
|
||||
<span class="tag ambig">ambiguous ({s.hits.length} hits)</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); openNewBeta(); }}>
|
||||
<input type="text" placeholder="Beta name" bind:value={newBetaName} disabled={submitting} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Server pattern (*, t5_kino_*, comma-sep)"
|
||||
bind:value={newBetaPatterns}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<button type="submit" disabled={submitting || !newBetaName}>open beta</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<h3>Open betas</h3>
|
||||
{#if betas.length === 0}
|
||||
<p class="empty">no open betas</p>
|
||||
{:else}
|
||||
{#each betas as b (b.slug)}
|
||||
<div class="beta">
|
||||
<div class="head">
|
||||
<strong>{b.name}</strong>
|
||||
<span class="rev">rev {b.revision}</span>
|
||||
</div>
|
||||
<div class="row"><span class="k">scripts</span> {b.scripts.map((s) => s.target).join(', ')}</div>
|
||||
<div class="row"><span class="k">servers</span> {b.servers.join(', ')}</div>
|
||||
<div class="actions">
|
||||
<button onclick={() => act(b.slug, 'update')}>update</button>
|
||||
<button class="primary" onclick={() => act(b.slug, 'promote')}>promote</button>
|
||||
<button class="danger" onclick={() => act(b.slug, 'rollback')}>rollback</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if flash}
|
||||
<p class="flash">{flash}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: var(--pad-panel);
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--ink-300);
|
||||
}
|
||||
h3:not(:first-of-type) { margin-top: var(--gap-section); }
|
||||
.empty {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
margin: 0;
|
||||
}
|
||||
.staged { list-style: none; padding: 0; margin: 0 0 18px; }
|
||||
.staged li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.staged code {
|
||||
color: var(--cream);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
.tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.tag.new {
|
||||
background: rgba(124, 212, 255, 0.10);
|
||||
border-color: rgba(124, 212, 255, 0.3);
|
||||
color: var(--spark-500);
|
||||
}
|
||||
.tag.replaces {
|
||||
background: rgba(124, 192, 245, 0.10);
|
||||
border-color: rgba(124, 192, 245, 0.28);
|
||||
color: var(--cream);
|
||||
}
|
||||
.tag.ambig {
|
||||
background: rgba(255, 48, 80, 0.1);
|
||||
border-color: rgba(255, 48, 80, 0.3);
|
||||
color: var(--danger-500);
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
form input {
|
||||
flex: 1;
|
||||
background: var(--bg-page);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
}
|
||||
button {
|
||||
background: var(--bg-card);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
}
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
button.primary {
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
border-color: var(--blue-400);
|
||||
color: var(--cream);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.danger {
|
||||
background: var(--bg-nav);
|
||||
border-color: rgba(255, 48, 80, 0.4);
|
||||
color: var(--danger-500);
|
||||
}
|
||||
.beta {
|
||||
padding: 16px 18px;
|
||||
margin: 10px 0;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.head strong { color: var(--cream); font-weight: 600; font-size: var(--text-md); }
|
||||
.rev {
|
||||
color: var(--ink-500);
|
||||
font-size: var(--text-mono-sm);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.row {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-300);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.k {
|
||||
color: var(--ink-500);
|
||||
margin-right: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; margin-top: 14px; }
|
||||
.flash {
|
||||
color: var(--danger-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
margin: 18px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
interface Held {
|
||||
target: string;
|
||||
slug: string;
|
||||
betaId: number;
|
||||
}
|
||||
interface Props {
|
||||
name: string;
|
||||
@@ -96,7 +96,7 @@
|
||||
{#if held.length > 0}
|
||||
<div class="held">
|
||||
{#each held as h (h.target)}
|
||||
<span class="chip" title="held by beta: {h.slug}">β {h.target}</span>
|
||||
<span class="chip" title="held by beta {h.betaId}">β {h.target}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
</a>
|
||||
<div class="tabs">
|
||||
<a href="/" class:on={$page.url.pathname === '/'}>Fleet</a>
|
||||
<a href="/scripts" class:on={$page.url.pathname.startsWith('/scripts')}>Scripts</a>
|
||||
<a href="/deployments" class:on={$page.url.pathname.startsWith('/deployments')}>Deployments</a>
|
||||
<a href="/betas" class:on={$page.url.pathname.startsWith('/betas')}>Betas</a>
|
||||
</div>
|
||||
|
||||
+9
-62
@@ -5,7 +5,7 @@
|
||||
import { statusByServer } from '$ui/stores/servers.js';
|
||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||
|
||||
interface HeldRow { target: string; slug: string; }
|
||||
interface HeldRow { target: string; betaId: number; }
|
||||
interface ServerRow {
|
||||
name: string;
|
||||
komodo_type: string;
|
||||
@@ -24,8 +24,6 @@
|
||||
}
|
||||
|
||||
let servers = $state<ServerRow[]>([]);
|
||||
let pushing = $state(false);
|
||||
let flash = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const res = await fetch('/api/servers');
|
||||
@@ -33,25 +31,6 @@
|
||||
servers = body.servers;
|
||||
});
|
||||
|
||||
async function push(dryRun: boolean) {
|
||||
pushing = true;
|
||||
flash = null;
|
||||
const res = await fetch('/api/push', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ patterns: ['*'], dryRun, create: false, delete: false })
|
||||
});
|
||||
pushing = false;
|
||||
const body = (await res.json()) as { totalChangedServers?: number; totalFiles?: number; deploymentId?: string | null };
|
||||
if (!res.ok) {
|
||||
flash = `push failed: HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
flash = dryRun
|
||||
? `dry run: ${body.totalChangedServers ?? 0} servers would change, ${body.totalFiles ?? 0} files`
|
||||
: `pushed to ${body.totalChangedServers ?? 0} servers · ${body.totalFiles ?? 0} files`;
|
||||
}
|
||||
|
||||
// Live server view — merge SSE data over the initial GET
|
||||
function liveFor(name: string, fallback: ServerRow) {
|
||||
const live = $statusByServer.get(name);
|
||||
@@ -85,12 +64,8 @@
|
||||
<h1>Fleet</h1>
|
||||
<span class="count">{servers.length} server{servers.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick={() => push(true)} disabled={pushing}>dry run</button>
|
||||
<button class="primary" onclick={() => push(false)} disabled={pushing}>push all</button>
|
||||
</div>
|
||||
<a class="scripts-link" href="/scripts">manage scripts →</a>
|
||||
</header>
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
{#if servers.length === 0}
|
||||
<p class="empty">no servers registered yet — check SERVERS_ROOT</p>
|
||||
{:else}
|
||||
@@ -150,35 +125,17 @@
|
||||
color: var(--ink-500);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.actions { display: flex; gap: 10px; }
|
||||
button {
|
||||
background: var(--bg-card);
|
||||
color: var(--ink-100);
|
||||
.scripts-link {
|
||||
color: var(--spark-400);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
}
|
||||
button.primary {
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
color: var(--cream);
|
||||
border-color: var(--blue-400);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover:not(:disabled) {
|
||||
border-color: var(--spark-500);
|
||||
box-shadow: var(--spark-glow), 0 0 20px rgba(46, 134, 209, 0.45);
|
||||
}
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
.scripts-link:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: var(--gap-cards);
|
||||
@@ -193,14 +150,4 @@
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.flash {
|
||||
color: var(--spark-400);
|
||||
margin: 0 0 20px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
padding: 12px 16px;
|
||||
background: rgba(124, 212, 255, 0.06);
|
||||
border: 1px solid rgba(124, 212, 255, 0.25);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,55 +1,73 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { openBeta, updateBeta, listBetas, listBetaScripts, listBetaServers, BetaError } from '$lib/server/fleet/beta.js';
|
||||
import { stagedFiles, findTargets } from '$lib/server/fleet/files.js';
|
||||
import {
|
||||
listBetas,
|
||||
listBetaServers,
|
||||
openBeta,
|
||||
BetaError,
|
||||
type BetaTriggerKind
|
||||
} from '$lib/server/fleet/beta.js';
|
||||
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export async function GET() {
|
||||
const staged = await stagedFiles();
|
||||
const withHints = await Promise.all(
|
||||
staged.map(async (base) => ({
|
||||
base,
|
||||
hits: await findTargets(base)
|
||||
}))
|
||||
);
|
||||
const betas = listBetas().map((b) => ({
|
||||
...b,
|
||||
servers: listBetaServers(b.slug),
|
||||
scripts: listBetaScripts(b.slug)
|
||||
}));
|
||||
return json({ staged: withHints, betas });
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
const stateFilter = url.searchParams.get('state') as
|
||||
| 'running'
|
||||
| 'ready-promote'
|
||||
| 'promoted'
|
||||
| 'rolled-back'
|
||||
| null;
|
||||
const rows = listBetas(stateFilter ?? undefined).map((b) => {
|
||||
const script = getScript(b.script_id);
|
||||
const version = getVersion(b.script_version_id);
|
||||
return {
|
||||
...b,
|
||||
script: script ? { id: script.id, name: script.name, target_rel_path: script.target_rel_path } : null,
|
||||
version: version ? { id: version.id, version: version.version, sha256: version.sha256 } : null,
|
||||
servers: listBetaServers(b.id)
|
||||
};
|
||||
});
|
||||
return json({ betas: rows });
|
||||
}
|
||||
|
||||
// POST body: { scriptId, scriptVersionId, servers[], waitForHealth, waitForMatch, triggerKind, notes? }
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
name?: string;
|
||||
patterns?: string[];
|
||||
dryRun?: boolean;
|
||||
create?: boolean;
|
||||
scriptId?: number;
|
||||
scriptVersionId?: number;
|
||||
servers?: string[];
|
||||
waitForHealth?: boolean;
|
||||
waitForMatch?: boolean;
|
||||
triggerKind?: BetaTriggerKind;
|
||||
notes?: string;
|
||||
};
|
||||
if (!body.name) return json({ ok: false, error: 'name is required' }, { status: 400 });
|
||||
try {
|
||||
const res = await openBeta({
|
||||
name: body.name,
|
||||
patterns: body.patterns ?? ['*'],
|
||||
dryRun: body.dryRun ?? false,
|
||||
create: body.create ?? false
|
||||
});
|
||||
if (!res.dryRun) void kick();
|
||||
return json({ ok: true, result: res });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH({ request }: { request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as { name?: string; dryRun?: boolean };
|
||||
if (!body.name) return json({ ok: false, error: 'name is required' }, { status: 400 });
|
||||
if (
|
||||
typeof body.scriptId !== 'number' ||
|
||||
typeof body.scriptVersionId !== 'number' ||
|
||||
!Array.isArray(body.servers)
|
||||
) {
|
||||
return json(
|
||||
{ ok: false, error: 'scriptId, scriptVersionId, and servers[] are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (body.servers.length === 0) {
|
||||
return json({ ok: false, error: 'at least one server is required' }, { status: 400 });
|
||||
}
|
||||
const triggerKind: BetaTriggerKind = body.triggerKind ?? 'unscoped';
|
||||
|
||||
try {
|
||||
const res = await updateBeta(body.name, body.dryRun ?? false);
|
||||
return json({ ok: true, result: res });
|
||||
const result = await openBeta({
|
||||
scriptId: body.scriptId,
|
||||
scriptVersionId: body.scriptVersionId,
|
||||
servers: body.servers,
|
||||
waitForHealth: body.waitForHealth ?? true,
|
||||
waitForMatch: body.waitForMatch ?? false,
|
||||
triggerKind,
|
||||
notes: body.notes
|
||||
});
|
||||
void kick();
|
||||
return json({ ok: true, result });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getBeta, listBetaServers } from '$lib/server/fleet/beta.js';
|
||||
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function GET({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const beta = getBeta(id);
|
||||
if (!beta) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
const script = getScript(beta.script_id);
|
||||
const version = getVersion(beta.script_version_id);
|
||||
const servers = listBetaServers(id);
|
||||
|
||||
return json({ beta, script, version, servers });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { promoteBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function POST({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
try {
|
||||
const result = await promoteBeta(id);
|
||||
return json({ ok: true, result });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { emit, type BetaStage } from '$lib/server/pipeline/bus.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// Reset one failed beta_server row back to its natural retry point. The runner
|
||||
// picks it up on the next tick. Body: { server: string }.
|
||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||
const betaId = parseId(params.id);
|
||||
if (betaId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const body = (await request.json().catch(() => ({}))) as { server?: string };
|
||||
if (!body.server) return json({ ok: false, error: 'server is required' }, { status: 400 });
|
||||
|
||||
const row = db()
|
||||
.prepare('SELECT stage, restarted_at, healthy_at FROM beta_servers WHERE beta_id = ? AND server = ?')
|
||||
.get(betaId, body.server) as
|
||||
| { stage: string; restarted_at: string | null; healthy_at: string | null }
|
||||
| undefined;
|
||||
if (!row) return json({ ok: false, error: 'no such beta_server' }, { status: 404 });
|
||||
if (row.stage !== 'failed') return json({ ok: false, error: `stage is ${row.stage}, not failed` }, { status: 409 });
|
||||
|
||||
// Best-effort resume point: if we already got past restart, go back to
|
||||
// health check; otherwise back to waiting for the server to be empty.
|
||||
const nextStage: BetaStage = row.restarted_at ? 'awaiting-health' : 'awaiting-empty';
|
||||
const now = new Date().toISOString();
|
||||
db()
|
||||
.prepare('UPDATE beta_servers SET stage = ?, error = NULL, updated_at = ? WHERE beta_id = ? AND server = ?')
|
||||
.run(nextStage, now, betaId, body.server);
|
||||
emit({ type: 'beta-server-stage', betaId, server: body.server, stage: nextStage, error: null });
|
||||
return json({ ok: true, stage: nextStage });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { rollbackBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function POST({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
try {
|
||||
const result = await rollbackBeta(id);
|
||||
return json({ ok: true, result });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { promoteBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export async function POST({ params, request }: { params: { slug: string }; request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as { dryRun?: boolean };
|
||||
try {
|
||||
const res = await promoteBeta(params.slug, body.dryRun ?? false);
|
||||
if (!res.dryRun) void kick();
|
||||
return json({ ok: true, result: res });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { rollbackBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export async function POST({ params, request }: { params: { slug: string }; request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as { dryRun?: boolean };
|
||||
try {
|
||||
// callers pass name; slug in URL is the slugified name — either works
|
||||
const res = await rollbackBeta(params.slug, body.dryRun ?? false);
|
||||
if (!res.dryRun) void kick();
|
||||
return json({ ok: true, result: res });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { doPush } from '$lib/server/fleet/push.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
patterns?: string[];
|
||||
dryRun?: boolean;
|
||||
delete?: boolean;
|
||||
create?: boolean;
|
||||
};
|
||||
const result = await doPush({
|
||||
patterns: body.patterns ?? ['*'],
|
||||
dryRun: body.dryRun ?? false,
|
||||
delete: body.delete ?? false,
|
||||
create: body.create ?? false,
|
||||
triggeredBy: 'ui'
|
||||
});
|
||||
if (result.deploymentId) void kick();
|
||||
return json(result);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
createScript,
|
||||
listScripts,
|
||||
listVersions,
|
||||
serversMatchingScope,
|
||||
ScriptError,
|
||||
type ScopeType
|
||||
} from '$lib/server/fleet/scripts.js';
|
||||
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function scopeTypeOrNull(v: FormDataEntryValue | null): ScopeType | null {
|
||||
if (v === null) return null;
|
||||
const s = String(v);
|
||||
if (s === '' || s === 'none') return null;
|
||||
if (s === 'game' || s === 'map') return s;
|
||||
throw new ScriptError(`invalid scope_type: ${s}`, 'invalid-scope');
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const scripts = listScripts().map((s) => {
|
||||
const versions = listVersions(s.id);
|
||||
const matched = s.scope_type && s.scope_value
|
||||
? serversMatchingScope(s.scope_type as ScopeType, s.scope_value)
|
||||
: [];
|
||||
const runningBeta = findRunningBetaForScript(s.id);
|
||||
return {
|
||||
...s,
|
||||
latestVersion: versions[0]
|
||||
? {
|
||||
id: versions[0].id,
|
||||
version: versions[0].version,
|
||||
sha256: versions[0].sha256,
|
||||
size: versions[0].size,
|
||||
uploaded_at: versions[0].uploaded_at
|
||||
}
|
||||
: null,
|
||||
versionCount: versions.length,
|
||||
servers: matched,
|
||||
runningBetaId: runningBeta?.id ?? null
|
||||
};
|
||||
});
|
||||
return json({ scripts });
|
||||
}
|
||||
|
||||
// Multipart POST — creates a script + version 1 from a single file upload.
|
||||
// Fields: name (text), targetRelPath (text), scopeType (optional: 'game'|'map'),
|
||||
// scopeValue (optional string), notes (optional), content (file).
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const targetRelPath = String(form.get('targetRelPath') ?? '').trim();
|
||||
const notes = form.get('notes') ? String(form.get('notes')) : undefined;
|
||||
let scopeType: ScopeType | null;
|
||||
try {
|
||||
scopeType = scopeTypeOrNull(form.get('scopeType'));
|
||||
} catch (err) {
|
||||
return json({ ok: false, error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
const scopeValueRaw = form.get('scopeValue');
|
||||
const scopeValue = scopeValueRaw ? String(scopeValueRaw).trim() : null;
|
||||
const file = form.get('content');
|
||||
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
|
||||
|
||||
const content = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
try {
|
||||
const { script, version } = createScript({
|
||||
name,
|
||||
targetRelPath,
|
||||
scopeType,
|
||||
scopeValue,
|
||||
content,
|
||||
notes
|
||||
});
|
||||
return json({ ok: true, script, versionId: version.id });
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
getScript,
|
||||
listVersions,
|
||||
deleteScript,
|
||||
serversMatchingScope,
|
||||
ScriptError,
|
||||
type ScopeType
|
||||
} from '$lib/server/fleet/scripts.js';
|
||||
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function parseId(idRaw: string): number | null {
|
||||
const n = Number.parseInt(idRaw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function GET({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const script = getScript(id);
|
||||
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
const versions = listVersions(id).map((v) => ({
|
||||
id: v.id,
|
||||
version: v.version,
|
||||
sha256: v.sha256,
|
||||
size: v.size,
|
||||
notes: v.notes,
|
||||
uploaded_at: v.uploaded_at
|
||||
}));
|
||||
const matched = script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type as ScopeType, script.scope_value)
|
||||
: [];
|
||||
const runningBeta = findRunningBetaForScript(id);
|
||||
|
||||
return json({
|
||||
script,
|
||||
versions,
|
||||
matchedServers: matched,
|
||||
runningBetaId: runningBeta?.id ?? null
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
try {
|
||||
deleteScript(id);
|
||||
return json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getScript, setScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
|
||||
import { unsetScopeAndCleanup } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// POST body: { scopeType: 'game' | 'map' | null, scopeValue?: string }
|
||||
// Setting/changing scope on a script with a current_version_id returns a
|
||||
// 409 with kind='scope-requires-beta'; unsetting cleans the file off matching
|
||||
// servers.
|
||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||
const scriptId = parseId(params.id);
|
||||
if (scriptId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const script = getScript(scriptId);
|
||||
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
scopeType?: ScopeType | null;
|
||||
scopeValue?: string | null;
|
||||
};
|
||||
|
||||
const scopeType = body.scopeType ?? null;
|
||||
const scopeValue = body.scopeValue ?? null;
|
||||
|
||||
try {
|
||||
if (scopeType === null) {
|
||||
const cleaned = await unsetScopeAndCleanup(scriptId);
|
||||
return json({ ok: true, cleaned });
|
||||
}
|
||||
const updated = setScope({ scriptId, scopeType, scopeValue });
|
||||
return json({ ok: true, script: updated });
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js';
|
||||
|
||||
function parseId(idRaw: string): number | null {
|
||||
const n = Number.parseInt(idRaw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// Multipart POST — creates a new version from an uploaded file. Fields:
|
||||
// content (file), notes (optional). Response includes a "betaRequired" hint
|
||||
// when the script has a scope so the UI can nudge the operator to open a beta.
|
||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||
const scriptId = parseId(params.id);
|
||||
if (scriptId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const script = getScript(scriptId);
|
||||
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
const form = await request.formData();
|
||||
const notes = form.get('notes') ? String(form.get('notes')) : undefined;
|
||||
const file = form.get('content');
|
||||
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
|
||||
|
||||
const content = Buffer.from(await file.arrayBuffer());
|
||||
try {
|
||||
const version = uploadNewVersion(scriptId, content, notes);
|
||||
return json({
|
||||
ok: true,
|
||||
version: {
|
||||
id: version.id,
|
||||
version: version.version,
|
||||
sha256: version.sha256,
|
||||
size: version.size,
|
||||
notes: version.notes,
|
||||
uploaded_at: version.uploaded_at
|
||||
},
|
||||
betaRequired: script.scope_type !== null
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { deleteVersion, ScriptError } from '$lib/server/fleet/scripts.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function DELETE({ params }: { params: { id: string; versionId: string } }) {
|
||||
const versionId = parseId(params.versionId);
|
||||
if (versionId === null) return json({ ok: false, error: 'bad version id' }, { status: 400 });
|
||||
try {
|
||||
deleteVersion(versionId);
|
||||
return json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export async function GET() {
|
||||
// Derived endpoint — same logic as endpointFor() but without asking
|
||||
// Komodo. If we don't have a port yet, endpoint is null.
|
||||
endpoint: s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
|
||||
held: [...heldByBeta(s.name).entries()].map(([target, slug]) => ({ target, slug }))
|
||||
held: [...heldByBeta(s.name).entries()].map(([target, betaId]) => ({ target, betaId }))
|
||||
}));
|
||||
return json({
|
||||
servers,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServer } from '$lib/server/fleet/servers.js';
|
||||
import { listServerScripts } from '$lib/server/fleet/inventory.js';
|
||||
|
||||
export async function GET({ params }: { params: { name: string } }) {
|
||||
const server = getServer(params.name);
|
||||
if (!server) return json({ ok: false, error: 'no such server' }, { status: 404 });
|
||||
const scripts = listServerScripts(params.name).map((r) => ({
|
||||
relPath: r.rel_path,
|
||||
scriptVersionId: r.script_version_id,
|
||||
sha256: r.sha256,
|
||||
size: r.size,
|
||||
updatedAt: r.updated_at,
|
||||
source: r.source
|
||||
}));
|
||||
return json({ scripts });
|
||||
}
|
||||
@@ -1,43 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import BetaPanel from '$ui/components/BetaPanel.svelte';
|
||||
|
||||
let staged = $state<Array<{ base: string; hits: string[] }>>([]);
|
||||
let betas = $state<Array<{
|
||||
slug: string; name: string; opened_at: string; updated_at: string; revision: number;
|
||||
servers: string[]; scripts: Array<{ staged_as: string; target: string; revision: number }>
|
||||
}>>([]);
|
||||
interface BetaListRow {
|
||||
id: number;
|
||||
script_id: number;
|
||||
script_version_id: number;
|
||||
trigger_kind: string;
|
||||
wait_for_health: number;
|
||||
wait_for_match: number;
|
||||
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||
opened_at: string;
|
||||
closed_at: string | null;
|
||||
script: { id: number; name: string; target_rel_path: string } | null;
|
||||
version: { id: number; version: number; sha256: string } | null;
|
||||
servers: Array<{ server: string; stage: string; error: string | null }>;
|
||||
}
|
||||
|
||||
let betas = $state<BetaListRow[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
async function load(): Promise<void> {
|
||||
const res = await fetch('/api/betas');
|
||||
const body = (await res.json()) as { staged: typeof staged; betas: typeof betas };
|
||||
staged = body.staged;
|
||||
const body = (await res.json()) as { betas: BetaListRow[] };
|
||||
betas = body.betas;
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
onMount(load);
|
||||
|
||||
const stateOrder = ['running', 'ready-promote', 'promoted', 'rolled-back'] as const;
|
||||
let grouped = $derived(
|
||||
stateOrder.map((state) => ({ state, rows: betas.filter((b) => b.state === state) }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<h1>Betas</h1>
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">loading…</p>
|
||||
{:else if betas.length === 0}
|
||||
<p class="empty">no betas yet — open one from a script's page</p>
|
||||
{:else}
|
||||
<BetaPanel {staged} {betas} />
|
||||
{#each grouped as g (g.state)}
|
||||
{#if g.rows.length > 0}
|
||||
<section class="panel">
|
||||
<h2>{g.state.replace('-', ' ')} · {g.rows.length}</h2>
|
||||
<div class="rows">
|
||||
{#each g.rows as b (b.id)}
|
||||
<a class="row" href="/betas/{b.id}">
|
||||
<span class="id">#{b.id}</span>
|
||||
<span class="script">{b.script?.name ?? '(deleted)'}</span>
|
||||
<span class="mono target">{b.script?.target_rel_path ?? ''}</span>
|
||||
<span class="mono v">v{b.version?.version ?? '?'}</span>
|
||||
<span class="tag state-{b.state}">{b.state}</span>
|
||||
<span class="servers mono">{b.servers.length} server{b.servers.length === 1 ? '' : 's'}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
color: var(--cream);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-3xl);
|
||||
letter-spacing: var(--tracking-display);
|
||||
line-height: var(--leading-tight);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
h1 { color: var(--cream); font-family: var(--font-display); font-size: var(--text-3xl); letter-spacing: var(--tracking-display); margin: 0 0 28px; }
|
||||
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-base); }
|
||||
.panel { background: var(--bg-nav); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--pad-panel); margin-bottom: 20px; }
|
||||
h2 { margin: 0 0 14px; font-family: var(--font-body); font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-300); }
|
||||
.rows { display: flex; flex-direction: column; gap: 6px; }
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 220px 1fr 80px 130px 120px;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-100);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-mono);
|
||||
align-items: center;
|
||||
}
|
||||
.row:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
|
||||
.id { color: var(--ink-500); font-family: var(--font-mono); }
|
||||
.script { color: var(--cream); font-weight: 600; font-family: var(--font-body); }
|
||||
.target { color: var(--ink-300); font-family: var(--font-mono); word-break: break-all; }
|
||||
.v { color: var(--ink-500); }
|
||||
.servers { color: var(--ink-500); }
|
||||
.tag { padding: 3px 10px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 1px; border: 1px solid transparent; text-align: center; }
|
||||
.state-running { background: rgba(255, 176, 32, 0.15); border-color: rgba(255, 176, 32, 0.4); color: var(--amber-500); }
|
||||
.state-ready-promote { background: rgba(124, 212, 255, 0.15); border-color: rgba(124, 212, 255, 0.4); color: var(--spark-500); }
|
||||
.state-promoted { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.28); color: var(--cream); }
|
||||
.state-rolled-back { background: var(--bg-nav); border-color: var(--border); color: var(--ink-500); }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface BetaRow {
|
||||
id: number;
|
||||
script_id: number;
|
||||
script_version_id: number;
|
||||
trigger_kind: string;
|
||||
wait_for_health: number;
|
||||
wait_for_match: number;
|
||||
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||
opened_at: string;
|
||||
closed_at: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
interface BetaServerRow {
|
||||
beta_id: number;
|
||||
server: string;
|
||||
had_original: number;
|
||||
prior_version_id: number | null;
|
||||
stage: string;
|
||||
error: string | null;
|
||||
deployed_at: string | null;
|
||||
restarted_at: string | null;
|
||||
healthy_at: string | null;
|
||||
match_seen_at: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
interface Script { id: number; name: string; target_rel_path: string }
|
||||
interface Version { id: number; version: number; sha256: string; size: number }
|
||||
|
||||
let beta = $state<BetaRow | null>(null);
|
||||
let script = $state<Script | null>(null);
|
||||
let version = $state<Version | null>(null);
|
||||
let servers = $state<BetaServerRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
let acting = $state<string | null>(null);
|
||||
|
||||
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const res = await fetch(`/api/betas/${id}`);
|
||||
if (!res.ok) { loading = false; return; }
|
||||
const body = (await res.json()) as {
|
||||
beta: BetaRow;
|
||||
script: Script;
|
||||
version: Version;
|
||||
servers: BetaServerRow[];
|
||||
};
|
||||
beta = body.beta;
|
||||
script = body.script;
|
||||
version = body.version;
|
||||
servers = body.servers;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => {
|
||||
load();
|
||||
poll = setInterval(load, 5000);
|
||||
});
|
||||
onDestroy(() => { if (poll) clearInterval(poll); });
|
||||
|
||||
async function promote(): Promise<void> {
|
||||
acting = 'promote';
|
||||
flash = null;
|
||||
try {
|
||||
const res = await fetch(`/api/betas/${id}/promote`, { method: 'POST' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'promote failed'; return; }
|
||||
await load();
|
||||
} finally { acting = null; }
|
||||
}
|
||||
async function rollback(): Promise<void> {
|
||||
if (!confirm('Rollback this beta?')) return;
|
||||
acting = 'rollback';
|
||||
flash = null;
|
||||
try {
|
||||
const res = await fetch(`/api/betas/${id}/rollback`, { method: 'POST' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
|
||||
await load();
|
||||
} finally { acting = null; }
|
||||
}
|
||||
async function retry(server: string): Promise<void> {
|
||||
acting = `retry:${server}`;
|
||||
flash = null;
|
||||
try {
|
||||
const res = await fetch(`/api/betas/${id}/retry`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server })
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'retry failed'; return; }
|
||||
await load();
|
||||
} finally { acting = null; }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">loading…</p>
|
||||
{:else if !beta || !script || !version}
|
||||
<p class="empty">beta not found</p>
|
||||
{:else}
|
||||
<a class="back" href="/betas">← all betas</a>
|
||||
<div class="head-row">
|
||||
<h1>Beta #{beta.id}</h1>
|
||||
<span class="tag state-{beta.state}">{beta.state}</span>
|
||||
</div>
|
||||
<div class="sub">
|
||||
<a href="/scripts/{script.id}">{script.name}</a>
|
||||
<span class="mono">· {script.target_rel_path}</span>
|
||||
<span class="mono">· v{version.version}</span>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Options</h2>
|
||||
<div class="opts mono">
|
||||
<span>trigger: <strong>{beta.trigger_kind}</strong></span>
|
||||
<span>wait for health: <strong>{beta.wait_for_health ? 'yes' : 'no'}</strong></span>
|
||||
<span>wait for match: <strong>{beta.wait_for_match ? 'yes' : 'no'}</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Server progress</h2>
|
||||
<div class="rows">
|
||||
{#each servers as bs (bs.server)}
|
||||
<div class="srv-row srv-{bs.stage}">
|
||||
<span class="srv-name mono">{bs.server}</span>
|
||||
<span class="tag stage-{bs.stage}">{bs.stage}</span>
|
||||
<div class="ticks mono">
|
||||
<span class:on={bs.deployed_at}>deploy</span>
|
||||
<span class:on={bs.stage !== 'awaiting-empty' && bs.stage !== 'deploying'}>empty</span>
|
||||
<span class:on={bs.restarted_at}>restart</span>
|
||||
<span class:on={bs.healthy_at}>healthy</span>
|
||||
{#if beta.wait_for_match}
|
||||
<span class:on={bs.match_seen_at}>match</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if bs.error}
|
||||
<span class="err mono">{bs.error}</span>
|
||||
{/if}
|
||||
{#if bs.stage === 'failed'}
|
||||
<button class="mini" onclick={() => retry(bs.server)} disabled={acting === `retry:${bs.server}`}>
|
||||
{acting === `retry:${bs.server}` ? 'retrying…' : 'retry'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Actions</h2>
|
||||
<div class="action-row">
|
||||
<button
|
||||
class="primary"
|
||||
onclick={promote}
|
||||
disabled={beta.state !== 'ready-promote' || acting !== null}
|
||||
title={beta.state !== 'ready-promote' ? 'all servers must reach ready first' : ''}
|
||||
>
|
||||
{acting === 'promote' ? 'promoting…' : 'promote'}
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
onclick={rollback}
|
||||
disabled={beta.state === 'promoted' || beta.state === 'rolled-back' || acting !== null}
|
||||
>
|
||||
{acting === 'rollback' ? 'rolling back…' : 'rollback'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.back { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono-sm); text-decoration: none; }
|
||||
.back:hover { color: var(--ink-100); }
|
||||
.head-row { display: flex; align-items: center; gap: 12px; margin: 12px 0 4px; }
|
||||
h1 { color: var(--cream); font-family: var(--font-display); font-size: var(--text-2xl); letter-spacing: 1.5px; margin: 0; }
|
||||
.sub { color: var(--ink-500); font-size: var(--text-mono); margin-bottom: 24px; display: flex; gap: 8px; align-items: baseline; }
|
||||
.sub a { color: var(--spark-500); text-decoration: none; }
|
||||
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono); }
|
||||
|
||||
.panel { background: var(--bg-nav); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--pad-panel); margin-bottom: 20px; }
|
||||
h2 { margin: 0 0 14px; font-family: var(--font-body); font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-300); }
|
||||
|
||||
.tag { padding: 3px 10px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 1px; border: 1px solid transparent; }
|
||||
.state-running { background: rgba(255, 176, 32, 0.15); border-color: rgba(255, 176, 32, 0.4); color: var(--amber-500); }
|
||||
.state-ready-promote { background: rgba(124, 212, 255, 0.15); border-color: rgba(124, 212, 255, 0.4); color: var(--spark-500); }
|
||||
.state-promoted { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.28); color: var(--cream); }
|
||||
.state-rolled-back { background: var(--bg-card); border-color: var(--border); color: var(--ink-500); }
|
||||
|
||||
.opts { display: flex; gap: 24px; color: var(--ink-100); font-size: var(--text-mono); }
|
||||
.opts strong { color: var(--cream); }
|
||||
|
||||
.rows { display: flex; flex-direction: column; gap: 8px; }
|
||||
.srv-row { display: grid; grid-template-columns: 220px 130px 1fr auto auto; gap: 14px; padding: 12px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r-sm); align-items: center; }
|
||||
.srv-ready { border-left: 3px solid var(--spark-500); }
|
||||
.srv-failed { border-left: 3px solid var(--danger-500); }
|
||||
.srv-awaiting-empty, .srv-restarting, .srv-awaiting-health, .srv-awaiting-match, .srv-deploying { border-left: 3px solid var(--amber-500); }
|
||||
|
||||
.srv-name { color: var(--cream); font-size: var(--text-md); }
|
||||
.stage-deploying, .stage-awaiting-empty, .stage-restarting, .stage-awaiting-health, .stage-awaiting-match { background: rgba(255, 176, 32, 0.10); border-color: rgba(255, 176, 32, 0.3); color: var(--amber-500); }
|
||||
.stage-ready { background: rgba(124, 212, 255, 0.15); border-color: rgba(124, 212, 255, 0.4); color: var(--spark-500); }
|
||||
.stage-failed { background: rgba(255, 48, 80, 0.15); border-color: rgba(255, 48, 80, 0.4); color: var(--danger-500); }
|
||||
.ticks { display: flex; gap: 14px; font-size: var(--text-mono-sm); color: var(--ink-500); }
|
||||
.ticks span { position: relative; padding-left: 14px; }
|
||||
.ticks span::before { content: '○'; position: absolute; left: 0; color: var(--ink-500); }
|
||||
.ticks span.on { color: var(--cream); }
|
||||
.ticks span.on::before { content: '●'; color: var(--spark-500); }
|
||||
.err { color: var(--danger-500); font-size: var(--text-mono-sm); word-break: break-word; }
|
||||
.mini { background: var(--bg-nav); color: var(--cream); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 6px 12px; font-family: var(--font-body); font-size: var(--text-xs); cursor: pointer; }
|
||||
|
||||
.action-row { display: flex; gap: 12px; }
|
||||
.primary { background: linear-gradient(180deg, var(--blue-500), var(--blue-600)); border: 1px solid var(--blue-400); color: var(--cream); border-radius: var(--r-sm); padding: 12px 20px; font-family: var(--font-body); font-weight: 600; cursor: pointer; }
|
||||
.primary:disabled { opacity: 0.4; cursor: default; }
|
||||
.danger { background: var(--bg-nav); border: 1px solid rgba(255, 48, 80, 0.4); color: var(--danger-500); border-radius: var(--r-sm); padding: 12px 20px; font-family: var(--font-body); cursor: pointer; }
|
||||
.danger:disabled { opacity: 0.4; cursor: default; }
|
||||
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface ScriptRow {
|
||||
id: number;
|
||||
name: string;
|
||||
target_rel_path: string;
|
||||
scope_type: 'game' | 'map' | null;
|
||||
scope_value: string | null;
|
||||
current_version_id: number | null;
|
||||
latestVersion: {
|
||||
id: number;
|
||||
version: number;
|
||||
sha256: string;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
} | null;
|
||||
versionCount: number;
|
||||
servers: string[];
|
||||
runningBetaId: number | null;
|
||||
}
|
||||
|
||||
let scripts = $state<ScriptRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
let uploading = $state(false);
|
||||
|
||||
// Form state for creating a new script.
|
||||
let newName = $state('');
|
||||
let newTargetRelPath = $state('');
|
||||
let newScopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let newScopeValue = $state('');
|
||||
let newFile = $state<File | null>(null);
|
||||
let newNotes = $state('');
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const res = await fetch('/api/scripts');
|
||||
const body = (await res.json()) as { scripts: ScriptRow[] };
|
||||
scripts = body.scripts;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function pickFile(e: Event): void {
|
||||
const t = e.target as HTMLInputElement;
|
||||
newFile = t.files?.[0] ?? null;
|
||||
if (!newTargetRelPath && newFile) newTargetRelPath = newFile.name;
|
||||
if (!newName && newFile) newName = newFile.name.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
async function createScript(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (uploading || !newFile || !newName.trim() || !newTargetRelPath.trim()) return;
|
||||
uploading = true;
|
||||
flash = null;
|
||||
const form = new FormData();
|
||||
form.append('name', newName.trim());
|
||||
form.append('targetRelPath', newTargetRelPath.trim());
|
||||
form.append('scopeType', newScopeType);
|
||||
if (newScopeValue.trim()) form.append('scopeValue', newScopeValue.trim());
|
||||
if (newNotes.trim()) form.append('notes', newNotes.trim());
|
||||
form.append('content', newFile);
|
||||
try {
|
||||
const res = await fetch('/api/scripts', { method: 'POST', body: form });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
newName = '';
|
||||
newTargetRelPath = '';
|
||||
newScopeType = 'none';
|
||||
newScopeValue = '';
|
||||
newNotes = '';
|
||||
newFile = null;
|
||||
await load();
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(s: ScriptRow): string {
|
||||
if (!s.scope_type) return 'unscoped';
|
||||
return `${s.scope_type}=${s.scope_value ?? '?'}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Scripts</h1>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Upload a script</h2>
|
||||
<form onsubmit={createScript} class="upload-form">
|
||||
<label>
|
||||
<span class="k">name</span>
|
||||
<input type="text" bind:value={newName} placeholder="hud tweaks" disabled={uploading} />
|
||||
</label>
|
||||
<label>
|
||||
<span class="k">target rel path</span>
|
||||
<input type="text" bind:value={newTargetRelPath} placeholder="maps/mp_zom_theater/hud.gsc" disabled={uploading} />
|
||||
</label>
|
||||
<label>
|
||||
<span class="k">scope</span>
|
||||
<div class="scope-row">
|
||||
<select bind:value={newScopeType} disabled={uploading}>
|
||||
<option value="none">unscoped</option>
|
||||
<option value="game">game</option>
|
||||
<option value="map">map</option>
|
||||
</select>
|
||||
{#if newScopeType !== 'none'}
|
||||
<input type="text" bind:value={newScopeValue} placeholder={newScopeType === 'game' ? 't5' : 'zombie_theater'} disabled={uploading} />
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span class="k">notes (optional)</span>
|
||||
<input type="text" bind:value={newNotes} placeholder="what changed" disabled={uploading} />
|
||||
</label>
|
||||
<label>
|
||||
<span class="k">file</span>
|
||||
<input type="file" onchange={pickFile} disabled={uploading} />
|
||||
</label>
|
||||
<button type="submit" class="primary" disabled={uploading || !newFile || !newName || !newTargetRelPath}>
|
||||
{uploading ? 'uploading…' : 'upload script'}
|
||||
</button>
|
||||
</form>
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>All scripts</h2>
|
||||
{#if loading}
|
||||
<p class="empty">loading…</p>
|
||||
{:else if scripts.length === 0}
|
||||
<p class="empty">no scripts yet — upload one above</p>
|
||||
{:else}
|
||||
<div class="scripts">
|
||||
{#each scripts as s (s.id)}
|
||||
<a class="script-row" href="/scripts/{s.id}">
|
||||
<div class="row-top">
|
||||
<span class="script-name">{s.name}</span>
|
||||
<span class="scope tag scope-{s.scope_type ?? 'none'}">{scopeLabel(s)}</span>
|
||||
{#if s.runningBetaId}
|
||||
<span class="tag beta">beta {s.runningBetaId} running</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="row-meta mono">
|
||||
<span>{s.target_rel_path}</span>
|
||||
<span>v{s.latestVersion?.version ?? '—'} · {s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span>
|
||||
<span>{s.servers.length ? `${s.servers.length} matching server${s.servers.length === 1 ? '' : 's'}` : ''}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
color: var(--cream);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-3xl);
|
||||
letter-spacing: var(--tracking-display);
|
||||
line-height: var(--leading-tight);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
.panel {
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: var(--pad-panel);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--ink-300);
|
||||
}
|
||||
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono); margin: 0; }
|
||||
|
||||
.upload-form { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
|
||||
.upload-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--text-xs); }
|
||||
.upload-form label .k { color: var(--ink-500); text-transform: uppercase; letter-spacing: 1px; }
|
||||
.upload-form input[type=text], .upload-form select, .upload-form input[type=file] {
|
||||
background: var(--bg-page);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
.upload-form input[type=text]:focus, .upload-form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
}
|
||||
.scope-row { display: flex; gap: 8px; }
|
||||
.scope-row select { flex: 0 0 120px; }
|
||||
.scope-row input { flex: 1; }
|
||||
.primary {
|
||||
grid-column: 1 / -1;
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
border: 1px solid var(--blue-400);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 12px 18px;
|
||||
color: var(--cream);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary:disabled { opacity: 0.4; cursor: default; }
|
||||
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
|
||||
|
||||
.scripts { display: flex; flex-direction: column; gap: 8px; }
|
||||
.script-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 14px 16px;
|
||||
text-decoration: none;
|
||||
color: var(--ink-100);
|
||||
}
|
||||
.script-row:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
|
||||
.row-top { display: flex; gap: 12px; align-items: center; }
|
||||
.script-name { color: var(--cream); font-weight: 600; font-size: var(--text-md); }
|
||||
.row-meta { display: flex; gap: 24px; color: var(--ink-500); font-size: var(--text-mono-sm); }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.scope-none { background: var(--bg-nav); border-color: var(--border); color: var(--ink-500); }
|
||||
.scope-game { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.3); color: var(--spark-500); }
|
||||
.scope-map { background: rgba(255, 176, 32, 0.10); border-color: rgba(255, 176, 32, 0.35); color: var(--amber-500); }
|
||||
.beta { background: rgba(255, 176, 32, 0.10); border-color: rgba(255, 176, 32, 0.3); color: var(--amber-500); }
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface Script {
|
||||
id: number;
|
||||
name: string;
|
||||
target_rel_path: string;
|
||||
scope_type: 'game' | 'map' | null;
|
||||
scope_value: string | null;
|
||||
current_version_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
interface Version {
|
||||
id: number;
|
||||
version: number;
|
||||
sha256: string;
|
||||
size: number;
|
||||
notes: string | null;
|
||||
uploaded_at: string;
|
||||
}
|
||||
interface ServerRow { name: string; game: string | null; map: string | null }
|
||||
|
||||
let script = $state<Script | null>(null);
|
||||
let versions = $state<Version[]>([]);
|
||||
let matchedServers = $state<string[]>([]);
|
||||
let runningBetaId = $state<number | null>(null);
|
||||
let servers = $state<ServerRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
|
||||
// Upload-new-version form.
|
||||
let versionFile = $state<File | null>(null);
|
||||
let versionNotes = $state('');
|
||||
let uploadingVersion = $state(false);
|
||||
|
||||
// Scope form.
|
||||
let scopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let scopeValue = $state('');
|
||||
let updatingScope = $state(false);
|
||||
|
||||
// Open-beta form.
|
||||
let betaVersionId = $state<number | null>(null);
|
||||
let betaServers = $state<Set<string>>(new Set());
|
||||
let waitForHealth = $state(true);
|
||||
let waitForMatch = $state(false);
|
||||
let openingBeta = $state(false);
|
||||
|
||||
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const [detailRes, serversRes] = await Promise.all([
|
||||
fetch(`/api/scripts/${id}`),
|
||||
fetch('/api/servers')
|
||||
]);
|
||||
if (!detailRes.ok) { loading = false; return; }
|
||||
const body = (await detailRes.json()) as {
|
||||
script: Script;
|
||||
versions: Version[];
|
||||
matchedServers: string[];
|
||||
runningBetaId: number | null;
|
||||
};
|
||||
script = body.script;
|
||||
versions = body.versions;
|
||||
matchedServers = body.matchedServers;
|
||||
runningBetaId = body.runningBetaId;
|
||||
scopeType = script.scope_type ?? 'none';
|
||||
scopeValue = script.scope_value ?? '';
|
||||
if (versions[0]) betaVersionId = versions[0].id;
|
||||
|
||||
if (serversRes.ok) {
|
||||
const s = (await serversRes.json()) as { servers: ServerRow[] };
|
||||
servers = s.servers;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
onMount(load);
|
||||
|
||||
function pickVersionFile(e: Event): void {
|
||||
const t = e.target as HTMLInputElement;
|
||||
versionFile = t.files?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function uploadVersion(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!versionFile || uploadingVersion) return;
|
||||
uploadingVersion = true;
|
||||
flash = null;
|
||||
const form = new FormData();
|
||||
form.append('content', versionFile);
|
||||
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
|
||||
try {
|
||||
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; betaRequired?: boolean };
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
versionFile = null;
|
||||
versionNotes = '';
|
||||
if (body.betaRequired) {
|
||||
flash = 'new version uploaded — open a beta below to deploy it';
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
uploadingVersion = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScope(): Promise<void> {
|
||||
if (updatingScope) return;
|
||||
updatingScope = true;
|
||||
flash = null;
|
||||
try {
|
||||
const res = await fetch(`/api/scripts/${id}/scope`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scopeType: scopeType === 'none' ? null : scopeType,
|
||||
scopeValue: scopeType === 'none' ? null : scopeValue.trim()
|
||||
})
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; kind?: string };
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
updatingScope = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBetaServer(name: string): void {
|
||||
const next = new Set(betaServers);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
betaServers = next;
|
||||
}
|
||||
|
||||
function candidateServers(): string[] {
|
||||
if (!script) return [];
|
||||
if (!script.scope_type) return servers.map((s) => s.name);
|
||||
return matchedServers;
|
||||
}
|
||||
|
||||
async function openBeta(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!script || betaVersionId === null || betaServers.size === 0 || openingBeta) return;
|
||||
openingBeta = true;
|
||||
flash = null;
|
||||
const triggerKind = !script.scope_type
|
||||
? 'unscoped'
|
||||
: script.current_version_id === null
|
||||
? 'mark-required'
|
||||
: 'version-update';
|
||||
try {
|
||||
const res = await fetch('/api/betas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scriptId: script.id,
|
||||
scriptVersionId: betaVersionId,
|
||||
servers: [...betaServers],
|
||||
waitForHealth,
|
||||
waitForMatch,
|
||||
triggerKind
|
||||
})
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
result?: { betaId: number };
|
||||
};
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
if (body.result) await goto(`/betas/${body.result.betaId}`);
|
||||
} finally {
|
||||
openingBeta = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteScript(): Promise<void> {
|
||||
if (!confirm('Delete this script?')) return;
|
||||
const res = await fetch(`/api/scripts/${id}`, { method: 'DELETE' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'delete failed'; return; }
|
||||
goto('/scripts');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">loading…</p>
|
||||
{:else if !script}
|
||||
<p class="empty">script not found</p>
|
||||
{:else}
|
||||
<a class="back" href="/scripts">← all scripts</a>
|
||||
<h1>{script.name}</h1>
|
||||
<div class="sub mono">{script.target_rel_path}</div>
|
||||
|
||||
{#if runningBetaId}
|
||||
<section class="panel banner">
|
||||
<p>
|
||||
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is currently running for this script.
|
||||
Rollback or promote it before opening a new one.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Scope</h2>
|
||||
<div class="scope-form">
|
||||
<select bind:value={scopeType} disabled={updatingScope}>
|
||||
<option value="none">unscoped</option>
|
||||
<option value="game">game</option>
|
||||
<option value="map">map</option>
|
||||
</select>
|
||||
{#if scopeType !== 'none'}
|
||||
<input type="text" bind:value={scopeValue} placeholder={scopeType === 'game' ? 't5' : 'zombie_theater'} disabled={updatingScope} />
|
||||
{/if}
|
||||
<button onclick={saveScope} disabled={updatingScope}>save scope</button>
|
||||
</div>
|
||||
{#if script.scope_type && matchedServers.length > 0}
|
||||
<p class="hint mono">applies to: {matchedServers.join(', ')}</p>
|
||||
{:else if script.scope_type}
|
||||
<p class="hint mono">no servers currently match this scope</p>
|
||||
{:else}
|
||||
<p class="hint mono">unscoped — deploy to specific servers via a beta below</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Upload new version</h2>
|
||||
<form onsubmit={uploadVersion} class="version-form">
|
||||
<input type="file" onchange={pickVersionFile} disabled={uploadingVersion} />
|
||||
<input type="text" bind:value={versionNotes} placeholder="notes (optional)" disabled={uploadingVersion} />
|
||||
<button type="submit" class="primary" disabled={uploadingVersion || !versionFile}>
|
||||
{uploadingVersion ? 'uploading…' : 'upload version'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Open beta with a version</h2>
|
||||
{#if runningBetaId}
|
||||
<p class="empty">a beta is already running for this script</p>
|
||||
{:else if versions.length === 0}
|
||||
<p class="empty">no versions yet — upload one above</p>
|
||||
{:else}
|
||||
<form onsubmit={openBeta} class="beta-form">
|
||||
<label>
|
||||
<span class="k">version</span>
|
||||
<select bind:value={betaVersionId}>
|
||||
{#each versions as v (v.id)}
|
||||
<option value={v.id}>v{v.version} · {v.sha256.slice(0, 8)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="k">test servers</span>
|
||||
<div class="server-picker">
|
||||
{#each candidateServers() as name (name)}
|
||||
<label class="chk">
|
||||
<input type="checkbox" checked={betaServers.has(name)} onchange={() => toggleBetaServer(name)} />
|
||||
{name}
|
||||
</label>
|
||||
{/each}
|
||||
{#if candidateServers().length === 0}
|
||||
<span class="empty">no candidate servers</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
<div class="opts">
|
||||
<label class="chk"><input type="checkbox" bind:checked={waitForHealth} /> wait for server start healthy</label>
|
||||
<label class="chk"><input type="checkbox" bind:checked={waitForMatch} /> wait for server match</label>
|
||||
</div>
|
||||
<button type="submit" class="primary" disabled={openingBeta || betaVersionId === null || betaServers.size === 0}>
|
||||
{openingBeta ? 'opening…' : 'open beta'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Version history</h2>
|
||||
{#if versions.length === 0}
|
||||
<p class="empty">none</p>
|
||||
{:else}
|
||||
<div class="versions">
|
||||
{#each versions as v (v.id)}
|
||||
<div class="version {script.current_version_id === v.id ? 'current' : ''}">
|
||||
<span class="v-num mono">v{v.version}</span>
|
||||
<span class="v-sha mono">{v.sha256.slice(0, 12)}</span>
|
||||
<span class="v-size mono">{v.size}B</span>
|
||||
<span class="v-notes">{v.notes ?? ''}</span>
|
||||
<span class="v-when mono">{v.uploaded_at}</span>
|
||||
{#if script.current_version_id === v.id}<span class="tag cur">current</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Danger</h2>
|
||||
<button class="danger" onclick={deleteScript}>delete script</button>
|
||||
</section>
|
||||
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.back { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono-sm); text-decoration: none; }
|
||||
.back:hover { color: var(--ink-100); }
|
||||
h1 { color: var(--cream); font-family: var(--font-display); font-size: var(--text-2xl); letter-spacing: 1.5px; margin: 12px 0 4px; }
|
||||
.sub { color: var(--ink-500); font-size: var(--text-mono); margin-bottom: 24px; }
|
||||
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono); margin: 0; }
|
||||
|
||||
.panel { background: var(--bg-nav); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--pad-panel); margin-bottom: 20px; }
|
||||
.banner { background: rgba(255, 176, 32, 0.08); border-color: rgba(255, 176, 32, 0.3); }
|
||||
.banner p { margin: 0; color: var(--cream-dim); font-family: var(--font-body); font-size: var(--text-sm); }
|
||||
.banner a { color: var(--amber-500); }
|
||||
h2 { margin: 0 0 14px; font-family: var(--font-body); font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-300); }
|
||||
|
||||
.scope-form { display: flex; gap: 10px; align-items: center; }
|
||||
.scope-form select, .scope-form input, .version-form input, .beta-form select {
|
||||
background: var(--bg-page);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
.scope-form button, .version-form .primary, .beta-form .primary {
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
border: 1px solid var(--blue-400);
|
||||
color: var(--cream);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-form button:disabled, .primary:disabled { opacity: 0.4; cursor: default; }
|
||||
.hint { color: var(--ink-500); font-size: var(--text-mono-sm); margin: 12px 0 0; }
|
||||
|
||||
.version-form { display: flex; gap: 10px; align-items: center; }
|
||||
.version-form input[type=text] { flex: 1; }
|
||||
|
||||
.beta-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.beta-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--text-xs); }
|
||||
.beta-form .k { color: var(--ink-500); text-transform: uppercase; letter-spacing: 1px; }
|
||||
.server-picker { display: flex; flex-wrap: wrap; gap: 10px; padding: 12px; background: var(--bg-page); border: 1px solid var(--border); border-radius: var(--r-sm); }
|
||||
.chk { color: var(--ink-100); font-family: var(--font-mono); font-size: var(--text-mono); display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.opts { display: flex; gap: 24px; }
|
||||
|
||||
.versions { display: flex; flex-direction: column; gap: 6px; }
|
||||
.version {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 130px 90px 1fr 200px auto;
|
||||
gap: 14px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
align-items: center;
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
.version.current { border-color: var(--spark-500); box-shadow: 0 0 0 1px var(--spark-500); }
|
||||
.v-num { color: var(--cream); font-weight: 600; }
|
||||
.v-sha, .v-size, .v-when { color: var(--ink-500); }
|
||||
.v-notes { color: var(--ink-300); font-family: var(--font-body); font-size: var(--text-sm); }
|
||||
.tag { padding: 2px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
.tag.cur { background: rgba(124, 212, 255, 0.15); border: 1px solid rgba(124, 212, 255, 0.4); color: var(--spark-500); }
|
||||
|
||||
.danger {
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid rgba(255, 48, 80, 0.4);
|
||||
color: var(--danger-500);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
</style>
|
||||
@@ -64,6 +64,8 @@
|
||||
komodo_server: string | null;
|
||||
game_port: number | null;
|
||||
rcon_host: string | null;
|
||||
game: string | null;
|
||||
map: string | null;
|
||||
last_players: number | null;
|
||||
last_checked_at: string | null;
|
||||
last_map: string | null;
|
||||
@@ -74,7 +76,15 @@
|
||||
health: Health;
|
||||
endpoint: RconEndpoint | null;
|
||||
replySnippet: string | null;
|
||||
held: Array<{ target: string; slug: string }>;
|
||||
held: Array<{ target: string; betaId: number }>;
|
||||
}
|
||||
interface InventoryEntry {
|
||||
relPath: string;
|
||||
scriptVersionId: number | null;
|
||||
sha256: string;
|
||||
size: number;
|
||||
updatedAt: string;
|
||||
source: string | null;
|
||||
}
|
||||
interface Integrations {
|
||||
rcon: boolean;
|
||||
@@ -93,6 +103,7 @@
|
||||
let server = $state<Server | null>(null);
|
||||
let integrations = $state<Integrations | null>(null);
|
||||
let history = $state<DeploymentRow[]>([]);
|
||||
let inventory = $state<InventoryEntry[]>([]);
|
||||
let name = $derived($page.params.name ?? '');
|
||||
let komodoProbe = $state<KomodoProbeResult | null>(null);
|
||||
let probing = $state(false);
|
||||
@@ -102,12 +113,20 @@
|
||||
let probingRcon = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
const [srvRes, depRes] = await Promise.all([fetch('/api/servers'), fetch('/api/deployments')]);
|
||||
const [srvRes, depRes, invRes] = await Promise.all([
|
||||
fetch('/api/servers'),
|
||||
fetch('/api/deployments'),
|
||||
fetch(`/api/servers/${encodeURIComponent(name)}/scripts`)
|
||||
]);
|
||||
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
|
||||
const depBody = (await depRes.json()) as { deployments: DeploymentRow[] };
|
||||
server = srvBody.servers.find((s) => s.name === name) ?? null;
|
||||
integrations = srvBody.integrations;
|
||||
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name));
|
||||
if (invRes.ok) {
|
||||
const invBody = (await invRes.json()) as { scripts: InventoryEntry[] };
|
||||
inventory = invBody.scripts;
|
||||
}
|
||||
});
|
||||
|
||||
let live = $derived($statusByServer.get(name));
|
||||
@@ -420,15 +439,41 @@
|
||||
<div class="k">game_port</div>
|
||||
<div class="v mono">{server.game_port ?? '(unresolved)'}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="k">game</div>
|
||||
<div class="v mono">{server.game ?? '(unset)'}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="k">map</div>
|
||||
<div class="v mono">{server.map ?? '(unset)'}</div>
|
||||
</div>
|
||||
{#if server.held.length > 0}
|
||||
<div class="stat">
|
||||
<div class="k">held by beta</div>
|
||||
<div class="v mono">{server.held.map((h) => h.target).join(', ')}</div>
|
||||
<div class="v mono">{server.held.map((h) => `${h.target} (beta ${h.betaId})`).join(', ')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Scripts on this server ({inventory.filter((f) => !f.relPath.endsWith('.OLD') && !/-beta\./.test(f.relPath)).length})</h2>
|
||||
{#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>
|
||||
{:else}
|
||||
<div class="inv-list">
|
||||
{#each inventory as f (f.relPath)}
|
||||
<div class="inv-row inv-{f.relPath.endsWith('.OLD') ? 'old' : /-beta\./.test(f.relPath) ? 'beta' : 'live'}">
|
||||
<span class="rel mono">{f.relPath}</span>
|
||||
<span class="size mono">{f.size}B</span>
|
||||
<span class="src mono">{f.source ?? '—'}</span>
|
||||
<span class="sha mono">{f.sha256.slice(0, 12)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Deployment history</h2>
|
||||
{#if history.length === 0}
|
||||
@@ -843,4 +888,22 @@
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.inv-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.inv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 90px 160px 140px;
|
||||
gap: 14px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--text-mono-sm);
|
||||
align-items: center;
|
||||
}
|
||||
.inv-row.inv-old { opacity: 0.55; border-left: 3px solid var(--ink-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 .size, .inv-row .src, .inv-row .sha { color: var(--ink-500); }
|
||||
</style>
|
||||
|
||||
+10
-26
@@ -1,56 +1,45 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface Fixture {
|
||||
root: string;
|
||||
shared: string;
|
||||
staging: string;
|
||||
t5: string;
|
||||
betaArchive: string;
|
||||
dbPath: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a zombies/ tree in a temp dir and point env at it BEFORE the code
|
||||
* under test is imported. Returns paths + a cleanup fn.
|
||||
* Build a servers-root tree in a temp dir and point env at it BEFORE the code
|
||||
* under test is imported. There's no shared/scripts directory anymore — the
|
||||
* upload-only model puts script content in the DB.
|
||||
*/
|
||||
export async function newFixture(files: Record<string, string> = {}, servers: string[] = ['t5_kino_1']): Promise<Fixture> {
|
||||
export async function newFixture(servers: string[] = ['t5_kino_1']): Promise<Fixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'cherry-'));
|
||||
const shared = join(root, 'shared/scripts');
|
||||
const staging = join(shared, 'betas');
|
||||
const t5 = join(root, 't5');
|
||||
const betaArchive = join(root, 'beta-archive');
|
||||
const dbPath = join(root, 'cherry.db');
|
||||
await mkdir(staging, { recursive: true });
|
||||
await mkdir(t5, { recursive: true });
|
||||
await mkdir(betaArchive, { recursive: true });
|
||||
|
||||
for (const [rel, body] of Object.entries(files)) {
|
||||
const p = join(shared, rel);
|
||||
await mkdir(join(p, '..'), { recursive: true });
|
||||
await writeFile(p, body);
|
||||
}
|
||||
for (const s of servers) {
|
||||
await mkdir(join(t5, s, 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true });
|
||||
}
|
||||
|
||||
process.env.SHARED_SCRIPTS = shared;
|
||||
process.env.SERVERS_ROOT = t5;
|
||||
process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp';
|
||||
process.env.BETA_ARCHIVE_DIR = betaArchive;
|
||||
process.env.DB_PATH = dbPath;
|
||||
process.env.DEPLOY_KEEP_DAYS = '7';
|
||||
process.env.HTTP_TIMEOUT_MS = '2000';
|
||||
|
||||
return {
|
||||
root,
|
||||
shared,
|
||||
staging,
|
||||
t5,
|
||||
betaArchive,
|
||||
dbPath,
|
||||
cleanup: async () => {
|
||||
// On Windows the SQLite handle keeps a lock on cherry.db-shm/wal
|
||||
// and rmdir refuses to remove the temp tree. Close the singleton
|
||||
// (if this test imported it) before removing the dir. Fresh
|
||||
// dynamic import so we grab whatever handle the test opened.
|
||||
try {
|
||||
const mod = (await import('../../src/lib/server/db/index.js')) as {
|
||||
closeDb: () => void;
|
||||
@@ -64,11 +53,6 @@ export async function newFixture(files: Record<string, string> = {}, servers: st
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh module-level state. Vitest caches modules per file — this forces a
|
||||
* re-import so the tests can rebuild the singleton DB handle against a new
|
||||
* fixture.
|
||||
*/
|
||||
export async function importFresh<T>(mod: string): Promise<T> {
|
||||
return (await import(`${mod}?t=${Date.now()}${Math.random()}`)) as T;
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture(
|
||||
{ 'hud.gsc': 'v1', 'zom.gsc': 'v1' },
|
||||
['t5_kino_1', 't5_ascension_1']
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
describe('beta lifecycle', () => {
|
||||
it('open → rollback restores original state', async () => {
|
||||
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||
const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
|
||||
// Populate servers with v1 first
|
||||
await doPush({ patterns: ['*'] });
|
||||
|
||||
// Stage a new hud
|
||||
await writeFile(join(fixture.staging, 'hud.gsc'), 'v2');
|
||||
|
||||
// Open beta on t5_kino_1 only
|
||||
const opened = await openBeta({ name: 'test1', patterns: ['t5_kino_1'] });
|
||||
expect(opened.slug).toBe('test1');
|
||||
expect(opened.servers).toEqual(['t5_kino_1']);
|
||||
|
||||
// On kino: hud-beta.gsc = v2, hud.OLD = v1, hud.gsc gone
|
||||
const kinoSp = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp');
|
||||
expect(await readFile(join(kinoSp, 'hud-beta.gsc'), 'utf8')).toBe('v2');
|
||||
expect(await readFile(join(kinoSp, 'hud.OLD'), 'utf8')).toBe('v1');
|
||||
expect(existsSync(join(kinoSp, 'hud.gsc'))).toBe(false);
|
||||
|
||||
// ascension untouched
|
||||
const ascSp = join(fixture.t5, 't5_ascension_1', 'runtime/plutonium/storage/t5/raw/scripts/sp');
|
||||
expect(await readFile(join(ascSp, 'hud.gsc'), 'utf8')).toBe('v1');
|
||||
|
||||
// Rollback
|
||||
const back = await rollbackBeta('test1');
|
||||
expect(back.restored).toContain('hud.gsc');
|
||||
expect(await readFile(join(kinoSp, 'hud.gsc'), 'utf8')).toBe('v1');
|
||||
expect(existsSync(join(kinoSp, 'hud-beta.gsc'))).toBe(false);
|
||||
expect(existsSync(join(kinoSp, 'hud.OLD'))).toBe(false);
|
||||
});
|
||||
|
||||
it('open → promote lands the beta on shared/scripts and clears beta files', async () => {
|
||||
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||
const { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
|
||||
await doPush({ patterns: ['*'] });
|
||||
await writeFile(join(fixture.staging, 'hud.gsc'), 'v2');
|
||||
await openBeta({ name: 'promote-me', patterns: ['t5_kino_1'] });
|
||||
|
||||
const kinoSp = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp');
|
||||
expect(existsSync(join(kinoSp, 'hud-beta.gsc'))).toBe(true);
|
||||
|
||||
await promoteBeta('promote-me');
|
||||
|
||||
// shared has v2 now
|
||||
expect(await readFile(join(fixture.shared, 'hud.gsc'), 'utf8')).toBe('v2');
|
||||
// kino has plain hud.gsc = v2, no OLD, no -beta
|
||||
expect(await readFile(join(kinoSp, 'hud.gsc'), 'utf8')).toBe('v2');
|
||||
expect(existsSync(join(kinoSp, 'hud-beta.gsc'))).toBe(false);
|
||||
expect(existsSync(join(kinoSp, 'hud.OLD'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to open a beta whose staged file matches an already-owned target', async () => {
|
||||
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||
const { openBeta, BetaError } = await import('../../src/lib/server/fleet/beta.js');
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
await doPush({ patterns: ['*'] });
|
||||
|
||||
// First beta claims hud on kino
|
||||
await writeFile(join(fixture.staging, 'hud.gsc'), 'v2');
|
||||
await openBeta({ name: 'first', patterns: ['t5_kino_1'] });
|
||||
|
||||
// Second attempt on kino should conflict
|
||||
await writeFile(join(fixture.staging, 'hud.gsc'), 'v3');
|
||||
await expect(openBeta({ name: 'second', patterns: ['t5_kino_1'] })).rejects.toThrow(BetaError);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { betaFile, oldFile, slugify } from '../../src/lib/server/fleet/files.js';
|
||||
import { betaFile, oldFile } from '../../src/lib/server/fleet/files.js';
|
||||
import { slugify } from '../../src/lib/server/fleet/scripts.js';
|
||||
|
||||
describe('slugify', () => {
|
||||
it('matches the shell script behavior', () => {
|
||||
it('matches the legacy behavior', () => {
|
||||
expect(slugify('New HUD Test')).toBe('new-hud-test');
|
||||
expect(slugify(' --Weird!!! ')).toBe('weird');
|
||||
expect(slugify('t5_kino_1')).toBe('t5-kino-1');
|
||||
|
||||
@@ -8,8 +8,8 @@ import { tmpdir } from 'node:os';
|
||||
// stash the root globally and reset between describe blocks.
|
||||
const TMP_ROOT = resolve(tmpdir(), `ec-logs-test-${Date.now()}`);
|
||||
process.env.SERVERS_ROOT = TMP_ROOT;
|
||||
process.env.SHARED_SCRIPTS = TMP_ROOT;
|
||||
process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp';
|
||||
process.env.BETA_ARCHIVE_DIR = resolve(TMP_ROOT, 'beta-archive');
|
||||
process.env.DB_PATH = resolve(TMP_ROOT, 'test.db');
|
||||
|
||||
const { resolveLogPath, tailLog, isLogKey } = await import(
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture(
|
||||
{
|
||||
'hud.gsc': 'v1',
|
||||
'zom.gsc': 'v1',
|
||||
'perks/perk1.gsc': 'p1'
|
||||
},
|
||||
['t5_kino_1', 't5_ascension_1']
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
describe('doPush', () => {
|
||||
it('adds every source file on a fresh server', async () => {
|
||||
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
|
||||
const res = await doPush({ patterns: ['*'], dryRun: false, create: false });
|
||||
expect(res.totalChangedServers).toBe(2);
|
||||
expect(res.totalFiles).toBe(3 * 2);
|
||||
expect(res.deploymentId).toBeTruthy();
|
||||
|
||||
const written = await readFile(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp/hud.gsc'), 'utf8');
|
||||
expect(written).toBe('v1');
|
||||
});
|
||||
|
||||
it('dry-run makes no changes and creates no deployment row', async () => {
|
||||
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
|
||||
const res = await doPush({ patterns: ['*'], dryRun: true, create: false });
|
||||
expect(res.deploymentId).toBeNull();
|
||||
expect(res.totalChangedServers).toBe(2);
|
||||
|
||||
// no file created
|
||||
await expect(readFile(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp/hud.gsc'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('skips servers that are already up to date', async () => {
|
||||
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
await doPush({ patterns: ['*'], dryRun: false });
|
||||
|
||||
// second run: everything matches → no deployment
|
||||
const res = await doPush({ patterns: ['*'], dryRun: false });
|
||||
expect(res.deploymentId).toBeNull();
|
||||
expect(res.totalChangedServers).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ let fixture: Fixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture({ 'hud.gsc': 'v1' }, ['t5_kino_1', 't5_ascension_1']);
|
||||
fixture = await newFixture(['t5_kino_1', 't5_ascension_1']);
|
||||
// No integrations → sweep marks rows manual, exercising that branch.
|
||||
delete process.env.KOMODO_ADDRESS;
|
||||
delete process.env.RCON_PASSWORD;
|
||||
@@ -17,20 +17,33 @@ afterEach(async () => {
|
||||
|
||||
describe('sweep', () => {
|
||||
it('marks all pending rows manual when integrations are unset', async () => {
|
||||
const { doPush } = await import('../../src/lib/server/fleet/push.js');
|
||||
const { sweep } = await import('../../src/lib/server/pipeline/sweep.js');
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
await seedServerRegistry();
|
||||
|
||||
const push = await doPush({ patterns: ['*'] });
|
||||
expect(push.deploymentId).not.toBeNull();
|
||||
// Seed a deployment row directly — the code path being tested is the
|
||||
// "integrations not configured" branch, which just walks deployment_servers.
|
||||
const depId = 'dep-test-001';
|
||||
db().prepare("INSERT INTO deployments (id, action, started_at, triggered_by) VALUES (?, 'test', ?, 'ui')")
|
||||
.run(depId, new Date().toISOString());
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, attempts) VALUES (?, ?, 1, 'pending', 0)"
|
||||
)
|
||||
.run(depId, 't5_kino_1');
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, attempts) VALUES (?, ?, 1, 'pending', 0)"
|
||||
)
|
||||
.run(depId, 't5_ascension_1');
|
||||
|
||||
await sweep();
|
||||
|
||||
const rows = db()
|
||||
.prepare("SELECT restart_state FROM deployment_servers WHERE deployment_id = ?")
|
||||
.all(push.deploymentId!) as Array<{ restart_state: string }>;
|
||||
.all(depId) as Array<{ restart_state: string }>;
|
||||
expect(rows.length).toBe(2);
|
||||
for (const r of rows) expect(r.restart_state).toBe('manual');
|
||||
});
|
||||
|
||||
@@ -39,7 +52,6 @@ describe('sweep', () => {
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
|
||||
// No deployments → no pending rows. Should return zero counts.
|
||||
const result = await sweep();
|
||||
expect(result).toEqual({ checked: 0, restarted: 0, held: 0, failed: 0 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user