mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 20:29:10 +00:00
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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user