From 6ae9004ed2ea8f44a49a9f0623f6cf591891e1ab Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 19:16:14 -0400 Subject: [PATCH] Drift now covers all three destinations --- src/lib/server/fleet/drift.ts | 52 ++++++++++++++++++++------------- src/routes/api/drift/+server.ts | 4 +-- tests/unit/drift-kinds.test.ts | 51 ++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 22 deletions(-) create mode 100644 tests/unit/drift-kinds.test.ts diff --git a/src/lib/server/fleet/drift.ts b/src/lib/server/fleet/drift.ts index e990e7a..f61fe62 100644 --- a/src/lib/server/fleet/drift.ts +++ b/src/lib/server/fleet/drift.ts @@ -1,17 +1,22 @@ import { readdir, readFile, rm, stat } from 'node:fs/promises'; import { join, posix, relative, sep } from 'node:path'; -import { destOf } from '../config.js'; +import { 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'; +// 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']; + export interface DriftEntry { server: string; relPath: string; size: number; sha256: string; + kind: ArtifactKind; } function toPosix(p: string): string { @@ -38,28 +43,35 @@ async function walkFiles(root: string): Promise { } /** - * Scan one server's sp/ tree and return every file that isn't tracked in - * server_scripts. This includes user-placed scripts and leftovers from - * before Electric Cherry started tracking inventory. Beta artifacts - * (-beta.gsc, .OLD) are tracked while a beta is active so they don't - * appear as drift. + * Scan every managed destination tree on a server (scripts, configs, plugins) + * and return every file that isn't tracked in server_scripts. Beta artifacts + * (-beta.gsc, .OLD, -beta.dll, composed dedicated_sp.cfg) are tracked while + * a beta is active so they don't appear as drift. + * + * The inventory table (server_scripts) keys on (server, rel_path) with no + * destination discriminator. Rel_paths across destinations don't collide in + * practice (`.gsc` for scripts, `.cfg` for configs, `.dll` for plugins), so + * a shared tracked-set is safe. */ export async function scanServerDrift(server: string): Promise { - const dest = destOf(server); - const onDisk = await walkFiles(dest); - if (onDisk.length === 0) return []; const tracked = new Set(listServerScripts(server).map((r) => r.rel_path)); - const drift: DriftEntry[] = []; - for (const relPath of onDisk) { - if (tracked.has(relPath)) continue; - const abs = join(dest, relPath); - try { - const st = await stat(abs); - const buf = await readFile(abs); - drift.push({ server, relPath, size: st.size, sha256: sha256Buffer(buf) }); - } catch { - /* file disappeared between walk and stat — skip */ + + for (const kind of DRIFT_DESTS) { + const dest = destOfKind(server, kind); + const onDisk = await walkFiles(dest); + if (onDisk.length === 0) continue; + + for (const relPath of onDisk) { + if (tracked.has(relPath)) continue; + const abs = join(dest, relPath); + try { + const st = await stat(abs); + const buf = await readFile(abs); + drift.push({ server, relPath, size: st.size, sha256: sha256Buffer(buf), kind }); + } catch { + /* file disappeared between walk and stat — skip */ + } } } return drift; @@ -157,8 +169,8 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise>(); + const byServer = new Map>(); for (const e of entries) { const arr = byServer.get(e.server) ?? []; - arr.push({ relPath: e.relPath, size: e.size, sha256: e.sha256 }); + arr.push({ relPath: e.relPath, size: e.size, sha256: e.sha256, kind: e.kind }); byServer.set(e.server, arr); } return json({ diff --git a/tests/unit/drift-kinds.test.ts b/tests/unit/drift-kinds.test.ts new file mode 100644 index 0000000..12bc892 --- /dev/null +++ b/tests/unit/drift-kinds.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, writeFile } 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(['t5_kino_1']); + delete process.env.KOMODO_ADDRESS; + delete process.env.RCON_PASSWORD; + // Ensure the three per-kind destinations exist under the fixture tree. + await mkdir(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true }); + await mkdir(join(fixture.t5, 't5_kino_1', 'configs'), { recursive: true }); + await mkdir(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins'), { recursive: true }); +}); + +afterEach(async () => { + await fixture.cleanup(); +}); + +describe('drift scanning', () => { + it('detects untracked files across script, config, and plugin destinations', async () => { + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js'); + await seedServerRegistry(); + + // Drop one stray file in each managed destination. + const server = 't5_kino_1'; + await writeFile( + join(fixture.t5, server, 'runtime/plutonium/storage/t5/raw/scripts/sp', 'stray.gsc'), + 'main(){}\n' + ); + await writeFile( + join(fixture.t5, server, 'configs', 'stray.cfg'), + 'set foo "bar"\n' + ); + await writeFile( + join(fixture.t5, server, 'runtime/plutonium/plugins', 'stray.dll'), + Buffer.from([0xde, 0xad]) + ); + + const drift = await scanServerDrift(server); + const byKind = Object.fromEntries(drift.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); + }); +});