diff --git a/src/lib/server/fleet/drift.ts b/src/lib/server/fleet/drift.ts index f61fe62..6f90830 100644 --- a/src/lib/server/fleet/drift.ts +++ b/src/lib/server/fleet/drift.ts @@ -1,22 +1,50 @@ -import { readdir, readFile, rm, stat } from 'node:fs/promises'; -import { join, posix, relative, sep } from 'node:path'; -import { destOfKind, type ArtifactKind } from '../config.js'; +import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, join, posix, relative, sep } from 'node:path'; +import { config, destOfKind, type ArtifactKind } from '../config.js'; import { db } from '../db/index.js'; import { emit } from '../pipeline/bus.js'; import { sha256Buffer } from './files.js'; -import { listServerScripts, removeScript } from './inventory.js'; -import { allServers, listServers } from './servers.js'; +import { + getServerScript, + listServerScripts, + recordScript, + removeScript +} from './inventory.js'; +import { + getBaselineForScope, + getVersion, + listPromotedOverlaysForScope, + listScripts, + type ScriptRow, + type ScriptVersionRow +} from './scripts.js'; +import { composeCfg } from './config-compose.js'; +import { allServers, getServer, listServers } from './servers.js'; // Destinations drift scans, in a stable order so the UI shows scripts first. // Baseline shares the config destination — one physical dir, two logical kinds. const DRIFT_DESTS: ReadonlyArray = ['script', 'config', 'plugin']; +// Two flavors of drift: +// 'untracked' — file on disk that inventory doesn't know about (delete it) +// 'missing' — artifact promoted for this server's scope, but file is gone +// (write it back from the DB) +export type DriftKind = 'untracked' | 'missing'; + export interface DriftEntry { server: string; relPath: string; + kind: ArtifactKind; + driftKind: DriftKind; + // Populated for on-disk detections (untracked) and missing configs where + // we compute the expected content. size: number; sha256: string; - kind: ArtifactKind; + // Populated for 'missing' entries so runDriftDeployment can write the + // right content back. + scriptId?: number; + scriptVersionId?: number | null; } function toPosix(p: string): string { @@ -57,6 +85,7 @@ export async function scanServerDrift(server: string): Promise { const tracked = new Set(listServerScripts(server).map((r) => r.rel_path)); const drift: DriftEntry[] = []; + // Pass 1 — untracked files on disk in each managed destination. for (const kind of DRIFT_DESTS) { const dest = destOfKind(server, kind); const onDisk = await walkFiles(dest); @@ -68,15 +97,150 @@ export async function scanServerDrift(server: string): Promise { try { const st = await stat(abs); const buf = await readFile(abs); - drift.push({ server, relPath, size: st.size, sha256: sha256Buffer(buf), kind }); + drift.push({ + server, relPath, + size: st.size, + sha256: sha256Buffer(buf), + kind, + driftKind: 'untracked' + }); } catch { /* file disappeared between walk and stat — skip */ } } } + + // Pass 2 — artifacts that should be on this server (based on scope) but + // whose target file is missing on disk. + const missing = await scanMissingForServer(server); + drift.push(...missing); return drift; } +// Every promoted artifact whose scope matches this server's game/map should +// have its target file present on disk. Anything absent is 'missing' drift. +// Configs are handled as a composed layer (baseline + overlays → one cfg). +async function scanMissingForServer(server: string): Promise { + const srv = getServer(server); + if (!srv) return []; + const out: DriftEntry[] = []; + + const scopeApplies = (r: { scope_type: string | null; scope_value: string | null }): boolean => { + if (!r.scope_type || !r.scope_value) return false; + const field = r.scope_type === 'game' ? srv.game : srv.map; + return field === r.scope_value; + }; + + // scripts + plugins: one file per artifact at destOfKind(server, kind). + for (const kind of ['script', 'plugin'] as const) { + const rows = listScripts(kind); + for (const s of rows) { + if (!s.current_version_id) continue; // never promoted → not expected on disk + if (!scopeApplies(s)) continue; + const dest = destOfKind(server, kind); + const abs = join(dest, s.target_rel_path); + if (existsSync(abs)) continue; + const ver = getVersion(s.current_version_id); + if (!ver) continue; + out.push({ + server, + relPath: s.target_rel_path, + kind, + driftKind: 'missing', + size: ver.size, + sha256: ver.sha256, + scriptId: s.id, + scriptVersionId: ver.id + }); + } + } + + // Composed cfg: the effective dedicated_sp.cfg is baseline + all promoted + // overlays for this server's scope. It's "missing" when either the file + // isn't on disk at all, or its sha256 differs from what we'd compose. + // Only meaningful when there's a promoted baseline for this server's game. + if (srv.game) { + const baseline = getBaselineForScope('game', srv.game); + if (baseline?.current_version_id) { + const composed = composeExpectedCfg(server, srv.game, srv.map); + if (composed) { + const dest = destOfKind(server, 'config'); + const targetRel = config.configTargetFile; + const abs = join(dest, targetRel); + const inv = getServerScript(server, targetRel); + const onDiskMatches = existsSync(abs) && inv?.sha256 === composed.sha256; + if (!onDiskMatches) { + out.push({ + server, + relPath: targetRel, + kind: 'config', + driftKind: 'missing', + size: composed.content.byteLength, + sha256: composed.sha256, + scriptId: baseline.id, + scriptVersionId: baseline.current_version_id + }); + } + } + } + } + + return out; +} + +// Recompute what dedicated_sp.cfg SHOULD look like on this server given the +// current promoted baseline + overlays. Returns null if no baseline exists. +// Resolve the content we should write for a 'missing' drift entry. For +// scripts/plugins, that's the current version's BLOB. For configs, recompose +// the effective cfg from baseline + overlays. +async function writeMissingEntry(server: string, entry: DriftEntry): Promise { + if (entry.kind === 'config') { + const srv = getServer(server); + if (!srv?.game) return null; + const composed = composeExpectedCfg(server, srv.game, srv.map); + return composed?.content ?? null; + } + if (!entry.scriptVersionId) return null; + const ver = getVersion(entry.scriptVersionId); + return ver?.content ?? null; +} + +function composeExpectedCfg( + server: string, + game: string | null, + map: string | null +): { content: Buffer; sha256: string } | null { + if (!game) return null; + const baseline = getBaselineForScope('game', game); + if (!baseline?.current_version_id) return null; + const baselineVer = getVersion(baseline.current_version_id); + if (!baselineVer) return null; + + const scopeApplies = (r: ScriptRow): boolean => { + if (!r.scope_type || !r.scope_value) return false; + const field = r.scope_type === 'game' ? game : map; + return field === r.scope_value; + }; + + const overlayInputs: Array<{ id: number; text: string }> = []; + const seen = new Set(); + const collect = (rows: ScriptRow[]): void => { + for (const r of rows) { + if (seen.has(r.id) || !scopeApplies(r) || !r.current_version_id) continue; + const v = getVersion(r.current_version_id); + if (!v) continue; + overlayInputs.push({ id: r.id, text: v.content.toString('utf8') }); + seen.add(r.id); + } + }; + collect(listPromotedOverlaysForScope('game', game)); + if (map) collect(listPromotedOverlaysForScope('map', map)); + + const text = composeCfg(baselineVer.content.toString('utf8'), overlayInputs); + const content = Buffer.from(text, 'utf8'); + return { content, sha256: sha256Buffer(content) }; +} + /** * Scan the whole fleet. Servers are read from the DB (registered fleet), not * the on-disk SERVERS_ROOT listing, so unregistered folders don't leak in. @@ -103,8 +267,11 @@ export interface RunDriftInput { export interface RunDriftResult { deploymentId: string | null; - perServer: Array<{ server: string; removed: string[]; skipped: string[] }>; + // `removed` = untracked files deleted from disk (drift kind='untracked') + // `restored` = missing artifacts written back to disk (drift kind='missing') + perServer: Array<{ server: string; removed: string[]; restored: string[]; skipped: string[] }>; totalRemoved: number; + totalRestored: number; totalServers: number; dryRun: boolean; } @@ -127,10 +294,16 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise s.name); - const perServer: Array<{ server: string; removed: string[]; skipped: string[]; entries: DriftEntry[] }> = []; + const perServer: Array<{ + server: string; + removed: string[]; + restored: string[]; + skipped: string[]; + entries: DriftEntry[]; + }> = []; for (const name of scanTargets) { const entries = await scanServerDrift(name); - perServer.push({ server: name, removed: [], skipped: [], entries }); + perServer.push({ server: name, removed: [], restored: [], skipped: [], entries }); } const totalDrift = perServer.reduce((sum, s) => sum + s.entries.length, 0); @@ -139,10 +312,18 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise ({ server: s.server, - removed: s.entries.map((e) => e.relPath), + removed: s.entries.filter((e) => e.driftKind === 'untracked').map((e) => e.relPath), + restored: s.entries.filter((e) => e.driftKind === 'missing').map((e) => e.relPath), skipped: [] })), - totalRemoved: totalDrift, + totalRemoved: perServer.reduce( + (n, s) => n + s.entries.filter((e) => e.driftKind === 'untracked').length, + 0 + ), + totalRestored: perServer.reduce( + (n, s) => n + s.entries.filter((e) => e.driftKind === 'missing').length, + 0 + ), totalServers: perServer.filter((s) => s.entries.length > 0).length, dryRun }; @@ -163,7 +344,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise 0) { - insertServer.run(depId, s.server, s.removed.length); + const changes = s.removed.length + s.restored.length; + if (changes > 0) { + insertServer.run(depId, s.server, changes); affected.push(s.server); } } @@ -194,8 +398,11 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise ({ server: s.server, removed: s.removed, skipped: s.skipped })), + perServer: perServer.map((s) => ({ + server: s.server, removed: s.removed, restored: s.restored, skipped: s.skipped + })), totalRemoved: 0, + totalRestored: 0, totalServers: 0, dryRun: false }; @@ -210,8 +417,11 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise ({ server: s.server, removed: s.removed, skipped: s.skipped })), + perServer: perServer.map((s) => ({ + server: s.server, removed: s.removed, restored: s.restored, skipped: s.skipped + })), totalRemoved: perServer.reduce((sum, s) => sum + s.removed.length, 0), + totalRestored: perServer.reduce((sum, s) => sum + s.restored.length, 0), totalServers: affected.length, dryRun: false }; diff --git a/src/routes/api/drift/+server.ts b/src/routes/api/drift/+server.ts index e9fe393..c1bff9a 100644 --- a/src/routes/api/drift/+server.ts +++ b/src/routes/api/drift/+server.ts @@ -5,10 +5,19 @@ import { kick } from '$lib/server/pipeline/worker.js'; // GET returns the current drift plan for the whole fleet without touching disk. export async function GET() { const entries = await scanFleetDrift(); - const byServer = new Map>(); + const byServer = new Map< + string, + Array<{ relPath: string; size: number; sha256: string; kind: string; driftKind: string }> + >(); for (const e of entries) { const arr = byServer.get(e.server) ?? []; - arr.push({ relPath: e.relPath, size: e.size, sha256: e.sha256, kind: e.kind }); + arr.push({ + relPath: e.relPath, + size: e.size, + sha256: e.sha256, + kind: e.kind, + driftKind: e.driftKind + }); byServer.set(e.server, arr); } return json({ diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte index e0fcb2a..d6ba246 100644 --- a/src/routes/deployments/+page.svelte +++ b/src/routes/deployments/+page.svelte @@ -33,7 +33,13 @@ scripts: DepScript[]; } - interface DriftFile { relPath: string; size: number; sha256: string } + interface DriftFile { + relPath: string; + size: number; + sha256: string; + kind: 'script' | 'config' | 'plugin' | 'baseline'; + driftKind: 'untracked' | 'missing'; + } interface DriftServer { server: string; files: DriftFile[] } interface DriftPreview { totalDrift: number; servers: DriftServer[] } @@ -61,9 +67,21 @@ } } + let driftCounts = $derived.by(() => { + if (!drift) return { untracked: 0, missing: 0 }; + let u = 0, m = 0; + for (const s of drift.servers) for (const f of s.files) { + if (f.driftKind === 'untracked') u++; else m++; + } + return { untracked: u, missing: m }; + }); + async function runDrift(): Promise { if (!drift || drift.totalDrift === 0) return; - if (!confirm(`Remove ${drift.totalDrift} untracked file(s) across ${drift.servers.length} server(s)?`)) return; + const parts: string[] = []; + if (driftCounts.untracked > 0) parts.push(`delete ${driftCounts.untracked} untracked file(s)`); + if (driftCounts.missing > 0) parts.push(`restore ${driftCounts.missing} missing artifact(s)`); + if (!confirm(`Reconcile drift on ${drift.servers.length} server(s)? Will ${parts.join(' and ')}.`)) return; running = true; flash = null; try { @@ -75,10 +93,20 @@ const body = (await res.json()) as { ok: boolean; error?: string; - result?: { deploymentId: string | null; totalRemoved: number; totalServers: number }; + result?: { + deploymentId: string | null; + totalRemoved: number; + totalRestored?: number; + totalServers: number; + }; }; if (!body.ok) { flash = body.error ?? 'drift run failed'; return; } - flash = `removed ${body.result?.totalRemoved ?? 0} files across ${body.result?.totalServers ?? 0} servers`; + const removed = body.result?.totalRemoved ?? 0; + const restored = body.result?.totalRestored ?? 0; + const parts2: string[] = []; + if (removed > 0) parts2.push(`removed ${removed}`); + if (restored > 0) parts2.push(`restored ${restored}`); + flash = `${parts2.join(', ') || 'no'} file(s) across ${body.result?.totalServers ?? 0} server(s)`; await Promise.all([loadDeployments(), scanDrift()]); } finally { running = false; @@ -149,11 +177,11 @@ {:else}
-

