mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
refactor(backend): extract stacks router (phase 4c-6, final route extraction) (#744)
Move the 17 /api/stacks/* endpoints out of index.ts into routes/stacks.ts. Endpoints covered: - list, statuses (bulk-status cache via CacheService) - get / put stack compose content - envs (resolve), env read, env write (multi env_file aware) - create (plain + from-git with policy gate + optional deploy) - delete (three-stage Docker-down, FS-delete, DB cleanup) - containers list, services list - lifecycle: deploy / down / restart / stop / start - update-preview, update, rollback (Skipper+), backup info The inline resolveAllEnvFilePaths helper moves with the router as a file-local function. Handlers moved verbatim; middleware chains, response shapes, and error messages preserved. Removes twenty-two now-unused imports from index.ts: DockerController, ComposeService, path, UpdatePreviewService, CacheService, GitSourceService, GitSourceError, gitRepoHost, sendGitSourceError, STACK_STATUSES_CACHE_TTL_MS, requirePermission, requirePaid, buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, getTerminalWs, invalidateNodeCaches, getErrorMessage, enforcePolicyPreDeploy, isValidStackName, isPathWithinBase, YAML. index.ts drops from 1021 to 305 lines. All /api/* route groups now live in routes/*.ts. index.ts contains only wiring (createApp, createServer, attachUpgrade, route mounts, remote proxy, static serving, error handler) and startup/shutdown lifecycles. Bootstrap extraction follows in phase 5.
This commit is contained in:
+4
-720
@@ -1,34 +1,20 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import DockerController from './services/DockerController';
|
||||
import { FileSystemService } from './services/FileSystemService';
|
||||
import { ComposeService } from './services/ComposeService';
|
||||
import path from 'path';
|
||||
import { DatabaseService } from './services/DatabaseService';
|
||||
import { MonitorService } from './services/MonitorService';
|
||||
import { AutoHealService } from './services/AutoHealService';
|
||||
import { DockerEventManager } from './services/DockerEventManager';
|
||||
import { ImageUpdateService } from './services/ImageUpdateService';
|
||||
import { UpdatePreviewService } from './services/UpdatePreviewService';
|
||||
import { NodeRegistry } from './services/NodeRegistry';
|
||||
import { LicenseService } from './services/LicenseService';
|
||||
import { SchedulerService } from './services/SchedulerService';
|
||||
import { CacheService } from './services/CacheService';
|
||||
import { GitSourceService, GitSourceError, sweepStaleTempDirs as sweepStaleGitTempDirs, repoHost as gitRepoHost } from './services/GitSourceService';
|
||||
import { sendGitSourceError } from './utils/gitSourceHttp';
|
||||
import { sweepStaleTempDirs as sweepStaleGitTempDirs } from './services/GitSourceService';
|
||||
import './types/express';
|
||||
import {
|
||||
PORT,
|
||||
MFA_REPLAY_TTL_MS,
|
||||
MFA_REPLAY_PURGE_INTERVAL_MS,
|
||||
STACK_STATUSES_CACHE_TTL_MS,
|
||||
} from './helpers/constants';
|
||||
import { requirePermission } from './middleware/permissions';
|
||||
import { requirePaid } from './middleware/tierGates';
|
||||
import {
|
||||
buildPolicyGateOptions,
|
||||
runPolicyGate,
|
||||
triggerPostDeployScan,
|
||||
} from './helpers/policyGate';
|
||||
import { authGate, auditLog } from './middleware/authGate';
|
||||
import { enforceApiTokenScope } from './middleware/apiTokenScope';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
@@ -36,8 +22,6 @@ import { createApp } from './app';
|
||||
import { createRemoteProxyMiddleware } from './proxy/remoteNodeProxy';
|
||||
import { createServer } from './server';
|
||||
import { attachUpgrade } from './websocket/upgradeHandler';
|
||||
import { getTerminalWs } from './websocket/generic';
|
||||
import { invalidateNodeCaches } from './helpers/cacheInvalidation';
|
||||
import { metaRouter } from './routes/meta';
|
||||
import { authRouter } from './routes/auth';
|
||||
import { mfaRouter } from './routes/mfa';
|
||||
@@ -68,14 +52,11 @@ import { templatesRouter } from './routes/templates';
|
||||
import { securityRouter } from './routes/security';
|
||||
import { containersRouter, portsRouter } from './routes/containers';
|
||||
import { nodesRouter } from './routes/nodes';
|
||||
import { stacksRouter } from './routes/stacks';
|
||||
|
||||
import { isDebugEnabled } from './utils/debug';
|
||||
import { getErrorMessage } from './utils/errors';
|
||||
import SelfUpdateService from './services/SelfUpdateService';
|
||||
import TrivyService from './services/TrivyService';
|
||||
import { enforcePolicyPreDeploy } from './services/PolicyEnforcement';
|
||||
import { isValidStackName, isPathWithinBase } from './utils/validation';
|
||||
import YAML from 'yaml';
|
||||
import { isDebugEnabled } from './utils/debug';
|
||||
|
||||
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
|
||||
// util._extend internally. The warning fires at runtime when createProxyServer() is
|
||||
@@ -152,6 +133,7 @@ app.use('/api/security', securityRouter);
|
||||
app.use('/api/containers', containersRouter);
|
||||
app.use('/api/ports', portsRouter);
|
||||
app.use('/api/nodes', nodesRouter);
|
||||
app.use('/api/stacks', stacksRouter);
|
||||
|
||||
// Remote Node HTTP Proxy (see proxy/remoteNodeProxy.ts). Mounted here after
|
||||
// authGate + auditLog + apiTokenScope so local Sencho enforces auth first;
|
||||
@@ -167,704 +149,6 @@ const { server, wss, pilotTunnelWss } = createServer(app);
|
||||
attachUpgrade(server, { wss, pilotTunnelWss });
|
||||
|
||||
|
||||
// API Routes (all protected by authMiddleware)
|
||||
|
||||
|
||||
|
||||
// Stack Routes - Updated to use stackName (directory name) instead of filename
|
||||
|
||||
app.get('/api/stacks', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
res.json(stacks);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch stacks' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stacks/statuses', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await CacheService.getInstance().getOrFetch(
|
||||
`stack-statuses:${req.nodeId}`,
|
||||
STACK_STATUSES_CACHE_TTL_MS,
|
||||
async () => {
|
||||
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const stackNames = stacks.map((s: string) => s.replace(/\.(yml|yaml)$/, ''));
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const bulkInfo = await dockerController.getBulkStackStatuses(stackNames);
|
||||
// Map back to filenames to match frontend expectations
|
||||
const data: Record<string, { status: 'running' | 'exited' | 'unknown'; mainPort?: number; runningSince?: number }> = {};
|
||||
for (const stack of stacks) {
|
||||
const name = stack.replace(/\.(yml|yaml)$/, '');
|
||||
data[stack] = bulkInfo[name] ?? { status: 'unknown' };
|
||||
}
|
||||
return data;
|
||||
},
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stack statuses:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch stack statuses' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read stack' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const { content } = req.body;
|
||||
if (typeof content !== 'string') {
|
||||
console.error('Content is not a string:', content);
|
||||
return res.status(400).json({ error: 'Content must be a string' });
|
||||
}
|
||||
await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Compose file saved: ${stackName}`);
|
||||
res.json({ message: 'Stack saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to save stack:', error);
|
||||
res.status(500).json({ error: 'Failed to save stack' });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: resolve all env file paths dynamically from compose.yaml's env_file field
|
||||
async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
|
||||
const fsService = FileSystemService.getInstance(nodeId);
|
||||
const stackDir = path.join(fsService.getBaseDir(), stackName);
|
||||
const defaultEnvPath = path.join(stackDir, '.env');
|
||||
|
||||
try {
|
||||
// Try to read and parse the compose file
|
||||
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||
let composeContent: string | null = null;
|
||||
|
||||
for (const file of composeFiles) {
|
||||
try {
|
||||
composeContent = await fsService.readFile(path.join(stackDir, file), 'utf-8');
|
||||
break;
|
||||
} catch {
|
||||
// Try next file
|
||||
}
|
||||
}
|
||||
|
||||
if (!composeContent) return [defaultEnvPath];
|
||||
|
||||
const parsed = YAML.parse(composeContent);
|
||||
if (!parsed?.services) return [defaultEnvPath];
|
||||
|
||||
const envFiles = new Set<string>();
|
||||
|
||||
// Iterate through all services and collect every env_file declaration
|
||||
for (const serviceName of Object.keys(parsed.services)) {
|
||||
const service = parsed.services[serviceName];
|
||||
if (!service?.env_file) continue;
|
||||
|
||||
const addEnvPath = (rawPath: string) => {
|
||||
const resolved = path.resolve(stackDir, rawPath);
|
||||
if (!isPathWithinBase(resolved, stackDir)) return;
|
||||
envFiles.add(resolved);
|
||||
};
|
||||
|
||||
if (typeof service.env_file === 'string') {
|
||||
addEnvPath(service.env_file);
|
||||
} else if (Array.isArray(service.env_file)) {
|
||||
for (const entry of service.env_file) {
|
||||
const entryPath = typeof entry === 'string' ? entry : (entry?.path || '');
|
||||
if (entryPath) addEnvPath(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (envFiles.size === 0) {
|
||||
envFiles.add(defaultEnvPath);
|
||||
}
|
||||
|
||||
// Filter to only include files that actually exist on disk
|
||||
const existing: string[] = [];
|
||||
for (const f of envFiles) {
|
||||
try {
|
||||
await fsService.access(f);
|
||||
existing.push(f);
|
||||
} catch {
|
||||
// File does not exist - skip
|
||||
}
|
||||
}
|
||||
return existing;
|
||||
} catch (error) {
|
||||
console.warn(`Could not parse compose.yaml for env_file resolution in stack "${stackName}":`, error);
|
||||
}
|
||||
|
||||
// Fallback: return default only if it exists
|
||||
try {
|
||||
await fsService.access(defaultEnvPath);
|
||||
return [defaultEnvPath];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
res.json({ envFiles: envPaths });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to resolve env files' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const requestedFile = req.query.file as string | undefined;
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
|
||||
let envPath = envPaths[0]; // Fallback to the first
|
||||
|
||||
if (requestedFile) {
|
||||
// Validate that the requested file exists in the allowed resolved list
|
||||
if (envPaths.includes(requestedFile)) {
|
||||
envPath = requestedFile;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Requested env file not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
const fsService = FileSystemService.getInstance(req.nodeId);
|
||||
|
||||
try {
|
||||
await fsService.access(envPath);
|
||||
} catch (e: unknown) {
|
||||
const code = (e as NodeJS.ErrnoException)?.code;
|
||||
if (code !== 'ENOENT') {
|
||||
console.error('[Sencho] Unexpected error checking env file existence:', (e as Error).message);
|
||||
}
|
||||
return res.status(404).json({ error: 'Env file not found' });
|
||||
}
|
||||
|
||||
const content = await fsService.readFile(envPath, 'utf-8');
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
console.error('Failed to read env file:', error);
|
||||
res.status(500).json({ error: 'Failed to read env file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const { content } = req.body;
|
||||
if (typeof content !== 'string') {
|
||||
return res.status(400).json({ error: 'Content must be a string' });
|
||||
}
|
||||
|
||||
const requestedFile = req.query.file as string | undefined;
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
|
||||
let envPath = envPaths[0]; // Fallback
|
||||
|
||||
if (requestedFile) {
|
||||
if (envPaths.includes(requestedFile)) {
|
||||
envPath = requestedFile;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Requested env file not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
const fsService = FileSystemService.getInstance(req.nodeId);
|
||||
await fsService.writeFile(envPath, content, 'utf-8');
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
const envFileName = path.basename(envPath);
|
||||
console.log(`[Stacks] Env file saved: ${stackName}/${envFileName}`);
|
||||
res.json({ message: 'Env file saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to save env file:', error);
|
||||
res.status(500).json({ error: 'Failed to save env file' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.post('/api/stacks', async (req: Request, res: Response) => {
|
||||
if (!requirePermission(req, res, 'stack:create')) return;
|
||||
try {
|
||||
const { stackName } = req.body;
|
||||
if (!stackName || typeof stackName !== 'string') {
|
||||
return res.status(400).json({ error: 'Stack name is required and must be a string' });
|
||||
}
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' });
|
||||
}
|
||||
await FileSystemService.getInstance(req.nodeId).createStack(stackName);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Stack created: ${stackName}`);
|
||||
res.json({ message: 'Stack created successfully', name: stackName });
|
||||
} catch (error: unknown) {
|
||||
const message = getErrorMessage(error, '');
|
||||
if (message.includes('already exists')) {
|
||||
return res.status(409).json({ error: 'Stack already exists' });
|
||||
}
|
||||
console.error('Failed to create stack:', error);
|
||||
res.status(500).json({ error: 'Failed to create stack' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks/from-git', async (req: Request, res: Response) => {
|
||||
if (!requirePermission(req, res, 'stack:create')) return;
|
||||
const fromGitStartedAt = Date.now();
|
||||
const fromGitDiag = isDebugEnabled();
|
||||
let fromGitStackName = '';
|
||||
try {
|
||||
const {
|
||||
stack_name,
|
||||
repo_url,
|
||||
branch,
|
||||
compose_path,
|
||||
sync_env,
|
||||
env_path,
|
||||
auth_type,
|
||||
token,
|
||||
auto_apply_on_webhook,
|
||||
auto_deploy_on_apply,
|
||||
deploy_now,
|
||||
} = req.body ?? {};
|
||||
fromGitStackName = typeof stack_name === 'string' ? stack_name : '';
|
||||
|
||||
if (typeof stack_name !== 'string' || !stack_name.trim()) {
|
||||
return res.status(400).json({ error: 'stack_name is required' });
|
||||
}
|
||||
if (!isValidStackName(stack_name)) {
|
||||
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' });
|
||||
}
|
||||
if (typeof repo_url !== 'string' || !repo_url.trim()) {
|
||||
return res.status(400).json({ error: 'repo_url is required' });
|
||||
}
|
||||
if (typeof branch !== 'string' || !branch.trim()) {
|
||||
return res.status(400).json({ error: 'branch is required' });
|
||||
}
|
||||
if (typeof compose_path !== 'string' || !compose_path.trim()) {
|
||||
return res.status(400).json({ error: 'compose_path is required' });
|
||||
}
|
||||
const resolvedAuthType = auth_type === 'token' ? 'token' : 'none';
|
||||
if (!/^https:\/\//i.test(repo_url)) {
|
||||
return res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
|
||||
}
|
||||
if (repo_url.length > 2048) {
|
||||
return res.status(400).json({ error: 'repo_url is too long' });
|
||||
}
|
||||
if (branch.length > 256) {
|
||||
return res.status(400).json({ error: 'branch is too long' });
|
||||
}
|
||||
if (compose_path.length > 1024) {
|
||||
return res.status(400).json({ error: 'compose_path is too long' });
|
||||
}
|
||||
if (typeof env_path === 'string' && env_path.length > 1024) {
|
||||
return res.status(400).json({ error: 'env_path is too long' });
|
||||
}
|
||||
if (typeof token === 'string' && token.length > 8192) {
|
||||
return res.status(400).json({ error: 'token is too long' });
|
||||
}
|
||||
|
||||
// Reject if a stack with this name already exists on disk. Without this
|
||||
// the service would catch it at createStack() time, but erroring early
|
||||
// avoids spinning up a temp clone we will not use.
|
||||
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
if (stacks.includes(stack_name)) {
|
||||
return res.status(409).json({ error: 'Stack already exists' });
|
||||
}
|
||||
|
||||
const syncEnv = Boolean(sync_env);
|
||||
const resolvedEnvPath = syncEnv
|
||||
? (typeof env_path === 'string' && env_path.trim()
|
||||
? env_path
|
||||
: path.posix.join(path.posix.dirname(compose_path.replace(/\\/g, '/')) || '.', '.env'))
|
||||
: null;
|
||||
|
||||
if (fromGitDiag) {
|
||||
console.log(
|
||||
`[Stacks:diag] from-git start stack=${stack_name} nodeId=${req.nodeId ?? 'local'} host=${gitRepoHost(repo_url)} branch=${branch} composePath=${compose_path} envPath=${resolvedEnvPath ?? 'none'} authType=${resolvedAuthType} autoApplyOnWebhook=${Boolean(auto_apply_on_webhook)} autoDeployOnApply=${Boolean(auto_deploy_on_apply)} deployNow=${deploy_now === true}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await GitSourceService.getInstance().createStackFromGit({
|
||||
stackName: stack_name.trim(),
|
||||
repoUrl: repo_url.trim(),
|
||||
branch: branch.trim(),
|
||||
composePath: compose_path.trim(),
|
||||
syncEnv,
|
||||
envPath: resolvedEnvPath,
|
||||
authType: resolvedAuthType,
|
||||
token: resolvedAuthType === 'token' && typeof token === 'string' && token !== '' ? token : null,
|
||||
autoApplyOnWebhook: Boolean(auto_apply_on_webhook),
|
||||
autoDeployOnApply: Boolean(auto_deploy_on_apply),
|
||||
});
|
||||
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
|
||||
// Deploy is best-effort. The compose file is already on disk and the
|
||||
// git source is linked, so a deploy failure does not roll back the
|
||||
// stack; the user can retry the deploy from the editor. This mirrors
|
||||
// the apply-then-deploy behavior in GitSourceService.apply().
|
||||
let deployed = false;
|
||||
let deployError: string | undefined;
|
||||
if (deploy_now === true) {
|
||||
const gate = await enforcePolicyPreDeploy(
|
||||
stack_name,
|
||||
req.nodeId,
|
||||
buildPolicyGateOptions(req),
|
||||
);
|
||||
if (!gate.ok) {
|
||||
deployError = `Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`;
|
||||
} else {
|
||||
try {
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stack_name);
|
||||
deployed = true;
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
} catch (e) {
|
||||
deployError = getErrorMessage(e, 'Deploy failed');
|
||||
console.error(`[Stacks] Deploy after create-from-git failed for ${stack_name}:`, deployError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Stacks] Stack created from Git: ${stack_name} at ${result.commitSha.slice(0, 7)}`);
|
||||
if (fromGitDiag) {
|
||||
console.log(
|
||||
`[Stacks:diag] from-git ok stack=${stack_name} sha=${result.commitSha.slice(0, 7)} deployed=${deployed} envWritten=${result.envWritten} warnings=${result.warnings.length} elapsedMs=${Date.now() - fromGitStartedAt}`
|
||||
);
|
||||
}
|
||||
res.json({
|
||||
name: stack_name,
|
||||
source: result.source,
|
||||
commitSha: result.commitSha,
|
||||
envWritten: result.envWritten,
|
||||
warnings: result.warnings,
|
||||
deployed,
|
||||
deployError,
|
||||
});
|
||||
if (deployed) {
|
||||
triggerPostDeployScan(stack_name, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stack_name}:`, err),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (fromGitDiag) {
|
||||
const code = error instanceof GitSourceError ? error.code : 'UNKNOWN';
|
||||
console.log(
|
||||
`[Stacks:diag] from-git fail stack=${fromGitStackName} code=${code} elapsedMs=${Date.now() - fromGitStartedAt}`
|
||||
);
|
||||
}
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:delete', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
// Stage 1: Tell Docker to clean up ghost networks/containers
|
||||
try {
|
||||
await ComposeService.getInstance(req.nodeId).downStack(stackName);
|
||||
} catch (downErr) {
|
||||
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
|
||||
}
|
||||
|
||||
// Stage 2: Obliterate the files. Capture failure so Stage 3 still runs.
|
||||
let fsErr: unknown = null;
|
||||
try {
|
||||
await FileSystemService.getInstance(req.nodeId).deleteStack(stackName);
|
||||
} catch (err) {
|
||||
fsErr = err;
|
||||
console.error(`[Stacks] File deletion failed for ${stackName}, continuing with DB cleanup:`, err);
|
||||
}
|
||||
|
||||
// Stage 3: DB cleanup. Runs unconditionally because an orphan git_source
|
||||
// row would silently auto-link to a future stack with the same name, and
|
||||
// stale scoped role assignments / update badges confuse the dashboard.
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
|
||||
DatabaseService.getInstance().deleteGitSource(stackName);
|
||||
|
||||
if (fsErr) throw fsErr;
|
||||
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Stack deleted: ${stackName}`);
|
||||
res.json({ success: true });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Failed to delete stack ${stackName}:`, error);
|
||||
const message = getErrorMessage(error, 'Failed to delete stack');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
res.json(containers);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch containers' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stacks/:stackName/services', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
|
||||
const parsed = YAML.parse(content);
|
||||
const services = parsed?.services ? Object.keys(parsed.services) : [];
|
||||
res.json(services);
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to fetch services:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch services' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
||||
const debug = isDebugEnabled();
|
||||
const atomic = LicenseService.getInstance().getTier() === '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);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Deploy completed: ${stackName}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
|
||||
res.json({ message: 'Deployed successfully' });
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Deploy failed: ${stackName}`, error);
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
if (rolledBack) console.warn(`[Stacks] Deploy failed, rolled back: ${stackName}`);
|
||||
const message = getErrorMessage(error, 'Failed to deploy stack');
|
||||
res.status(500).json({ error: message, rolledBack });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs());
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Down completed: ${stackName}`);
|
||||
res.json({ status: 'Command started' });
|
||||
} catch (error) {
|
||||
console.error(`[Stacks] Down failed: ${stackName}`, error);
|
||||
res.status(500).json({ error: 'Failed to start command' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
|
||||
if (!containers || containers.length === 0) {
|
||||
return res.status(404).json({ error: 'No containers found for this stack.' });
|
||||
}
|
||||
|
||||
await Promise.all(containers.map(c => dockerController.restartContainer(c.Id)));
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Restart completed: ${stackName} (${containers.length} containers)`);
|
||||
res.json({ success: true, message: 'Restart completed via Engine API.' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Restart failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Failed to restart containers');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
|
||||
if (!containers || containers.length === 0) {
|
||||
return res.status(404).json({ error: 'No containers found for this stack.' });
|
||||
}
|
||||
|
||||
await Promise.all(containers.map(c => dockerController.stopContainer(c.Id)));
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Stop completed: ${stackName} (${containers.length} containers)`);
|
||||
res.json({ success: true, message: 'Stop completed via Engine API.' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Stop failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Failed to stop containers');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
|
||||
if (!containers || containers.length === 0) {
|
||||
return res.status(404).json({ error: 'No containers found for this stack.' });
|
||||
}
|
||||
|
||||
await Promise.all(containers.map(c => dockerController.startContainer(c.Id)));
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Start completed: ${stackName} (${containers.length} containers)`);
|
||||
res.json({ success: true, message: 'Start completed via Engine API.' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Start failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Failed to start containers');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update preview: semver diff, risk tagging, rollback target for the readiness board
|
||||
app.get('/api/stacks/:stackName/update-preview', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
|
||||
res.json(preview);
|
||||
} catch (error) {
|
||||
console.error(`[Stacks] Update preview failed: ${stackName}`, error);
|
||||
res.status(500).json({ error: 'Failed to compute update preview' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update stack: pull images and recreate containers
|
||||
app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
||||
const debug = isDebugEnabled();
|
||||
const atomic = LicenseService.getInstance().getTier() === '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);
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Update completed: ${stackName}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
|
||||
res.json({ status: 'Update completed' });
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[Stacks] Update failed: ${stackName}`, error);
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
if (rolledBack) console.warn(`[Stacks] Update failed, rolled back: ${stackName}`);
|
||||
res.status(500).json({ error: 'Failed to update', rolledBack });
|
||||
}
|
||||
});
|
||||
|
||||
// Manual rollback endpoint (Skipper+ and Admin)
|
||||
app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
||||
const backupInfo = await fsSvc.getBackupInfo(stackName);
|
||||
if (!backupInfo.exists) {
|
||||
return res.status(404).json({ error: 'No backup available for this stack.' });
|
||||
}
|
||||
console.log(`[Stacks] Rollback initiated: ${stackName}`);
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
// Re-deploy with restored files (non-atomic to avoid loops)
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), false);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Rollback completed: ${stackName}`);
|
||||
res.json({ message: 'Stack rolled back successfully.' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Rollback failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Rollback failed.');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Backup info endpoint (read-only)
|
||||
app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
||||
const info = await fsSvc.getBackupInfo(stackName);
|
||||
res.json(info);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to get backup info:', error);
|
||||
const message = getErrorMessage(error, 'Failed to get backup info.');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve static files in production (for Docker deployment)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { ComposeService } 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 } from '../middleware/tierGates';
|
||||
import { isValidStackName, isPathWithinBase } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sendGitSourceError } from '../utils/gitSourceHttp';
|
||||
import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
|
||||
import { getTerminalWs } from '../websocket/generic';
|
||||
|
||||
async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
|
||||
const fsService = FileSystemService.getInstance(nodeId);
|
||||
const stackDir = path.join(fsService.getBaseDir(), stackName);
|
||||
const defaultEnvPath = path.join(stackDir, '.env');
|
||||
|
||||
try {
|
||||
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||
let composeContent: string | null = null;
|
||||
|
||||
for (const file of composeFiles) {
|
||||
try {
|
||||
composeContent = await fsService.readFile(path.join(stackDir, file), 'utf-8');
|
||||
break;
|
||||
} catch {
|
||||
// Try next file
|
||||
}
|
||||
}
|
||||
|
||||
if (!composeContent) return [defaultEnvPath];
|
||||
|
||||
const parsed = YAML.parse(composeContent);
|
||||
if (!parsed?.services) return [defaultEnvPath];
|
||||
|
||||
const envFiles = new Set<string>();
|
||||
|
||||
for (const serviceName of Object.keys(parsed.services)) {
|
||||
const service = parsed.services[serviceName];
|
||||
if (!service?.env_file) continue;
|
||||
|
||||
const addEnvPath = (rawPath: string) => {
|
||||
const resolved = path.resolve(stackDir, rawPath);
|
||||
if (!isPathWithinBase(resolved, stackDir)) return;
|
||||
envFiles.add(resolved);
|
||||
};
|
||||
|
||||
if (typeof service.env_file === 'string') {
|
||||
addEnvPath(service.env_file);
|
||||
} else if (Array.isArray(service.env_file)) {
|
||||
for (const entry of service.env_file) {
|
||||
const entryPath = typeof entry === 'string' ? entry : (entry?.path || '');
|
||||
if (entryPath) addEnvPath(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (envFiles.size === 0) {
|
||||
envFiles.add(defaultEnvPath);
|
||||
}
|
||||
|
||||
const existing: string[] = [];
|
||||
for (const f of envFiles) {
|
||||
try {
|
||||
await fsService.access(f);
|
||||
existing.push(f);
|
||||
} catch {
|
||||
// File does not exist, skip
|
||||
}
|
||||
}
|
||||
return existing;
|
||||
} catch (error) {
|
||||
console.warn(`Could not parse compose.yaml for env_file resolution in stack "${stackName}":`, error);
|
||||
}
|
||||
|
||||
try {
|
||||
await fsService.access(defaultEnvPath);
|
||||
return [defaultEnvPath];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const stacksRouter = Router();
|
||||
|
||||
stacksRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
res.json(stacks);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch stacks' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/statuses', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await CacheService.getInstance().getOrFetch(
|
||||
`stack-statuses:${req.nodeId}`,
|
||||
STACK_STATUSES_CACHE_TTL_MS,
|
||||
async () => {
|
||||
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const stackNames = stacks.map((s: string) => s.replace(/\.(yml|yaml)$/, ''));
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const bulkInfo = await dockerController.getBulkStackStatuses(stackNames);
|
||||
const data: Record<string, { status: 'running' | 'exited' | 'unknown'; mainPort?: number; runningSince?: number }> = {};
|
||||
for (const stack of stacks) {
|
||||
const name = stack.replace(/\.(yml|yaml)$/, '');
|
||||
data[stack] = bulkInfo[name] ?? { status: 'unknown' };
|
||||
}
|
||||
return data;
|
||||
},
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stack statuses:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch stack statuses' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read stack' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.put('/:stackName', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const { content } = req.body;
|
||||
if (typeof content !== 'string') {
|
||||
console.error('Content is not a string:', content);
|
||||
return res.status(400).json({ error: 'Content must be a string' });
|
||||
}
|
||||
await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Compose file saved: ${stackName}`);
|
||||
res.json({ message: 'Stack saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to save stack:', error);
|
||||
res.status(500).json({ error: 'Failed to save stack' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName/envs', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
res.json({ envFiles: envPaths });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to resolve env files' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName/env', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const requestedFile = req.query.file as string | undefined;
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
|
||||
let envPath = envPaths[0];
|
||||
|
||||
if (requestedFile) {
|
||||
if (envPaths.includes(requestedFile)) {
|
||||
envPath = requestedFile;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Requested env file not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
const fsService = FileSystemService.getInstance(req.nodeId);
|
||||
|
||||
try {
|
||||
await fsService.access(envPath);
|
||||
} catch (e: unknown) {
|
||||
const code = (e as NodeJS.ErrnoException)?.code;
|
||||
if (code !== 'ENOENT') {
|
||||
console.error('[Sencho] Unexpected error checking env file existence:', (e as Error).message);
|
||||
}
|
||||
return res.status(404).json({ error: 'Env file not found' });
|
||||
}
|
||||
|
||||
const content = await fsService.readFile(envPath, 'utf-8');
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
console.error('Failed to read env file:', error);
|
||||
res.status(500).json({ error: 'Failed to read env file' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.put('/:stackName/env', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const { content } = req.body;
|
||||
if (typeof content !== 'string') {
|
||||
return res.status(400).json({ error: 'Content must be a string' });
|
||||
}
|
||||
|
||||
const requestedFile = req.query.file as string | undefined;
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
|
||||
let envPath = envPaths[0];
|
||||
|
||||
if (requestedFile) {
|
||||
if (envPaths.includes(requestedFile)) {
|
||||
envPath = requestedFile;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Requested env file not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
const fsService = FileSystemService.getInstance(req.nodeId);
|
||||
await fsService.writeFile(envPath, content, 'utf-8');
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
const envFileName = path.basename(envPath);
|
||||
console.log(`[Stacks] Env file saved: ${stackName}/${envFileName}`);
|
||||
res.json({ message: 'Env file saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to save env file:', error);
|
||||
res.status(500).json({ error: 'Failed to save env file' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/', async (req: Request, res: Response) => {
|
||||
if (!requirePermission(req, res, 'stack:create')) return;
|
||||
try {
|
||||
const { stackName } = req.body;
|
||||
if (!stackName || typeof stackName !== 'string') {
|
||||
return res.status(400).json({ error: 'Stack name is required and must be a string' });
|
||||
}
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' });
|
||||
}
|
||||
await FileSystemService.getInstance(req.nodeId).createStack(stackName);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Stack created: ${stackName}`);
|
||||
res.json({ message: 'Stack created successfully', name: stackName });
|
||||
} catch (error: unknown) {
|
||||
const message = getErrorMessage(error, '');
|
||||
if (message.includes('already exists')) {
|
||||
return res.status(409).json({ error: 'Stack already exists' });
|
||||
}
|
||||
console.error('Failed to create stack:', error);
|
||||
res.status(500).json({ error: 'Failed to create stack' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/from-git', async (req: Request, res: Response) => {
|
||||
if (!requirePermission(req, res, 'stack:create')) return;
|
||||
const fromGitStartedAt = Date.now();
|
||||
const fromGitDiag = isDebugEnabled();
|
||||
let fromGitStackName = '';
|
||||
try {
|
||||
const {
|
||||
stack_name,
|
||||
repo_url,
|
||||
branch,
|
||||
compose_path,
|
||||
sync_env,
|
||||
env_path,
|
||||
auth_type,
|
||||
token,
|
||||
auto_apply_on_webhook,
|
||||
auto_deploy_on_apply,
|
||||
deploy_now,
|
||||
} = req.body ?? {};
|
||||
fromGitStackName = typeof stack_name === 'string' ? stack_name : '';
|
||||
|
||||
if (typeof stack_name !== 'string' || !stack_name.trim()) {
|
||||
return res.status(400).json({ error: 'stack_name is required' });
|
||||
}
|
||||
if (!isValidStackName(stack_name)) {
|
||||
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' });
|
||||
}
|
||||
if (typeof repo_url !== 'string' || !repo_url.trim()) {
|
||||
return res.status(400).json({ error: 'repo_url is required' });
|
||||
}
|
||||
if (typeof branch !== 'string' || !branch.trim()) {
|
||||
return res.status(400).json({ error: 'branch is required' });
|
||||
}
|
||||
if (typeof compose_path !== 'string' || !compose_path.trim()) {
|
||||
return res.status(400).json({ error: 'compose_path is required' });
|
||||
}
|
||||
const resolvedAuthType = auth_type === 'token' ? 'token' : 'none';
|
||||
if (!/^https:\/\//i.test(repo_url)) {
|
||||
return res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
|
||||
}
|
||||
if (repo_url.length > 2048) {
|
||||
return res.status(400).json({ error: 'repo_url is too long' });
|
||||
}
|
||||
if (branch.length > 256) {
|
||||
return res.status(400).json({ error: 'branch is too long' });
|
||||
}
|
||||
if (compose_path.length > 1024) {
|
||||
return res.status(400).json({ error: 'compose_path is too long' });
|
||||
}
|
||||
if (typeof env_path === 'string' && env_path.length > 1024) {
|
||||
return res.status(400).json({ error: 'env_path is too long' });
|
||||
}
|
||||
if (typeof token === 'string' && token.length > 8192) {
|
||||
return res.status(400).json({ error: 'token is too long' });
|
||||
}
|
||||
|
||||
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
if (stacks.includes(stack_name)) {
|
||||
return res.status(409).json({ error: 'Stack already exists' });
|
||||
}
|
||||
|
||||
const syncEnv = Boolean(sync_env);
|
||||
const resolvedEnvPath = syncEnv
|
||||
? (typeof env_path === 'string' && env_path.trim()
|
||||
? env_path
|
||||
: path.posix.join(path.posix.dirname(compose_path.replace(/\\/g, '/')) || '.', '.env'))
|
||||
: null;
|
||||
|
||||
if (fromGitDiag) {
|
||||
console.log(
|
||||
`[Stacks:diag] from-git start stack=${stack_name} nodeId=${req.nodeId ?? 'local'} host=${gitRepoHost(repo_url)} branch=${branch} composePath=${compose_path} envPath=${resolvedEnvPath ?? 'none'} authType=${resolvedAuthType} autoApplyOnWebhook=${Boolean(auto_apply_on_webhook)} autoDeployOnApply=${Boolean(auto_deploy_on_apply)} deployNow=${deploy_now === true}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await GitSourceService.getInstance().createStackFromGit({
|
||||
stackName: stack_name.trim(),
|
||||
repoUrl: repo_url.trim(),
|
||||
branch: branch.trim(),
|
||||
composePath: compose_path.trim(),
|
||||
syncEnv,
|
||||
envPath: resolvedEnvPath,
|
||||
authType: resolvedAuthType,
|
||||
token: resolvedAuthType === 'token' && typeof token === 'string' && token !== '' ? token : null,
|
||||
autoApplyOnWebhook: Boolean(auto_apply_on_webhook),
|
||||
autoDeployOnApply: Boolean(auto_deploy_on_apply),
|
||||
});
|
||||
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
|
||||
let deployed = false;
|
||||
let deployError: string | undefined;
|
||||
if (deploy_now === true) {
|
||||
const gate = await enforcePolicyPreDeploy(
|
||||
stack_name,
|
||||
req.nodeId,
|
||||
buildPolicyGateOptions(req),
|
||||
);
|
||||
if (!gate.ok) {
|
||||
deployError = `Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`;
|
||||
} else {
|
||||
try {
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stack_name);
|
||||
deployed = true;
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
} catch (e) {
|
||||
deployError = getErrorMessage(e, 'Deploy failed');
|
||||
console.error(`[Stacks] Deploy after create-from-git failed for ${stack_name}:`, deployError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Stacks] Stack created from Git: ${stack_name} at ${result.commitSha.slice(0, 7)}`);
|
||||
if (fromGitDiag) {
|
||||
console.log(
|
||||
`[Stacks:diag] from-git ok stack=${stack_name} sha=${result.commitSha.slice(0, 7)} deployed=${deployed} envWritten=${result.envWritten} warnings=${result.warnings.length} elapsedMs=${Date.now() - fromGitStartedAt}`
|
||||
);
|
||||
}
|
||||
res.json({
|
||||
name: stack_name,
|
||||
source: result.source,
|
||||
commitSha: result.commitSha,
|
||||
envWritten: result.envWritten,
|
||||
warnings: result.warnings,
|
||||
deployed,
|
||||
deployError,
|
||||
});
|
||||
if (deployed) {
|
||||
triggerPostDeployScan(stack_name, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stack_name}:`, err),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (fromGitDiag) {
|
||||
const code = error instanceof GitSourceError ? error.code : 'UNKNOWN';
|
||||
console.log(
|
||||
`[Stacks:diag] from-git fail stack=${fromGitStackName} code=${code} elapsedMs=${Date.now() - fromGitStartedAt}`
|
||||
);
|
||||
}
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:delete', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
try {
|
||||
await ComposeService.getInstance(req.nodeId).downStack(stackName);
|
||||
} catch (downErr) {
|
||||
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
|
||||
}
|
||||
|
||||
let fsErr: unknown = null;
|
||||
try {
|
||||
await FileSystemService.getInstance(req.nodeId).deleteStack(stackName);
|
||||
} catch (err) {
|
||||
fsErr = err;
|
||||
console.error(`[Stacks] File deletion failed for ${stackName}, continuing with DB cleanup:`, err);
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
|
||||
DatabaseService.getInstance().deleteGitSource(stackName);
|
||||
|
||||
if (fsErr) throw fsErr;
|
||||
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Stack deleted: ${stackName}`);
|
||||
res.json({ success: true });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Failed to delete stack ${stackName}:`, error);
|
||||
const message = getErrorMessage(error, 'Failed to delete stack');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName/containers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
res.json(containers);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch containers' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName/services', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
|
||||
const parsed = YAML.parse(content);
|
||||
const services = parsed?.services ? Object.keys(parsed.services) : [];
|
||||
res.json(services);
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to fetch services:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch services' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
||||
const debug = isDebugEnabled();
|
||||
const atomic = LicenseService.getInstance().getTier() === '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);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Deploy completed: ${stackName}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
|
||||
res.json({ message: 'Deployed successfully' });
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Deploy failed: ${stackName}`, error);
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
if (rolledBack) console.warn(`[Stacks] Deploy failed, rolled back: ${stackName}`);
|
||||
const message = getErrorMessage(error, 'Failed to deploy stack');
|
||||
res.status(500).json({ error: message, rolledBack });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs());
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Down completed: ${stackName}`);
|
||||
res.json({ status: 'Command started' });
|
||||
} catch (error) {
|
||||
console.error(`[Stacks] Down failed: ${stackName}`, error);
|
||||
res.status(500).json({ error: 'Failed to start command' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/restart', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
|
||||
if (!containers || containers.length === 0) {
|
||||
return res.status(404).json({ error: 'No containers found for this stack.' });
|
||||
}
|
||||
|
||||
await Promise.all(containers.map(c => dockerController.restartContainer(c.Id)));
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Restart completed: ${stackName} (${containers.length} containers)`);
|
||||
res.json({ success: true, message: 'Restart completed via Engine API.' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Restart failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Failed to restart containers');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/stop', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
|
||||
if (!containers || containers.length === 0) {
|
||||
return res.status(404).json({ error: 'No containers found for this stack.' });
|
||||
}
|
||||
|
||||
await Promise.all(containers.map(c => dockerController.stopContainer(c.Id)));
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Stop completed: ${stackName} (${containers.length} containers)`);
|
||||
res.json({ success: true, message: 'Stop completed via Engine API.' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Stop failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Failed to stop containers');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/start', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
|
||||
if (!containers || containers.length === 0) {
|
||||
return res.status(404).json({ error: 'No containers found for this stack.' });
|
||||
}
|
||||
|
||||
await Promise.all(containers.map(c => dockerController.startContainer(c.Id)));
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Start completed: ${stackName} (${containers.length} containers)`);
|
||||
res.json({ success: true, message: 'Start completed via Engine API.' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Start failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Failed to start containers');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
|
||||
res.json(preview);
|
||||
} catch (error) {
|
||||
console.error(`[Stacks] Update preview failed: ${stackName}`, error);
|
||||
res.status(500).json({ error: 'Failed to compute update preview' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
||||
const debug = isDebugEnabled();
|
||||
const atomic = LicenseService.getInstance().getTier() === '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);
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Update completed: ${stackName}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
|
||||
res.json({ status: 'Update completed' });
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[Stacks] Update failed: ${stackName}`, error);
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
if (rolledBack) console.warn(`[Stacks] Update failed, rolled back: ${stackName}`);
|
||||
res.status(500).json({ error: 'Failed to update', rolledBack });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
||||
const backupInfo = await fsSvc.getBackupInfo(stackName);
|
||||
if (!backupInfo.exists) {
|
||||
return res.status(404).json({ error: 'No backup available for this stack.' });
|
||||
}
|
||||
console.log(`[Stacks] Rollback initiated: ${stackName}`);
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), false);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Rollback completed: ${stackName}`);
|
||||
res.json({ message: 'Stack rolled back successfully.' });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Rollback failed: ${stackName}`, error);
|
||||
const message = getErrorMessage(error, 'Rollback failed.');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName/backup', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
||||
const info = await fsSvc.getBackupInfo(stackName);
|
||||
res.json(info);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to get backup info:', error);
|
||||
const message = getErrorMessage(error, 'Failed to get backup info.');
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user