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 { 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<CreateSer
const hostDir = `${config.serversRoot}/${name}`;
const initialConfig = getPromotedServerConfig('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
// after — plutainer expects to be able to write to /home/plutainer/app.
try {
await mkdir(hostDir, { recursive: true, mode: 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);
await mkdir(dirname(mapConfigPath), { recursive: true, mode: 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, 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) {
// Roll back Komodo + FS if the DB insert fails (very unlikely — most
+40 -58
View File
@@ -11,15 +11,11 @@ import {
recordScript,
removeScript
} from './inventory.js';
import {
getBaselineForScope,
getVersion,
listScripts,
type ScriptVersionRow
} from './scripts.js';
import { getBaselineForScope, getVersion } from './scripts.js';
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
import { T5_ZOMBIE_MAPS } from './map-aliases.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.
// 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.
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<string[]> {
*/
export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
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<DriftEntry[]> {
&& 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<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;
}
// 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[]> {
async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
const srv = getServer(server);
if (!srv) return [];
const out: DriftEntry[] = [];
@@ -149,39 +143,22 @@ async function scanMissingForServer(server: string): Promise<DriftEntry[]> {
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<DriftEntry[]> {
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<DriftEntry[]> {
// 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<Buffer | null> {
async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<Buffer | null> {
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<RunDriftResult> {
@@ -316,7 +293,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
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 === '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<Run
0
),
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
),
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);
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);
// 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;
}
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(
@@ -382,7 +363,8 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
`drift:${depId}`
);
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) {
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;
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}
<Badge variant="warning" size="sm">{driftCounts.missing} missing</Badge>
{/if}
{#if driftCounts.replace > 0}
<Badge variant="warning" size="sm">{driftCounts.replace} to replace</Badge>
{/if}
<span class="drift-servers">
across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'}
</span>
@@ -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}
</Badge>
{/each}
</div>