diff --git a/.env.example b/.env.example index 9bdb2a9..f152f6c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index dcb1cb6..f0394b9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,5 @@ build *.db-wal *.db-shm data/ -test-fixtures/*/zombies/ -!test-fixtures/*/zombies/.gitkeep +test-fixtures/ +.gstack/ diff --git a/docker-compose.yml b/docker-compose.yml index dfacccf..702c665 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/entrypoint.sh b/entrypoint.sh index 1ccd734..69eebc5 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 diff --git a/komodo/stack.toml b/komodo/stack.toml index e0855d2..7216ea8 100644 --- a/komodo/stack.toml +++ b/komodo/stack.toml @@ -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 diff --git a/package.json b/package.json index 8549a92..62b45b4 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/import-legacy.ts b/scripts/import-legacy.ts deleted file mode 100644 index b0d143d..0000000 --- a/scripts/import-legacy.ts +++ /dev/null @@ -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; - betas?: Record; - deployments?: LegacyDeployment[]; -} - -async function main(): Promise { - 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); -}); diff --git a/scripts/seed-fixture.ts b/scripts/seed-fixture.ts index d250985..769f7c4 100644 --- a/scripts/seed-fixture.ts +++ b/scripts/seed-fixture.ts @@ -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 { - 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) => { diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 3165839..70adf01 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -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}`; diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 79cf543..c4ffa40 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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); diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 2dce41f..b055e57 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -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:' | 'promote:' | 'rollback:' + 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, diff --git a/src/lib/server/fleet/beta.ts b/src/lib/server/fleet/beta.ts index 905c326..6de7aeb 100644 --- a/src/lib/server/fleet/beta.ts +++ b/src/lib/server/fleet/beta.ts @@ -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 { - 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 { + 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(); - 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// 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 { - 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//, 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 { - 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 { + 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 { - 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 { + 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//*.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 { + 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; } diff --git a/src/lib/server/fleet/files.ts b/src/lib/server/fleet/files.ts index 4d512d3..cf9dd76 100644 --- a/src/lib/server/fleet/files.ts +++ b/src/lib/server/fleet/files.ts @@ -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 { - const out: string[] = []; - async function recur(dir: string): Promise { - 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 { - 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 { - 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 { - 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 { const buf = await readFile(path); return createHash('sha256').update(buf).digest('hex'); @@ -136,3 +44,7 @@ export async function sha256OrNull(path: string): Promise { } return sha256(path); } + +export function sha256Buffer(buf: Buffer): string { + return createHash('sha256').update(buf).digest('hex'); +} diff --git a/src/lib/server/fleet/inventory.ts b/src/lib/server/fleet/inventory.ts new file mode 100644 index 0000000..d293639 --- /dev/null +++ b/src/lib/server/fleet/inventory.ts @@ -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[]; +} diff --git a/src/lib/server/fleet/match-log.ts b/src/lib/server/fleet/match-log.ts new file mode 100644 index 0000000..8fcc8fb --- /dev/null +++ b/src/lib/server/fleet/match-log.ts @@ -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 }; +} diff --git a/src/lib/server/fleet/push.ts b/src/lib/server/fleet/push.ts deleted file mode 100644 index ca24a7f..0000000 --- a/src/lib/server/fleet/push.ts +++ /dev/null @@ -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:' | ... -} - -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--` 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 { - 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 { - const out: string[] = []; - async function recur(dir: string, prefix: string): Promise { - 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> { - const applied: Array = []; - 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 { - 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 - }; -} diff --git a/src/lib/server/fleet/scripts.ts b/src/lib/server/fleet/scripts.ts new file mode 100644 index 0000000..831da00 --- /dev/null +++ b/src/lib/server/fleet/scripts.ts @@ -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); +} diff --git a/src/lib/server/fleet/servers.ts b/src/lib/server/fleet/servers.ts index d9f9a18..b55bc00 100644 --- a/src/lib/server/fleet/servers.ts +++ b/src/lib/server/fleet/servers.ts @@ -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 { let entries; @@ -22,8 +22,7 @@ export async function allServers(): Promise { /** * 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 { 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 = [ '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`. 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. 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 { +export function heldByBeta(server: string): Map { 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(); - for (const r of rows) map.set(r.target, r.slug); + .all(server) as Array<{ target: string; beta_id: number }>; + const map = new Map(); + 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; } diff --git a/src/lib/server/integrations/komodo.ts b/src/lib/server/integrations/komodo.ts index 400ad1a..df4f777 100644 --- a/src/lib/server/integrations/komodo.ts +++ b/src/lib/server/integrations/komodo.ts @@ -251,6 +251,103 @@ export async function komodoResolveGamePort(serverName: string): Promise(); + 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; + // Prefer the explicit docker inspect shape. + const s = obj['State']; + if (s && typeof s === 'object') { + const status = (s as Record)['Status']; + const running = (s as Record)['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 { + 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; + 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. diff --git a/src/lib/server/pipeline/beta-runner.ts b/src/lib/server/pipeline/beta-runner.ts new file mode 100644 index 0000000..2a63506 --- /dev/null +++ b/src/lib/server/pipeline/beta-runner.ts @@ -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(); + 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 { + 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; + } + } +} diff --git a/src/lib/server/pipeline/bus.ts b/src/lib/server/pipeline/bus.ts index e35d9a0..c7ebda5 100644 --- a/src/lib/server/pipeline/bus.ts +++ b/src/lib/server/pipeline/bus.ts @@ -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() { diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts index 8da2cbe..639e7bd 100644 --- a/src/lib/server/pipeline/worker.ts +++ b/src/lib/server/pipeline/worker.ts @@ -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 { } catch (err) { console.error('[worker] sweep error:', err); } + try { + await betaTick(); + } catch (err) { + console.error('[worker] beta-runner error:', err); + } } /** diff --git a/src/lib/ui/components/BetaPanel.svelte b/src/lib/ui/components/BetaPanel.svelte deleted file mode 100644 index 6d6f3f5..0000000 --- a/src/lib/ui/components/BetaPanel.svelte +++ /dev/null @@ -1,271 +0,0 @@ - - -
-

Staged

- {#if staged.length === 0} -

nothing staged

- {:else} -
    - {#each staged as s (s.base)} -
  • - {s.base} - {#if s.hits.length === 0} - new - {:else if s.hits.length === 1} - replaces {s.hits[0]} - {:else} - ambiguous ({s.hits.length} hits) - {/if} -
  • - {/each} -
- -
{ e.preventDefault(); openNewBeta(); }}> - - - -
- {/if} - -

Open betas

- {#if betas.length === 0} -

no open betas

- {:else} - {#each betas as b (b.slug)} -
-
- {b.name} - rev {b.revision} -
-
scripts {b.scripts.map((s) => s.target).join(', ')}
-
servers {b.servers.join(', ')}
-
- - - -
-
- {/each} - {/if} - - {#if flash} -

{flash}

- {/if} -
- - diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index 8a2c90d..c676de4 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -5,7 +5,7 @@ interface Held { target: string; - slug: string; + betaId: number; } interface Props { name: string; @@ -96,7 +96,7 @@ {#if held.length > 0}
{#each held as h (h.target)} - β {h.target} + β {h.target} {/each}
{/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f26059e..c072123 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -24,6 +24,7 @@ diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 13767fe..5efb159 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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([]); - let pushing = $state(false); - let flash = $state(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 @@

Fleet

{servers.length} server{servers.length === 1 ? '' : 's'} -
- - -
+ manage scripts → - {#if flash}

{flash}

{/if} {#if servers.length === 0}

no servers registered yet — check SERVERS_ROOT

{: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); - } diff --git a/src/routes/api/betas/+server.ts b/src/routes/api/betas/+server.ts index a365ece..10af95b 100644 --- a/src/routes/api/betas/+server.ts +++ b/src/routes/api/betas/+server.ts @@ -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 }); diff --git a/src/routes/api/betas/[id]/+server.ts b/src/routes/api/betas/[id]/+server.ts new file mode 100644 index 0000000..187a494 --- /dev/null +++ b/src/routes/api/betas/[id]/+server.ts @@ -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 }); +} diff --git a/src/routes/api/betas/[id]/promote/+server.ts b/src/routes/api/betas/[id]/promote/+server.ts new file mode 100644 index 0000000..4bab597 --- /dev/null +++ b/src/routes/api/betas/[id]/promote/+server.ts @@ -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; + } +} diff --git a/src/routes/api/betas/[id]/retry/+server.ts b/src/routes/api/betas/[id]/retry/+server.ts new file mode 100644 index 0000000..9ae975e --- /dev/null +++ b/src/routes/api/betas/[id]/retry/+server.ts @@ -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 }); +} diff --git a/src/routes/api/betas/[id]/rollback/+server.ts b/src/routes/api/betas/[id]/rollback/+server.ts new file mode 100644 index 0000000..e05b4a8 --- /dev/null +++ b/src/routes/api/betas/[id]/rollback/+server.ts @@ -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; + } +} diff --git a/src/routes/api/betas/[slug]/promote/+server.ts b/src/routes/api/betas/[slug]/promote/+server.ts deleted file mode 100644 index af0951a..0000000 --- a/src/routes/api/betas/[slug]/promote/+server.ts +++ /dev/null @@ -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; - } -} diff --git a/src/routes/api/betas/[slug]/rollback/+server.ts b/src/routes/api/betas/[slug]/rollback/+server.ts deleted file mode 100644 index c92a963..0000000 --- a/src/routes/api/betas/[slug]/rollback/+server.ts +++ /dev/null @@ -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; - } -} diff --git a/src/routes/api/push/+server.ts b/src/routes/api/push/+server.ts deleted file mode 100644 index cc36792..0000000 --- a/src/routes/api/push/+server.ts +++ /dev/null @@ -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); -} diff --git a/src/routes/api/scripts/+server.ts b/src/routes/api/scripts/+server.ts new file mode 100644 index 0000000..c98d28d --- /dev/null +++ b/src/routes/api/scripts/+server.ts @@ -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; + } +} diff --git a/src/routes/api/scripts/[id]/+server.ts b/src/routes/api/scripts/[id]/+server.ts new file mode 100644 index 0000000..93887a8 --- /dev/null +++ b/src/routes/api/scripts/[id]/+server.ts @@ -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; + } +} diff --git a/src/routes/api/scripts/[id]/scope/+server.ts b/src/routes/api/scripts/[id]/scope/+server.ts new file mode 100644 index 0000000..4f31e9a --- /dev/null +++ b/src/routes/api/scripts/[id]/scope/+server.ts @@ -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; + } +} diff --git a/src/routes/api/scripts/[id]/versions/+server.ts b/src/routes/api/scripts/[id]/versions/+server.ts new file mode 100644 index 0000000..1b35e28 --- /dev/null +++ b/src/routes/api/scripts/[id]/versions/+server.ts @@ -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; + } +} diff --git a/src/routes/api/scripts/[id]/versions/[versionId]/+server.ts b/src/routes/api/scripts/[id]/versions/[versionId]/+server.ts new file mode 100644 index 0000000..a4aa10c --- /dev/null +++ b/src/routes/api/scripts/[id]/versions/[versionId]/+server.ts @@ -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; + } +} diff --git a/src/routes/api/servers/+server.ts b/src/routes/api/servers/+server.ts index 0c048f1..0d57668 100644 --- a/src/routes/api/servers/+server.ts +++ b/src/routes/api/servers/+server.ts @@ -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, diff --git a/src/routes/api/servers/[name]/scripts/+server.ts b/src/routes/api/servers/[name]/scripts/+server.ts new file mode 100644 index 0000000..f585395 --- /dev/null +++ b/src/routes/api/servers/[name]/scripts/+server.ts @@ -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 }); +} diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte index 4441715..1b1adc6 100644 --- a/src/routes/betas/+page.svelte +++ b/src/routes/betas/+page.svelte @@ -1,43 +1,95 @@

Betas

{#if loading}

loading…

+{:else if betas.length === 0} +

no betas yet — open one from a script's page

{:else} - + {#each grouped as g (g.state)} + {#if g.rows.length > 0} +
+

{g.state.replace('-', ' ')} · {g.rows.length}

+ +
+ {/if} + {/each} {/if} diff --git a/src/routes/betas/[id]/+page.svelte b/src/routes/betas/[id]/+page.svelte new file mode 100644 index 0000000..1dc55f2 --- /dev/null +++ b/src/routes/betas/[id]/+page.svelte @@ -0,0 +1,228 @@ + + +{#if loading} +

loading…

+{:else if !beta || !script || !version} +

beta not found

+{:else} + ← all betas +
+

Beta #{beta.id}

+ {beta.state} +
+
+ {script.name} + · {script.target_rel_path} + · v{version.version} +
+ +
+

Options

+
+ trigger: {beta.trigger_kind} + wait for health: {beta.wait_for_health ? 'yes' : 'no'} + wait for match: {beta.wait_for_match ? 'yes' : 'no'} +
+
+ +
+

Server progress

+
+ {#each servers as bs (bs.server)} +
+ {bs.server} + {bs.stage} +
+ deploy + empty + restart + healthy + {#if beta.wait_for_match} + match + {/if} +
+ {#if bs.error} + {bs.error} + {/if} + {#if bs.stage === 'failed'} + + {/if} +
+ {/each} +
+
+ +
+

Actions

+
+ + +
+
+ + {#if flash}

{flash}

{/if} +{/if} + + diff --git a/src/routes/scripts/+page.svelte b/src/routes/scripts/+page.svelte new file mode 100644 index 0000000..d507041 --- /dev/null +++ b/src/routes/scripts/+page.svelte @@ -0,0 +1,247 @@ + + +

Scripts

+ +
+

Upload a script

+
+ + + + + + +
+ {#if flash}

{flash}

{/if} +
+ +
+

All scripts

+ {#if loading} +

loading…

+ {:else if scripts.length === 0} +

no scripts yet — upload one above

+ {:else} + + {/if} +
+ + diff --git a/src/routes/scripts/[id]/+page.svelte b/src/routes/scripts/[id]/+page.svelte new file mode 100644 index 0000000..1e25422 --- /dev/null +++ b/src/routes/scripts/[id]/+page.svelte @@ -0,0 +1,392 @@ + + +{#if loading} +

loading…

+{:else if !script} +

script not found

+{:else} + ← all scripts +

{script.name}

+
{script.target_rel_path}
+ + {#if runningBetaId} + + {/if} + +
+

Scope

+
+ + {#if scopeType !== 'none'} + + {/if} + +
+ {#if script.scope_type && matchedServers.length > 0} +

applies to: {matchedServers.join(', ')}

+ {:else if script.scope_type} +

no servers currently match this scope

+ {:else} +

unscoped — deploy to specific servers via a beta below

+ {/if} +
+ +
+

Upload new version

+
+ + + +
+
+ +
+

Open beta with a version

+ {#if runningBetaId} +

a beta is already running for this script

+ {:else if versions.length === 0} +

no versions yet — upload one above

+ {:else} +
+ + +
+ + +
+ +
+ {/if} +
+ +
+

Version history

+ {#if versions.length === 0} +

none

+ {:else} +
+ {#each versions as v (v.id)} +
+ v{v.version} + {v.sha256.slice(0, 12)} + {v.size}B + {v.notes ?? ''} + {v.uploaded_at} + {#if script.current_version_id === v.id}current{/if} +
+ {/each} +
+ {/if} +
+ +
+

Danger

+ +
+ + {#if flash}

{flash}

{/if} +{/if} + + diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index a7bed51..8f4ee4e 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -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(null); let integrations = $state(null); let history = $state([]); + let inventory = $state([]); let name = $derived($page.params.name ?? ''); let komodoProbe = $state(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 @@
game_port
{server.game_port ?? '(unresolved)'}
+
+
game
+
{server.game ?? '(unset)'}
+
+
+
map
+
{server.map ?? '(unset)'}
+
{#if server.held.length > 0}
held by beta
-
{server.held.map((h) => h.target).join(', ')}
+
{server.held.map((h) => `${h.target} (beta ${h.betaId})`).join(', ')}
{/if} +
+

Scripts on this server ({inventory.filter((f) => !f.relPath.endsWith('.OLD') && !/-beta\./.test(f.relPath)).length})

+ {#if inventory.length === 0} +

no scripts tracked — upload one from the Scripts page and open a beta to deploy it here

+ {:else} +
+ {#each inventory as f (f.relPath)} +
+ {f.relPath} + {f.size}B + {f.source ?? '—'} + {f.sha256.slice(0, 12)} +
+ {/each} +
+ {/if} +
+

Deployment history

{#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); } diff --git a/tests/helpers/fixture.ts b/tests/helpers/fixture.ts index 1f8cbb4..a2b36ef 100644 --- a/tests/helpers/fixture.ts +++ b/tests/helpers/fixture.ts @@ -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; } /** - * 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 = {}, servers: string[] = ['t5_kino_1']): Promise { +export async function newFixture(servers: string[] = ['t5_kino_1']): Promise { 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 = {}, 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(mod: string): Promise { return (await import(`${mod}?t=${Date.now()}${Math.random()}`)) as T; } diff --git a/tests/unit/beta.test.ts b/tests/unit/beta.test.ts deleted file mode 100644 index fbd7e16..0000000 --- a/tests/unit/beta.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/tests/unit/files.test.ts b/tests/unit/files.test.ts index 22809db..b6fa43f 100644 --- a/tests/unit/files.test.ts +++ b/tests/unit/files.test.ts @@ -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'); diff --git a/tests/unit/logs.test.ts b/tests/unit/logs.test.ts index 95eb453..bed93be 100644 --- a/tests/unit/logs.test.ts +++ b/tests/unit/logs.test.ts @@ -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( diff --git a/tests/unit/push.test.ts b/tests/unit/push.test.ts deleted file mode 100644 index 62fb56d..0000000 --- a/tests/unit/push.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/tests/unit/sweep.test.ts b/tests/unit/sweep.test.ts index f14594c..30c7ef2 100644 --- a/tests/unit/sweep.test.ts +++ b/tests/unit/sweep.test.ts @@ -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 }); });