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:
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user