fix: harden atomic deployment rollback (#1029)

* fix: harden atomic deployment rollback

* fix: update Docker toolchain to Go 1.26.3

* fix: repair Dockerfile tr argument split across lines

* fix: bump protobufjs to clear npm audit high-severity advisories

* fix: sanitize error objects in console.error to prevent log injection
This commit is contained in:
Anso
2026-05-12 15:58:30 -04:00
committed by GitHub
parent 69b6ac1f3b
commit 74ae2ce0c6
11 changed files with 739 additions and 530 deletions
+2 -3
View File
@@ -6,11 +6,10 @@ import { CacheService } from '../services/CacheService';
import { ImageUpdateService } from '../services/ImageUpdateService';
import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import { LicenseService } from '../services/LicenseService';
import { NotificationService } from '../services/NotificationService';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { effectiveTier, requireAdmin, requirePaid } from '../middleware/tierGates';
import { buildPolicyGateOptions } from '../helpers/policyGate';
import { isValidStackName } from '../utils/validation';
import { sanitizeForLog } from '../utils/safeLog';
@@ -215,7 +214,7 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
const imageUpdateService = ImageUpdateService.getInstance();
const compose = ComposeService.getInstance(req.nodeId);
const db = DatabaseService.getInstance();
const atomic = LicenseService.getInstance().getTier() === 'paid';
const atomic = effectiveTier(req) === 'paid';
const results: string[] = [];
for (const stackName of stackNames) {
+18 -9
View File
@@ -3,16 +3,15 @@ import path from 'path';
import YAML from 'yaml';
import multer from 'multer';
import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
import DockerController from '../services/DockerController';
import { DatabaseService } from '../services/DatabaseService';
import { CacheService } from '../services/CacheService';
import { LicenseService } from '../services/LicenseService';
import { UpdatePreviewService } from '../services/UpdatePreviewService';
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { requirePermission } from '../middleware/permissions';
import { requirePaid, requireAdmin } from '../middleware/tierGates';
import { requirePaid, requireAdmin, effectiveTier } from '../middleware/tierGates';
import { NotificationService, type NotificationCategory } from '../services/NotificationService';
import { isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
@@ -588,7 +587,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
try {
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
const debug = isDebugEnabled();
const atomic = LicenseService.getInstance().getTier() === 'paid';
const atomic = effectiveTier(req) === 'paid';
if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId });
const t0 = Date.now();
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic);
@@ -602,8 +601,13 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
);
} catch (error: unknown) {
console.error('[Stacks] Deploy failed: %s', sanitizeForLog(stackName), error);
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
if (rolledBack) console.warn('[Stacks] Deploy failed, rolled back: %s', sanitizeForLog(stackName));
const rollbackInfo = getComposeRollbackInfo(error);
const rolledBack = rollbackInfo?.rolledBack ?? false;
if (rolledBack) {
console.warn('[Stacks] Deploy failed, rolled back: %s', sanitizeForLog(stackName));
} else if (rollbackInfo?.attempted) {
console.warn('[Stacks] Deploy failed, rollback did not complete: %s', sanitizeForLog(stackName));
}
const message = getErrorMessage(error, 'Failed to deploy stack');
notifyActionFailure('deploy', stackName, error);
res.status(500).json({ error: message, rolledBack });
@@ -762,7 +766,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
try {
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
const debug = isDebugEnabled();
const atomic = LicenseService.getInstance().getTier() === 'paid';
const atomic = effectiveTier(req) === 'paid';
if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId });
const t0 = Date.now();
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(), atomic);
@@ -777,8 +781,13 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
);
} catch (error: unknown) {
console.error('[Stacks] Update failed: %s', sanitizeForLog(stackName), error);
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
if (rolledBack) console.warn(`[Stacks] Update failed, rolled back: ${sanitizeForLog(stackName)}`);
const rollbackInfo = getComposeRollbackInfo(error);
const rolledBack = rollbackInfo?.rolledBack ?? false;
if (rolledBack) {
console.warn(`[Stacks] Update failed, rolled back: ${sanitizeForLog(stackName)}`);
} else if (rollbackInfo?.attempted) {
console.warn(`[Stacks] Update failed, rollback did not complete: ${sanitizeForLog(stackName)}`);
}
notifyActionFailure('update', stackName, error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to update'), rolledBack });
}
+9 -4
View File
@@ -2,13 +2,12 @@ import { Router, type Request, type Response } from 'express';
import path from 'path';
import { promises as fsPromises } from 'fs';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { effectiveTier, requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { templateService } from '../services/TemplateService';
import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import { DatabaseService } from '../services/DatabaseService';
import { LicenseService } from '../services/LicenseService';
import { ErrorParser } from '../utils/ErrorParser';
import { isValidStackName, isPathWithinBase } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
@@ -125,7 +124,7 @@ templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Respon
}
return;
}
const atomic = LicenseService.getInstance().getTier() === 'paid';
const atomic = effectiveTier(req) === 'paid';
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic);
invalidateNodeCaches(req.nodeId);
console.log(`[Templates] Deploy completed: ${stackName}`);
@@ -141,25 +140,31 @@ templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Respon
const parsed = ErrorParser.parse(rawError);
const shouldRollback = parsed.rule ? parsed.rule.canSilentlyRollback : true;
let rolledBack = false;
if (shouldRollback) {
let dockerDownCompleted = true;
let fileDeleteCompleted = true;
try {
await ComposeService.getInstance(req.nodeId).downStack(stackName);
} catch (downErr) {
dockerDownCompleted = false;
console.error("[Templates] Rollback Stage 1 (Docker down) failed:", downErr);
}
try {
await fsService.deleteStack(stackName);
} catch (fsErr) {
fileDeleteCompleted = false;
console.error("[Templates] Rollback Stage 2 (File deletion) failed:", fsErr);
}
rolledBack = dockerDownCompleted && fileDeleteCompleted;
}
invalidateNodeCaches(req.nodeId);
res.status(500).json({
error: parsed.message,
rolledBack: shouldRollback,
rolledBack,
ruleId: parsed.rule?.id || 'UNKNOWN'
});
}