Defer server file changes until guarded restart activation
Persist beta, promotion, drift, and rollback payloads until occupancy and restart-capacity checks pass. Preserve rollback snapshots and campaign ordering, and allow held rollback campaigns to resume safely. Fixes #28 Validation: 805 tests passed, Svelte checks clean, production build passed.
This commit is contained in:
@@ -1,21 +1,18 @@
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
|
||||
import { dirname, join, posix, relative, sep } from 'node:path';
|
||||
import { join, posix, relative, sep } from 'node:path';
|
||||
import { configFileOf, destOfKind, serverDir, type ArtifactKind } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
import { sha256Buffer } from './files.js';
|
||||
import {
|
||||
getServerScript,
|
||||
recordScript,
|
||||
removeScript
|
||||
} from './inventory.js';
|
||||
import { getServerScript } from './inventory.js';
|
||||
import { getBaselineForScope, getVersion } from './scripts.js';
|
||||
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
|
||||
import { selectedZombieMap } from './map-aliases.js';
|
||||
import { getServer, listServers } from './servers.js';
|
||||
import { promotedArtifacts } from './promoted-artifacts.js';
|
||||
import { queueFileChange } from './staged-files.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.
|
||||
@@ -309,8 +306,8 @@ export async function previewDrift(servers?: string[]): Promise<DriftPlan> {
|
||||
|
||||
export interface RunDriftResult {
|
||||
deploymentId: string | null;
|
||||
// `removed` = untracked files deleted from disk (drift kind='untracked')
|
||||
// `restored` = missing or existing artifacts installed and tracked
|
||||
// `removed` and `restored` describe queued changes. Provisioning applies
|
||||
// them only after the server passes its activation gates.
|
||||
perServer: Array<{ server: string; removed: string[]; restored: string[]; skipped: string[] }>;
|
||||
totalRemoved: number;
|
||||
totalRestored: number;
|
||||
@@ -381,9 +378,8 @@ export function createDriftRebuildCampaign(
|
||||
|
||||
/**
|
||||
* Detect drift on the requested servers (or the whole fleet) and either
|
||||
* 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.
|
||||
* report the plan (dryRun) or stage the changes as a single deployment.
|
||||
* Provisioning applies them and restarts each affected server once it's empty.
|
||||
*/
|
||||
export async function runDriftDeployment(input: RunDriftInput = {}): Promise<RunDriftResult> {
|
||||
const { dryRun = false, triggeredBy = 'ui' } = input;
|
||||
@@ -471,15 +467,19 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
||||
}
|
||||
}
|
||||
if (entry.driftKind === 'untracked') {
|
||||
// Delete a file inventory doesn't know about.
|
||||
// Capture the reviewed deletion without touching the live tree.
|
||||
const content = await readFile(abs);
|
||||
if (hasActiveBeta(s.server)) throw new Error('active beta owns this server; reconciliation deferred');
|
||||
await rm(abs, { force: true });
|
||||
removeScript(s.server, entry.relPath);
|
||||
db().transaction(() => {
|
||||
const file = insertFile.run(depId, s.server, 'removed', entry.relPath, sha256Buffer(content), null, content, null);
|
||||
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: s.server,
|
||||
kind: entry.kind, relPath: entry.relPath, content: null,
|
||||
inventorySource: `drift:${depId}`, deploymentFileId: Number(file.lastInsertRowid),
|
||||
expectedBeforeSha: reviewed?.beforeSha256 ?? sha256Buffer(content) });
|
||||
})();
|
||||
s.removed.push(entry.relPath);
|
||||
insertFile.run(depId, s.server, 'removed', entry.relPath, entry.sha256, null, content, null);
|
||||
} else {
|
||||
// Install the managed artifact without deleting the target first.
|
||||
// Persist exact replacement bytes so waiting cannot change the plan.
|
||||
const written = await resolveManagedEntry(s.server, entry);
|
||||
if (!written) {
|
||||
s.skipped.push(`${entry.relPath}: missing artifact could not be resolved from the DB`);
|
||||
@@ -493,20 +493,17 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
||||
if (err.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
});
|
||||
await mkdir(dirname(abs), { recursive: true });
|
||||
if (hasActiveBeta(s.server)) throw new Error('active beta owns this server; reconciliation deferred');
|
||||
await writeFile(abs, written);
|
||||
recordScript(
|
||||
s.server,
|
||||
entry.relPath,
|
||||
entry.kind === 'config' ? null : entry.scriptVersionId ?? null,
|
||||
sha256Buffer(written),
|
||||
written.byteLength,
|
||||
`drift:${depId}`
|
||||
);
|
||||
db().transaction(() => {
|
||||
const file = insertFile.run(depId, s.server, before ? 'updated' : 'added', entry.relPath,
|
||||
before ? sha256Buffer(before) : null, sha256Buffer(written), before, written);
|
||||
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: s.server,
|
||||
kind: entry.kind, relPath: entry.relPath, content: written,
|
||||
scriptVersionId: entry.kind === 'config' ? null : entry.scriptVersionId ?? null,
|
||||
inventorySource: `drift:${depId}`, deploymentFileId: Number(file.lastInsertRowid),
|
||||
expectedBeforeSha: reviewed ? reviewed.beforeSha256 : before ? sha256Buffer(before) : null });
|
||||
})();
|
||||
s.restored.push(entry.relPath);
|
||||
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