Add drift detection and removal as a real deployment

A drift deployment scans each server's sp/ tree for files not in the
server_scripts inventory (untracked scripts left over from before EC was
managing the fleet), deletes them, and records the removed bytes in
deployment_files for audit. The existing sweep then runs the standard
wait-for-empty → Komodo restart cycle on each affected server.

To make that cycle safer, the sweep now has a second phase: after Komodo
restart succeeds we wait for komodoInspectHealthy + RCON to both come
back green before marking the deployment_server row 'done'. If either
stays broken the row stays pending and keeps retrying.

New: /api/drift (GET scan, POST run), drift panel on /deployments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 13:44:52 -04:00
co-authored by Claude Opus 4.7
parent 590f803d93
commit edfe4dfc09
5 changed files with 440 additions and 49 deletions
+206
View File
@@ -0,0 +1,206 @@
import { readdir, readFile, rm, stat } from 'node:fs/promises';
import { join, posix, relative, sep } from 'node:path';
import { destOf } 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';
export interface DriftEntry {
server: string;
relPath: string;
size: number;
sha256: string;
}
function toPosix(p: string): string {
return p.split(sep).join(posix.sep);
}
async function walkFiles(root: string): Promise<string[]> {
const out: string[] = [];
async function recur(dir: string): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const abs = join(dir, e.name);
if (e.isDirectory()) await recur(abs);
else if (e.isFile()) out.push(toPosix(relative(root, abs)));
}
}
await recur(root);
return out.sort();
}
/**
* 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.
*/
export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
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 */
}
}
return drift;
}
/**
* 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.
*/
export async function scanFleetDrift(): Promise<DriftEntry[]> {
const servers = listServers().map((s) => s.name);
const registered = new Set(servers);
const allDirs = new Set(await allServers());
// Only touch server dirs that are both registered AND present on disk.
const eligible = servers.filter((n) => registered.has(n) && allDirs.has(n));
const out: DriftEntry[] = [];
for (const name of eligible) {
const rows = await scanServerDrift(name);
out.push(...rows);
}
return out;
}
export interface RunDriftInput {
servers?: string[]; // if omitted, scans the whole fleet
dryRun?: boolean;
triggeredBy?: 'ui' | 'sweep' | 'cron';
}
export interface RunDriftResult {
deploymentId: string | null;
perServer: Array<{ server: string; removed: string[]; skipped: string[] }>;
totalRemoved: number;
totalServers: number;
dryRun: boolean;
}
function deploymentIdNow(): string {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
return `dep-${stamp}-drift`;
}
/**
* Detect drift on the requested servers (or the whole fleet) and either
* report the plan (dryRun) or actually remove the untracked files as a
* single drift deployment. The sweep restarts each affected server once
* it's empty and waits for it to come back healthy.
*/
export async function runDriftDeployment(input: RunDriftInput = {}): Promise<RunDriftResult> {
const { dryRun = false, triggeredBy = 'ui' } = input;
const scanTargets = input.servers && input.servers.length > 0
? input.servers
: listServers().map((s) => s.name);
const perServer: Array<{ server: string; removed: string[]; skipped: string[]; entries: DriftEntry[] }> = [];
for (const name of scanTargets) {
const entries = await scanServerDrift(name);
perServer.push({ server: name, removed: [], skipped: [], entries });
}
const totalDrift = perServer.reduce((sum, s) => sum + s.entries.length, 0);
if (totalDrift === 0 || dryRun) {
return {
deploymentId: null,
perServer: perServer.map((s) => ({
server: s.server,
removed: s.entries.map((e) => e.relPath),
skipped: []
})),
totalRemoved: totalDrift,
totalServers: perServer.filter((s) => s.entries.length > 0).length,
dryRun
};
}
// Commit. Create the deployment row FIRST so audit rows (which FK to it)
// can be inserted as we go.
const depId = deploymentIdNow();
const now = new Date().toISOString();
db().prepare(
`INSERT INTO deployments (id, action, started_at, triggered_by) VALUES (?, 'drift', ?, ?)`
).run(depId, now, triggeredBy);
const insertServer = 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 insertFile = db().prepare(
`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
VALUES (?, ?, 'removed', ?, ?, NULL, ?, NULL)`
);
const affected: string[] = [];
for (const s of perServer) {
if (s.entries.length === 0) continue;
const dest = destOf(s.server);
for (const entry of s.entries) {
const abs = join(dest, entry.relPath);
try {
const content = await readFile(abs);
await rm(abs, { force: true });
removeScript(s.server, entry.relPath);
s.removed.push(entry.relPath);
insertFile.run(depId, s.server, entry.relPath, entry.sha256, content);
} catch (err) {
s.skipped.push(`${entry.relPath}: ${(err as Error).message}`);
}
}
if (s.removed.length > 0) {
insertServer.run(depId, s.server, s.removed.length);
affected.push(s.server);
}
}
if (affected.length === 0) {
// Everything failed. Roll back the empty deployment row so it doesn't
// dangle in the UI as a mysterious no-op.
db().prepare('DELETE FROM deployments WHERE id = ?').run(depId);
return {
deploymentId: null,
perServer: perServer.map((s) => ({ server: s.server, removed: s.removed, skipped: s.skipped })),
totalRemoved: 0,
totalServers: 0,
dryRun: false
};
}
emit({
type: 'deployment-opened',
deploymentId: depId,
action: 'drift',
servers: affected
});
return {
deploymentId: depId,
perServer: perServer.map((s) => ({ server: s.server, removed: s.removed, skipped: s.skipped })),
totalRemoved: perServer.reduce((sum, s) => sum + s.removed.length, 0),
totalServers: affected.length,
dryRun: false
};
}