Fix initial artifact deployment and drift file replacement
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,40 +143,23 @@ 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;
|
||||||
|
|
||||||
// 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({
|
out.push({
|
||||||
server,
|
server, relPath: script.target_rel_path, kind,
|
||||||
relPath: s.target_rel_path,
|
driftKind: present ? 'replace' : 'missing',
|
||||||
kind,
|
size: version.size, sha256: version.sha256,
|
||||||
driftKind: 'missing',
|
scriptId: script.id, scriptVersionId: version.id
|
||||||
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
|
||||||
// overlays for this server's scope. It's "missing" when either the file
|
// overlays for this server's scope. It's "missing" when either the file
|
||||||
@@ -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 }] : [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user