Fix initial artifact deployment and drift file replacement
This commit is contained in:
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user