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
+37 -3
View File
@@ -100,7 +100,7 @@ vi.mock('../services/LogFormatter', () => ({
LogFormatter: { formatLine: (line: string) => line },
}));
import { ComposeService } from '../services/ComposeService';
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
/** Creates an EventEmitter that mimics a child_process spawn result */
function createMockProcess() {
@@ -235,7 +235,19 @@ describe('ComposeService - deployStack', () => {
expect(mockBackupStackFiles).toHaveBeenCalledWith('my-stack');
});
it('throws CONTAINER_CRASHED when exited container has non-zero exit code', async () => {
it('aborts atomic deploy before docker side effects when backup fails', async () => {
mockBackupStackFiles.mockRejectedValueOnce(new Error('disk full'));
const svc = ComposeService.getInstance(1);
await expect(svc.deployStack('my-stack', undefined, true)).rejects.toThrow(
'Atomic deployment backup failed',
);
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockGetContainersByStack).not.toHaveBeenCalled();
});
it('throws sanitized CONTAINER_CRASHED when exited container has non-zero exit code', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([{
Id: 'crashed-c1',
@@ -243,7 +255,7 @@ describe('ComposeService - deployStack', () => {
Labels: { 'com.docker.compose.project': 'my-stack' },
}]);
mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
mockContainerLogs.mockResolvedValue(Buffer.from('Error: something failed'));
mockContainerLogs.mockResolvedValue(Buffer.from('SECRET_TOKEN=leaked'));
const svc = ComposeService.getInstance(1);
// Attach catch handler immediately so rejection is never "unhandled"
@@ -253,6 +265,8 @@ describe('ComposeService - deployStack', () => {
const error = await result;
expect(error).not.toBeNull();
expect(error!.message).toContain('CONTAINER_CRASHED');
expect(error!.message).not.toContain('SECRET_TOKEN');
expect(mockContainerLogs).not.toHaveBeenCalled();
});
it('rolls back on failure when atomic=true', async () => {
@@ -272,9 +286,29 @@ describe('ComposeService - deployStack', () => {
const error = await result;
expect(error).not.toBeNull();
expect(error!.message).toContain('CONTAINER_CRASHED');
expect(getComposeRollbackInfo(error)).toEqual({ attempted: true, rolledBack: true });
expect(mockRestoreStackFiles).toHaveBeenCalledWith('my-stack');
});
it('reports rollback failure when atomic restore fails', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([{
Id: 'crashed-c1',
State: 'exited',
Labels: { 'com.docker.compose.project': 'my-stack' },
}]);
mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
mockRestoreStackFiles.mockRejectedValueOnce(new Error('restore denied'));
const svc = ComposeService.getInstance(1);
const result = svc.deployStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
await vi.runAllTimersAsync();
const error = await result;
expect(error).not.toBeNull();
expect(getComposeRollbackInfo(error)).toEqual({ attempted: true, rolledBack: false });
});
it('does not roll back when atomic=false', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([{
@@ -1,6 +1,6 @@
/**
* Verifies that FileSystemService stores stack backups under
* <DATA_DIR>/backups/<stackName>/ rather than inside the user's compose
* <DATA_DIR>/backups/<nodeId>/<stackName>/ rather than inside the user's compose
* folder. The old in-stack-folder location failed with EACCES whenever a
* container had chowned the bind mount, breaking the atomic rollback
* feature for those stacks.
@@ -12,12 +12,12 @@ import { promises as fsPromises } from 'fs';
// Mutable state the mocked NodeRegistry reads. Each test rewrites these
// before instantiating FileSystemService.
const mockState = { composeDir: '' };
const mockState = { composeDir: '', composeDirs: new Map<number, string>() };
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getComposeDir: () => mockState.composeDir,
getComposeDir: (nodeId?: number) => mockState.composeDirs.get(nodeId ?? 1) ?? mockState.composeDir,
getDefaultNodeId: () => 1,
}),
},
@@ -34,6 +34,7 @@ describe('FileSystemService backup location', () => {
composeDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'sencho-compose-'));
dataDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'sencho-data-'));
mockState.composeDir = composeDir;
mockState.composeDirs = new Map([[1, composeDir]]);
originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
});
@@ -45,7 +46,7 @@ describe('FileSystemService backup location', () => {
await fsPromises.rm(dataDir, { recursive: true, force: true });
});
it('writes backups under <DATA_DIR>/backups/<stackName>/, not inside the stack folder', async () => {
it('writes backups under <DATA_DIR>/backups/<nodeId>/<stackName>/, not inside the stack folder', async () => {
const stackName = 'web';
const stackDir = path.join(composeDir, stackName);
await fsPromises.mkdir(stackDir, { recursive: true });
@@ -55,7 +56,7 @@ describe('FileSystemService backup location', () => {
const service = FileSystemService.getInstance();
await service.backupStackFiles(stackName);
const newBackupDir = path.join(dataDir, 'backups', stackName);
const newBackupDir = path.join(dataDir, 'backups', '1', stackName);
const oldBackupDir = path.join(stackDir, '.sencho-backup');
// New location has every backed-up file
@@ -83,6 +84,32 @@ describe('FileSystemService backup location', () => {
expect(typeof after.timestamp).toBe('number');
});
it('scopes backups by node id when stack names overlap', async () => {
const stackName = 'web';
const secondComposeDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'sencho-compose-'));
mockState.composeDirs.set(2, secondComposeDir);
try {
const nodeOneStackDir = path.join(composeDir, stackName);
const nodeTwoStackDir = path.join(secondComposeDir, stackName);
await fsPromises.mkdir(nodeOneStackDir, { recursive: true });
await fsPromises.mkdir(nodeTwoStackDir, { recursive: true });
await fsPromises.writeFile(path.join(nodeOneStackDir, 'compose.yaml'), 'services:\n one: {}\n', 'utf-8');
await fsPromises.writeFile(path.join(nodeTwoStackDir, 'compose.yaml'), 'services:\n two: {}\n', 'utf-8');
await FileSystemService.getInstance(1).backupStackFiles(stackName);
await FileSystemService.getInstance(2).backupStackFiles(stackName);
await expect(
fsPromises.readFile(path.join(dataDir, 'backups', '1', stackName, 'compose.yaml'), 'utf-8'),
).resolves.toContain('one');
await expect(
fsPromises.readFile(path.join(dataDir, 'backups', '2', stackName, 'compose.yaml'), 'utf-8'),
).resolves.toContain('two');
} finally {
await fsPromises.rm(secondComposeDir, { recursive: true, force: true });
}
});
it('restoreStackFiles copies files from the new location back to the stack dir', async () => {
const stackName = 'db';
const stackDir = path.join(composeDir, stackName);
@@ -12,7 +12,7 @@ const {
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
mockMarkStaleRunsAsFailed, mockDeleteOldScans,
mockGetTier, mockGetVariant,
mockGetTier, mockGetVariant, mockGetProxyHeaders,
mockGetContainersByStack, mockRestartContainer, mockPruneSystem,
mockUpdateStack,
mockGetStacks, mockGetStackContent, mockGetEnvContent,
@@ -42,6 +42,7 @@ const {
mockDeleteOldScans: vi.fn().mockReturnValue(0),
mockGetTier: vi.fn().mockReturnValue('paid'),
mockGetVariant: vi.fn().mockReturnValue('admiral'),
mockGetProxyHeaders: vi.fn().mockReturnValue({ tier: 'paid', variant: 'admiral' }),
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
mockRestartContainer: vi.fn().mockResolvedValue(undefined),
mockPruneSystem: vi.fn().mockResolvedValue({ success: true, reclaimedBytes: 0 }),
@@ -94,6 +95,7 @@ vi.mock('../services/LicenseService', () => ({
getInstance: () => ({
getTier: mockGetTier,
getVariant: mockGetVariant,
getProxyHeaders: mockGetProxyHeaders,
}),
},
}));
@@ -1349,6 +1351,11 @@ describe('SchedulerService - executeUpdateRemote', () => {
'http://remote:1852/api/auto-update/execute',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer test-token',
'x-sencho-tier': 'paid',
'x-sencho-variant': 'admiral',
}),
body: JSON.stringify({ target: 'web-app' }),
})
);
@@ -9,7 +9,9 @@
*/
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { ComposeRollbackError } from '../services/ComposeService';
// ── Hoisted mocks (must come before importing the app) ──────────────────────
@@ -143,6 +145,46 @@ describe('deploy_failure notification on /deploy error', () => {
expect(call[2]).toContain('network timeout');
expect(call[3]).toEqual({ stackName: 'webapp' });
});
it('returns rolledBack=true only when compose rollback completed', async () => {
mockDeployStack.mockRejectedValue(
new ComposeRollbackError(new Error('image pull failed'), true, true),
);
const res = await request(app)
.post('/api/stacks/myapp/deploy')
.set('Cookie', authCookie);
expect(res.status).toBe(500);
expect(res.body).toMatchObject({ rolledBack: true });
});
it('returns rolledBack=false when compose rollback failed', async () => {
mockDeployStack.mockRejectedValue(
new ComposeRollbackError(new Error('image pull failed'), true, false),
);
const res = await request(app)
.post('/api/stacks/myapp/deploy')
.set('Cookie', authCookie);
expect(res.status).toBe(500);
expect(res.body).toMatchObject({ rolledBack: false });
});
it('uses trusted proxy tier headers for remote atomic deploys', async () => {
mockDeployStack.mockResolvedValue(undefined);
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.post('/api/stacks/myapp/deploy')
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'paid')
.set('x-sencho-variant', 'skipper');
expect(res.status).toBe(200);
expect(mockDeployStack.mock.calls[0][2]).toBe(true);
});
});
describe('deploy_failure notification on /down error', () => {
@@ -229,4 +271,17 @@ describe('deploy_failure notification on /update error', () => {
{ stackName: 'myapp' },
);
});
it('returns rollback completion status when updateStack throws rollback metadata', async () => {
mockUpdateStack.mockRejectedValue(
new ComposeRollbackError(new Error('image not found'), true, false),
);
const res = await request(app)
.post('/api/stacks/myapp/update')
.set('Cookie', authCookie);
expect(res.status).toBe(500);
expect(res.body).toMatchObject({ rolledBack: false });
});
});
+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'
});
}
File diff suppressed because it is too large Load Diff
+14 -7
View File
@@ -51,11 +51,11 @@ const MIME_MAP: Record<string, string> = {
*/
export class FileSystemService {
private baseDir: string;
private nodeId: number;
constructor(nodeId?: number) {
this.baseDir = NodeRegistry.getInstance().getComposeDir(
nodeId ?? NodeRegistry.getInstance().getDefaultNodeId()
);
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
this.baseDir = NodeRegistry.getInstance().getComposeDir(this.nodeId);
}
public static getInstance(nodeId?: number): FileSystemService {
@@ -77,6 +77,13 @@ export class FileSystemService {
return stackDir;
}
private getBackupDir(stackName: string): string {
if (!isValidStackName(stackName)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
return path.join(getBackupBaseDir(), String(this.nodeId), stackName);
}
async hasComposeFile(dir: string): Promise<boolean> {
this.assertWithinBase(dir);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
@@ -298,7 +305,7 @@ export class FileSystemService {
/**
* Backup stack files (compose.yaml + .env) into Sencho's data dir.
*
* Backups live at <DATA_DIR>/backups/<stackName>/ (NOT inside the user's
* Backups live at <DATA_DIR>/backups/<nodeId>/<stackName>/ (NOT inside the user's
* compose folder) so the operation always succeeds even when the stack
* folder is owned by another UID (e.g., a container running as root has
* chowned its bind mount). DATA_DIR is the same writable location that
@@ -308,7 +315,7 @@ export class FileSystemService {
const debug = isDebugEnabled();
const t0 = Date.now();
const stackDir = this.resolveStackDir(stackName);
const backupDir = path.join(getBackupBaseDir(), stackName);
const backupDir = this.getBackupDir(stackName);
await fsPromises.mkdir(backupDir, { recursive: true });
// Copy compose file
@@ -347,7 +354,7 @@ export class FileSystemService {
const debug = isDebugEnabled();
const t0 = Date.now();
const stackDir = this.resolveStackDir(stackName);
const backupDir = path.join(getBackupBaseDir(), stackName);
const backupDir = this.getBackupDir(stackName);
const items = await fsPromises.readdir(backupDir);
for (const item of items) {
@@ -358,7 +365,7 @@ export class FileSystemService {
}
async getBackupInfo(stackName: string): Promise<{ exists: boolean; timestamp: number | null }> {
const backupDir = path.join(getBackupBaseDir(), stackName);
const backupDir = this.getBackupDir(stackName);
try {
await fsPromises.access(backupDir);
const tsFile = path.join(backupDir, '.timestamp');
+4
View File
@@ -2,6 +2,7 @@ import { CronExpressionParser } from 'cron-parser';
import { DatabaseService } from './DatabaseService';
import type { ScheduledTask } from './DatabaseService';
import { LicenseService } from './LicenseService';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
import DockerController from './DockerController';
import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
@@ -625,6 +626,7 @@ export class SchedulerService {
}
const baseUrl = proxyTarget.apiUrl.replace(/\/$/, '');
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
if (isDebugEnabled()) {
console.log(`[SchedulerService] executeUpdateRemote: node=${nodeId} target=${target}`);
}
@@ -634,6 +636,8 @@ export class SchedulerService {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${proxyTarget.apiToken}`,
[PROXY_TIER_HEADER]: proxyHeaders.tier,
[PROXY_VARIANT_HEADER]: proxyHeaders.variant ?? '',
},
body: JSON.stringify({ target }),
signal: AbortSignal.timeout(300_000), // 5 minute timeout for long updates
@@ -14,8 +14,11 @@ import type { PolicyBlockPayload } from '../../stack/PolicyBlockDialog';
interface RunResult {
ok: boolean;
errorMessage?: string;
rolledBack?: boolean;
}
type StackActionError = Error & { rolledBack?: boolean };
type EditorState = ReturnType<typeof useEditorViewState>;
type StackListState = ReturnType<typeof useStackListState>;
type NavState = ReturnType<typeof useViewNavigationState>;
@@ -36,6 +39,30 @@ interface UseStackActionsOptions {
diffPreviewEnabled: boolean;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const parseStackActionError = (rawBody: string, fallback: string): StackActionError => {
let message = rawBody || fallback;
let rolledBack = false;
try {
const parsed: unknown = JSON.parse(rawBody);
if (isRecord(parsed)) {
if (typeof parsed.error === 'string' && parsed.error.trim()) {
message = parsed.error;
}
rolledBack = parsed.rolledBack === true;
}
} catch {
/* not JSON */
}
const error = new Error(message) as StackActionError;
error.rolledBack = rolledBack;
return error;
};
export function useStackActions(options: UseStackActionsOptions) {
const {
editorState,
@@ -349,7 +376,7 @@ export function useStackActions(options: UseStackActionsOptions) {
stackFile: string,
ignorePolicy: boolean,
started?: Promise<void>,
): Promise<{ ok: boolean; errorMessage?: string }> => {
): Promise<RunResult> => {
const previousStatus = stackListState.stackStatuses[stackFile];
stackListState.setOptimisticStatus(stackFile, 'running');
try {
@@ -381,7 +408,7 @@ export function useStackActions(options: UseStackActionsOptions) {
};
}
}
throw new Error(rawBody || 'Deploy failed');
throw parseStackActionError(rawBody, 'Deploy failed');
}
overlayState.setPolicyBlock(null);
toast.success(
@@ -405,13 +432,14 @@ export function useStackActions(options: UseStackActionsOptions) {
console.error('Failed to deploy:', error);
if (previousStatus !== undefined)
stackListState.setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
const errorMessage = (error as Error).message || 'Failed to deploy stack';
const deployError = error as StackActionError;
const errorMessage = deployError.message || 'Failed to deploy stack';
toast.error(
isPaid
isPaid && deployError.rolledBack === true
? `${errorMessage} - automatically rolled back to previous version.`
: errorMessage,
);
return { ok: false, errorMessage };
return { ok: false, errorMessage, rolledBack: deployError.rolledBack };
}
};
@@ -550,7 +578,12 @@ export function useStackActions(options: UseStackActionsOptions) {
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' });
if (!response.ok) {
const errText = await response.text();
return { ok: false as const, errorMessage: errText || `${action} failed` };
const actionError = parseStackActionError(errText, `${action} failed`);
return {
ok: false as const,
errorMessage: actionError.message,
rolledBack: actionError.rolledBack,
};
}
toast.success(successMessage);
if (action === 'update') stackListState.fetchImageUpdates();
@@ -695,7 +728,7 @@ export function useStackActions(options: UseStackActionsOptions) {
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' });
if (!response.ok) {
const errText = await response.text();
throw new Error(errText || `${action} failed`);
throw parseStackActionError(errText, `${action} failed`);
}
toast.success(`Stack ${action}ed successfully!`);
if (stackListState.selectedFile === stackFile) {
@@ -714,9 +747,10 @@ export function useStackActions(options: UseStackActionsOptions) {
}
} catch (error) {
console.error(`Failed to ${action}:`, error);
const msg = (error as Error).message || `Failed to ${action} stack`;
const actionError = error as StackActionError;
const msg = actionError.message || `Failed to ${action} stack`;
toast.error(
action === 'deploy' && isPaid
action === 'deploy' && isPaid && actionError.rolledBack === true
? `${msg} - automatically rolled back to previous version.`
: msg,
);