Fix initial artifact deployment and drift file replacement

This commit is contained in:
2026-09-17 16:15:47 -04:00
parent e8e7332365
commit 60e44636aa
6 changed files with 171 additions and 65 deletions
+17
View File
@@ -20,6 +20,7 @@ import { kick } from '../pipeline/worker.js';
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js'; import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
import { recordScript } from './inventory.js'; import { recordScript } from './inventory.js';
import { sha256Buffer } from './files.js'; import { sha256Buffer } from './files.js';
import { promotedArtifacts } from './promoted-artifacts.js';
// Currently every t5 zombie server uses the same config file + game id. If we // Currently every t5 zombie server uses the same config file + game id. If we
// ever add MP or SP, split these based on mode. // ever add MP or SP, split these based on mode.
@@ -187,12 +188,25 @@ async function createServerUnlocked(input: CreateServerInput): Promise<CreateSer
const hostDir = `${config.serversRoot}/${name}`; const hostDir = `${config.serversRoot}/${name}`;
const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical); const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical);
const mapConfig = getServerMapConfig('t5', mapEntry.canonical)!; const mapConfig = getServerMapConfig('t5', mapEntry.canonical)!;
const initialArtifacts = promotedArtifacts('t5', mapEntry.canonical);
// Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly // Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly
// after — plutainer expects to be able to write to /home/plutainer/app. // after — plutainer expects to be able to write to /home/plutainer/app.
try { try {
await mkdir(hostDir, { recursive: true, mode: 0o777 }); await mkdir(hostDir, { recursive: true, mode: 0o777 });
await chmod(hostDir, 0o777); await chmod(hostDir, 0o777);
for (const { kind, script, version } of initialArtifacts) {
const dest = destOfKind(name, kind);
const target = join(dest, script.target_rel_path);
await mkdir(dirname(target), { recursive: true, mode: 0o777 });
// Make every newly created ancestor writable by Plutainer, even
// when the manager's umask removed write permissions at mkdir.
for (let dir = dirname(target); dir !== join(hostDir) && dir !== dirname(dir); dir = dirname(dir)) {
await chmod(dir, 0o777);
}
await writeFile(target, version.content);
await chmod(target, 0o666);
}
const mapConfigPath = join(destOfKind(name, 'config'), MAP_CONFIG_FILE); const mapConfigPath = join(destOfKind(name, 'config'), MAP_CONFIG_FILE);
await mkdir(dirname(mapConfigPath), { recursive: true, mode: 0o777 }); await mkdir(dirname(mapConfigPath), { recursive: true, mode: 0o777 });
await chmod(dirname(mapConfigPath), 0o777); await chmod(dirname(mapConfigPath), 0o777);
@@ -240,6 +254,9 @@ async function createServerUnlocked(input: CreateServerInput): Promise<CreateSer
recordScript(name, T5_ZOMBIE_CONFIG_FILE, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create'); recordScript(name, T5_ZOMBIE_CONFIG_FILE, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create');
} }
recordScript(name, MAP_CONFIG_FILE, null, sha256Buffer(mapConfig), mapConfig.byteLength, 'create-map'); recordScript(name, MAP_CONFIG_FILE, null, sha256Buffer(mapConfig), mapConfig.byteLength, 'create-map');
for (const { script, version } of initialArtifacts) {
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, 'create');
}
})(); })();
} catch (err) { } catch (err) {
// Roll back Komodo + FS if the DB insert fails (very unlikely — most // Roll back Komodo + FS if the DB insert fails (very unlikely — most
+40 -58
View File
@@ -11,15 +11,11 @@ import {
recordScript, recordScript,
removeScript removeScript
} from './inventory.js'; } from './inventory.js';
import { import { getBaselineForScope, getVersion } from './scripts.js';
getBaselineForScope,
getVersion,
listScripts,
type ScriptVersionRow
} from './scripts.js';
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js'; import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
import { T5_ZOMBIE_MAPS } from './map-aliases.js'; import { T5_ZOMBIE_MAPS } from './map-aliases.js';
import { allServers, getServer, listServers } from './servers.js'; import { allServers, getServer, listServers } from './servers.js';
import { promotedArtifacts } from './promoted-artifacts.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.
@@ -30,11 +26,12 @@ const DRIFT_DESTS: ReadonlyArray<ArtifactKind> = ['script', 'config', 'plugin'];
// via config-compose instead. // via config-compose instead.
const DRIFT_IGNORE_FILENAMES = new Set(['dedicated.cfg']); const DRIFT_IGNORE_FILENAMES = new Set(['dedicated.cfg']);
// Two flavors of drift: // Three flavors of drift:
// 'untracked' — file on disk that inventory doesn't know about (delete it) // '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 // 'missing' — artifact promoted for this server's scope, but file is gone
// (write it back from the DB) // (write it back from the DB)
export type DriftKind = 'untracked' | 'missing'; export type DriftKind = 'untracked' | 'missing' | 'replace';
export interface DriftEntry { export interface DriftEntry {
server: string; server: string;
@@ -45,7 +42,7 @@ export interface DriftEntry {
// we compute the expected content. // we compute the expected content.
size: number; size: number;
sha256: string; sha256: string;
// Populated for 'missing' entries so runDriftDeployment can write the // Populated for missing/replacement entries so runDriftDeployment can write the
// right content back. // right content back.
scriptId?: number; scriptId?: number;
scriptVersionId?: number | null; scriptVersionId?: number | null;
@@ -90,7 +87,8 @@ async function walkFiles(root: string): Promise<string[]> {
*/ */
export async function scanServerDrift(server: string): Promise<DriftEntry[]> { 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 = await scanManagedForServer(server);
const managed = new Set(drift.map((entry) => `${entry.kind}:${entry.relPath}`));
const srv = getServer(server); const srv = getServer(server);
// Without a promoted baseline, Plutainer owns the seeded config. Keep it // Without a promoted baseline, Plutainer owns the seeded config. Keep it
// until a baseline is promoted; startup arguments handle map selection. // until a baseline is promoted; startup arguments handle map selection.
@@ -98,14 +96,14 @@ export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
&& T5_ZOMBIE_MAPS.some((entry) => entry.canonical === srv.map) && T5_ZOMBIE_MAPS.some((entry) => entry.canonical === srv.map)
&& !getBaselineForScope('game', srv.game)?.current_version_id; && !getBaselineForScope('game', srv.game)?.current_version_id;
// Pass 1 — untracked files on disk in each managed destination. // Scan for untracked files only after resolving managed targets.
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);
if (onDisk.length === 0) continue; if (onDisk.length === 0) continue;
for (const relPath of onDisk) { for (const relPath of onDisk) {
if (tracked.has(relPath)) continue; if (tracked.has(relPath) || managed.has(`${kind}:${relPath}`)) continue;
if (keepDefaultConfig && kind === 'config' && relPath === config.configTargetFile) continue; if (keepDefaultConfig && kind === 'config' && relPath === config.configTargetFile) continue;
if (kind === 'config' && relPath === MAP_CONFIG_FILE && srv?.created_by if (kind === 'config' && relPath === MAP_CONFIG_FILE && srv?.created_by
&& srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map)) continue; && srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map)) continue;
@@ -126,17 +124,13 @@ export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
} }
} }
// 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 // Every promoted artifact whose scope matches this server's game/map should
// have its target file present on disk. Anything absent is 'missing' drift. // have its target file present on disk. Anything absent is 'missing' drift.
// Configs are handled as a composed layer (baseline + overlays → one cfg). // Configs are handled as a composed layer (baseline + overlays → one cfg).
async function scanMissingForServer(server: string): Promise<DriftEntry[]> { async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
const srv = getServer(server); const srv = getServer(server);
if (!srv) return []; if (!srv) return [];
const out: DriftEntry[] = []; const out: DriftEntry[] = [];
@@ -149,39 +143,22 @@ async function scanMissingForServer(server: string): Promise<DriftEntry[]> {
const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE); const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE);
const content = await readFile(abs).catch(() => null); const content = await readFile(abs).catch(() => null);
if (!content?.equals(mapConfig)) { if (!content?.equals(mapConfig)) {
out.push({ server, relPath: MAP_CONFIG_FILE, kind: 'config', driftKind: 'missing', out.push({ server, relPath: MAP_CONFIG_FILE, kind: 'config', driftKind: content ? 'replace' : 'missing',
size: mapConfig.byteLength, sha256: sha256Buffer(mapConfig) }); size: mapConfig.byteLength, sha256: sha256Buffer(mapConfig) });
} }
} }
const scopeApplies = (r: { scope_type: string | null; scope_value: string | null }): boolean => { // Existing files without inventory must be installed/tracked in one pass,
if (!r.scope_type || !r.scope_value) return false; // never deleted first. Preserve tracked files (including beta versions).
const field = r.scope_type === 'game' ? srv.game : srv.map; for (const { kind, script, version } of promotedArtifacts(srv.game, srv.map)) {
return field === r.scope_value; const present = existsSync(join(destOfKind(server, kind), script.target_rel_path));
}; if (present && getServerScript(server, script.target_rel_path)) continue;
out.push({
// scripts + plugins: one file per artifact at destOfKind(server, kind). server, relPath: script.target_rel_path, kind,
for (const kind of ['script', 'plugin'] as const) { driftKind: present ? 'replace' : 'missing',
const rows = listScripts(kind); size: version.size, sha256: version.sha256,
for (const s of rows) { scriptId: script.id, scriptVersionId: version.id
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 // Composed cfg: the effective dedicated_sp.cfg is baseline + all promoted
@@ -203,7 +180,7 @@ async function scanMissingForServer(server: string): Promise<DriftEntry[]> {
server, server,
relPath: targetRel, relPath: targetRel,
kind: 'config', kind: 'config',
driftKind: 'missing', driftKind: existsSync(abs) ? 'replace' : 'missing',
size: composed.content.byteLength, size: composed.content.byteLength,
sha256: composed.sha256, sha256: composed.sha256,
scriptId: baseline.id, scriptId: baseline.id,
@@ -219,10 +196,10 @@ async function scanMissingForServer(server: string): Promise<DriftEntry[]> {
// Recompute what dedicated_sp.cfg SHOULD look like on this server given the // Recompute what dedicated_sp.cfg SHOULD look like on this server given the
// current promoted baseline + overlays. Returns null if no baseline exists. // current promoted baseline + overlays. Returns null if no baseline exists.
// Resolve the content we should write for a 'missing' drift entry. For // Resolve the content we should install for a managed drift entry. For
// scripts/plugins, that's the current version's BLOB. For configs, recompose // scripts/plugins, that's the current version's BLOB. For configs, recompose
// the effective cfg from baseline + overlays. // the effective cfg from baseline + overlays.
async function writeMissingEntry(server: string, entry: DriftEntry): Promise<Buffer | null> { async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<Buffer | null> {
if (entry.kind === 'config') { if (entry.kind === 'config') {
const srv = getServer(server); const srv = getServer(server);
if (!srv?.game) return null; if (!srv?.game) return null;
@@ -271,7 +248,7 @@ export interface RunDriftInput {
export interface RunDriftResult { export interface RunDriftResult {
deploymentId: string | null; deploymentId: string | null;
// `removed` = untracked files deleted from disk (drift kind='untracked') // `removed` = untracked files deleted from disk (drift kind='untracked')
// `restored` = missing artifacts written back to disk (drift kind='missing') // `restored` = missing or existing artifacts installed and tracked
perServer: Array<{ server: string; removed: string[]; restored: string[]; skipped: string[] }>; perServer: Array<{ server: string; removed: string[]; restored: string[]; skipped: string[] }>;
totalRemoved: number; totalRemoved: number;
totalRestored: number; totalRestored: number;
@@ -286,8 +263,8 @@ function deploymentIdNow(): string {
/** /**
* Detect drift on the requested servers (or the whole fleet) and either * Detect drift on the requested servers (or the whole fleet) and either
* report the plan (dryRun) or actually remove the untracked files as a * report the plan (dryRun) or remove untracked files and install managed
* single drift deployment. The sweep restarts each affected server once * artifacts as a single deployment. The sweep restarts each affected server once
* it's empty and waits for it to come back healthy. * it's empty and waits for it to come back healthy.
*/ */
export async function runDriftDeployment(input: RunDriftInput = {}): Promise<RunDriftResult> { export async function runDriftDeployment(input: RunDriftInput = {}): Promise<RunDriftResult> {
@@ -316,7 +293,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
perServer: perServer.map((s) => ({ perServer: perServer.map((s) => ({
server: s.server, server: s.server,
removed: s.entries.filter((e) => e.driftKind === 'untracked').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), restored: s.entries.filter((e) => e.driftKind !== 'untracked').map((e) => e.relPath),
skipped: [] skipped: []
})), })),
totalRemoved: perServer.reduce( totalRemoved: perServer.reduce(
@@ -324,7 +301,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
0 0
), ),
totalRestored: perServer.reduce( totalRestored: perServer.reduce(
(n, s) => n + s.entries.filter((e) => e.driftKind === 'missing').length, (n, s) => n + s.entries.filter((e) => e.driftKind !== 'untracked').length,
0 0
), ),
totalServers: perServer.filter((s) => s.entries.length > 0).length, totalServers: perServer.filter((s) => s.entries.length > 0).length,
@@ -365,12 +342,16 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
s.removed.push(entry.relPath); s.removed.push(entry.relPath);
insertFile.run(depId, s.server, 'removed', entry.relPath, entry.sha256, null, content, null); insertFile.run(depId, s.server, 'removed', entry.relPath, entry.sha256, null, content, null);
} else { } else {
// Write back a missing artifact from the DB. // Install the managed artifact without deleting the target first.
const written = await writeMissingEntry(s.server, entry); const written = await resolveManagedEntry(s.server, entry);
if (!written) { if (!written) {
s.skipped.push(`${entry.relPath}: missing artifact could not be resolved from the DB`); s.skipped.push(`${entry.relPath}: missing artifact could not be resolved from the DB`);
continue; 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 mkdir(dirname(abs), { recursive: true });
await writeFile(abs, written); await writeFile(abs, written);
recordScript( recordScript(
@@ -382,7 +363,8 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
`drift:${depId}` `drift:${depId}`
); );
s.restored.push(entry.relPath); s.restored.push(entry.relPath);
insertFile.run(depId, s.server, 'added', entry.relPath, null, sha256Buffer(written), null, written); insertFile.run(depId, s.server, before ? 'updated' : 'added', entry.relPath,
before ? sha256Buffer(before) : null, sha256Buffer(written), before, written);
} }
} catch (err) { } catch (err) {
s.skipped.push(`${entry.relPath}: ${(err as Error).message}`); s.skipped.push(`${entry.relPath}: ${(err as Error).message}`);
@@ -0,0 +1,14 @@
import { getVersion, listScripts } from './scripts.js';
/** Published file artifacts shared by provisioning and drift reconciliation. */
export function promotedArtifacts(game: string | null, map: string | null) {
return (['script', 'plugin'] as const).flatMap((kind) =>
listScripts(kind).flatMap((script) => {
const scopeMatches = script.scope_type === 'game' ? script.scope_value === game
: script.scope_type === 'map' ? script.scope_value === map : false;
if (!scopeMatches || !script.current_version_id) return [];
const version = getVersion(script.current_version_id);
return version ? [{ kind, script, version }] : [];
})
);
}
+11 -7
View File
@@ -42,7 +42,7 @@
size: number; size: number;
sha256: string; sha256: string;
kind: 'script' | 'config' | 'plugin' | 'baseline'; kind: 'script' | 'config' | 'plugin' | 'baseline';
driftKind: 'untracked' | 'missing'; driftKind: 'untracked' | 'missing' | 'replace';
} }
interface DriftServer { server: string; files: DriftFile[] } interface DriftServer { server: string; files: DriftFile[] }
interface DriftPreview { totalDrift: number; servers: DriftServer[] } interface DriftPreview { totalDrift: number; servers: DriftServer[] }
@@ -74,12 +74,12 @@
} }
let driftCounts = $derived.by(() => { let driftCounts = $derived.by(() => {
if (!drift) return { untracked: 0, missing: 0 }; if (!drift) return { untracked: 0, missing: 0, replace: 0 };
let u = 0, m = 0; let u = 0, m = 0, r = 0;
for (const s of drift.servers) for (const f of s.files) { for (const s of drift.servers) for (const f of s.files) {
if (f.driftKind === 'untracked') u++; else m++; if (f.driftKind === 'untracked') u++; else if (f.driftKind === 'replace') r++; else m++;
} }
return { untracked: u, missing: m }; return { untracked: u, missing: m, replace: r };
}); });
// Auto-open on first scan if there's drift; leave user's toggle alone after. // Auto-open on first scan if there's drift; leave user's toggle alone after.
@@ -96,6 +96,7 @@
const parts: string[] = []; const parts: string[] = [];
if (driftCounts.untracked > 0) parts.push(`delete ${driftCounts.untracked} untracked file(s)`); 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 (driftCounts.missing > 0) parts.push(`restore ${driftCounts.missing} missing artifact(s)`);
if (driftCounts.replace > 0) parts.push(`replace ${driftCounts.replace} existing artifact(s)`);
if (!confirm(`Reconcile drift on ${drift.servers.length} server(s)? Will ${parts.join(' and ')}.`)) return; if (!confirm(`Reconcile drift on ${drift.servers.length} server(s)? Will ${parts.join(' and ')}.`)) return;
running = true; flash = null; running = true; flash = null;
try { try {
@@ -113,7 +114,7 @@
const restored = body.result?.totalRestored ?? 0; const restored = body.result?.totalRestored ?? 0;
const parts2: string[] = []; const parts2: string[] = [];
if (removed > 0) parts2.push(`removed ${removed}`); if (removed > 0) parts2.push(`removed ${removed}`);
if (restored > 0) parts2.push(`restored ${restored}`); if (restored > 0) parts2.push(`installed ${restored}`);
flash = `${parts2.join(', ') || 'no'} file(s) across ${body.result?.totalServers ?? 0} server(s)`; 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 {
@@ -232,6 +233,9 @@
{#if driftCounts.missing > 0} {#if driftCounts.missing > 0}
<Badge variant="warning" size="sm">{driftCounts.missing} missing</Badge> <Badge variant="warning" size="sm">{driftCounts.missing} missing</Badge>
{/if} {/if}
{#if driftCounts.replace > 0}
<Badge variant="warning" size="sm">{driftCounts.replace} to replace</Badge>
{/if}
<span class="drift-servers"> <span class="drift-servers">
across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'} across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'}
</span> </span>
@@ -276,7 +280,7 @@
mono mono
title="[{f.kind}] {f.driftKind} · {f.size}B · {f.sha256.slice(0, 12)}" title="[{f.kind}] {f.driftKind} · {f.size}B · {f.sha256.slice(0, 12)}"
> >
[{f.driftKind === 'missing' ? '+' : '−'} {f.kind}] {f.relPath} [{f.driftKind === 'missing' ? '+' : f.driftKind === 'replace' ? '↻' : '−'} {f.kind}] {f.relPath}
</Badge> </Badge>
{/each} {/each}
</div> </div>
+40
View File
@@ -53,6 +53,40 @@ async function promotedConfig() {
} }
describe('server creation map selection', () => { describe('server creation map selection', () => {
it('installs only promoted scope-matching scripts and plugins before first boot', async () => {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js');
const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js');
const artifacts = [
{ kind: 'script' as const, target: 'zm_spawn_fix.gsc', scopeType: 'game' as const, scopeValue: 't5', promoted: true, expected: true },
{ kind: 'plugin' as const, target: 'plugin.dll', scopeType: 'map' as const, scopeValue: 'zombie_cosmodrome', promoted: true, expected: true },
{ kind: 'script' as const, target: 'other.gsc', scopeType: 'map' as const, scopeValue: 'zombie_theater', promoted: true, expected: false },
{ kind: 'script' as const, target: 'draft.gsc', scopeType: 'game' as const, scopeValue: 't5', promoted: false, expected: false }
].map((a) => {
const { script, version } = createScript({ name: a.target, kind: a.kind, targetRelPath: a.target,
scopeType: a.scopeType, scopeValue: a.scopeValue, content: Buffer.from(a.target) });
if (a.promoted) db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
return { ...a, version };
});
vi.mocked(komodoDeploy).mockImplementationOnce(async () => {
for (const a of artifacts) {
const path = join(destOfKind(serverName, a.kind), a.target);
expect(existsSync(path)).toBe(a.expected);
if (a.expected) {
expect(await readFile(path)).toEqual(a.version.content);
expect(getServerScript(serverName, a.target)).toMatchObject({ script_version_id: a.version.id, source: 'create' });
}
}
return { ok: true };
});
expect(await createServer(input)).toMatchObject({ ok: true });
expect(await scanServerDrift(serverName)).toEqual([]);
});
it('keeps Plutainer defaults without a baseline and overrides the map before rotation', async () => { it('keeps Plutainer defaults without a baseline and overrides the map before rotation', async () => {
const { createServer } = await import('../../src/lib/server/fleet/create.js'); const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoCreateDeployment, komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js'); const { komodoCreateDeployment, komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js');
@@ -100,6 +134,11 @@ describe('server creation map selection', () => {
it('removes the generated config and inventory if deployment fails', async () => { it('removes the generated config and inventory if deployment fails', async () => {
await promotedConfig(); await promotedConfig();
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { script, version } = createScript({ name: 'spawn fix', kind: 'script', scopeType: 'game', scopeValue: 't5',
targetRelPath: 'zm_spawn_fix.gsc', content: Buffer.from('main(){}') });
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
const { createServer } = await import('../../src/lib/server/fleet/create.js'); const { createServer } = await import('../../src/lib/server/fleet/create.js');
const { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js'); const { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js');
const { getServer } = await import('../../src/lib/server/fleet/servers.js'); const { getServer } = await import('../../src/lib/server/fleet/servers.js');
@@ -108,6 +147,7 @@ describe('server creation map selection', () => {
expect(await createServer(input)).toMatchObject({ ok: false, kind: 'komodo' }); expect(await createServer(input)).toMatchObject({ ok: false, kind: 'komodo' });
expect(getServer(serverName)).toBeUndefined(); expect(getServer(serverName)).toBeUndefined();
expect(getServerScript(serverName, 'dedicated_sp.cfg')).toBeUndefined(); expect(getServerScript(serverName, 'dedicated_sp.cfg')).toBeUndefined();
expect(getServerScript(serverName, 'zm_spawn_fix.gsc')).toBeUndefined();
expect(existsSync(join(fixture.t5, serverName))).toBe(false); expect(existsSync(join(fixture.t5, serverName))).toBe(false);
}); });
+49
View File
@@ -22,6 +22,55 @@ afterEach(async () => {
}); });
describe('drift scanning', () => { describe('drift scanning', () => {
it('replaces a seeded config once instead of planning both removal and installation', async () => {
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { getPromotedServerConfig } = await import('../../src/lib/server/fleet/server-config.js');
await seedServerRegistry();
const server = 't5_zombie_theater_1';
const { script, version } = createScript({ name: 'baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't5',
content: Buffer.from('set sv_voice "1"\n') });
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
const path = join(fixture.t5, server, 'configs', 'dedicated_sp.cfg');
await writeFile(path, 'seeded config');
expect(await scanServerDrift(server)).toEqual([expect.objectContaining({ kind: 'config', driftKind: 'replace' })]);
expect(await runDriftDeployment({ servers: [server] })).toMatchObject({ totalRemoved: 0, totalRestored: 1 });
expect(await readFile(path)).toEqual(getPromotedServerConfig('t5', 'zombie_theater'));
expect(await scanServerDrift(server)).toEqual([]);
});
it.each(['identical', 'different'])('reconciles an existing %s scoped file in one pass with an update audit', async (mode) => {
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js');
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
await seedServerRegistry();
const server = 't5_zombie_theater_1';
const content = Buffer.from('main(){}\n');
const before = mode === 'identical' ? content : Buffer.from('old bytes');
const { script, version } = createScript({ name: 'spawn fix', kind: 'script', scopeType: 'game', scopeValue: 't5',
targetRelPath: 'zm_spawn_fix.gsc', content });
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
const target = join(destOfKind(server, 'script'), 'zm_spawn_fix.gsc');
await writeFile(target, before);
expect(await scanServerDrift(server)).toEqual([expect.objectContaining({ relPath: 'zm_spawn_fix.gsc', driftKind: 'replace' })]);
expect(await runDriftDeployment({ servers: [server], dryRun: true })).toMatchObject({ totalRemoved: 0, totalRestored: 1, deploymentId: null });
expect(await readFile(target)).toEqual(before);
expect(getServerScript(server, 'zm_spawn_fix.gsc')).toBeUndefined();
const result = await runDriftDeployment({ servers: [server] });
expect(result).toMatchObject({ totalRemoved: 0, totalRestored: 1 });
expect(await readFile(target)).toEqual(content);
expect(getServerScript(server, 'zm_spawn_fix.gsc')?.script_version_id).toBe(version.id);
expect(db().prepare('SELECT verb, content_before, content_after FROM deployment_files WHERE deployment_id = ?').all(result.deploymentId))
.toEqual([{ verb: 'updated', content_before: before, content_after: content }]);
expect(await scanServerDrift(server)).toEqual([]);
expect((await runDriftDeployment({ servers: [server] })).deploymentId).toBeNull();
});
it('detects untracked files across script, config, and plugin destinations', async () => { it('detects untracked files across script, config, and plugin destinations', async () => {
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js'); const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js');