Drift{drift ? ` · ${drift.totalDrift} untracked file${drift.totalDrift === 1 ? '' : 's'}` : ''}

+

Drift{drift ? ` · ${driftCounts.untracked} untracked, ${driftCounts.missing} missing` : ''}

@@ -164,10 +192,13 @@ {:else} {#each drift.servers as s (s.server)}
-
{s.server} · {s.files.length} file{s.files.length === 1 ? '' : 's'}
+
{s.server} · {s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}
- {#each s.files as f (f.relPath)} - {f.relPath} + {#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)} + [{f.driftKind === 'missing' ? '+' : '−'} {f.kind}] {f.relPath} {/each}
@@ -363,12 +394,20 @@ .drift-server-head { color: var(--cream); font-size: var(--text-mono); margin-bottom: 6px; } .drift-files { display: flex; flex-wrap: wrap; gap: 6px; } .drift-file { - background: rgba(255, 48, 80, 0.08); - border: 1px solid rgba(255, 48, 80, 0.25); - color: var(--danger-500); padding: 3px 8px; border-radius: var(--r-sm); font-size: var(--text-mono-sm); + border: 1px solid transparent; + } + .drift-file.untracked { + background: rgba(255, 48, 80, 0.08); + border-color: rgba(255, 48, 80, 0.25); + color: var(--danger-500); + } + .drift-file.missing { + background: rgba(255, 176, 32, 0.08); + border-color: rgba(255, 176, 32, 0.3); + color: var(--amber-500); } .flash { color: var(--spark-400); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; } .mono { font-family: var(--font-mono); } diff --git a/tests/unit/drift-kinds.test.ts b/tests/unit/drift-kinds.test.ts index 12bc892..185e371 100644 --- a/tests/unit/drift-kinds.test.ts +++ b/tests/unit/drift-kinds.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { newFixture, type Fixture } from '../helpers/fixture.js'; @@ -42,10 +43,149 @@ describe('drift scanning', () => { ); const drift = await scanServerDrift(server); - const byKind = Object.fromEntries(drift.map((d) => [d.kind, d.relPath])); + const untracked = drift.filter((d) => d.driftKind === 'untracked'); + const byKind = Object.fromEntries(untracked.map((d) => [d.kind, d.relPath])); expect(byKind.script).toBe('stray.gsc'); expect(byKind.config).toBe('stray.cfg'); expect(byKind.plugin).toBe('stray.dll'); - expect(drift.length).toBe(3); + expect(untracked.length).toBe(3); + }); +}); + +// Deploys a promoted artifact to a scope-matching server, then deletes it +// off disk so drift's "missing" pass has something to find. +async function promoteAndOrphan( + createScript: (i: { + name: string; + kind: 'script' | 'plugin' | 'baseline' | 'config'; + scopeType?: 'game' | 'map'; + scopeValue?: string; + targetRelPath?: string; + content: Buffer; + }) => { script: { id: number }; version: { id: number } }, + getLatestVersion: (id: number) => { id: number } | undefined, + openBeta: (i: { + scripts: Array<{ scriptId: number; scriptVersionId: number }>; + servers: string[]; + waitForHealth: boolean; + waitForMatch: boolean; + }) => Promise<{ betaId: number }>, + promoteBeta: (id: number) => Promise, + db: () => { prepare: (q: string) => { run: (...args: unknown[]) => unknown } } +): Promise<{ scriptId: number }> { + const created = createScript({ + name: 't5 hud', + kind: 'script', + scopeType: 'game', + scopeValue: 't5', + targetRelPath: 'hud.gsc', + content: Buffer.from('main(){}\n') + }); + const ver = getLatestVersion(created.script.id)!; + const { betaId } = await openBeta({ + scripts: [{ scriptId: created.script.id, scriptVersionId: ver.id }], + servers: ['t5_kino_1'], + waitForHealth: false, + waitForMatch: false + }); + db().prepare( + `UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?` + ).run(new Date().toISOString(), betaId); + db().prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ?`).run(betaId); + await promoteBeta(betaId); + return { scriptId: created.script.id }; +} + +describe('missing artifact detection', () => { + it('flags a promoted script that is absent on a scope-matching server as missing, then restores it on reconcile', async () => { + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + const { createScript, getLatestVersion } = await import( + '../../src/lib/server/fleet/scripts.js' + ); + const { openBeta, promoteBeta } = await import( + '../../src/lib/server/fleet/beta.js' + ); + const { scanServerDrift, runDriftDeployment } = await import( + '../../src/lib/server/fleet/drift.js' + ); + const { db } = await import('../../src/lib/server/db/index.js'); + await seedServerRegistry(); + + await promoteAndOrphan( + createScript as never, + getLatestVersion as never, + openBeta as never, + promoteBeta as never, + db as never + ); + + const targetPath = join( + fixture.t5, + 't5_kino_1', + 'runtime/plutonium/storage/t5/raw/scripts/sp', + 'hud.gsc' + ); + // Sanity check: promoted script lands on disk. + expect(existsSync(targetPath)).toBe(true); + + // Simulate someone deleting the file out-of-band. + await rm(targetPath, { force: true }); + + const drift = await scanServerDrift('t5_kino_1'); + const missing = drift.filter((d) => d.driftKind === 'missing' && d.kind === 'script'); + expect(missing.length).toBe(1); + expect(missing[0].relPath).toBe('hud.gsc'); + + const result = await runDriftDeployment({ + servers: ['t5_kino_1'], + dryRun: false, + triggeredBy: 'ui' + }); + expect(result.totalRestored).toBeGreaterThanOrEqual(1); + expect(existsSync(targetPath)).toBe(true); + const restored = await readFile(targetPath); + expect(restored.toString()).toBe('main(){}\n'); + }); + + it('flags a scope-matching server whose composed dedicated_sp.cfg is missing', async () => { + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + const { createScript, getLatestVersion } = await import( + '../../src/lib/server/fleet/scripts.js' + ); + const { openBeta, promoteBeta } = await import( + '../../src/lib/server/fleet/beta.js' + ); + const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + await seedServerRegistry(); + + const baseline = createScript({ + name: 't5 baseline', + kind: 'baseline', + scopeType: 'game', + scopeValue: 't5', + content: Buffer.from('set g_inactivity "190"\n') + }); + const bVer = getLatestVersion(baseline.script.id)!; + const { betaId } = await openBeta({ + scripts: [{ scriptId: baseline.script.id, scriptVersionId: bVer.id }], + servers: ['t5_kino_1'], + waitForHealth: false, + waitForMatch: false + }); + db().prepare( + `UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?` + ).run(new Date().toISOString(), betaId); + db().prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ?`).run(betaId); + await promoteBeta(betaId); + + const cfgPath = join(fixture.t5, 't5_kino_1', 'configs', 'dedicated_sp.cfg'); + expect(existsSync(cfgPath)).toBe(true); + await rm(cfgPath, { force: true }); + + const drift = await scanServerDrift('t5_kino_1'); + const missingCfg = drift.filter((d) => d.driftKind === 'missing' && d.kind === 'config'); + expect(missingCfg.length).toBe(1); + expect(missingCfg[0].relPath).toBe('dedicated_sp.cfg'); }); });