Files
sencho/backend/src/services/StackUpdateOrchestrator.ts
T
Anso 63213c0960 feat: add service-scoped Compose update and restore (#1648)
* feat: add service-scoped Compose update and restore

Allow updating or rebuilding one declared Compose service on multi-service
stacks without recreating siblings, with recovery snapshots, health-gate
observation, and prune holds for rollback images. Full-stack update paths
and single-service UX stay unchanged.

* fix: sanitize service-scoped update log messages for CodeQL

* fix: address service-scoped update audit findings B-01 through B-07

* fix: complete service-scoped update audit metadata and surfaces

* test: wrap Updates readiness tests for deploy-feedback context

* fix: keep service recovery reachable without Deploy Progress

Make failed service-gate recovery discoverable when Deploy Progress is
disabled or dismissed, suppress stale image-scan notification side
effects, normalize ComposeService line endings, and add focused
regression coverage.

* fix: resurface ContainersHealth density and expand on multi-service stacks

Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
2026-07-19 02:42:29 -04:00

685 lines
25 KiB
TypeScript

import WebSocket from 'ws';
import DockerController from './DockerController';
import { ComposeService } from './ComposeService';
import { HealthGateService } from './HealthGateService';
import { AutoHealService } from './AutoHealService';
import { ImageUpdateService } from './ImageUpdateService';
import {
ServiceUpdateRecoveryService,
type ServiceReplicaSnapshot,
} from './ServiceUpdateRecoveryService';
import type { ServiceUpdateRecoveryRow } from './DatabaseService';
import { buildEffectiveServiceModel } from './effectiveServiceModel';
import { DriftLedgerService } from './DriftLedgerService';
import { MeshService } from './MeshService';
import { DatabaseService } from './DatabaseService';
import { enforcePolicyForImageRefs, type PolicyEnforcementOptions } from './PolicyEnforcement';
import { describePolicyBlock } from '../helpers/policyGate';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import type { EffectiveServiceSpec } from './effectiveServiceModel';
export type UpdateTarget =
| { scope: 'stack' }
| { scope: 'service'; serviceName: string };
export type UpdateTrigger =
| 'manual' | 'scheduled' | 'automatic' | 'blueprint' | 'webhook' | 'bulk';
export interface UpdateOperationContext {
nodeId: number;
stackName: string;
target: UpdateTarget;
trigger: UpdateTrigger;
actor: string | null;
}
export interface StackComposeOptions {
atomic: boolean;
terminalWs?: WebSocket | null;
}
export interface ServiceUpdateOptions {
policyOptions: PolicyEnforcementOptions;
terminalWs?: WebSocket | null;
/** Restore only: binds the operation to a recovery snapshot. */
recoveryId?: string;
}
export type OrchestratorResult =
| { kind: 'stack_compose_done' }
| {
kind: 'service_done';
serviceName: string;
healthGateId: string | null;
observing: boolean;
recoveryId: string | null;
recoveryAvailable: boolean;
previousImageId?: string | null;
newImageId?: string | null;
recheckWarning?: string;
}
| {
kind: 'service_failed';
code: string;
error: string;
serviceName?: string;
mutationStage?: string;
recoveryId?: string | null;
};
function serviceFailed(
code: string,
error: string,
extra?: { serviceName?: string; mutationStage?: string; recoveryId?: string | null },
): Extract<OrchestratorResult, { kind: 'service_failed' }> {
return { kind: 'service_failed', code, error, ...extra };
}
/** Shorten a local image id for activity copy (sha256:abcdef… → abcdef…). */
export function shortImageId(imageId: string | null | undefined): string | null {
if (!imageId) return null;
const bare = imageId.startsWith('sha256:') ? imageId.slice(7) : imageId;
return bare.slice(0, 12);
}
/**
* Post-mutation replica convergence rules shared by update and restore.
* Requires the exact expected count of inspected running image ids and a
* single shared image before success (independent of optional health gating).
*/
export function evaluateServiceReplicaConvergence(
serviceName: string,
expectedReplicas: number,
ids: string[],
inspectErrors: number,
):
| { kind: 'converged'; imageId: string | null }
| { kind: 'divergent'; error: string }
| { kind: 'inspect_failed'; error: string } {
if (expectedReplicas === 0) {
return { kind: 'converged', imageId: null };
}
if (inspectErrors > 0 && ids.length === 0) {
return {
kind: 'inspect_failed',
error: `Could not inspect replicas for service "${serviceName}" after the update.`,
};
}
const distinct = new Set(ids);
if (distinct.size === 0) {
return { kind: 'divergent', error: `Service "${serviceName}" has no running replicas after the update.` };
}
if (ids.length !== expectedReplicas) {
return {
kind: 'divergent',
error: `Service "${serviceName}" has ${ids.length} running replica(s); expected ${expectedReplicas}.`,
};
}
if (distinct.size > 1) {
return { kind: 'divergent', error: `Service "${serviceName}" replicas did not converge on a single image.` };
}
return { kind: 'converged', imageId: [...distinct][0] };
}
/** Parse a tag image ref into a Docker tag() repo + tag pair. */
function splitImageRef(ref: string): { repo: string; tag: string } {
const lastSlash = ref.lastIndexOf('/');
const lastColon = ref.lastIndexOf(':');
if (lastColon > lastSlash) {
return { repo: ref.slice(0, lastColon), tag: ref.slice(lastColon + 1) };
}
return { repo: ref, tag: 'latest' };
}
/**
* Single entry point for stack-scope Compose updates and the manual
* service-scoped update/restore flows.
*
* Ownership boundaries (§3): the five stack update callers keep every
* side effect (policy, lock, clear status, invalidate, broadcast, notify,
* post-scan, and the post-success `beginStack`); this orchestrator's stack
* branch only runs `ComposeService.updateStack`. The service branch (nested
* manual routes only) owns policy, the recovery snapshot, the pre-mutation
* health gate (prepare/attach/beginPrepared), Auto-Heal suppression, and the
* per-service recheck; the route holds the stack lock and maps the result to
* HTTP. Expected failures never throw past `execute`; they return a
* `service_failed` result.
*/
export class StackUpdateOrchestrator {
private static instance: StackUpdateOrchestrator;
public static getInstance(): StackUpdateOrchestrator {
if (!StackUpdateOrchestrator.instance) {
StackUpdateOrchestrator.instance = new StackUpdateOrchestrator();
}
return StackUpdateOrchestrator.instance;
}
public async execute(
ctx: UpdateOperationContext,
options: StackComposeOptions | ServiceUpdateOptions,
): Promise<OrchestratorResult> {
if (ctx.target.scope === 'stack') {
return this.executeStack(ctx, options as StackComposeOptions);
}
// Service scope: automation never selects this branch.
if (ctx.trigger !== 'manual') {
throw new Error('Service-scoped updates require a manual trigger');
}
const serviceOptions = options as ServiceUpdateOptions;
if (serviceOptions.recoveryId) {
return this.executeRestore(ctx, ctx.target.serviceName, serviceOptions);
}
return this.executeServiceUpdate(ctx, ctx.target.serviceName, serviceOptions);
}
/**
* Stack branch: run the full-stack Compose update only. Side effects and the
* post-success `beginStack(..., 'update')` stay with the caller.
*/
private async executeStack(
ctx: UpdateOperationContext,
options: StackComposeOptions,
): Promise<OrchestratorResult> {
await ComposeService.getInstance(ctx.nodeId).updateStack(
ctx.stackName, options.terminalWs ?? undefined, options.atomic,
);
return { kind: 'stack_compose_done' };
}
private async executeServiceUpdate(
ctx: UpdateOperationContext,
serviceName: string,
options: ServiceUpdateOptions,
): Promise<OrchestratorResult> {
const { nodeId, stackName } = ctx;
const loaded = await this.loadServiceSpec(nodeId, stackName, serviceName);
if (!loaded.ok) return loaded.result;
const { spec, services } = loaded;
if (services.length <= 1) {
return serviceFailed(
'service_update_single_service',
'Service-scoped updates require a stack with more than one service.',
);
}
if (!spec.hasBuild && !spec.declaredImage) {
return serviceFailed(
'service_not_updatable',
`Service "${serviceName}" has neither an image nor a build, so it cannot be updated.`,
);
}
AutoHealService.getInstance().suppress(nodeId, stackName, serviceName);
let observing = false;
let recoveryId: string | null = null;
let recoveryAvailable = false;
try {
const policyFailure = await this.checkPolicyOrFail(
stackName, nodeId, serviceName, spec.declaredImage ? [spec.declaredImage] : [], options.policyOptions, 'update',
);
if (policyFailure) return policyFailure;
const prepared = await HealthGateService.getInstance().prepare({
nodeId, stackName,
target: { scope: 'service', serviceName },
trigger: 'service_update',
expectedReplicas: spec.expectedReplicas,
});
const prepareToken = prepared.prepareToken;
const prepareSnapshotOk = prepared.snapshotOk;
const replicas = await this.snapshotServiceReplicas(nodeId, stackName, serviceName);
const recovery = ServiceUpdateRecoveryService.getInstance().createIfEligible({
nodeId, stackName, serviceName,
replicas,
declaredImageRef: spec.declaredImage,
createdBy: ctx.actor,
});
const previousImageId = recovery.eligible
? recovery.row.majority_image_id
: (replicas[0]?.imageId ?? null);
if (recovery.eligible) {
recoveryId = recovery.row.id;
recoveryAvailable = true;
}
try {
await ComposeService.getInstance(nodeId).updateService(
stackName, serviceName, spec.hasBuild, options.terminalWs ?? undefined,
);
} catch (composeError) {
return serviceFailed(
'service_update_compose_failed',
getErrorMessage(composeError, 'Service update failed'),
{ serviceName, mutationStage: 'compose', recoveryId },
);
}
const observed = await this.observePreparedService(
prepareToken, nodeId, stackName, serviceName, spec.expectedReplicas, ctx.actor,
'service_update_replica_divergence', recoveryId, prepareSnapshotOk,
);
if (!observed.ok) return observed.result;
observing = observed.observing;
const healthGateId = observed.healthGateId;
if (recoveryId && healthGateId) {
try {
ServiceUpdateRecoveryService.getInstance().linkHealthGate(recoveryId, healthGateId);
} catch (linkError) {
console.warn(
'[StackUpdateOrchestrator] Failed to link recovery %s to gate %s: %s',
sanitizeForLog(recoveryId), sanitizeForLog(healthGateId),
sanitizeForLog(getErrorMessage(linkError, 'unknown')),
);
}
}
const recheck = await ImageUpdateService.getInstance().recheckStack(nodeId, stackName);
await DriftLedgerService.getInstance().reconcileServiceForStack(nodeId, stackName, serviceName);
await this.refreshMeshIfEnabled(nodeId, stackName);
const warnings = [observed.gateWarning, recheck.warning].filter((w): w is string => !!w);
return {
kind: 'service_done',
serviceName,
healthGateId,
observing,
recoveryId,
recoveryAvailable,
previousImageId,
newImageId: observed.newImageId ?? null,
recheckWarning: warnings.length > 0 ? warnings.join(' ') : undefined,
};
} finally {
if (!observing) {
AutoHealService.getInstance().clearSuppress(nodeId, stackName, serviceName);
}
}
}
private async executeRestore(
ctx: UpdateOperationContext,
serviceName: string,
options: ServiceUpdateOptions,
): Promise<OrchestratorResult> {
const { nodeId, stackName } = ctx;
const recoveryId = options.recoveryId as string;
const recovery = ServiceUpdateRecoveryService.getInstance().get(recoveryId);
if (
!recovery ||
recovery.node_id !== nodeId ||
recovery.stack_name !== stackName ||
recovery.service_name !== serviceName
) {
return serviceFailed('recovery_not_found', 'No matching recovery snapshot for this service.');
}
if (recovery.status !== 'active') {
return serviceFailed(
'recovery_not_restorable',
`This recovery snapshot is ${recovery.status} and can no longer be restored.`,
{ recoveryId },
);
}
AutoHealService.getInstance().suppress(nodeId, stackName, serviceName);
let observing = false;
let claimed = false;
let consumed = false;
try {
const loaded = await this.loadServiceSpec(nodeId, stackName, serviceName, recoveryId);
if (!loaded.ok) return loaded.result;
const { spec, services } = loaded;
if (services.length <= 1) {
return serviceFailed(
'service_update_single_service',
'Service-scoped restore requires a stack with more than one service.',
{ recoveryId },
);
}
const scanTarget = await this.resolveRestoreScanTarget(nodeId, recovery);
const policyFailure = await this.checkPolicyOrFail(
stackName, nodeId, serviceName, [scanTarget], options.policyOptions, 'rollback', recoveryId,
);
if (policyFailure) return policyFailure;
const prepared = await HealthGateService.getInstance().prepare({
nodeId, stackName,
target: { scope: 'service', serviceName },
trigger: 'service_restore',
expectedReplicas: spec.expectedReplicas,
});
const prepareToken = prepared.prepareToken;
const prepareSnapshotOk = prepared.snapshotOk;
const claimedRow = ServiceUpdateRecoveryService.getInstance().claim(recoveryId);
if (!claimedRow) {
return serviceFailed(
'recovery_claim_failed',
'This recovery snapshot is being restored by another operation.',
{ recoveryId },
);
}
claimed = true;
ServiceUpdateRecoveryService.getInstance().startClaimRenewal(recoveryId);
const previousImageId = (await this.snapshotServiceReplicas(nodeId, stackName, serviceName))[0]?.imageId ?? null;
try {
const { repo, tag } = splitImageRef(recovery.declared_image_ref);
await DockerController.getInstance(nodeId).getDocker()
.getImage(recovery.majority_image_id).tag({ repo, tag });
} catch (retagError) {
return serviceFailed(
'restore_retag_failed',
getErrorMessage(retagError, 'Could not retag the recovery image'),
{ serviceName, mutationStage: 'retag', recoveryId },
);
}
try {
await ComposeService.getInstance(nodeId).recreateServiceFromLocal(
stackName, serviceName, options.terminalWs ?? undefined,
);
} catch (composeError) {
return serviceFailed(
'restore_compose_failed',
getErrorMessage(composeError, 'Service restore failed'),
{ serviceName, mutationStage: 'compose', recoveryId },
);
}
const observed = await this.observePreparedService(
prepareToken, nodeId, stackName, serviceName, spec.expectedReplicas, ctx.actor,
'restore_replica_divergence', recoveryId, prepareSnapshotOk,
);
if (!observed.ok) return observed.result;
observing = observed.observing;
const healthGateId = observed.healthGateId;
try {
ServiceUpdateRecoveryService.getInstance().markConsumed(recoveryId, healthGateId);
consumed = true;
} catch (consumeError) {
console.warn(
'[StackUpdateOrchestrator] Failed to mark recovery %s consumed: %s',
sanitizeForLog(recoveryId), sanitizeForLog(getErrorMessage(consumeError, 'unknown')),
);
// Compose already succeeded; treat as consumed for lease/cleanup purposes so
// we do not reactivate a snapshot that may no longer match the running image.
consumed = true;
}
const recheck = await ImageUpdateService.getInstance().recheckStack(nodeId, stackName);
await this.refreshMeshIfEnabled(nodeId, stackName);
const warnings = [observed.gateWarning, recheck.warning].filter((w): w is string => !!w);
return {
kind: 'service_done',
serviceName,
healthGateId,
observing,
recoveryId,
recoveryAvailable: false,
previousImageId,
newImageId: observed.newImageId ?? null,
recheckWarning: warnings.length > 0 ? warnings.join(' ') : undefined,
};
} finally {
ServiceUpdateRecoveryService.getInstance().stopClaimRenewal(recoveryId);
if (claimed && !consumed) {
const stillLocal = await this.imageIsLocal(nodeId, recovery.majority_image_id);
if (stillLocal) {
ServiceUpdateRecoveryService.getInstance().reactivate(recoveryId);
} else {
ServiceUpdateRecoveryService.getInstance().invalidate(recoveryId);
}
}
if (!observing) {
AutoHealService.getInstance().clearSuppress(nodeId, stackName, serviceName);
}
}
}
/** Best-effort mesh alias refresh when this stack is opted into Mesh. */
private async refreshMeshIfEnabled(nodeId: number, stackName: string): Promise<void> {
try {
if (!DatabaseService.getInstance().isMeshStackEnabled(nodeId, stackName)) return;
await MeshService.getInstance().refreshAliasCache();
} catch (error) {
console.warn(
'[StackUpdateOrchestrator] Mesh alias refresh failed for %s on node %d: %s',
sanitizeForLog(stackName), nodeId, sanitizeForLog(getErrorMessage(error, 'unknown')),
);
}
}
private async loadServiceSpec(
nodeId: number,
stackName: string,
serviceName: string,
recoveryId?: string | null,
): Promise<
| { ok: true; spec: EffectiveServiceSpec; services: EffectiveServiceSpec[] }
| { ok: false; result: Extract<OrchestratorResult, { kind: 'service_failed' }> }
> {
const model = await buildEffectiveServiceModel(nodeId, stackName);
if (!model.renderable) {
return { ok: false, result: serviceFailed(model.code, model.error, { recoveryId }) };
}
const spec = model.services.find(s => s.name === serviceName);
if (!spec) {
return {
ok: false,
result: serviceFailed(
'service_not_found',
`Service "${serviceName}" is not declared in this stack.`,
{ recoveryId },
),
};
}
return { ok: true, spec, services: model.services };
}
private async checkPolicyOrFail(
stackName: string,
nodeId: number,
serviceName: string,
refs: string[],
policyOptions: PolicyEnforcementOptions,
action: 'update' | 'rollback',
recoveryId?: string | null,
): Promise<Extract<OrchestratorResult, { kind: 'service_failed' }> | null> {
if (refs.length === 0) return null;
const gate = await enforcePolicyForImageRefs(stackName, nodeId, refs, policyOptions);
if (gate.ok || gate.bypassed) return null;
return serviceFailed(
'policy_blocked',
`Service "${serviceName}": ${describePolicyBlock(gate.policy, gate.violations, action)}`,
{ serviceName, mutationStage: 'policy', recoveryId },
);
}
private async observePreparedService(
prepareToken: string,
nodeId: number,
stackName: string,
serviceName: string,
expectedReplicas: number,
actor: string | null,
divergenceCode: string,
recoveryId: string | null,
prepareSnapshotOk: boolean,
): Promise<
| { ok: true; healthGateId: string | null; observing: boolean; newImageId: string | null; gateWarning?: string }
| { ok: false; result: Extract<OrchestratorResult, { kind: 'service_failed' }> }
> {
const convergence = await this.resolvePrimaryImage(nodeId, stackName, serviceName, expectedReplicas);
if (convergence.kind === 'inspect_failed') {
return {
ok: false,
result: serviceFailed('replica_inspect_failed', convergence.error, {
serviceName, mutationStage: 'inspect', recoveryId,
}),
};
}
if (convergence.kind === 'divergent') {
return {
ok: false,
result: serviceFailed(divergenceCode, convergence.error, {
serviceName, mutationStage: 'inspect', recoveryId,
}),
};
}
if (!prepareSnapshotOk) {
return {
ok: true,
healthGateId: null,
observing: false,
newImageId: convergence.imageId,
gateWarning: 'Health gate skipped: pre-update container snapshot was unavailable.',
};
}
const begin = this.beginPrepared(prepareToken, convergence.imageId, actor);
return {
ok: true,
healthGateId: begin.runId,
observing: begin.observing,
newImageId: convergence.imageId,
};
}
/** Attach the single converged image id (scale>0) and begin the prepared gate. */
private beginPrepared(
prepareToken: string,
imageId: string | null,
actor: string | null,
): { runId: string | null; observing: boolean } {
if (imageId) {
HealthGateService.getInstance().attachExpectedImage(prepareToken, imageId);
}
return HealthGateService.getInstance().beginPrepared({ prepareToken, actor });
}
/**
* Post-mutation replica convergence check. Returns the single shared image id
* when every expected primary replica agrees, null for a scale-0 service, or a
* typed divergence when replicas are missing or run 2+ distinct images.
*/
private async resolvePrimaryImage(
nodeId: number,
stackName: string,
serviceName: string,
expectedReplicas: number,
): Promise<
| { kind: 'converged'; imageId: string | null }
| { kind: 'divergent'; error: string }
| { kind: 'inspect_failed'; error: string }
> {
if (expectedReplicas === 0) {
return { kind: 'converged', imageId: null };
}
const inspected = await this.inspectServiceContainers(nodeId, stackName, serviceName);
return evaluateServiceReplicaConvergence(
serviceName, expectedReplicas, inspected.ids, inspected.inspectErrors,
);
}
private async inspectPrimaryImageIds(nodeId: number, stackName: string, serviceName: string): Promise<string[]> {
return (await this.inspectServiceContainers(nodeId, stackName, serviceName)).ids;
}
/** Snapshot the current per-replica image ids + repo digests for the recovery row. */
private async snapshotServiceReplicas(
nodeId: number,
stackName: string,
serviceName: string,
): Promise<ServiceReplicaSnapshot[]> {
const docker = DockerController.getInstance(nodeId).getDocker();
const snapshots: ServiceReplicaSnapshot[] = [];
for (const imageId of (await this.inspectServiceContainers(nodeId, stackName, serviceName)).ids) {
let repoDigest: string | null = null;
try {
const image = await docker.getImage(imageId).inspect();
const digests = (image.RepoDigests ?? []) as string[];
repoDigest = digests.length > 0 ? digests[0] : null;
} catch {
// The digest is optional context; a missing image inspect leaves it null.
}
snapshots.push({ imageId, repoDigest });
}
return snapshots;
}
private async inspectServiceContainers(
nodeId: number,
stackName: string,
serviceName: string,
): Promise<{ ids: string[]; inspectErrors: number }> {
const docker = DockerController.getInstance(nodeId).getDocker();
const listed = await docker.listContainers({
all: true,
filters: { label: [`com.docker.compose.project=${stackName}`, `com.docker.compose.service=${serviceName}`] },
});
const ids: string[] = [];
let inspectErrors = 0;
for (const info of listed) {
try {
const inspect = await docker.getContainer(info.Id).inspect();
// Convergence requires running replicas; exited/created leftovers must
// not count toward the expected replica total or the shared image id.
if (inspect.State?.Status !== 'running') continue;
if (inspect.Image) ids.push(inspect.Image);
} catch (error) {
if ((error as { statusCode?: number })?.statusCode === 404) continue;
inspectErrors += 1;
console.warn('[Orchestrator] Replica inspect failed for %s/%s:',
sanitizeForLog(stackName), sanitizeForLog(serviceName), getErrorMessage(error, 'unknown'));
}
}
return { ids, inspectErrors };
}
/**
* Immutable restore scan target: the captured repo digest when it resolves
* locally to the majority image id, otherwise the majority image id directly.
* Never the moving declared tag (RESTORE-POLICY-TARGET-2).
*/
private async resolveRestoreScanTarget(nodeId: number, recovery: ServiceUpdateRecoveryRow): Promise<string> {
let replicas: ServiceReplicaSnapshot[] = [];
try {
const parsed: unknown = JSON.parse(recovery.replicas_json);
if (Array.isArray(parsed)) replicas = parsed as ServiceReplicaSnapshot[];
} catch {
// Corrupt snapshot: fall back to the majority image id below.
}
const withDigest = replicas.find(r => r.imageId === recovery.majority_image_id && r.repoDigest);
if (withDigest?.repoDigest) {
const resolvedId = await this.resolveImageId(nodeId, withDigest.repoDigest);
if (resolvedId === recovery.majority_image_id) return withDigest.repoDigest;
}
return recovery.majority_image_id;
}
private async resolveImageId(nodeId: number, ref: string): Promise<string | null> {
try {
const inspect = await DockerController.getInstance(nodeId).getDocker().getImage(ref).inspect();
return inspect.Id ?? null;
} catch {
return null;
}
}
private async imageIsLocal(nodeId: number, imageId: string): Promise<boolean> {
try {
await DockerController.getInstance(nodeId).getDocker().getImage(imageId).inspect();
return true;
} catch {
return false;
}
}
}