drift update
This commit is contained in:
+228
-18
@@ -1,22 +1,50 @@
|
|||||||
import { readdir, readFile, rm, stat } from 'node:fs/promises';
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
import { join, posix, relative, sep } from 'node:path';
|
import { existsSync } from 'node:fs';
|
||||||
import { destOfKind, type ArtifactKind } from '../config.js';
|
import { dirname, join, posix, relative, sep } from 'node:path';
|
||||||
|
import { config, destOfKind, type ArtifactKind } from '../config.js';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { emit } from '../pipeline/bus.js';
|
import { emit } from '../pipeline/bus.js';
|
||||||
import { sha256Buffer } from './files.js';
|
import { sha256Buffer } from './files.js';
|
||||||
import { listServerScripts, removeScript } from './inventory.js';
|
import {
|
||||||
import { allServers, listServers } from './servers.js';
|
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.
|
// Destinations drift scans, in a stable order so the UI shows scripts first.
|
||||||
// Baseline shares the config destination — one physical dir, two logical kinds.
|
// Baseline shares the config destination — one physical dir, two logical kinds.
|
||||||
const DRIFT_DESTS: ReadonlyArray<ArtifactKind> = ['script', 'config', 'plugin'];
|
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 {
|
export interface DriftEntry {
|
||||||
server: string;
|
server: string;
|
||||||
relPath: string;
|
relPath: string;
|
||||||
|
kind: ArtifactKind;
|
||||||
|
driftKind: DriftKind;
|
||||||
|
// Populated for on-disk detections (untracked) and missing configs where
|
||||||
|
// we compute the expected content.
|
||||||
size: number;
|
size: number;
|
||||||
sha256: string;
|
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 {
|
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 tracked = new Set(listServerScripts(server).map((r) => r.rel_path));
|
||||||
const drift: DriftEntry[] = [];
|
const drift: DriftEntry[] = [];
|
||||||
|
|
||||||
|
// Pass 1 — untracked files on disk in each managed destination.
|
||||||
for (const kind of DRIFT_DESTS) {
|
for (const kind of DRIFT_DESTS) {
|
||||||
const dest = destOfKind(server, kind);
|
const dest = destOfKind(server, kind);
|
||||||
const onDisk = await walkFiles(dest);
|
const onDisk = await walkFiles(dest);
|
||||||
@@ -68,15 +97,150 @@ export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
|
|||||||
try {
|
try {
|
||||||
const st = await stat(abs);
|
const st = await stat(abs);
|
||||||
const buf = await readFile(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 {
|
} catch {
|
||||||
/* file disappeared between walk and stat — skip */
|
/* 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;
|
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
|
* 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.
|
* the on-disk SERVERS_ROOT listing, so unregistered folders don't leak in.
|
||||||
@@ -103,8 +267,11 @@ export interface RunDriftInput {
|
|||||||
|
|
||||||
export interface RunDriftResult {
|
export interface RunDriftResult {
|
||||||
deploymentId: string | null;
|
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;
|
totalRemoved: number;
|
||||||
|
totalRestored: number;
|
||||||
totalServers: number;
|
totalServers: number;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
}
|
}
|
||||||
@@ -127,10 +294,16 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
|||||||
? input.servers
|
? input.servers
|
||||||
: listServers().map((s) => s.name);
|
: 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) {
|
for (const name of scanTargets) {
|
||||||
const entries = await scanServerDrift(name);
|
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);
|
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,
|
deploymentId: null,
|
||||||
perServer: perServer.map((s) => ({
|
perServer: perServer.map((s) => ({
|
||||||
server: s.server,
|
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: []
|
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,
|
totalServers: perServer.filter((s) => s.entries.length > 0).length,
|
||||||
dryRun
|
dryRun
|
||||||
};
|
};
|
||||||
@@ -163,7 +344,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
|||||||
const insertFile = db().prepare(
|
const insertFile = db().prepare(
|
||||||
`INSERT INTO deployment_files
|
`INSERT INTO deployment_files
|
||||||
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
|
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
|
||||||
VALUES (?, ?, 'removed', ?, ?, NULL, ?, NULL)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
);
|
);
|
||||||
|
|
||||||
const affected: string[] = [];
|
const affected: string[] = [];
|
||||||
@@ -173,17 +354,40 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
|||||||
const dest = destOfKind(s.server, entry.kind);
|
const dest = destOfKind(s.server, entry.kind);
|
||||||
const abs = join(dest, entry.relPath);
|
const abs = join(dest, entry.relPath);
|
||||||
try {
|
try {
|
||||||
|
if (entry.driftKind === 'untracked') {
|
||||||
|
// Delete a file inventory doesn't know about.
|
||||||
const content = await readFile(abs);
|
const content = await readFile(abs);
|
||||||
await rm(abs, { force: true });
|
await rm(abs, { force: true });
|
||||||
removeScript(s.server, entry.relPath);
|
removeScript(s.server, entry.relPath);
|
||||||
s.removed.push(entry.relPath);
|
s.removed.push(entry.relPath);
|
||||||
insertFile.run(depId, s.server, entry.relPath, entry.sha256, content);
|
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) {
|
} catch (err) {
|
||||||
s.skipped.push(`${entry.relPath}: ${(err as Error).message}`);
|
s.skipped.push(`${entry.relPath}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (s.removed.length > 0) {
|
const changes = s.removed.length + s.restored.length;
|
||||||
insertServer.run(depId, s.server, s.removed.length);
|
if (changes > 0) {
|
||||||
|
insertServer.run(depId, s.server, changes);
|
||||||
affected.push(s.server);
|
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);
|
db().prepare('DELETE FROM deployments WHERE id = ?').run(depId);
|
||||||
return {
|
return {
|
||||||
deploymentId: null,
|
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,
|
totalRemoved: 0,
|
||||||
|
totalRestored: 0,
|
||||||
totalServers: 0,
|
totalServers: 0,
|
||||||
dryRun: false
|
dryRun: false
|
||||||
};
|
};
|
||||||
@@ -210,8 +417,11 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
deploymentId: depId,
|
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),
|
totalRemoved: perServer.reduce((sum, s) => sum + s.removed.length, 0),
|
||||||
|
totalRestored: perServer.reduce((sum, s) => sum + s.restored.length, 0),
|
||||||
totalServers: affected.length,
|
totalServers: affected.length,
|
||||||
dryRun: false
|
dryRun: false
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
// GET returns the current drift plan for the whole fleet without touching disk.
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const entries = await scanFleetDrift();
|
const entries = await scanFleetDrift();
|
||||||
const byServer = new Map<string, Array<{ relPath: string; size: number; sha256: string; kind: string }>>();
|
const byServer = new Map<
|
||||||
|
string,
|
||||||
|
Array<{ relPath: string; size: number; sha256: string; kind: string; driftKind: string }>
|
||||||
|
>();
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const arr = byServer.get(e.server) ?? [];
|
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);
|
byServer.set(e.server, arr);
|
||||||
}
|
}
|
||||||
return json({
|
return json({
|
||||||
|
|||||||
@@ -33,7 +33,13 @@
|
|||||||
scripts: DepScript[];
|
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 DriftServer { server: string; files: DriftFile[] }
|
||||||
interface DriftPreview { totalDrift: number; servers: DriftServer[] }
|
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<void> {
|
async function runDrift(): Promise<void> {
|
||||||
if (!drift || drift.totalDrift === 0) return;
|
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;
|
running = true;
|
||||||
flash = null;
|
flash = null;
|
||||||
try {
|
try {
|
||||||
@@ -75,10 +93,20 @@
|
|||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
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; }
|
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()]);
|
await Promise.all([loadDeployments(), scanDrift()]);
|
||||||
} finally {
|
} finally {
|
||||||
running = false;
|
running = false;
|
||||||
@@ -149,11 +177,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<section class="drift-panel">
|
<section class="drift-panel">
|
||||||
<div class="drift-head">
|
<div class="drift-head">
|
||||||
<h2>Drift{drift ? ` · ${drift.totalDrift} untracked file${drift.totalDrift === 1 ? '' : 's'}` : ''}</h2>
|
<h2>Drift{drift ? ` · ${driftCounts.untracked} untracked, ${driftCounts.missing} missing` : ''}</h2>
|
||||||
<div class="drift-actions">
|
<div class="drift-actions">
|
||||||
<button onclick={scanDrift} disabled={scanning || running}>{scanning ? 'scanning…' : 'rescan'}</button>
|
<button onclick={scanDrift} disabled={scanning || running}>{scanning ? 'scanning…' : 'rescan'}</button>
|
||||||
<button class="primary" onclick={runDrift} disabled={running || scanning || !drift || drift.totalDrift === 0}>
|
<button class="primary" onclick={runDrift} disabled={running || scanning || !drift || drift.totalDrift === 0}>
|
||||||
{running ? 'removing…' : 'remove drift'}
|
{running ? 'reconciling…' : 'reconcile drift'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,10 +192,13 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each drift.servers as s (s.server)}
|
{#each drift.servers as s (s.server)}
|
||||||
<div class="drift-server">
|
<div class="drift-server">
|
||||||
<div class="drift-server-head mono">{s.server} · {s.files.length} file{s.files.length === 1 ? '' : 's'}</div>
|
<div class="drift-server-head mono">{s.server} · {s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</div>
|
||||||
<div class="drift-files">
|
<div class="drift-files">
|
||||||
{#each s.files as f (f.relPath)}
|
{#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)}
|
||||||
<span class="drift-file mono" title="{f.size}B · {f.sha256.slice(0, 12)}">{f.relPath}</span>
|
<span
|
||||||
|
class="drift-file mono {f.driftKind}"
|
||||||
|
title="[{f.kind}] {f.driftKind} · {f.size}B · {f.sha256.slice(0, 12)}"
|
||||||
|
>[{f.driftKind === 'missing' ? '+' : '−'} {f.kind}] {f.relPath}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,12 +394,20 @@
|
|||||||
.drift-server-head { color: var(--cream); font-size: var(--text-mono); margin-bottom: 6px; }
|
.drift-server-head { color: var(--cream); font-size: var(--text-mono); margin-bottom: 6px; }
|
||||||
.drift-files { display: flex; flex-wrap: wrap; gap: 6px; }
|
.drift-files { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.drift-file {
|
.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;
|
padding: 3px 8px;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
font-size: var(--text-mono-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; }
|
.flash { color: var(--spark-400); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
|
||||||
.mono { font-family: var(--font-mono); }
|
.mono { font-family: var(--font-mono); }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 { join } from 'node:path';
|
||||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
@@ -42,10 +43,149 @@ describe('drift scanning', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const drift = await scanServerDrift(server);
|
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.script).toBe('stray.gsc');
|
||||||
expect(byKind.config).toBe('stray.cfg');
|
expect(byKind.config).toBe('stray.cfg');
|
||||||
expect(byKind.plugin).toBe('stray.dll');
|
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<unknown>,
|
||||||
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user