Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
464 lines
20 KiB
TypeScript
464 lines
20 KiB
TypeScript
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
import { randomUUID } from 'node:crypto';
|
|
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
|
|
import { existsSync } from 'node:fs';
|
|
import { dirname, join, posix, relative, sep } from 'node:path';
|
|
import { configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
|
import { db } from '../db/index.js';
|
|
import { emit } from '../pipeline/bus.js';
|
|
import { sha256Buffer } from './files.js';
|
|
import {
|
|
getServerScript,
|
|
listServerScripts,
|
|
recordScript,
|
|
removeScript
|
|
} from './inventory.js';
|
|
import { getBaselineForScope, getVersion } from './scripts.js';
|
|
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
|
|
import { selectedZombieMap } from './map-aliases.js';
|
|
import { serverDirectories, getServer, listServers } from './servers.js';
|
|
import { promotedArtifacts } from './promoted-artifacts.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'];
|
|
|
|
// Filenames we never treat as drift. dedicated.cfg is placed by Plutonium for
|
|
// multiplayer and regenerates if deleted; we manage dedicated_sp.cfg (zombies)
|
|
// via config-compose instead.
|
|
const DRIFT_IGNORE_FILENAMES = new Set(['dedicated.cfg']);
|
|
|
|
// Three flavors of drift:
|
|
// 'untracked' — file outside inventory and promoted scope (delete it)
|
|
// 'replace' — managed target exists but needs installation/tracking
|
|
// 'missing' — artifact promoted for this server's scope, but file is gone
|
|
// (write it back from the DB)
|
|
export type DriftKind = 'untracked' | 'missing' | 'replace';
|
|
|
|
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;
|
|
// Populated for missing/replacement entries so runDriftDeployment can write the
|
|
// right content back.
|
|
scriptId?: number;
|
|
scriptVersionId?: number | null;
|
|
}
|
|
|
|
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) {
|
|
if (DRIFT_IGNORE_FILENAMES.has(e.name)) continue;
|
|
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 every managed destination tree on a server (scripts, configs, plugins)
|
|
* and return every file that isn't tracked in server_scripts. During a beta,
|
|
* the beta bytes live at the actual target filename and the inventory row is
|
|
* updated to reflect them, so beta files register as tracked with no
|
|
* special-casing (pre-beta bytes are snapshotted to beta_file_snapshots for
|
|
* rollback rather than left on disk as .OLD).
|
|
*
|
|
* The inventory table (server_scripts) keys on (server, rel_path) with no
|
|
* destination discriminator. Rel_paths across destinations don't collide in
|
|
* practice (`.gsc` for scripts, `.cfg` for configs, `.dll` for plugins), so
|
|
* a shared tracked-set is safe.
|
|
*/
|
|
export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
|
|
const tracked = new Set(listServerScripts(server).map((r) => r.rel_path));
|
|
const drift = await scanManagedForServer(server);
|
|
const managed = new Set(drift.map((entry) => `${entry.kind}:${entry.relPath}`));
|
|
const srv = getServer(server);
|
|
// Without a promoted baseline, Plutainer owns the seeded config. Keep it
|
|
// until a baseline is promoted; startup arguments handle map selection.
|
|
const keepDefaultConfig = srv?.game
|
|
&& selectedZombieMap(srv.game, srv.map, srv.map_slug)
|
|
&& !getBaselineForScope('game', srv.game)?.current_version_id;
|
|
|
|
// Scan for untracked files only after resolving managed targets.
|
|
for (const kind of DRIFT_DESTS) {
|
|
const dest = destOfKind(server, kind);
|
|
const onDisk = await walkFiles(dest);
|
|
if (onDisk.length === 0) continue;
|
|
|
|
for (const relPath of onDisk) {
|
|
if (tracked.has(relPath) || managed.has(`${kind}:${relPath}`)) continue;
|
|
if (keepDefaultConfig && kind === 'config' && relPath === configFileOf(server)) continue;
|
|
if (kind === 'config' && relPath === MAP_CONFIG_FILE && srv?.created_by
|
|
&& srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map, srv.map_slug)) 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),
|
|
kind,
|
|
driftKind: 'untracked'
|
|
});
|
|
} catch {
|
|
/* file disappeared between walk and stat — skip */
|
|
}
|
|
}
|
|
}
|
|
|
|
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 scanManagedForServer(server: string): Promise<DriftEntry[]> {
|
|
const srv = getServer(server);
|
|
if (!srv) return [];
|
|
const out: DriftEntry[] = [];
|
|
|
|
// Provisioned servers load this generated file after their main cfg. It
|
|
// remains managed even when there is no promoted baseline yet.
|
|
const mapConfig = srv.created_by && srv.game_mode === 'zombie'
|
|
? getServerMapConfig(srv.game, srv.map, srv.map_slug) : null;
|
|
if (mapConfig) {
|
|
const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE);
|
|
const content = await readFile(abs).catch(() => null);
|
|
if (!content?.equals(mapConfig)) {
|
|
out.push({ server, relPath: MAP_CONFIG_FILE, kind: 'config', driftKind: content ? 'replace' : 'missing',
|
|
size: mapConfig.byteLength, sha256: sha256Buffer(mapConfig) });
|
|
}
|
|
}
|
|
|
|
// Existing files without inventory must be installed/tracked in one pass,
|
|
// never deleted first. Preserve tracked files (including beta versions).
|
|
for (const { kind, script, version } of promotedArtifacts(srv.game, srv.map)) {
|
|
const present = existsSync(join(destOfKind(server, kind), script.target_rel_path));
|
|
if (present && getServerScript(server, script.target_rel_path)) continue;
|
|
out.push({
|
|
server, relPath: script.target_rel_path, kind,
|
|
driftKind: present ? 'replace' : 'missing',
|
|
size: version.size, sha256: version.sha256,
|
|
scriptId: script.id, scriptVersionId: version.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(srv.game, srv.map, srv.map_slug);
|
|
if (composed) {
|
|
const dest = destOfKind(server, 'config');
|
|
const targetRel = configFileOf(server);
|
|
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: existsSync(abs) ? 'replace' : '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 install for a managed drift entry. For
|
|
// scripts/plugins, that's the current version's BLOB. For configs, recompose
|
|
// the effective cfg from baseline + overlays.
|
|
async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<Buffer | null> {
|
|
if (entry.kind === 'config') {
|
|
const srv = getServer(server);
|
|
if (!srv?.game) return null;
|
|
if (entry.relPath === MAP_CONFIG_FILE) return getServerMapConfig(srv.game, srv.map, srv.map_slug);
|
|
const composed = composeExpectedCfg(srv.game, srv.map, srv.map_slug);
|
|
return composed?.content ?? null;
|
|
}
|
|
if (!entry.scriptVersionId) return null;
|
|
const ver = getVersion(entry.scriptVersionId);
|
|
return ver?.content ?? null;
|
|
}
|
|
|
|
function composeExpectedCfg(
|
|
game: string | null,
|
|
map: string | null,
|
|
mapSlug?: string | null
|
|
): { content: Buffer; sha256: string } | null {
|
|
const content = getPromotedServerConfig(game, map, mapSlug);
|
|
if (!content) return null;
|
|
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.
|
|
*/
|
|
export async function scanFleetDrift(): Promise<DriftEntry[]> {
|
|
const servers = listServers().map((s) => s.name);
|
|
const registered = new Set(servers);
|
|
const allDirs = new Set(await serverDirectories());
|
|
// 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';
|
|
expectedFingerprint?: string;
|
|
}
|
|
|
|
export class DriftPlanChanged extends Error {
|
|
constructor(public plan: DriftPlan) { super('The files or target servers changed. Nothing was changed. Review the updated plan before continuing.'); }
|
|
}
|
|
|
|
function driftTargets(servers?: string[]): string[] {
|
|
const registered = listServers().map(server => server.name);
|
|
if (servers && (!servers.length || servers.some(name => !registered.includes(name)))) throw new Error('Select at least one registered server. Refresh the fleet if a server was removed.');
|
|
return [...new Set(servers ?? registered)].sort();
|
|
}
|
|
|
|
async function describePlan(servers: string[], entries: DriftEntry[]): Promise<DriftPlan> {
|
|
const files: DriftPlanFile[] = [];
|
|
for (const entry of entries) {
|
|
const before = await readFile(join(destOfKind(entry.server, entry.kind), entry.relPath)).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'ENOENT') return null;
|
|
throw error;
|
|
});
|
|
const after = entry.driftKind === 'untracked' ? null : await resolveManagedEntry(entry.server, entry);
|
|
files.push({ server: entry.server, relPath: entry.relPath, kind: entry.kind, driftKind: entry.driftKind,
|
|
beforeSha256: before ? sha256Buffer(before) : null, afterSha256: after ? sha256Buffer(after) : null,
|
|
beforeSize: before?.byteLength ?? null, afterSize: after?.byteLength ?? null });
|
|
}
|
|
files.sort((a, b) => `${a.server}:${a.kind}:${a.relPath}`.localeCompare(`${b.server}:${b.kind}:${b.relPath}`));
|
|
return { servers, files, scannedAt: new Date().toISOString(), fingerprint: sha256Buffer(Buffer.from(JSON.stringify({ servers, files }))) };
|
|
}
|
|
|
|
export async function previewDrift(servers?: string[]): Promise<DriftPlan> {
|
|
const targets = driftTargets(servers);
|
|
const entries: DriftEntry[] = [];
|
|
for (const server of targets) entries.push(...await scanServerDrift(server));
|
|
return describePlan(targets, entries);
|
|
}
|
|
|
|
export interface RunDriftResult {
|
|
deploymentId: string | null;
|
|
// `removed` = untracked files deleted from disk (drift kind='untracked')
|
|
// `restored` = missing or existing artifacts installed and tracked
|
|
perServer: Array<{ server: string; removed: string[]; restored: string[]; skipped: string[] }>;
|
|
totalRemoved: number;
|
|
totalRestored: number;
|
|
totalServers: number;
|
|
dryRun: boolean;
|
|
}
|
|
|
|
function deploymentIdNow(): string {
|
|
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
return `dep-${stamp}-drift-${randomUUID().slice(0, 8)}`;
|
|
}
|
|
|
|
/**
|
|
* Detect drift on the requested servers (or the whole fleet) and either
|
|
* report the plan (dryRun) or remove untracked files and install managed
|
|
* artifacts as a single 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 = driftTargets(input.servers);
|
|
|
|
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: [], restored: [], skipped: [], entries });
|
|
}
|
|
|
|
// Recheck identities, existing bytes, and replacement bytes before any write.
|
|
const plan = input.expectedFingerprint ? await describePlan(scanTargets, perServer.flatMap(server => server.entries)) : null;
|
|
if (plan && plan.fingerprint !== input.expectedFingerprint) throw new DriftPlanChanged(plan);
|
|
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.filter((e) => e.driftKind === 'untracked').map((e) => e.relPath),
|
|
restored: s.entries.filter((e) => e.driftKind !== 'untracked').map((e) => e.relPath),
|
|
skipped: []
|
|
})),
|
|
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 !== 'untracked').length,
|
|
0
|
|
),
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
);
|
|
|
|
const affected: string[] = [];
|
|
for (const s of perServer) {
|
|
if (s.entries.length === 0) continue;
|
|
for (const entry of s.entries) {
|
|
const dest = destOfKind(s.server, entry.kind);
|
|
const abs = join(dest, entry.relPath);
|
|
try {
|
|
const reviewed = plan?.files.find(file => file.server === s.server && file.kind === entry.kind && file.relPath === entry.relPath);
|
|
if (reviewed) {
|
|
const current = await readFile(abs).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null; throw error; });
|
|
if ((current ? sha256Buffer(current) : null) !== reviewed.beforeSha256) {
|
|
s.skipped.push(`${entry.relPath}: changed after review; scan and review again`);
|
|
continue;
|
|
}
|
|
}
|
|
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 {
|
|
// Install the managed artifact without deleting the target first.
|
|
const written = await resolveManagedEntry(s.server, entry);
|
|
if (!written) {
|
|
s.skipped.push(`${entry.relPath}: missing artifact could not be resolved from the DB`);
|
|
continue;
|
|
}
|
|
if (reviewed && sha256Buffer(written) !== reviewed.afterSha256) {
|
|
s.skipped.push(`${entry.relPath}: promoted content changed after review; scan and review again`);
|
|
continue;
|
|
}
|
|
const before = await readFile(abs).catch((err: NodeJS.ErrnoException) => {
|
|
if (err.code === 'ENOENT') return null;
|
|
throw err;
|
|
});
|
|
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, before ? 'updated' : 'added', entry.relPath,
|
|
before ? sha256Buffer(before) : null, sha256Buffer(written), before, written);
|
|
}
|
|
} catch (err) {
|
|
s.skipped.push(`${entry.relPath}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
const changes = s.removed.length + s.restored.length;
|
|
if (changes > 0) {
|
|
insertServer.run(depId, s.server, changes);
|
|
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, restored: s.restored, skipped: s.skipped
|
|
})),
|
|
totalRemoved: 0,
|
|
totalRestored: 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, 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
|
|
};
|
|
}
|