mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +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 express, { Request, Response } from 'express';
|
||||||
import DockerController from './services/DockerController';
|
|
||||||
import { FileSystemService } from './services/FileSystemService';
|
import { FileSystemService } from './services/FileSystemService';
|
||||||
import { ComposeService } from './services/ComposeService';
|
|
||||||
import path from 'path';
|
|
||||||
import { DatabaseService } from './services/DatabaseService';
|
import { DatabaseService } from './services/DatabaseService';
|
||||||
import { MonitorService } from './services/MonitorService';
|
import { MonitorService } from './services/MonitorService';
|
||||||
import { AutoHealService } from './services/AutoHealService';
|
import { AutoHealService } from './services/AutoHealService';
|
||||||
import { DockerEventManager } from './services/DockerEventManager';
|
import { DockerEventManager } from './services/DockerEventManager';
|
||||||
import { ImageUpdateService } from './services/ImageUpdateService';
|
import { ImageUpdateService } from './services/ImageUpdateService';
|
||||||
import { UpdatePreviewService } from './services/UpdatePreviewService';
|
|
||||||
import { NodeRegistry } from './services/NodeRegistry';
|
import { NodeRegistry } from './services/NodeRegistry';
|
||||||
import { LicenseService } from './services/LicenseService';
|
import { LicenseService } from './services/LicenseService';
|
||||||
import { SchedulerService } from './services/SchedulerService';
|
import { SchedulerService } from './services/SchedulerService';
|
||||||
import { CacheService } from './services/CacheService';
|
import { sweepStaleTempDirs as sweepStaleGitTempDirs } from './services/GitSourceService';
|
||||||
import { GitSourceService, GitSourceError, sweepStaleTempDirs as sweepStaleGitTempDirs, repoHost as gitRepoHost } from './services/GitSourceService';
|
|
||||||
import { sendGitSourceError } from './utils/gitSourceHttp';
|
|
||||||
import './types/express';
|
import './types/express';
|
||||||
import {
|
import {
|
||||||
PORT,
|
PORT,
|
||||||
MFA_REPLAY_TTL_MS,
|
MFA_REPLAY_TTL_MS,
|
||||||
MFA_REPLAY_PURGE_INTERVAL_MS,
|
MFA_REPLAY_PURGE_INTERVAL_MS,
|
||||||
STACK_STATUSES_CACHE_TTL_MS,
|
|
||||||
} from './helpers/constants';
|
} 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 { authGate, auditLog } from './middleware/authGate';
|
||||||
import { enforceApiTokenScope } from './middleware/apiTokenScope';
|
import { enforceApiTokenScope } from './middleware/apiTokenScope';
|
||||||
import { errorHandler } from './middleware/errorHandler';
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
@@ -36,8 +22,6 @@ import { createApp } from './app';
|
|||||||
import { createRemoteProxyMiddleware } from './proxy/remoteNodeProxy';
|
import { createRemoteProxyMiddleware } from './proxy/remoteNodeProxy';
|
||||||
import { createServer } from './server';
|
import { createServer } from './server';
|
||||||
import { attachUpgrade } from './websocket/upgradeHandler';
|
import { attachUpgrade } from './websocket/upgradeHandler';
|
||||||
import { getTerminalWs } from './websocket/generic';
|
|
||||||
import { invalidateNodeCaches } from './helpers/cacheInvalidation';
|
|
||||||
import { metaRouter } from './routes/meta';
|
import { metaRouter } from './routes/meta';
|
||||||
import { authRouter } from './routes/auth';
|
import { authRouter } from './routes/auth';
|
||||||
import { mfaRouter } from './routes/mfa';
|
import { mfaRouter } from './routes/mfa';
|
||||||
@@ -68,14 +52,11 @@ import { templatesRouter } from './routes/templates';
|
|||||||
import { securityRouter } from './routes/security';
|
import { securityRouter } from './routes/security';
|
||||||
import { containersRouter, portsRouter } from './routes/containers';
|
import { containersRouter, portsRouter } from './routes/containers';
|
||||||
import { nodesRouter } from './routes/nodes';
|
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 SelfUpdateService from './services/SelfUpdateService';
|
||||||
import TrivyService from './services/TrivyService';
|
import TrivyService from './services/TrivyService';
|
||||||
import { enforcePolicyPreDeploy } from './services/PolicyEnforcement';
|
import { isDebugEnabled } from './utils/debug';
|
||||||
import { isValidStackName, isPathWithinBase } from './utils/validation';
|
|
||||||
import YAML from 'yaml';
|
|
||||||
|
|
||||||
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
|
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
|
||||||
// util._extend internally. The warning fires at runtime when createProxyServer() is
|
// 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/containers', containersRouter);
|
||||||
app.use('/api/ports', portsRouter);
|
app.use('/api/ports', portsRouter);
|
||||||
app.use('/api/nodes', nodesRouter);
|
app.use('/api/nodes', nodesRouter);
|
||||||
|
app.use('/api/stacks', stacksRouter);
|
||||||
|
|
||||||
// Remote Node HTTP Proxy (see proxy/remoteNodeProxy.ts). Mounted here after
|
// Remote Node HTTP Proxy (see proxy/remoteNodeProxy.ts). Mounted here after
|
||||||
// authGate + auditLog + apiTokenScope so local Sencho enforces auth first;
|
// authGate + auditLog + apiTokenScope so local Sencho enforces auth first;
|
||||||
@@ -167,704 +149,6 @@ const { server, wss, pilotTunnelWss } = createServer(app);
|
|||||||
attachUpgrade(server, { wss, pilotTunnelWss });
|
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)
|
// Serve static files in production (for Docker deployment)
|
||||||
if (process.env.NODE_ENV === 'production') {
|
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