drift update

This commit is contained in:
2026-09-12 23:15:14 -04:00
parent bb1e5c6a95
commit f10f0cefef
4 changed files with 437 additions and 39 deletions
+232 -22
View File
@@ -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<ArtifactKind> = ['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<DriftEntry[]> {
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<DriftEntry[]> {
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<DriftEntry[]> {
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<Buffer | null> {
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<number>();
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<Run
? input.servers
: listServers().map((s) => 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<Run
deploymentId: null,
perServer: perServer.map((s) => ({
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<Run
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)`
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
const affected: string[] = [];
@@ -173,17 +354,40 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
const dest = destOfKind(s.server, entry.kind);
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);
if (entry.driftKind === 'untracked') {
// Delete a file inventory doesn't know about.
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, 'removed', entry.relPath, entry.sha256, null, content, null);
} else {
// Write back a missing artifact from the DB.
const written = await writeMissingEntry(s.server, entry);
if (!written) {
s.skipped.push(`${entry.relPath}: missing artifact could not be resolved from the DB`);
continue;
}
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, written);
recordScript(
s.server,
entry.relPath,
entry.kind === 'config' ? null : entry.scriptVersionId ?? null,
sha256Buffer(written),
written.byteLength,
`drift:${depId}`
);
s.restored.push(entry.relPath);
insertFile.run(depId, s.server, 'added', entry.relPath, null, sha256Buffer(written), null, written);
}
} catch (err) {
s.skipped.push(`${entry.relPath}: ${(err as Error).message}`);
}
}
if (s.removed.length > 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<Run
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 })),
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<Run
return {
deploymentId: depId,
perServer: perServer.map((s) => ({ 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
};