From 60e44636aa8e81355f3e22484fd5c3a9a7bb3edb Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 17 Sep 2026 16:15:47 -0400 Subject: [PATCH] Fix initial artifact deployment and drift file replacement --- src/lib/server/fleet/create.ts | 17 ++++ src/lib/server/fleet/drift.ts | 98 +++++++++------------- src/lib/server/fleet/promoted-artifacts.ts | 14 ++++ src/routes/deployments/+page.svelte | 18 ++-- tests/unit/create-server.test.ts | 40 +++++++++ tests/unit/drift-kinds.test.ts | 49 +++++++++++ 6 files changed, 171 insertions(+), 65 deletions(-) create mode 100644 src/lib/server/fleet/promoted-artifacts.ts diff --git a/src/lib/server/fleet/create.ts b/src/lib/server/fleet/create.ts index 729cabe..2605802 100644 --- a/src/lib/server/fleet/create.ts +++ b/src/lib/server/fleet/create.ts @@ -20,6 +20,7 @@ import { kick } from '../pipeline/worker.js'; import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js'; import { recordScript } from './inventory.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 // ever add MP or SP, split these based on mode. @@ -187,12 +188,25 @@ async function createServerUnlocked(input: CreateServerInput): Promise = ['script', 'config', 'plugin']; // via config-compose instead. const DRIFT_IGNORE_FILENAMES = new Set(['dedicated.cfg']); -// Two flavors of drift: -// 'untracked' — file on disk that inventory doesn't know about (delete it) +// 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'; +export type DriftKind = 'untracked' | 'missing' | 'replace'; export interface DriftEntry { server: string; @@ -45,7 +42,7 @@ export interface DriftEntry { // we compute the expected content. size: number; sha256: string; - // Populated for 'missing' entries so runDriftDeployment can write the + // Populated for missing/replacement entries so runDriftDeployment can write the // right content back. scriptId?: number; scriptVersionId?: number | null; @@ -90,7 +87,8 @@ async function walkFiles(root: string): Promise { */ export async function scanServerDrift(server: string): Promise { 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); // Without a promoted baseline, Plutainer owns the seeded config. Keep it // until a baseline is promoted; startup arguments handle map selection. @@ -98,14 +96,14 @@ export async function scanServerDrift(server: string): Promise { && T5_ZOMBIE_MAPS.some((entry) => entry.canonical === srv.map) && !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) { const dest = destOfKind(server, kind); const onDisk = await walkFiles(dest); if (onDisk.length === 0) continue; 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 (kind === 'config' && relPath === MAP_CONFIG_FILE && srv?.created_by && srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map)) continue; @@ -126,17 +124,13 @@ export async function scanServerDrift(server: string): Promise { } } - // 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 { +async function scanManagedForServer(server: string): Promise { const srv = getServer(server); if (!srv) return []; const out: DriftEntry[] = []; @@ -149,39 +143,22 @@ async function scanMissingForServer(server: string): Promise { 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: 'missing', + out.push({ server, relPath: MAP_CONFIG_FILE, kind: 'config', driftKind: content ? 'replace' : 'missing', size: mapConfig.byteLength, sha256: sha256Buffer(mapConfig) }); } } - 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 - }); - } + // 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 @@ -203,7 +180,7 @@ async function scanMissingForServer(server: string): Promise { server, relPath: targetRel, kind: 'config', - driftKind: 'missing', + driftKind: existsSync(abs) ? 'replace' : 'missing', size: composed.content.byteLength, sha256: composed.sha256, scriptId: baseline.id, @@ -219,10 +196,10 @@ async function scanMissingForServer(server: string): Promise { // 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 +// 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 writeMissingEntry(server: string, entry: DriftEntry): Promise { +async function resolveManagedEntry(server: string, entry: DriftEntry): Promise { if (entry.kind === 'config') { const srv = getServer(server); if (!srv?.game) return null; @@ -271,7 +248,7 @@ export interface RunDriftInput { export interface RunDriftResult { deploymentId: string | null; // `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[] }>; totalRemoved: number; totalRestored: number; @@ -286,8 +263,8 @@ function deploymentIdNow(): string { /** * Detect drift on the requested servers (or the whole fleet) and either - * report the plan (dryRun) or actually remove the untracked files as a - * single drift deployment. The sweep restarts each affected server once + * 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 { @@ -316,7 +293,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise ({ server: s.server, 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: [] })), totalRemoved: perServer.reduce( @@ -324,7 +301,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise n + s.entries.filter((e) => e.driftKind === 'missing').length, + (n, s) => n + s.entries.filter((e) => e.driftKind !== 'untracked').length, 0 ), totalServers: perServer.filter((s) => s.entries.length > 0).length, @@ -365,12 +342,16 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise { + if (err.code === 'ENOENT') return null; + throw err; + }); await mkdir(dirname(abs), { recursive: true }); await writeFile(abs, written); recordScript( @@ -382,7 +363,8 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise + 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 }] : []; + }) + ); +} diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte index 9fddc82..e820c00 100644 --- a/src/routes/deployments/+page.svelte +++ b/src/routes/deployments/+page.svelte @@ -42,7 +42,7 @@ size: number; sha256: string; kind: 'script' | 'config' | 'plugin' | 'baseline'; - driftKind: 'untracked' | 'missing'; + driftKind: 'untracked' | 'missing' | 'replace'; } interface DriftServer { server: string; files: DriftFile[] } interface DriftPreview { totalDrift: number; servers: DriftServer[] } @@ -74,12 +74,12 @@ } let driftCounts = $derived.by(() => { - if (!drift) return { untracked: 0, missing: 0 }; - let u = 0, m = 0; + if (!drift) return { untracked: 0, missing: 0, replace: 0 }; + let u = 0, m = 0, r = 0; 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. @@ -96,6 +96,7 @@ 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 (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; running = true; flash = null; try { @@ -113,7 +114,7 @@ const restored = body.result?.totalRestored ?? 0; const parts2: string[] = []; 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)`; await Promise.all([loadDeployments(), scanDrift()]); } finally { @@ -232,6 +233,9 @@ {#if driftCounts.missing > 0} {driftCounts.missing} missing {/if} + {#if driftCounts.replace > 0} + {driftCounts.replace} to replace + {/if} across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'} @@ -276,7 +280,7 @@ mono 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} {/each} diff --git a/tests/unit/create-server.test.ts b/tests/unit/create-server.test.ts index ec9adf2..368869e 100644 --- a/tests/unit/create-server.test.ts +++ b/tests/unit/create-server.test.ts @@ -53,6 +53,40 @@ async function promotedConfig() { } 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 () => { const { createServer } = await import('../../src/lib/server/fleet/create.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 () => { 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 { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.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(getServer(serverName)).toBeUndefined(); expect(getServerScript(serverName, 'dedicated_sp.cfg')).toBeUndefined(); + expect(getServerScript(serverName, 'zm_spawn_fix.gsc')).toBeUndefined(); expect(existsSync(join(fixture.t5, serverName))).toBe(false); }); diff --git a/tests/unit/drift-kinds.test.ts b/tests/unit/drift-kinds.test.ts index 2e6422e..8818ea7 100644 --- a/tests/unit/drift-kinds.test.ts +++ b/tests/unit/drift-kinds.test.ts @@ -22,6 +22,55 @@ afterEach(async () => { }); 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 () => { const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js');