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.
This commit is contained in:
Anso
2026-07-19 02:42:29 -04:00
committed by GitHub
parent 31d4e4669b
commit 63213c0960
89 changed files with 7608 additions and 331 deletions
+4 -2
View File
@@ -10,6 +10,7 @@ import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPD
import { NodeRegistry } from '../services/NodeRegistry';
import { computeNodeNetworkingSummary, type NodeNetworkingSummary } from '../services/network/networkingSummary';
import DockerController from '../services/DockerController';
import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService';
import { getHostMemory } from '../helpers/hostMemory';
import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
@@ -2115,9 +2116,10 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
targetResults.push({ target, success: true, reclaimedBytes: estimate.reclaimableBytes, dryRun: true });
continue;
}
const isImageHeld = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(node.id);
const result = scope === 'managed'
? await dockerController.pruneManagedOnly(target, knownStacks)
: await dockerController.pruneSystem(target);
? await dockerController.pruneManagedOnly(target, knownStacks, isImageHeld)
: await dockerController.pruneSystem(target, undefined, isImageHeld);
targetResults.push({ target, success: true, reclaimedBytes: result.reclaimedBytes });
if (result.reclaimedBytes > 0 || result.success) anySuccess = true;
} catch (err) {
+6 -4
View File
@@ -7,7 +7,7 @@ import { NodeRegistry } from '../services/NodeRegistry';
import { CacheService } from '../services/CacheService';
import { ImageUpdateService } from '../services/ImageUpdateService';
import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import { StackUpdateOrchestrator } from '../services/StackUpdateOrchestrator';
import { StackOpLockService, stackOpSkipMessage } from '../services/StackOpLockService';
import { NotificationService } from '../services/NotificationService';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
@@ -342,7 +342,6 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
const docker = DockerController.getInstance(req.nodeId);
const imageUpdateService = ImageUpdateService.getInstance();
const compose = ComposeService.getInstance(req.nodeId);
const db = DatabaseService.getInstance();
const atomic = true;
const results: string[] = [];
@@ -417,14 +416,17 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
const lock = await StackOpLockService.getInstance().runExclusive(
req.nodeId, stackName, 'update', 'system',
() => compose.updateStack(stackName, undefined, atomic),
() => StackUpdateOrchestrator.getInstance().execute(
{ nodeId: req.nodeId, stackName, target: { scope: 'stack' }, trigger: 'automatic', actor: `auto-update:${req.user?.username ?? 'scheduler'}` },
{ atomic, terminalWs: null },
),
);
if (!lock.ran) {
results.push(stackOpSkipMessage(stackName, lock.existing.action));
continue;
}
db.clearStackUpdateStatus(req.nodeId, stackName);
HealthGateService.getInstance().begin(req.nodeId, stackName, 'update', `auto-update:${req.user?.username ?? 'scheduler'}`);
HealthGateService.getInstance().beginStack(req.nodeId, stackName, 'update', `auto-update:${req.user?.username ?? 'scheduler'}`);
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
+265 -8
View File
@@ -13,6 +13,7 @@ import { FileSystemService } from '../services/FileSystemService';
import { StackFileRootsService, STACK_SOURCE_ROOT_ID, stackSourceFileRoot, type StackFileRoot } from '../services/StackFileRootsService';
import { FileRootGateway } from '../services/FileRootGateway';
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
import { StackUpdateOrchestrator, shortImageId, type OrchestratorResult } from '../services/StackUpdateOrchestrator';
import DockerController, { type BulkStackInfo } from '../services/DockerController';
import { DatabaseService, type StackDossierFields } from '../services/DatabaseService';
import { MeshService } from '../services/MeshService';
@@ -30,11 +31,12 @@ import { buildStackNetworkFacts } from '../services/network/composeNetworkInspec
import { buildStorageInventory } from '../services/storage/inventory';
import { probeComposeDiscovery } from '../services/ComposeDiscoveryService';
import { buildEffectiveAnatomy } from '../services/effectiveAnatomy';
import { buildEffectiveServiceModel } from '../services/effectiveServiceModel';
import { buildEnvInventory } from '../services/EnvInventoryService';
import { buildStackLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types';
import { UpdateGuardService } from '../services/UpdateGuardService';
import { UpdateGuardService, SingleServiceUpdateReadinessError } from '../services/UpdateGuardService';
import { HealthGateService } from '../services/HealthGateService';
import { classifyFailure } from '../services/updateGuard/failureClassifier';
import { requirePermission, checkPermission } from '../middleware/permissions';
@@ -58,7 +60,8 @@ import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/e
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
import { isSelfStack, refuseIfSelfStack, selfStackProtectedBulkResult } from '../helpers/selfStackGuard';
import { getActiveCapabilities, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY } from '../services/CapabilityRegistry';
import { getActiveCapabilities, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, SERVICE_SCOPED_UPDATE_CAPABILITY } from '../services/CapabilityRegistry';
import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService';
// Authenticated users with edit permission can write arbitrarily large compose
// files. Refuse to YAML.parse anything beyond this bound so a malformed (or
@@ -471,7 +474,10 @@ async function runStackBulkOp(
};
}
const atomic = true;
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
await StackUpdateOrchestrator.getInstance().execute(
{ nodeId: req.nodeId, stackName, target: { scope: 'stack' }, trigger: 'bulk', actor: user },
{ atomic, terminalWs: getTerminalWs(req.get(DEPLOY_SESSION_HEADER)) },
);
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
@@ -485,7 +491,7 @@ async function runStackBulkOp(
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err),
);
const healthGateId = HealthGateService.getInstance().begin(req.nodeId, stackName, 'update', req.user?.username ?? null);
const healthGateId = HealthGateService.getInstance().beginStack(req.nodeId, stackName, 'update', req.user?.username ?? null);
return { stackName, ok: true, healthGateId };
} else {
const outcome = await containerActionForStack(req.nodeId, stackName, action);
@@ -1143,6 +1149,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
try {
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
DatabaseService.getInstance().clearStackScanAttempts(req.nodeId, stackName);
DatabaseService.getInstance().deleteServiceUpdateRecoveries(req.nodeId, stackName);
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
DatabaseService.getInstance().deleteGitSource(stackName);
DatabaseService.getInstance().deleteStackDossier(req.nodeId, stackName);
@@ -1558,6 +1565,24 @@ stacksRouter.get('/:stackName/effective-anatomy', async (req: Request, res: Resp
}
});
// Effective Service Model: per-service facts (declared image, build presence,
// expected replica count, dependencies, healthcheck) that service-scoped
// update/restore key off of, from the fully-merged effective model. Read-only
// and advisory; auto-proxies to the active node. Never returns raw render
// stderr or any environment/label/command value.
stacksRouter.get('/:stackName/effective-services', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
res.json(await buildEffectiveServiceModel(req.nodeId, stackName));
} catch (error) {
console.error('[Stacks] Failed to build effective service model for %s:', sanitizeForLog(stackName),
sanitizeForLog(inspect(error, { depth: 4 })));
res.status(500).json({ error: 'Failed to build effective service model' });
}
});
// Environment inventory: per-stack env vars with their source, scope (Compose
// interpolation vs container injection), and status (present/missing/unused/
// duplicate/unpersisted), plus likely-secret classification. Read-only and
@@ -1656,12 +1681,17 @@ stacksRouter.put('/:stackName/exposure', async (req: Request, res: Response) =>
// that owns it. Read-only, so stack:read is the correct gate.
stacksRouter.get('/:stackName/update-readiness', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
const serviceName = typeof req.query.service === 'string' && req.query.service.length > 0 ? req.query.service : undefined;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
const report = await UpdateGuardService.getInstance().computeUpdateReadiness(req.nodeId, stackName);
const report = await UpdateGuardService.getInstance().computeUpdateReadiness(req.nodeId, stackName, serviceName);
res.json(report);
} catch (error) {
if (error instanceof SingleServiceUpdateReadinessError) {
res.status(400).json({ error: error.message, code: 'service_update_single_service' });
return;
}
console.error('[Stacks] Failed to compute update readiness for %s:', sanitizeForLog(stackName),
sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to compute update readiness' });
@@ -1724,7 +1754,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
dlog(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`);
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
ok = true;
const healthGateId = HealthGateService.getInstance().begin(req.nodeId, stackName, 'deploy', req.user?.username ?? null);
const healthGateId = HealthGateService.getInstance().beginStack(req.nodeId, stackName, 'deploy', req.user?.username ?? null);
res.json({ message: 'Deployed successfully', healthGateId });
notifyActionSuccess('deploy_success', `${stackName} deployed`, stackName, req.user?.username ?? 'system');
if (!skipScan) {
@@ -1987,6 +2017,230 @@ stacksRouter.post('/:stackName/services/:serviceName/stop', (req, res) =>
stacksRouter.post('/:stackName/services/:serviceName/start', (req, res) =>
handleServiceAction(req, res, 'start'));
/** Map an orchestrator `service_failed` code to an HTTP status. */
function serviceFailureStatus(code: string): number {
switch (code) {
case 'service_update_single_service':
case 'service_not_updatable':
case 'effective_model_render_failed':
case 'recovery_id_required':
return 400;
case 'service_not_found':
case 'recovery_not_found':
return 404;
case 'policy_blocked':
case 'recovery_not_restorable':
case 'recovery_claim_failed':
return 409;
default:
// Compose, retag, inspect, and replica-divergence failures are server-side.
return 500;
}
}
/** Send an orchestrator service result as an HTTP response. Returns success. */
function sendServiceResult(res: Response, result: OrchestratorResult, serviceName: string): boolean {
if (result.kind === 'service_done') {
res.json({
serviceName: result.serviceName,
healthGateId: result.healthGateId,
observing: result.observing,
recoveryId: result.recoveryId,
recoveryAvailable: result.recoveryAvailable,
...(result.previousImageId ? { previousImageId: result.previousImageId } : {}),
...(result.newImageId ? { newImageId: result.newImageId } : {}),
...(result.recheckWarning ? { recheckWarning: result.recheckWarning } : {}),
});
return true;
}
if (result.kind === 'service_failed') {
res.status(serviceFailureStatus(result.code)).json({
error: result.error,
code: result.code,
serviceName: result.serviceName ?? serviceName,
...(result.mutationStage ? { mutationStage: result.mutationStage } : {}),
...(result.recoveryId ? { recoveryId: result.recoveryId } : {}),
});
return false;
}
// Stack results never reach the service routes; treat defensively.
res.status(500).json({ error: 'Unexpected orchestrator result', code: 'unexpected_result' });
return false;
}
function requireServiceScopedUpdateCapability(res: Response): boolean {
if (getActiveCapabilities().includes(SERVICE_SCOPED_UPDATE_CAPABILITY)) return true;
res.status(400).json({ error: 'Service-scoped updates are not supported on this node', code: 'capability_unavailable' });
return false;
}
async function handleServiceScopedMutation(
req: Request,
res: Response,
options: {
lockAction: 'update' | 'rollback';
recoveryId?: string;
notifyFailureAction: 'update' | 'rollback';
failureCode: string;
failureMessage: string;
onSuccess: (
stackName: string,
serviceName: string,
actor: string,
meta: { previousImageId?: string | null; newImageId?: string | null },
) => void;
},
): Promise<void> {
const stackName = req.params.stackName as string;
const serviceName = req.params.serviceName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
if (await refuseIfSelfStack(req, res, stackName)) return;
if (!requireServiceScopedUpdateCapability(res)) return;
if (!tryAcquireStackOpLock(req, res, stackName, options.lockAction)) return;
const t0 = Date.now();
let ok = false;
try {
const result = await StackUpdateOrchestrator.getInstance().execute(
{
nodeId: req.nodeId,
stackName,
target: { scope: 'service', serviceName },
trigger: 'manual',
actor: req.user?.username ?? null,
},
{
policyOptions: buildPolicyGateOptions(req),
terminalWs: getTerminalWs(req.get(DEPLOY_SESSION_HEADER)),
...(options.recoveryId ? { recoveryId: options.recoveryId } : {}),
},
);
invalidateNodeCaches(req.nodeId);
ok = sendServiceResult(res, result, serviceName);
if (ok && result.kind === 'service_done') {
options.onSuccess(stackName, serviceName, req.user?.username ?? 'system', {
previousImageId: result.previousImageId,
newImageId: result.newImageId,
});
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
scope: 'image-updates',
nodeId: req.nodeId,
stackName,
action: 'stack-updated',
ts: Date.now(),
});
} else if (result.kind === 'service_failed') {
notifyActionFailure(
options.notifyFailureAction,
stackName,
new Error(result.error),
req.user?.username ?? 'system',
);
}
} catch (error: unknown) {
console.error(
'[Stacks] Service %s failed: %s/%s: %s',
sanitizeForLog(options.notifyFailureAction),
sanitizeForLog(stackName),
sanitizeForLog(serviceName),
sanitizeForLog(getErrorMessage(error, 'unknown')),
);
notifyActionFailure(options.notifyFailureAction, stackName, error, req.user?.username ?? 'system');
if (!res.headersSent) {
res.status(500).json({ error: getErrorMessage(error, options.failureMessage), code: options.failureCode });
}
} finally {
StackOpMetricsService.getInstance().record(req.nodeId, 'update', Date.now() - t0, ok, {
targetScope: 'service',
serviceName,
});
releaseStackOpLock(req, stackName);
}
}
stacksRouter.post('/:stackName/services/:serviceName/update', async (req: Request, res: Response) => {
await handleServiceScopedMutation(req, res, {
lockAction: 'update',
notifyFailureAction: 'update',
failureCode: 'service_update_failed',
failureMessage: 'Failed to update service',
onSuccess: (stackName, serviceName, actor, meta) => {
const from = shortImageId(meta.previousImageId);
const to = shortImageId(meta.newImageId);
const transition = from && to && from !== to ? ` (${from} -> ${to})` : '';
notifyActionSuccess(
'image_update_applied',
`${stackName}/${serviceName} updated${transition}`,
stackName,
actor,
);
},
});
});
/** Latest restorable service recovery snapshot (active, unexpired), for UI discovery outside Deploy Progress. */
stacksRouter.get('/:stackName/services/:serviceName/recovery', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
const serviceName = req.params.serviceName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
if (!requireServiceScopedUpdateCapability(res)) return;
try {
const row = ServiceUpdateRecoveryService.getInstance().listActive(req.nodeId, stackName, serviceName)[0];
if (!row) {
res.json({ recovery: null });
return;
}
res.json({
recovery: {
id: row.id,
status: row.status,
healthGateId: row.health_gate_id,
expiresAt: row.expires_at,
createdAt: row.created_at,
majorityImageId: row.majority_image_id,
declaredImageRef: row.declared_image_ref,
},
});
} catch (error: unknown) {
console.error(
'[Stacks] Failed to list service recovery for %s/%s: %s',
sanitizeForLog(stackName),
sanitizeForLog(serviceName),
sanitizeForLog(getErrorMessage(error, 'unknown')),
);
res.status(500).json({ error: 'Failed to load service recovery', code: 'service_recovery_lookup_failed' });
}
});
stacksRouter.post('/:stackName/services/:serviceName/restore', async (req: Request, res: Response) => {
const recoveryId = typeof req.body?.recoveryId === 'string' ? req.body.recoveryId : '';
if (!recoveryId) {
res.status(400).json({ error: 'A recoveryId is required to restore a service.', code: 'recovery_id_required' });
return;
}
await handleServiceScopedMutation(req, res, {
lockAction: 'rollback',
recoveryId,
notifyFailureAction: 'rollback',
failureCode: 'service_restore_failed',
failureMessage: 'Failed to restore service',
onSuccess: (stackName, serviceName, actor, meta) => {
const from = shortImageId(meta.previousImageId);
const to = shortImageId(meta.newImageId);
const transition = from && to && from !== to ? ` (${from} -> ${to})` : '';
notifyActionSuccess(
'deploy_success',
`${stackName}/${serviceName} restored${transition}`,
stackName,
actor,
);
},
});
});
stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
try {
@@ -2013,7 +2267,10 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
const debug = isDebugEnabled();
const atomic = true;
if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId });
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
await StackUpdateOrchestrator.getInstance().execute(
{ nodeId: req.nodeId, stackName, target: { scope: 'stack' }, trigger: 'manual', actor: req.user?.username ?? null },
{ atomic, terminalWs: getTerminalWs(req.get(DEPLOY_SESSION_HEADER)) },
);
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
invalidateNodeCaches(req.nodeId);
NotificationService.getInstance().broadcastEvent({
@@ -2027,7 +2284,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
dlog(`[Stacks] Update completed: ${sanitizeForLog(stackName)}`);
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
ok = true;
const healthGateId = HealthGateService.getInstance().begin(req.nodeId, stackName, 'update', req.user?.username ?? null);
const healthGateId = HealthGateService.getInstance().beginStack(req.nodeId, stackName, 'update', req.user?.username ?? null);
res.json({ status: 'Update completed', healthGateId });
notifyActionSuccess('image_update_applied', `${stackName} updated`, stackName, req.user?.username ?? 'system');
if (!skipScan) {
+11 -7
View File
@@ -8,6 +8,7 @@ import DockerController, {
} from '../services/DockerController';
import { isPruneTarget } from '../services/prunePlan';
import { FileSystemService } from '../services/FileSystemService';
import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService';
import SelfIdentityService from '../services/SelfIdentityService';
import { requireAdmin } from '../middleware/tierGates';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
@@ -127,8 +128,9 @@ systemMaintenanceRouter.post('/prune/plan', async (req: Request, res: Response)
const pruneScope = parsePruneScope((req.body as { scope?: unknown }).scope);
const dockerController = DockerController.getInstance(req.nodeId);
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const isImageHeld = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(req.nodeId);
const plan = await withTimeout(
dockerController.buildPrunePlan(targets, pruneScope, knownStacks, req.nodeId),
dockerController.buildPrunePlan(targets, pruneScope, knownStacks, req.nodeId, isImageHeld),
PRUNE_ESTIMATE_TIMEOUT_MS,
'docker prune plan',
);
@@ -171,11 +173,12 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response
const planFingerprint = typeof body.planFingerprint === 'string' ? body.planFingerprint : null;
const dockerController = DockerController.getInstance(req.nodeId);
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const isImageHeld = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(req.nodeId);
if (isDryRun) {
// Dry-run returns the same itemized plan shape Resources uses for preview.
const plan = await withTimeout(
dockerController.buildPrunePlan(targets, pruneScope, knownStacks, req.nodeId),
dockerController.buildPrunePlan(targets, pruneScope, knownStacks, req.nodeId, isImageHeld),
PRUNE_ESTIMATE_TIMEOUT_MS,
'docker prune plan',
);
@@ -201,7 +204,7 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response
// fingerprint and keeps the legacy pruneManagedOnly / pruneSystem path.
if (planFingerprint) {
const built = await withTimeout(
dockerController.buildPrunePlan(targets, pruneScope, knownStacks, req.nodeId),
dockerController.buildPrunePlan(targets, pruneScope, knownStacks, req.nodeId, isImageHeld),
PRUNE_ESTIMATE_TIMEOUT_MS,
'docker prune plan',
);
@@ -215,7 +218,7 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response
`[Resources] System prune (plan): ${built.targets.join(',')} (scope: ${pruneScope}, items: ${built.items.length})`,
);
const pruneStartedAt = Date.now();
const result = await dockerController.executePrunePlan(built, knownStacks);
const result = await dockerController.executePrunePlan(built, knownStacks, isImageHeld);
console.log(
`[Resources] System prune completed: reclaimed ${result.reclaimedBytes} bytes, outcomes=${result.outcomes.length}`,
);
@@ -254,14 +257,15 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response
result = await dockerController.pruneManagedOnly(
target as 'images' | 'volumes' | 'networks',
knownStacks,
isImageHeld,
);
} else if (pruneScope === 'managed' && target === 'containers') {
// Managed containers must never fall through to system prune. Build and
// execute an itemized plan for this single target instead.
const plan = await dockerController.buildPrunePlan(['containers'], 'managed', knownStacks, req.nodeId);
result = await dockerController.executePrunePlan(plan, knownStacks);
const plan = await dockerController.buildPrunePlan(['containers'], 'managed', knownStacks, req.nodeId, isImageHeld);
result = await dockerController.executePrunePlan(plan, knownStacks, isImageHeld);
} else {
result = await dockerController.pruneSystem(target);
result = await dockerController.pruneSystem(target, undefined, isImageHeld);
}
console.log(`[Resources] System prune completed: ${target}, reclaimed ${result.reclaimedBytes} bytes`);