mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user