diff --git a/backend/src/index.ts b/backend/src/index.ts index b606933b..3454e487 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 = {}; - 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 { - 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(); - - // 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') { diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts new file mode 100644 index 00000000..304d1b7d --- /dev/null +++ b/backend/src/routes/stacks.ts @@ -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 { + 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(); + + 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 = {}; + 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 }); + } +});