drift update
This commit is contained in:
+232
-22
@@ -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
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
export async function GET() {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
return json({
|
||||
|
||||
@@ -33,7 +33,13 @@
|
||||
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 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> {
|
||||
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;
|
||||
flash = null;
|
||||
try {
|
||||
@@ -75,10 +93,20 @@
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean;
|
||||
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; }
|
||||
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()]);
|
||||
} finally {
|
||||
running = false;
|
||||
@@ -149,11 +177,11 @@
|
||||
{:else}
|
||||
<section class="drift-panel">
|
||||
<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">
|
||||
<button onclick={scanDrift} disabled={scanning || running}>{scanning ? 'scanning…' : 'rescan'}</button>
|
||||
<button class="primary" onclick={runDrift} disabled={running || scanning || !drift || drift.totalDrift === 0}>
|
||||
{running ? 'removing…' : 'remove drift'}
|
||||
{running ? 'reconciling…' : 'reconcile drift'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,10 +192,13 @@
|
||||
{:else}
|
||||
{#each drift.servers as s (s.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">
|
||||
{#each s.files as f (f.relPath)}
|
||||
<span class="drift-file mono" title="{f.size}B · {f.sha256.slice(0, 12)}">{f.relPath}</span>
|
||||
{#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)}
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,12 +394,20 @@
|
||||
.drift-server-head { color: var(--cream); font-size: var(--text-mono); margin-bottom: 6px; }
|
||||
.drift-files { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.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;
|
||||
border-radius: var(--r-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; }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
@@ -42,10 +43,149 @@ describe('drift scanning', () => {
|
||||
);
|
||||
|
||||
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.config).toBe('stray.cfg');
|
||||
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