Files
sencho/backend/src/services/UpdateGuardService.ts
T
Anso 5f1baa7522 fix: harden deploy/update concurrency and node-targeting safety (#1390)
* fix: harden deploy/update concurrency and node-targeting safety

Release stabilization for deploy/update operational safety.

Per-stack operation locking is now global. Background lifecycle paths
(scheduler auto stop/down/start/backup/update, webhook execute, Git source
auto-deploy, image auto-update, label bulk actions, fleet snapshot redeploy,
and mesh redeploy) acquire the per-node, per-stack lock through a new
StackOpLockService.runExclusive helper and skip rather than race a manual
deploy/update/rollback/backup on the same stack and node. Skips surface
honestly (a failed scheduled run, a recorded webhook failure, a per-stack
batch result, or a thrown error) instead of a silent no-op.

Update readiness and policy-bypass now run against the node captured when the
dialog opened, not the live active node, so switching nodes while a dialog is
open cannot retarget the update or the bypass retry.

Rollback readiness no longer presents a moving-tag or unpinned image as a ready
image revert. Restoring files does not revert a moving tag, so those stacks
read as partial, and the rollback success message states that the compose and
env files were restored.

* fix: lock blueprint reconcile against manual ops and correct rollback wording

Follow-up to the deploy/update safety hardening, closing two more gaps from a
verification pass.

BlueprintService.deployLocal and withdrawLocal called ComposeService directly,
so blueprint reconciliation could race a manual deploy/update/rollback/backup on
an owned stack. Both now run their compose lifecycle call through
StackOpLockService.runExclusive and skip (recorded as a failed reconcile,
retried on the next cycle) on conflict. The withdraw holds the lock across both
the compose down and the directory delete so neither races a manual operation.

The runtime rollback messages overstated recovery: a rollback restores the
compose and env files and recreates containers, but does not revert an image
behind a moving tag. The auto-rollback deploy-progress output, the recovery
panel and chip, the failure toasts, and the manual rollback route message now
state that the compose and env files were restored, with the matching OpenAPI
example and atomic-deployments doc updated.

* fix: acquire stack lock before blueprint deploy mutates compose and marker files

Local blueprint deploy wrote the compose and marker files and ran the policy
assert before acquiring the per-stack lock; the lock only wrapped the deploy
itself. A reconcile could therefore rewrite an owned stack's files while a
manual deploy/update/rollback/backup was running. The lock now wraps the whole
critical section (create, write compose, write marker, policy assert, deploy),
so on conflict nothing is written and the reconcile records a failed outcome.

Adds a test asserting a deploy under a held lock records failed, writes no
marker file, and leaves the manual lock untouched.

* fix: make remote blueprint apply atomic under the receiving node's stack lock

Remote blueprint deploy wrote the compose and marker files to the target node
via separate HTTP calls and only locked on the final deploy, so the file writes
could race a manual operation on that node. A node's operation lock is
process-local and cannot be held by the hub across HTTP calls, so the locked
create/write/deploy now runs on the receiving node.

The locked critical section is extracted into BlueprintService.applyLocalUnderLock
and exposed via POST /api/blueprints/apply-local. The hub posts the blueprint to
that endpoint in one call; the receiving node runs create + write compose+marker
+ deploy under its own per-stack lock. Older nodes without the route answer 404
and fall back to the legacy multi-call flow. The endpoint is gated by paid tier
and the same per-stack stack:edit and stack:deploy permissions as the
PUT-compose + deploy it bundles, validates the stack name, compose size, and
marker structure, and returns 409 on a lock conflict without writing anything.

Adds tests for the atomic single-call path, the 404 legacy fallback, the 409
lock-conflict mapping, the route validation and permission paths, and the
write-compose-then-marker-then-deploy ordering of the shared locked apply.

* fix(deps): bump undici to 7.28.0 to clear high-severity advisory

The frontend CI npm audit gate (--audit-level=high) failed on a transitive
undici 7.25.0 (a dev-only dependency via jsdom): TLS certificate validation
bypass (GHSA-vmh5-mc38-953g) and cross-user cache information disclosure
(GHSA-pr7r-676h-xcf6). Bumping undici within jsdom's existing ^7.25.0 range to
7.28.0 clears the high-severity advisory and unblocks the frontend job. Lockfile
only; no direct dependency or source change.
2026-06-18 13:38:19 -04:00

183 lines
8.0 KiB
TypeScript

import si from 'systeminformation';
import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { ComposeDoctorService } from './ComposeDoctorService';
import { UpdatePreviewService, isMovingTag } from './UpdatePreviewService';
import { withTimeout } from '../utils/withTimeout';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import {
aggregateRollbackOverall,
aggregateVerdict,
backupSlotSignal,
buildRollbackItems,
containersSignal,
diskSignal,
driftSignal,
healthchecksSignal,
preflightSignal,
updatePreviewSignal,
type Errored,
} from './updateGuard/readiness';
import type { ContainerProbe, RollbackReadinessReport, UpdateReadinessReport } from './updateGuard/types';
// Bound on the network-and-socket-backed inputs (container probe, update
// preview, disk stats) so a hung registry or Docker socket cannot stall the
// report past the dialog's own fetch timeout; the remaining inputs are local
// DB/file reads. A timed-out input degrades to its 'unknown' signal instead
// of failing the report.
const INPUT_TIMEOUT_MS = 3_000;
/**
* Computes update readiness and rollback readiness for a stack, on demand,
* from existing per-feature stores (preflight runs, drift findings, the atomic
* backup slot, the update preview, live Docker state). Derived data only;
* nothing here is persisted.
*/
export class UpdateGuardService {
private static instance: UpdateGuardService;
public static getInstance(): UpdateGuardService {
if (!UpdateGuardService.instance) {
UpdateGuardService.instance = new UpdateGuardService();
}
return UpdateGuardService.instance;
}
/**
* Probe the stack's containers via the compose project label, normalized for
* the pure scoring functions. Throws on Docker errors; callers map that to
* the 'error' sentinel.
*/
async probeContainers(nodeId: number, stackName: string): Promise<ContainerProbe[]> {
const docker = DockerController.getInstance(nodeId).getDocker();
const listed = await docker.listContainers({
all: true,
filters: { label: [`com.docker.compose.project=${stackName}`] },
});
const probes = await Promise.all(
listed.map(async (info): Promise<ContainerProbe | null> => {
const name = info.Names?.[0]?.replace(/^\//, '') ?? info.Id.slice(0, 12);
let inspect: Awaited<ReturnType<ReturnType<typeof docker.getContainer>['inspect']>>;
try {
inspect = await docker.getContainer(info.Id).inspect();
} catch (e: unknown) {
// A container removed between list and inspect (auto-heal or update
// churn) should not collapse the whole probe; skip just that one.
if ((e as { statusCode?: number })?.statusCode === 404) return null;
throw e;
}
const mounts = (inspect.Mounts ?? []).map(m =>
m.Type === 'volume' ? `volume ${m.Name ?? 'unnamed'}` : `${m.Type} ${m.Source ?? ''}`.trim(),
);
return {
name,
state: inspect.State?.Status ?? info.State ?? 'unknown',
health: inspect.State?.Health?.Status ?? null,
exitCode: typeof inspect.State?.ExitCode === 'number' ? inspect.State.ExitCode : null,
hasHealthcheck: !!inspect.Config?.Healthcheck?.Test?.length,
restartPolicy: inspect.HostConfig?.RestartPolicy?.Name || null,
mounts,
};
}),
);
return probes.filter((p): p is ContainerProbe => p !== null);
}
async computeUpdateReadiness(nodeId: number, stackName: string): Promise<UpdateReadinessReport> {
const db = DatabaseService.getInstance();
const now = Date.now();
const [preflight, drift, containers, preview, backup, disk] = await Promise.all([
this.collect('preflight', stackName, async () => ComposeDoctorService.getInstance().getLatest(nodeId, stackName)),
this.collect('drift', stackName, async () => db.getOpenDriftFindings(nodeId, stackName).length),
this.collect('containers', stackName, () =>
withTimeout(this.probeContainers(nodeId, stackName), INPUT_TIMEOUT_MS, 'readiness container probe')),
this.collect('update preview', stackName, () =>
withTimeout(UpdatePreviewService.getInstance().getPreview(nodeId, stackName), INPUT_TIMEOUT_MS, 'readiness update preview')),
this.collect('backup info', stackName, () => FileSystemService.getInstance(nodeId).getBackupInfo(stackName)),
this.collect('disk', stackName, () => this.readDiskUsage()),
]);
const settings = db.getGlobalSettings();
const limitPercent = parseInt(settings['host_disk_limit'] ?? '90', 10) || 90;
const signals = [
preflightSignal(preflight),
driftSignal(drift),
containersSignal(containers),
healthchecksSignal(containers),
updatePreviewSignal(preview === 'error' ? 'error' : preview.summary),
backupSlotSignal(backup, now),
diskSignal(typeof disk === 'number' ? { usePercent: disk, limitPercent } : 'error'),
];
return { stack: stackName, computedAt: now, verdict: aggregateVerdict(signals), signals };
}
async computeRollbackReadiness(nodeId: number, stackName: string): Promise<RollbackReadinessReport> {
const db = DatabaseService.getInstance();
const fsSvc = FileSystemService.getInstance(nodeId);
const now = Date.now();
const [backup, envSummary, stackHasEnv, preview, lastDeployAt, containers] = await Promise.all([
this.collect('backup info', stackName, () => fsSvc.getBackupInfo(stackName)),
this.collect('backup env summary', stackName, () => fsSvc.getBackupEnvSummary(stackName)),
this.collect('stack env presence', stackName, () => fsSvc.envExists(stackName)),
this.collect('update preview', stackName, () =>
withTimeout(UpdatePreviewService.getInstance().getPreview(nodeId, stackName), INPUT_TIMEOUT_MS, 'rollback readiness update preview')),
this.collect('activity history', stackName, async () => {
const events = db.getStackActivity(nodeId, stackName, { limit: 50 });
// A successful update is as good a known-good marker as a deploy.
return events.find(e => e.category === 'deploy_success' || e.category === 'image_update_applied')?.timestamp ?? null;
}),
this.collect('containers', stackName, () =>
withTimeout(this.probeContainers(nodeId, stackName), INPUT_TIMEOUT_MS, 'rollback readiness container probe')),
]);
const items = buildRollbackItems({
backup,
envSummary,
stackHasEnv,
rollbackTarget: preview === 'error'
? 'error'
: {
target: preview.rollback_target,
// Any image on a moving tag means restoring files cannot guarantee
// the image reverts, so the rollback target is not a true revert.
moving: preview.images.some(img => isMovingTag(img.current_tag)),
},
lastDeployAt,
containers,
}, now);
return { stack: stackName, computedAt: now, overall: aggregateRollbackOverall(items), items };
}
/** Host disk use percent for the main filesystem, or null when unavailable. */
private async readDiskUsage(): Promise<number | null> {
const fsSize = await withTimeout(si.fsSize(), INPUT_TIMEOUT_MS, 'readiness disk stats');
const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0];
if (typeof mainDisk?.use !== 'number') {
console.warn('[UpdateGuard] disk stats returned no usable mount; disk signal degrades to unknown');
return null;
}
return mainDisk.use;
}
/** Run one input collector; a failure degrades to the 'error' sentinel. */
private async collect<T>(label: string, stackName: string, fn: () => Promise<T>): Promise<T | Errored> {
try {
return await fn();
} catch (error) {
console.warn(
'[UpdateGuard] %s unavailable for %s:',
label, sanitizeForLog(stackName),
sanitizeForLog(getErrorMessage(error, 'unknown')),
);
return 'error';
}
}
}