Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
@@ -73,8 +73,15 @@ export function testView(betaId: number): BetaTestView | null {
|
||||
const runs = db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND retired_at IS NULL ORDER BY server').all(betaId) as TestRun[];
|
||||
return { plan, outcome: stored.outcome, reason: stored.reason,
|
||||
servers: runs.map(run => {
|
||||
const progress = evaluateRequirements(plan, readMetrics(run), run.phase);
|
||||
return { server: run.server, phase: run.phase, passed: progress.every(r => r.passed), progress };
|
||||
const metrics = readMetrics(run);
|
||||
const progress = evaluateRequirements(plan, metrics, run.phase);
|
||||
const latest = db().prepare("SELECT received_at, kind, payload FROM beta_test_evidence WHERE run_id = ? AND kind IN ('game_start','round_change','game_end') ORDER BY id DESC LIMIT 1").get(run.id) as { received_at: string; kind: string; payload: string } | undefined;
|
||||
const event = latest ? JSON.parse(latest.payload) : null;
|
||||
return { server: run.server, phase: run.phase, passed: progress.every(r => r.passed), progress, observingAt: run.observing_at,
|
||||
observation: { lastGameplayAt: latest?.received_at ?? null, lastEvent: latest?.kind ?? null,
|
||||
players: metrics.match?.players ?? null, round: event?.round ?? null,
|
||||
matchStarted: metrics.match?.started !== undefined, gameEnded: metrics.match?.ended ?? false,
|
||||
healthy: metrics.wasHealthy ?? null, healthCheckedAt: metrics.lastSample ? new Date(metrics.lastSample).toISOString() : null } };
|
||||
}),
|
||||
evidence: db().prepare(`SELECT e.id, r.server, e.received_at, e.kind, e.payload FROM beta_test_evidence e
|
||||
JOIN beta_test_runs r ON r.id = e.run_id WHERE r.beta_id = ? ORDER BY e.id DESC LIMIT 200`).all(betaId) as BetaTestView['evidence'] };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
|
||||
import { storedPlan } from './beta-tests.js';
|
||||
import type { BetaPromotionPreview } from '../../beta-promotion.js';
|
||||
|
||||
const operations = new Set<number>();
|
||||
export const betaOperationInFlight = (id: number) => operations.has(id);
|
||||
@@ -192,6 +193,7 @@ export class BetaError extends Error {
|
||||
| 'dest-fail'
|
||||
| 'invalid-state'
|
||||
| 'duplicate-script'
|
||||
| 'preview-changed'
|
||||
| 'duplicate-target',
|
||||
public readonly details?: unknown
|
||||
) {
|
||||
@@ -721,6 +723,54 @@ export interface PromoteResult {
|
||||
deploymentId: string;
|
||||
landed: string[]; // unique servers that received at least one file
|
||||
scriptIds: number[];
|
||||
awaitingActivation: number;
|
||||
failedServers: string[];
|
||||
}
|
||||
|
||||
/** Snapshot exactly the scope expansion and composed configuration reviewed by the operator. */
|
||||
function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configContent: Map<string, Buffer> } {
|
||||
const beta = getBeta(betaId);
|
||||
if (!beta) throw new BetaError('This beta no longer exists.', 'no-such-beta');
|
||||
const bundle = listBetaScripts(betaId).map(row => {
|
||||
const script = getScript(row.script_id), version = getVersion(row.script_version_id);
|
||||
if (!script || !version) throw new BetaError('A selected file version is no longer available.', 'no-such-version');
|
||||
return { script, version };
|
||||
});
|
||||
if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts');
|
||||
const tested = new Set(listBetaServers(betaId).map(row => row.server));
|
||||
const targets = (script: ScriptRow) => [...new Set([...tested, ...(script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type, script.scope_value) : [])])].sort();
|
||||
const files = bundle.map(({ script, version }) => ({ id: script.id, name: script.name, kind: script.kind,
|
||||
path: script.target_rel_path, versionId: version.id, version: version.version,
|
||||
release_version: version.release_version, sha256: version.sha256,
|
||||
scope: script.scope_type ? `${script.scope_type}: ${script.scope_value}` : 'Selected test servers only', servers: targets(script) }));
|
||||
const names = [...new Set(files.flatMap(file => file.servers))].sort();
|
||||
const blockers: string[] = [];
|
||||
const servers = names.map(name => {
|
||||
const server = getServer(name);
|
||||
if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`);
|
||||
return { name, tested: tested.has(name), game: server?.game ?? null, map: server?.map ?? null };
|
||||
});
|
||||
const collapsing = bundle.filter(({ script }) => isCollapsingKind(script.kind));
|
||||
const configContent = new Map<string, Buffer>();
|
||||
const configWrites = [...new Set(collapsing.flatMap(({ script }) => targets(script)))].sort().map(server => {
|
||||
const row = getServer(server);
|
||||
const composed = row && composeCfgForServer(server, row.game, row.map, {
|
||||
inBetaBaseline: collapsing.find(({ script }) => script.kind === 'baseline')?.version,
|
||||
inBetaOverlays: collapsing.filter(({ script }) => script.kind === 'config')
|
||||
});
|
||||
const error = composed ? null : `${server}: no baseline is available to compose its configuration.`;
|
||||
if (error) blockers.push(error);
|
||||
const content = composed ? Buffer.from(composed.text, 'utf8') : null;
|
||||
if (content) configContent.set(server, content);
|
||||
return { server, path: configFileOf(server), sha256: content ? sha256Buffer(content) : null, error };
|
||||
});
|
||||
const impact = { betaId, files, servers, configWrites, blockers, restartPolicy: 'additional-when-empty' as const };
|
||||
return { preview: { ...impact, token: sha256Buffer(Buffer.from(JSON.stringify(impact))), createdAt: nowIso() }, configContent };
|
||||
}
|
||||
|
||||
export function previewBetaPromotion(betaId: number): BetaPromotionPreview {
|
||||
return promotionPlan(betaId).preview;
|
||||
}
|
||||
|
||||
function promoteDeploymentId(betaId: number): string {
|
||||
@@ -728,14 +778,14 @@ function promoteDeploymentId(betaId: number): string {
|
||||
return `dep-${stamp}-promote-${betaId}`;
|
||||
}
|
||||
|
||||
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
export async function promoteBeta(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
|
||||
if (storedPlan(betaId)) {
|
||||
const { plannedBetaTick } = await import('../pipeline/beta-plan-runner.js');
|
||||
await plannedBetaTick();
|
||||
}
|
||||
return exclusive(betaId, () => promoteBetaInternal(betaId));
|
||||
return exclusive(betaId, () => promoteBetaInternal(betaId, reviewedToken));
|
||||
}
|
||||
async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
async function promoteBetaInternal(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
|
||||
const test = storedPlan(betaId);
|
||||
if (test && test.outcome !== 'passed') throw new BetaError('Test plan has not passed', 'not-ready-promote');
|
||||
const beta = getBeta(betaId);
|
||||
@@ -743,6 +793,15 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
if (beta.state !== 'ready-promote') {
|
||||
throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-ready-promote');
|
||||
}
|
||||
const reviewed = promotionPlan(betaId);
|
||||
const checkReview = () => {
|
||||
const current = previewBetaPromotion(betaId);
|
||||
if (current.token !== (reviewedToken ?? reviewed.preview.token)) {
|
||||
throw new BetaError('Promotion targets or versions changed. Review the updated plan before promoting.', 'preview-changed', current);
|
||||
}
|
||||
if (current.blockers.length) throw new BetaError(current.blockers.join(' '), 'invalid-state');
|
||||
};
|
||||
checkReview();
|
||||
const bundleRows = listBetaScripts(betaId);
|
||||
if (bundleRows.length === 0) throw new BetaError('beta has no scripts', 'no-scripts');
|
||||
const bundle = bundleRows.map((br) => ({
|
||||
@@ -755,8 +814,6 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
// Split bundle into non-collapsing (per-file) and collapsing (composed cfg).
|
||||
const nonCollapsing = bundle.filter((b) => !isCollapsingKind(b.script.kind));
|
||||
const collapsing = bundle.filter((b) => isCollapsingKind(b.script.kind));
|
||||
const collapsingBaseline = collapsing.find((b) => b.script.kind === 'baseline');
|
||||
const collapsingOverlays = collapsing.filter((b) => b.script.kind === 'config');
|
||||
|
||||
// Build a per-(script, server) plan for non-collapsing artifacts.
|
||||
interface Plan {
|
||||
@@ -776,10 +833,7 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
);
|
||||
|
||||
for (const { script, version } of nonCollapsing) {
|
||||
const scopeServers = script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type, script.scope_value)
|
||||
: [];
|
||||
const targets = new Set<string>([...betaServerSet, ...scopeServers]);
|
||||
const targets = reviewed.preview.files.find(file => file.id === script.id)!.servers;
|
||||
|
||||
for (const name of targets) {
|
||||
const dest = destOfKind(name, script.kind);
|
||||
@@ -830,23 +884,6 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
// Write files. Track per-plan success and per-server first-error.
|
||||
const planWritten = new Map<Plan, boolean>();
|
||||
const serverErr = new Map<string, string>();
|
||||
for (const p of plans) {
|
||||
const dest = destOfKind(p.server, p.script.kind);
|
||||
const targetPath = join(dest, p.script.target_rel_path);
|
||||
try {
|
||||
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
|
||||
// Beta servers already have the version bytes at targetPath from
|
||||
// openBeta; scope-expansion servers need the write. Idempotent
|
||||
// either way — we just overwrite with the promoted content.
|
||||
await writeFile(targetPath, p.version.content);
|
||||
planWritten.set(p, true);
|
||||
} catch (err) {
|
||||
planWritten.set(p, false);
|
||||
const msg = `${p.script.name}: ${(err as Error).message}`;
|
||||
if (!serverErr.has(p.server)) serverErr.set(p.server, msg);
|
||||
console.error(`[promote] ${p.server}/${p.script.name} failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsing config layer: one composed cfg per server. Union of beta
|
||||
// servers + scope-matching servers for every collapsing artifact.
|
||||
@@ -860,29 +897,18 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
}
|
||||
const cfgPlans: CfgPlan[] = [];
|
||||
if (collapsing.length > 0) {
|
||||
const cfgTargets = new Set<string>(betaServerSet);
|
||||
for (const { script } of collapsing) {
|
||||
if (script.scope_type && script.scope_value) {
|
||||
for (const s of serversMatchingScope(script.scope_type, script.scope_value)) {
|
||||
cfgTargets.add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
const cfgTargets = reviewed.preview.configWrites.map(write => write.server);
|
||||
for (const name of cfgTargets) {
|
||||
const srv = getServer(name);
|
||||
if (!srv) {
|
||||
serverErr.set(name, 'unknown server during config promote');
|
||||
continue;
|
||||
}
|
||||
const composedResult = composeCfgForServer(name, srv.game, srv.map, {
|
||||
inBetaBaseline: collapsingBaseline?.version,
|
||||
inBetaOverlays: collapsingOverlays
|
||||
});
|
||||
if (!composedResult) {
|
||||
const composedBuf = reviewed.configContent.get(name);
|
||||
if (!composedBuf) {
|
||||
serverErr.set(name, `no baseline available for ${name} (game=${srv.game ?? '?'})`);
|
||||
continue;
|
||||
}
|
||||
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
||||
const composedSha = sha256Buffer(composedBuf);
|
||||
const dest = destOfKind(name, 'config');
|
||||
const targetRel = configFileOf(name);
|
||||
@@ -913,6 +939,20 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// Recheck after asynchronous snapshot reads, before any mutation. Execution uses
|
||||
// the captured target lists/content so later membership changes never expand it.
|
||||
checkReview();
|
||||
for (const p of plans) {
|
||||
const dest = destOfKind(p.server, p.script.kind);
|
||||
try {
|
||||
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
|
||||
await writeFile(join(dest, p.script.target_rel_path), p.version.content);
|
||||
planWritten.set(p, true);
|
||||
} catch (err) {
|
||||
planWritten.set(p, false);
|
||||
if (!serverErr.has(p.server)) serverErr.set(p.server, `${p.script.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
// Write composed cfg to each target.
|
||||
const cfgWritten = new Map<CfgPlan, boolean>();
|
||||
for (const cp of cfgPlans) {
|
||||
@@ -1061,7 +1101,9 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
betaId,
|
||||
deploymentId: depId,
|
||||
landed,
|
||||
scriptIds: bundle.map((b) => b.script.id)
|
||||
scriptIds: bundle.map((b) => b.script.id),
|
||||
awaitingActivation: reviewed.preview.servers.filter(server => !server.tested && !serverErr.has(server.name)).length,
|
||||
failedServers: [...serverErr.keys()]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { config, komodoConfigured, rconConfigured } from '../config.js';
|
||||
import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js';
|
||||
import { creationQueue, nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js';
|
||||
import { getServerKey } from './server-keys.js';
|
||||
import { classifyHealth } from './health.js';
|
||||
import { getServer } from './servers.js';
|
||||
import { parseServerName, zombieMapBySlug, zombieMaps } from './map-aliases.js';
|
||||
|
||||
function coverage(entries: BlueprintEntry[]) {
|
||||
@@ -29,7 +31,15 @@ function coverage(entries: BlueprintEntry[]) {
|
||||
const matches = zombieMaps(game).filter(map => map.canonical === server.map);
|
||||
return matches.length === 1 && matches[0].slug === entry.recipe.mapSlug;
|
||||
}).map(server => server.name).sort();
|
||||
return { existingServers, existing: existingServers.length, missing: Math.max(0, entry.count - existingServers.length) };
|
||||
const coverage = existingServers.map(name => {
|
||||
const operation = db().prepare("SELECT id, state FROM server_deployments WHERE server = ? AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health') ORDER BY rowid DESC LIMIT 1").get(name) as { id: string; state: string } | undefined;
|
||||
const server = getServer(name);
|
||||
return { name, health: operation ? operation.state === 'queued' ? 'queued' : 'starting'
|
||||
: server?.container_state && !['running', 'starting', 'restarting'].includes(server.container_state) ? 'stopped'
|
||||
: classifyHealth(server?.last_seen_at ?? null, server?.last_error ?? null, server?.container_state),
|
||||
href: operation ? `/deployments/${encodeURIComponent(operation.id)}` : `/servers/${encodeURIComponent(name)}` };
|
||||
});
|
||||
return { existingServers, coverage, existing: existingServers.length, missing: Math.max(0, entry.count - existingServers.length) };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
|
||||
import { beginDeployment, deploymentStage, finishDeployment, recordDeploymentEvent, REMOVING_SERVER, resumeCleanup, resumeVerification } from './deployments.js';
|
||||
import { beginDeployment, deploymentStage, finishDeployment, recordDeploymentEvent, REMOVING_SERVER, resumeCleanup, resumeVerification, claimRemoval } from './deployments.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { LaunchPreview } from '$lib/deployment-plan.js';
|
||||
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from './launch-recipes.js';
|
||||
@@ -461,6 +461,7 @@ async function runDestructions(): Promise<void> {
|
||||
const queued = db().prepare("SELECT id, server, stage FROM server_deployments WHERE action = 'destroy' AND state = 'waiting' ORDER BY started_at").all() as { id: string; server: string; stage: string }[];
|
||||
for (const operation of queued) {
|
||||
if (creating || destroying.has(operation.server)) continue;
|
||||
if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) continue;
|
||||
destroying.add(operation.server);
|
||||
try {
|
||||
if (operation.stage === REMOVING_SERVER) {
|
||||
@@ -470,7 +471,7 @@ async function runDestructions(): Promise<void> {
|
||||
finishDeployment(operation.id, { ok: false, message: 'A newer creation exists; local cleanup cannot safely resume.' });
|
||||
continue;
|
||||
}
|
||||
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(operation.id);
|
||||
if (!claimRemoval(operation.id)) continue;
|
||||
finishDeployment(operation.id, await cleanupDestroyedServer(operation.server, operation.id));
|
||||
continue;
|
||||
}
|
||||
@@ -484,20 +485,21 @@ async function runDestructions(): Promise<void> {
|
||||
}
|
||||
// A fresh successful zero is required; cached occupancy and timeouts are not evidence of emptiness.
|
||||
const players = await rconGetPlayerCount(operation.server);
|
||||
if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) continue;
|
||||
if (players !== 0) {
|
||||
deploymentStage(operation.id, players === null
|
||||
? 'Waiting for player count: server could not be reached'
|
||||
: 'Waiting for server to empty: ' + players + ' player(s) online');
|
||||
continue;
|
||||
}
|
||||
if (!claimRemoval(operation.id)) continue;
|
||||
deploymentStage(operation.id, 'Server is empty (0 players verified)');
|
||||
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(operation.id);
|
||||
const result = await destroyServerUnlocked(operation.server, operation.id);
|
||||
finishDeployment(operation.id, result);
|
||||
} catch (err) {
|
||||
const row = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(operation.id) as { state: string };
|
||||
if (row.state === 'waiting') deploymentStage(operation.id, 'Waiting for player count: ' + (err as Error).message);
|
||||
else finishDeployment(operation.id, { ok: false, message: (err as Error).message });
|
||||
else if (row.state !== 'cancelled') finishDeployment(operation.id, { ok: false, message: (err as Error).message });
|
||||
} finally { destroying.delete(operation.server); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,31 @@ export function getDeployment(id: string) {
|
||||
const collection = db().prepare(`SELECT c.id, c.name FROM server_collection_members m
|
||||
JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ?`).get(id) ?? null;
|
||||
return { deployment, events, recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null,
|
||||
canResumeVerification: canResumeVerification(id), canResumeCleanup: canResumeCleanup(id), collection };
|
||||
canResumeVerification: canResumeVerification(id), canResumeCleanup: canResumeCleanup(id), canCancelRemoval: canCancelRemoval(id), collection };
|
||||
}
|
||||
|
||||
// This stage is written only after Komodo has confirmed deployment deletion.
|
||||
export const REMOVING_SERVER = 'Removing server files and registry';
|
||||
|
||||
export function canCancelRemoval(id: string): boolean {
|
||||
return !!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND action = 'destroy' AND state = 'waiting' AND stage != ?").get(id, REMOVING_SERVER);
|
||||
}
|
||||
|
||||
/** Competes atomically with the worker's claim, before any external removal. */
|
||||
export function cancelRemoval(id: string): boolean {
|
||||
return db().transaction(() => {
|
||||
const result = db().prepare(`UPDATE server_deployments SET state = 'cancelled', stage = 'Removal cancelled', finished_at = ?, error = NULL
|
||||
WHERE id = ? AND action = 'destroy' AND state = 'waiting' AND stage != ?`).run(new Date().toISOString(), id, REMOVING_SERVER);
|
||||
if (!result.changes) return false;
|
||||
recordDeploymentEvent(id, 'Queued removal cancelled. The server and its files were kept.');
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
export function claimRemoval(id: string): boolean {
|
||||
return db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ? AND action = 'destroy' AND state = 'waiting'").run(id).changes > 0;
|
||||
}
|
||||
|
||||
export function canResumeCleanup(id: string): boolean {
|
||||
return !!db().prepare(`SELECT d.id FROM server_deployments d
|
||||
WHERE d.id = ? AND d.action = 'destroy' AND d.state IN ('failed', 'interrupted') AND d.stage = ?
|
||||
@@ -81,8 +100,9 @@ export function finishDeployment(id: string, result: { ok: boolean; message?: st
|
||||
}
|
||||
|
||||
export function listDeployments() {
|
||||
return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
|
||||
FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all();
|
||||
return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists,
|
||||
(d.action = 'destroy' AND d.state = 'waiting' AND d.stage != ?) AS canCancelRemoval
|
||||
FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all(REMOVING_SERVER);
|
||||
}
|
||||
|
||||
export function interruptDeployments(): void {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, posix, relative, sep } from 'node:path';
|
||||
import { configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||
@@ -244,6 +246,40 @@ export interface RunDriftInput {
|
||||
servers?: string[]; // if omitted, scans the whole fleet
|
||||
dryRun?: boolean;
|
||||
triggeredBy?: 'ui' | 'sweep' | 'cron';
|
||||
expectedFingerprint?: string;
|
||||
}
|
||||
|
||||
export class DriftPlanChanged extends Error {
|
||||
constructor(public plan: DriftPlan) { super('The files or target servers changed. Nothing was changed. Review the updated plan before continuing.'); }
|
||||
}
|
||||
|
||||
function driftTargets(servers?: string[]): string[] {
|
||||
const registered = listServers().map(server => server.name);
|
||||
if (servers && (!servers.length || servers.some(name => !registered.includes(name)))) throw new Error('Select at least one registered server. Refresh the fleet if a server was removed.');
|
||||
return [...new Set(servers ?? registered)].sort();
|
||||
}
|
||||
|
||||
async function describePlan(servers: string[], entries: DriftEntry[]): Promise<DriftPlan> {
|
||||
const files: DriftPlanFile[] = [];
|
||||
for (const entry of entries) {
|
||||
const before = await readFile(join(destOfKind(entry.server, entry.kind), entry.relPath)).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
});
|
||||
const after = entry.driftKind === 'untracked' ? null : await resolveManagedEntry(entry.server, entry);
|
||||
files.push({ server: entry.server, relPath: entry.relPath, kind: entry.kind, driftKind: entry.driftKind,
|
||||
beforeSha256: before ? sha256Buffer(before) : null, afterSha256: after ? sha256Buffer(after) : null,
|
||||
beforeSize: before?.byteLength ?? null, afterSize: after?.byteLength ?? null });
|
||||
}
|
||||
files.sort((a, b) => `${a.server}:${a.kind}:${a.relPath}`.localeCompare(`${b.server}:${b.kind}:${b.relPath}`));
|
||||
return { servers, files, scannedAt: new Date().toISOString(), fingerprint: sha256Buffer(Buffer.from(JSON.stringify({ servers, files }))) };
|
||||
}
|
||||
|
||||
export async function previewDrift(servers?: string[]): Promise<DriftPlan> {
|
||||
const targets = driftTargets(servers);
|
||||
const entries: DriftEntry[] = [];
|
||||
for (const server of targets) entries.push(...await scanServerDrift(server));
|
||||
return describePlan(targets, entries);
|
||||
}
|
||||
|
||||
export interface RunDriftResult {
|
||||
@@ -259,7 +295,7 @@ export interface RunDriftResult {
|
||||
|
||||
function deploymentIdNow(): string {
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||
return `dep-${stamp}-drift`;
|
||||
return `dep-${stamp}-drift-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,9 +307,7 @@ function deploymentIdNow(): string {
|
||||
export async function runDriftDeployment(input: RunDriftInput = {}): Promise<RunDriftResult> {
|
||||
const { dryRun = false, triggeredBy = 'ui' } = input;
|
||||
|
||||
const scanTargets = input.servers && input.servers.length > 0
|
||||
? input.servers
|
||||
: listServers().map((s) => s.name);
|
||||
const scanTargets = driftTargets(input.servers);
|
||||
|
||||
const perServer: Array<{
|
||||
server: string;
|
||||
@@ -287,6 +321,9 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
||||
perServer.push({ server: name, removed: [], restored: [], skipped: [], entries });
|
||||
}
|
||||
|
||||
// Recheck identities, existing bytes, and replacement bytes before any write.
|
||||
const plan = input.expectedFingerprint ? await describePlan(scanTargets, perServer.flatMap(server => server.entries)) : null;
|
||||
if (plan && plan.fingerprint !== input.expectedFingerprint) throw new DriftPlanChanged(plan);
|
||||
const totalDrift = perServer.reduce((sum, s) => sum + s.entries.length, 0);
|
||||
if (totalDrift === 0 || dryRun) {
|
||||
return {
|
||||
@@ -335,6 +372,14 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
||||
const dest = destOfKind(s.server, entry.kind);
|
||||
const abs = join(dest, entry.relPath);
|
||||
try {
|
||||
const reviewed = plan?.files.find(file => file.server === s.server && file.kind === entry.kind && file.relPath === entry.relPath);
|
||||
if (reviewed) {
|
||||
const current = await readFile(abs).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null; throw error; });
|
||||
if ((current ? sha256Buffer(current) : null) !== reviewed.beforeSha256) {
|
||||
s.skipped.push(`${entry.relPath}: changed after review; scan and review again`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (entry.driftKind === 'untracked') {
|
||||
// Delete a file inventory doesn't know about.
|
||||
const content = await readFile(abs);
|
||||
@@ -349,6 +394,10 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
||||
s.skipped.push(`${entry.relPath}: missing artifact could not be resolved from the DB`);
|
||||
continue;
|
||||
}
|
||||
if (reviewed && sha256Buffer(written) !== reviewed.afterSha256) {
|
||||
s.skipped.push(`${entry.relPath}: promoted content changed after review; scan and review again`);
|
||||
continue;
|
||||
}
|
||||
const before = await readFile(abs).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
|
||||
@@ -35,17 +35,17 @@ export function fleetOperations(now = Date.now()): FleetOperation[] {
|
||||
// Only the newest lifecycle operation can still require action on a server.
|
||||
const lifecycle = handle.prepare(`SELECT d.* FROM server_deployments d
|
||||
WHERE d.state IN ('queued', 'running', 'waiting', 'awaiting-health')
|
||||
OR (d.state IN ('done', 'warning') AND d.finished_at >= ?)
|
||||
OR (d.state IN ('done', 'warning', 'cancelled') AND d.finished_at >= ?)
|
||||
OR (d.state IN ('failed', 'interrupted', 'warning') AND NOT EXISTS
|
||||
(SELECT 1 FROM server_deployments n WHERE n.server = d.server AND n.rowid > d.rowid))`)
|
||||
.all(since) as { id: string; server: string; action: string; state: string; stage: string;
|
||||
error: string | null; started_at: string }[];
|
||||
for (const row of lifecycle) {
|
||||
const state = row.state === 'done' || row.state === 'warning' ? 'done'
|
||||
const state = ['done', 'warning', 'cancelled'].includes(row.state) ? 'done'
|
||||
: ['failed', 'interrupted'].includes(row.state) ? 'attention' : 'active';
|
||||
operations.push({ key: `deployment:${row.id}`, group: `deployment:${row.id}`, kind: 'deployment',
|
||||
server: row.server, state, startedAt: row.started_at,
|
||||
label: state === 'done' ? row.state === 'warning' ? 'Completed with warnings' : 'Complete'
|
||||
label: state === 'done' ? row.state === 'cancelled' ? 'Removal cancelled' : row.state === 'warning' ? 'Completed with warnings' : 'Complete'
|
||||
: state === 'attention' ? `${row.action === 'create' ? 'Launch' : 'Removal'} needs attention` : row.stage,
|
||||
detail: row.error ?? row.stage, href: `/deployments/${encodeURIComponent(row.id)}` });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user