Files
sencho/backend/src/routes/stacks.ts
T
Anso 523ba5854c fix(stacks): return 404 for nonexistent stacks on deploy/down/update (F-7) (#1108)
POST /api/stacks/:name/{deploy,down,update} previously returned HTTP 500
with body {"error":"spawn docker ENOENT"} when invoked against a stack
whose compose directory was missing. The status code was wrong (the
named resource did not exist, so 404 is the right answer) and the
message misled operators into thinking the docker CLI was unavailable.

Add a small requireStackExists(nodeId, stackName, res) helper in
routes/stacks.ts that validates the stack name and confirms a compose
file is present via FileSystemService.hasComposeFile before any of the
three handlers spawn docker compose. The helper is called immediately
after requirePermission and before runPolicyGate so unauthorized
callers still get 403 first and the policy gate never runs against a
phantom stack.

In ComposeService.execute(), narrow the child.on('error') handler so
the genuine docker-binary-missing case (ENOENT on the spawn itself)
rejects with "Docker CLI unavailable on this node" instead of the raw
"spawn docker ENOENT". This is defense in depth for the rare case the
pre-check cannot cover, and it fixes the misleading-message half of
the bug as well.

Cover the new contract with stack-actions-missing-stack.test.ts (four
cases: deploy/down/update return 404, invalid name returns 400). Mock
ComposeService as a tripwire so a future code path that bypasses the
guard would fail loudly. Fix stacks-failure-notifications.test.ts by
adding hasComposeFile to its FileSystemService partial mock so the
existing happy-path-error-handling cases continue to flow into
ComposeService.
2026-05-19 00:13:57 -04:00

1229 lines
52 KiB
TypeScript

import { Router, type Request, type Response, type NextFunction } from 'express';
import path from 'path';
import YAML from 'yaml';
import multer from 'multer';
import { FileSystemService } from '../services/FileSystemService';
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
import DockerController from '../services/DockerController';
import { DatabaseService } from '../services/DatabaseService';
import { MeshService } from '../services/MeshService';
import { CacheService } from '../services/CacheService';
import { UpdatePreviewService } from '../services/UpdatePreviewService';
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { requirePermission } from '../middleware/permissions';
import { requirePaid, requireAdmin, effectiveTier } from '../middleware/tierGates';
import { NotificationService, type NotificationCategory } from '../services/NotificationService';
import { isValidGitSourcePath, isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
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';
function notifyActionFailure(action: string, stackName: string, error: unknown): void {
const message = getErrorMessage(error, `Failed to ${action} stack`);
NotificationService.getInstance()
.dispatchAlert('error', 'deploy_failure', message, { stackName })
.catch(err => console.error('[Stacks] Failed to dispatch failure notification for %s:', sanitizeForLog(stackName), err));
}
function notifyActionSuccess(category: NotificationCategory, message: string, stackName: string, actor: string): void {
NotificationService.getInstance()
.dispatchAlert('info', category, message, { stackName, actor })
.catch(err => console.error('[Stacks] Failed to dispatch activity for %s:', sanitizeForLog(stackName), err));
}
async function requireStackExists(nodeId: number, stackName: string, res: Response): Promise<boolean> {
if (!isValidStackName(stackName)) {
res.status(400).json({ error: 'Invalid stack name' });
return false;
}
const fsSvc = FileSystemService.getInstance(nodeId);
const stackDir = path.join(fsSvc.getBaseDir(), stackName);
try {
if (!(await fsSvc.hasComposeFile(stackDir))) {
res.status(404).json({ error: 'Stack not found' });
return false;
}
} catch {
res.status(404).json({ error: 'Stack not found' });
return false;
}
return true;
}
export 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 "%s":', sanitizeForLog(stackName), error);
}
try {
await fsService.access(defaultEnvPath);
return [defaultEnvPath];
} catch {
return [];
}
}
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024, files: 1 },
preservePath: true,
});
function getRelPath(req: Request): string {
return typeof req.query.path === 'string' ? req.query.path : '';
}
export const stacksRouter = Router();
stacksRouter.param('stackName', (req, res, next, stackName) => {
if (typeof stackName !== 'string' || !isValidStackName(stackName)) {
res.status(400).json({ error: 'Invalid stack name' });
return;
}
next();
});
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('/auto-update-settings', (req: Request, res: Response): void => {
try {
const settings = DatabaseService.getInstance().getStackAutoUpdateSettingsForNode(req.nodeId);
res.json(settings);
} catch (error) {
console.error('[Stacks] Failed to fetch auto-update settings:', error);
res.status(500).json({ error: 'Failed to fetch auto-update settings' });
}
});
stacksRouter.get('/:stackName/auto-update', (req: Request, res: Response): void => {
try {
const stackName = req.params.stackName as string;
const enabled = DatabaseService.getInstance().getStackAutoUpdateEnabled(req.nodeId, stackName);
res.json({ enabled });
} catch (error) {
console.error('[Stacks] Failed to fetch auto-update setting:', error);
res.status(500).json({ error: 'Failed to fetch auto-update setting' });
}
});
stacksRouter.put('/:stackName/auto-update', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
try {
const stackName = req.params.stackName as string;
const { enabled } = req.body as { enabled?: unknown };
if (typeof enabled !== 'boolean') {
res.status(400).json({ error: '"enabled" must be a boolean' });
return;
}
DatabaseService.getInstance().upsertStackAutoUpdateEnabled(req.nodeId, stackName, enabled);
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
scope: 'stack',
nodeId: req.nodeId,
stackName,
action: 'auto-update-settings-changed',
ts: Date.now(),
});
res.json({ enabled });
} catch (error) {
console.error('[Stacks] Failed to update auto-update setting:', error);
res.status(500).json({ error: 'Failed to update auto-update setting' });
}
});
stacksRouter.get('/:stackName', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
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;
try {
const { content } = req.body;
if (typeof content !== 'string') {
console.error('Content is not a string, got:', typeof 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: ${sanitizeForLog(stackName)}`);
res.json({ message: 'Stack saved successfully' });
} catch (error) {
console.error('Failed to save stack:', sanitizeForLog(getErrorMessage(error, 'unknown')));
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;
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;
const requestedFile = req.query.file as string | undefined;
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
let envPath: string | undefined = envPaths[0];
if (requestedFile) {
if (envPaths.includes(requestedFile)) {
envPath = requestedFile;
} else {
return res.status(400).json({ error: 'Requested env file not allowed' });
}
}
// Default path with no env files yet: reply 200 with an empty body and a
// header the frontend can read. This avoids surfacing a 404 for the
// legitimate "stack has no .env yet" case, which previous flows
// sometimes echoed back to the user as a confusing error string.
if (!envPath) {
res.setHeader('X-Env-Exists', 'false');
return res.send('');
}
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);
}
// No env file at the resolved path. For an explicit ?file= query we
// surface a 404 (the caller asked for something specific). Otherwise
// treat it as the empty-stack case above.
if (requestedFile) {
return res.status(404).json({ error: 'Env file not found' });
}
res.setHeader('X-Env-Exists', 'false');
return res.send('');
}
try {
const content = await fsService.readFile(envPath, 'utf-8');
res.setHeader('X-Env-Exists', 'true');
return res.send(content);
} catch (e: unknown) {
// TOCTOU: the file existed at access() but vanished before readFile().
// Return the same friendly empty-body shape rather than a generic 500
// that the frontend would otherwise echo as an opaque error.
const code = (e as NodeJS.ErrnoException)?.code;
if (code === 'ENOENT' && !requestedFile) {
res.setHeader('X-Env-Exists', 'false');
return res.send('');
}
throw e;
}
} 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;
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: ${sanitizeForLog(stackName)}/${sanitizeForLog(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: ${sanitizeForLog(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,
skip_scan,
} = 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' });
}
if (auto_apply_on_webhook !== undefined && typeof auto_apply_on_webhook !== 'boolean') {
return res.status(400).json({ error: 'auto_apply_on_webhook must be a boolean' });
}
if (auto_deploy_on_apply !== undefined && typeof auto_deploy_on_apply !== 'boolean') {
return res.status(400).json({ error: 'auto_deploy_on_apply must be a boolean' });
}
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' });
}
if (!isValidGitSourcePath(compose_path.trim())) {
return res.status(400).json({ error: 'compose_path must be a relative repository file path' });
}
if (typeof env_path === 'string' && env_path.trim() && !isValidGitSourcePath(env_path.trim())) {
return res.status(400).json({ error: 'env_path must be a relative repository file path' });
}
const autoApplyOnWebhook = auto_apply_on_webhook === true;
const autoDeployOnApply = auto_deploy_on_apply === true;
if (autoDeployOnApply && !requirePermission(req, res, 'stack:deploy', 'stack', stack_name)) return;
if (deploy_now === true && !requirePermission(req, res, 'stack:deploy', 'stack', stack_name)) return;
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=${sanitizeForLog(stack_name)} nodeId=${req.nodeId ?? 'local'} host=${sanitizeForLog(gitRepoHost(repo_url))} branch=${sanitizeForLog(branch)} composePath=${sanitizeForLog(compose_path)} envPath=${sanitizeForLog(resolvedEnvPath ?? 'none')} authType=${sanitizeForLog(resolvedAuthType)} autoApplyOnWebhook=${autoApplyOnWebhook} autoDeployOnApply=${autoDeployOnApply} 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,
autoDeployOnApply,
});
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 ${sanitizeForLog(stack_name)}:`, deployError);
}
}
}
console.log(`[Stacks] Stack created from Git: ${sanitizeForLog(stack_name)} at ${result.commitSha.slice(0, 7)}`);
if (fromGitDiag) {
console.log(
`[Stacks:diag] from-git ok stack=${sanitizeForLog(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 && skip_scan !== true) {
triggerPostDeployScan(stack_name, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${sanitizeForLog(stack_name)}:`, err),
);
}
} catch (error) {
if (fromGitDiag) {
const code = error instanceof GitSourceError ? error.code : 'UNKNOWN';
console.log(
`[Stacks:diag] from-git fail stack=${sanitizeForLog(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;
const pruneVolumes = req.query.pruneVolumes === 'true';
try {
try {
await ComposeService.getInstance(req.nodeId).downStack(stackName);
} catch (downErr) {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${sanitizeForLog(stackName)}`);
}
if (pruneVolumes) {
try {
const result = await DockerController.getInstance().pruneManagedOnly('volumes', [stackName]);
console.log(`[Stacks] Pruned volumes for ${sanitizeForLog(stackName)}: ${result.reclaimedBytes} bytes reclaimed`);
} catch (pruneErr) {
console.warn('[Stacks] Volume prune failed for %s, continuing delete:', sanitizeForLog(stackName), pruneErr);
}
}
let fsErr: unknown = null;
try {
await FileSystemService.getInstance(req.nodeId).deleteStack(stackName);
} catch (err) {
fsErr = err;
console.error('[Stacks] File deletion failed for %s, continuing with DB cleanup:', sanitizeForLog(stackName), err);
}
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
DatabaseService.getInstance().clearStackAutoUpdateSetting(req.nodeId, stackName);
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
DatabaseService.getInstance().deleteGitSource(stackName);
// Cascade a mesh opt-out so the mesh_stacks row, override file on disk,
// and derived aliases do not outlive the stack. optOutStack is idempotent
// (no-op when the stack was never opted in). Best-effort: a mesh cleanup
// failure must not regress the delete itself.
try {
await MeshService.getInstance().optOutStack(
req.nodeId,
stackName,
req.user?.username ?? 'system',
);
} catch (meshErr) {
console.warn(
'[Stacks] Mesh opt-out cascade failed for %s, continuing delete:',
sanitizeForLog(stackName),
meshErr,
);
}
if (fsErr) throw fsErr;
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Stack deleted: ${sanitizeForLog(stackName)}`);
res.json({ success: true });
} catch (error: unknown) {
console.error('[Stacks] Failed to delete stack %s:', sanitizeForLog(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;
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;
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:', sanitizeForLog(getErrorMessage(error, 'unknown')));
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 (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
const skipScan = req.body?.skip_scan === true;
const debug = isDebugEnabled();
const atomic = effectiveTier(req) === 'paid';
if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId });
const t0 = Date.now();
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`);
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
res.json({ message: 'Deployed successfully' });
notifyActionSuccess('deploy_success', `${stackName} deployed`, stackName, req.user?.username ?? 'system');
if (!skipScan) {
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err),
);
}
} catch (error: unknown) {
console.error('[Stacks] Deploy failed: %s', sanitizeForLog(stackName), error);
const rollbackInfo = getComposeRollbackInfo(error);
const rolledBack = rollbackInfo?.rolledBack ?? false;
if (rolledBack) {
console.warn('[Stacks] Deploy failed, rolled back: %s', sanitizeForLog(stackName));
} else if (rollbackInfo?.attempted) {
console.warn('[Stacks] Deploy failed, rollback did not complete: %s', sanitizeForLog(stackName));
}
const message = getErrorMessage(error, 'Failed to deploy stack');
notifyActionFailure('deploy', stackName, error);
res.status(500).json({ error: message, rolledBack });
}
});
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 (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs());
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Down completed: ${sanitizeForLog(stackName)}`);
res.json({ status: 'Command started' });
} catch (error: unknown) {
console.error('[Stacks] Down failed: %s', sanitizeForLog(stackName), error);
notifyActionFailure('down', stackName, error);
res.status(500).json({ error: 'Failed to start command' });
}
});
export type StackContainerAction = 'restart' | 'stop' | 'start';
const CONTAINER_ACTION_META: Record<StackContainerAction, { category: NotificationCategory; pastTense: string }> = {
restart: { category: 'stack_restarted', pastTense: 'restarted' },
stop: { category: 'stack_stopped', pastTense: 'stopped' },
start: { category: 'stack_started', pastTense: 'started' },
};
export type ContainerActionOutcome =
| { kind: 'ok'; count: number }
| { kind: 'no-containers' }
| { kind: 'error'; message: string };
export async function containerActionForStack(
nodeId: number,
stackName: string,
action: StackContainerAction,
): Promise<ContainerActionOutcome> {
try {
const dockerController = DockerController.getInstance(nodeId);
const containers = await dockerController.getContainersByStack(stackName);
if (!containers || containers.length === 0) return { kind: 'no-containers' };
const op =
action === 'restart' ? (id: string) => dockerController.restartContainer(id)
: action === 'stop' ? (id: string) => dockerController.stopContainer(id)
: (id: string) => dockerController.startContainer(id);
await Promise.all(containers.map(c => op(c.Id)));
return { kind: 'ok', count: containers.length };
} catch (error: unknown) {
return { kind: 'error', message: getErrorMessage(error, `Failed to ${action} containers`) };
}
}
async function bulkContainerOp(
req: Request,
res: Response,
action: StackContainerAction,
): Promise<void> {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
const titleCase = action.charAt(0).toUpperCase() + action.slice(1);
const outcome = await containerActionForStack(req.nodeId, stackName, action);
if (outcome.kind === 'no-containers') {
res.status(404).json({ error: 'No containers found for this stack.' });
return;
}
if (outcome.kind === 'error') {
console.error('[Stacks] %s failed: %s %s', sanitizeForLog(titleCase), sanitizeForLog(stackName), sanitizeForLog(outcome.message));
if (action !== 'start') notifyActionFailure(action, stackName, new Error(outcome.message));
res.status(500).json({ error: outcome.message });
return;
}
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] ${titleCase} completed: ${sanitizeForLog(stackName)} (${outcome.count} containers)`);
res.json({ success: true, message: `${titleCase} completed via Engine API.` });
const { category, pastTense } = CONTAINER_ACTION_META[action];
notifyActionSuccess(category, `${stackName} ${pastTense}`, stackName, req.user?.username ?? 'system');
}
stacksRouter.post('/:stackName/restart', (req, res) => bulkContainerOp(req, res, 'restart'));
stacksRouter.post('/:stackName/stop', (req, res) => bulkContainerOp(req, res, 'stop'));
stacksRouter.post('/:stackName/start', (req, res) => bulkContainerOp(req, res, 'start'));
type ServiceAction = 'start' | 'stop' | 'restart';
async function handleServiceAction(
req: Request,
res: Response,
action: ServiceAction,
): Promise<void> {
const stackName = req.params.stackName as string;
const serviceName = req.params.serviceName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (!isValidServiceName(serviceName)) {
res.status(400).json({ error: 'Invalid service name' });
return;
}
try {
const dockerController = DockerController.getInstance(req.nodeId);
const all = await dockerController.getContainersByStack(stackName);
if (!all || all.length === 0) {
res.status(404).json({ error: 'No containers found for this stack.' });
return;
}
const matching = all.filter(c => c.Service === serviceName);
if (matching.length === 0) {
res.status(404).json({ error: `Service '${serviceName}' not found in stack '${stackName}'.` });
return;
}
const op =
action === 'start'
? (id: string) => dockerController.startContainer(id)
: action === 'stop'
? (id: string) => dockerController.stopContainer(id)
: (id: string) => dockerController.restartContainer(id);
await Promise.all(matching.map(c => op(c.Id)));
invalidateNodeCaches(req.nodeId);
console.log(
`[Stacks] Service ${sanitizeForLog(action)} completed: ${sanitizeForLog(stackName)}/${sanitizeForLog(serviceName)} (${matching.length} containers)`,
);
res.json({
success: true,
message: `Service ${action} completed via Engine API.`,
count: matching.length,
});
} catch (error: unknown) {
console.error('[Stacks] Service %s failed: %s/%s', sanitizeForLog(action), sanitizeForLog(stackName), sanitizeForLog(serviceName), error);
res.status(500).json({ error: getErrorMessage(error, `Failed to ${action} service`) });
}
}
stacksRouter.post('/:stackName/services/:serviceName/restart', (req, res) =>
handleServiceAction(req, res, 'restart'));
stacksRouter.post('/:stackName/services/:serviceName/stop', (req, res) =>
handleServiceAction(req, res, 'stop'));
stacksRouter.post('/:stackName/services/:serviceName/start', (req, res) =>
handleServiceAction(req, res, 'start'));
stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
try {
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
res.json(preview);
} catch (error) {
console.error('[Stacks] Update preview failed: %s', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
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 (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
const skipScan = req.body?.skip_scan === true;
const debug = isDebugEnabled();
const atomic = effectiveTier(req) === 'paid';
if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId });
const t0 = Date.now();
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(), atomic);
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Update completed: ${sanitizeForLog(stackName)}`);
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
res.json({ status: 'Update completed' });
notifyActionSuccess('image_update_applied', `${stackName} updated`, stackName, req.user?.username ?? 'system');
if (!skipScan) {
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err),
);
}
} catch (error: unknown) {
console.error('[Stacks] Update failed: %s', sanitizeForLog(stackName), error);
const rollbackInfo = getComposeRollbackInfo(error);
const rolledBack = rollbackInfo?.rolledBack ?? false;
if (rolledBack) {
console.warn(`[Stacks] Update failed, rolled back: ${sanitizeForLog(stackName)}`);
} else if (rollbackInfo?.attempted) {
console.warn(`[Stacks] Update failed, rollback did not complete: ${sanitizeForLog(stackName)}`);
}
notifyActionFailure('update', stackName, error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to update'), rolledBack });
}
});
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;
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: ${sanitizeForLog(stackName)}`);
await fsSvc.restoreStackFiles(stackName);
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), false);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Rollback completed: ${sanitizeForLog(stackName)}`);
res.json({ message: 'Stack rolled back successfully.' });
} catch (error: unknown) {
console.error('[Stacks] Rollback failed: %s', sanitizeForLog(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;
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 });
}
});
// ── File explorer endpoints ──
type FsErrorCode =
| 'INVALID_PATH'
| 'SYMLINK_ESCAPE'
| 'IS_DIRECTORY'
| 'NOT_EMPTY'
| 'NOT_FOUND'
| 'TOO_LARGE'
| 'ALREADY_EXISTS';
function sendFsError(
res: Response,
err: unknown,
fallback: string,
opts: { notFoundMessage?: string } = {},
): Response {
const e = err as NodeJS.ErrnoException & { code?: string };
if (e.code === 'INVALID_PATH' || e.code === 'SYMLINK_ESCAPE') {
return res.status(400).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'IS_DIRECTORY') {
return res.status(400).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'NOT_EMPTY') {
return res.status(409).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'EEXIST') {
return res.status(409).json({ error: e.message, code: 'ALREADY_EXISTS' satisfies FsErrorCode });
}
if (e.code === 'ENOTDIR') {
return res.status(400).json({ error: 'Target path is not a directory', code: 'INVALID_PATH' satisfies FsErrorCode });
}
if (e.code === 'ENOENT') {
return res.status(404).json({ error: opts.notFoundMessage ?? 'File not found', code: 'NOT_FOUND' });
}
console.error(`[files] ${fallback}:`, sanitizeForLog(e.message));
return res.status(500).json({ error: fallback });
}
function logFileOperation(level: 'info' | 'warn', message: string, details: Record<string, unknown>): void {
const cleaned = Object.fromEntries(
Object.entries(details).map(([key, value]) => [key, sanitizeForLog(value)]),
);
const log = level === 'warn' ? console.warn : console.log;
log(`[Files] ${message}`, cleaned);
}
function fsErrorCode(err: unknown): string {
const code = (err as NodeJS.ErrnoException & { code?: unknown }).code;
return typeof code === 'string' ? code : 'UNKNOWN';
}
function logFileDiag(message: string, details: Record<string, unknown>): void {
if (DatabaseService.getInstance().getGlobalSettings().developer_mode !== '1') return;
const cleaned = Object.fromEntries(
Object.entries(details).map(([key, value]) => [key, sanitizeForLog(value)]),
);
console.debug(`[Files:diag] ${message}`, cleaned);
}
function isSafeUploadFilename(rawName: string): boolean {
if (!rawName || rawName === '.' || rawName === '..') return false;
if (rawName.includes('\0') || rawName.includes('/') || rawName.includes('\\')) return false;
if (/^[a-zA-Z]:/.test(rawName) || path.isAbsolute(rawName)) return false;
return path.basename(rawName) === rawName;
}
stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('list start', { stackName, relPath, nodeId: req.nodeId });
try {
const entries = await FileSystemService.getInstance(req.nodeId).listStackDirectory(stackName, relPath);
logFileDiag('list complete', { stackName, relPath, nodeId: req.nodeId, entries: entries.length, elapsedMs: Date.now() - startedAt });
return res.json(entries);
} catch (err: unknown) {
logFileOperation('warn', 'list failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to list directory');
}
});
stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('read start', { stackName, relPath, nodeId: req.nodeId });
try {
const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath);
logFileDiag('read complete', {
stackName,
relPath,
nodeId: req.nodeId,
binary: result.binary,
oversized: result.oversized,
size: result.size,
elapsedMs: Date.now() - startedAt,
});
return res.json(result);
} catch (err: unknown) {
logFileOperation('warn', 'read failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to read file');
}
});
stacksRouter.get('/:stackName/files/download', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('download start', { stackName, relPath, nodeId: req.nodeId });
try {
const result = await FileSystemService.getInstance(req.nodeId).streamStackFile(stackName, relPath);
res.setHeader('Content-Type', result.mime);
res.setHeader('Content-Length', result.size);
const encodedFilename = encodeURIComponent(result.filename);
const safeFilename = result.filename.replace(/[\\"]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`);
result.stream.on('error', (streamErr) => {
console.error('[files] stream error:', sanitizeForLog(getErrorMessage(streamErr, 'unknown')));
if (!res.headersSent) res.status(500).end();
else res.destroy();
});
req.on('close', () => result.stream.destroy());
logFileDiag('download stream opened', { stackName, relPath, nodeId: req.nodeId, size: result.size, elapsedMs: Date.now() - startedAt });
result.stream.pipe(res);
return;
} catch (err: unknown) {
logFileOperation('warn', 'download failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to download file');
}
});
stacksRouter.post(
'/:stackName/files/upload',
(req: Request, res: Response, next: NextFunction) => {
if (!requirePaid(req, res)) return;
upload.single('file')(req, res, (err) => {
if (err && (err as multer.MulterError).code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File exceeds 25 MB limit', code: 'TOO_LARGE' });
}
if (err) return res.status(500).json({ error: 'Upload failed' });
next();
});
},
async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!req.file) {
return res.status(400).json({ error: 'No file provided' });
}
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const originalName = req.file.originalname;
if (!isSafeUploadFilename(originalName)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName;
const startedAt = Date.now();
logFileDiag('upload start', { stackName, relPath: targetRelPath, nodeId: req.nodeId, size: req.file.size });
try {
await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer);
logFileOperation('info', 'upload complete', { nodeId: req.nodeId, size: req.file.size });
logFileDiag('upload timing', { stackName, relPath: targetRelPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'upload failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to upload file', { notFoundMessage: 'Target directory not found' });
}
},
);
stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const { content } = req.body as { content?: unknown };
if (typeof content !== 'string') {
return res.status(400).json({ error: '"content" must be a string' });
}
const startedAt = Date.now();
logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8') });
try {
await FileSystemService.getInstance(req.nodeId).writeStackFile(stackName, relPath, content);
logFileOperation('info', 'write complete', { nodeId: req.nodeId });
logFileDiag('write timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'write failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to write file');
}
});
stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath === '') return res.status(400).json({ error: 'Path is required for delete' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const recursive = req.query.recursive === '1';
const startedAt = Date.now();
logFileDiag('delete start', { stackName, relPath, recursive, nodeId: req.nodeId });
try {
await FileSystemService.getInstance(req.nodeId).deleteStackPath(stackName, relPath, recursive);
logFileOperation('info', 'delete complete', { nodeId: req.nodeId, recursive });
logFileDiag('delete timing', { stackName, relPath, recursive, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'delete failed', { nodeId: req.nodeId, recursive, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to delete path');
}
});
stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath === '') return res.status(400).json({ error: 'Path is required to create a folder' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('mkdir start', { stackName, relPath, nodeId: req.nodeId });
try {
await FileSystemService.getInstance(req.nodeId).mkdirStackPath(stackName, relPath);
logFileOperation('info', 'mkdir complete', { nodeId: req.nodeId });
logFileDiag('mkdir timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'mkdir failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to create folder');
}
});
stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const { from, to } = req.body as { from?: unknown; to?: unknown };
if (typeof from !== 'string' || !from) {
return res.status(400).json({ error: '"from" must be a non-empty string' });
}
if (typeof to !== 'string' || !to) {
return res.status(400).json({ error: '"to" must be a non-empty string' });
}
if (!isValidRelativeStackPath(from)) {
return res.status(400).json({ error: 'Invalid source path', code: 'INVALID_PATH' });
}
if (!isValidRelativeStackPath(to)) {
return res.status(400).json({ error: 'Invalid destination path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('rename start', { stackName, from, to, nodeId: req.nodeId });
try {
await FileSystemService.getInstance(req.nodeId).renameStackPath(stackName, from, to);
logFileOperation('info', 'rename complete', { nodeId: req.nodeId });
logFileDiag('rename timing', { stackName, from, to, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'rename failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to rename');
}
});
stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('permissions read start', { stackName, relPath, nodeId: req.nodeId });
try {
const result = await FileSystemService.getInstance(req.nodeId).getStackEntryMode(stackName, relPath);
logFileDiag('permissions read complete', { stackName, relPath, nodeId: req.nodeId, mode: result.octal, elapsedMs: Date.now() - startedAt });
return res.json(result);
} catch (err: unknown) {
logFileOperation('warn', 'permissions read failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to read permissions');
}
});
stacksRouter.put('/:stackName/files/permissions', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const { mode } = req.body as { mode?: unknown };
if (typeof mode !== 'number') {
return res.status(400).json({ error: '"mode" must be a number' });
}
const startedAt = Date.now();
logFileDiag('chmod start', { stackName, relPath, nodeId: req.nodeId, mode });
try {
await FileSystemService.getInstance(req.nodeId).chmodStackPath(stackName, relPath, mode);
logFileOperation('info', 'chmod complete', { nodeId: req.nodeId, mode });
logFileDiag('chmod timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'chmod failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to set permissions');
}
});