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
+21 -2
View File
@@ -5,7 +5,7 @@ import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY
import { LicenseService } from '../services/LicenseService';
import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
import { remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } from '../helpers/remoteCapabilities';
import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY } from '../services/CapabilityRegistry';
import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, SERVICE_SCOPED_UPDATE_CAPABILITY } from '../services/CapabilityRegistry';
import { getErrorMessage } from '../utils/errors';
import { DatabaseService } from '../services/DatabaseService';
import { redactSensitiveText } from '../utils/safeLog';
@@ -171,7 +171,7 @@ export function createRemoteProxyMiddleware(): RequestHandler {
finalizeProxyTiming(req, 'error');
console.error('[Proxy] Remote node error:', getErrorMessage(err, 'unknown'));
const path = req.originalUrl || req.url;
if (req.method === 'POST' && /^\/api\/stacks\/[^/]+\/(?:deploy|update)(?:\?|$)/.test(path)) {
if (req.method === 'POST' && /^\/api\/stacks\/[^/]+\/(?:deploy|update|services\/[^/]+\/(?:update|restore))(?:\?|$)/.test(path)) {
try {
DatabaseService.getInstance().insertAuditLog({
timestamp: Date.now(),
@@ -238,6 +238,14 @@ export function createRemoteProxyMiddleware(): RequestHandler {
}
}
if (isServiceScopedUpdateRoute(req)) {
const supported = await remoteAdvertisesCapability(req.nodeId, SERVICE_SCOPED_UPDATE_CAPABILITY);
if (!supported) {
res.status(400).json({ error: 'Service-scoped updates are not supported on this node', code: 'capability_unavailable' });
return;
}
}
// Mixed-version RBAC gate (non-admin only).
if (req.user?.role !== 'admin') {
const rbacSupported = await remoteSupportsCrossNodeRbac(req.nodeId);
@@ -264,3 +272,14 @@ function isStackDownWithRemoveVolumes(req: Request): boolean {
if (!/^\/stacks\/[^/]+\/down$/.test(req.path)) return false;
return req.query.removeVolumes === 'true';
}
/** Nested service update/restore/recovery routes (path is post-/api strip). */
function isServiceScopedUpdateRoute(req: Request): boolean {
if (req.method === 'GET') {
return /^\/stacks\/[^/]+\/services\/[^/]+\/recovery$/.test(req.path);
}
if (req.method === 'POST') {
return /^\/stacks\/[^/]+\/services\/[^/]+\/(?:update|restore)$/.test(req.path);
}
return false;
}