mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
52ff0725f4
* feat: add Compose Doctor preflight checks for stacks Add an on-demand, advisory preflight that renders a stack's effective Compose model with `docker compose config` and runs a registry of deterministic checks before deploy, surfacing findings grouped by severity (blocker, high, warning, info) with a remediation for each. Findings cover unset env vars, host-port conflicts on the node, broad 0.0.0.0 exposure, missing bind-mount paths, a mounted Docker socket, privileged and host networking, moving image tags, missing restart policy and healthcheck, Swarm-only deploy fields, missing external networks or volumes, and container_name collisions. The report is node-scoped and stored as the last run per stack, and the route auto-proxies to the active node so a remote stack is checked on the node that owns it. A new Doctor tab on the stack detail panel runs preflight and shows the grouped findings, with a severity dot on the tab when the last run has blocker or high findings. The tab is gated on a compose-doctor capability so older nodes hide it. No environment value is ever stored, returned, or logged: only env key names and structural facts are read, and render failures surface a generic message or the missing required-variable names, never raw stderr. * fix: scroll the stack tab strip when its tabs overflow Adding the Doctor tab can push the per-stack Anatomy tab strip past the panel width on narrower layouts. Make the tab row scroll horizontally with subtle edge fades that appear only while there is more to scroll in that direction, so a panel wide enough to show every tab is unchanged. * fix: add clickable arrows and wheel scroll to the stack tab strip Hiding the scrollbar left mouse users with no way to scroll the overflowing tab row: a vertical wheel does not move a horizontal overflow and native rows do not drag-scroll. Replace the passive edge fades with clickable chevron arrows shown only when the row overflows that edge, and translate a vertical wheel over the row into horizontal scroll. * fix: inline the path-injection barrier in renderConfig CodeQL's path-injection check does not credit the wrapped isPathWithinBase helper as a sanitizer, so move the containment check inline at the spawn cwd sink, matching the canonical barrier used elsewhere in the codebase. Behavior is unchanged: the resolved stack directory must be contained in the compose base and may not be the base itself. * fix: hoist the compose-config spawn into the path-barrier scope The earlier inline barrier sat in a different scope than the spawn cwd sink (separated by the Promise-executor closure) and used a compound guard, so CodeQL did not credit it. Use the exact canonical startsWith barrier and hoist the spawn into the same scope as the check. Behavior is unchanged: the executor runs synchronously in the same tick as the spawn, so handlers still attach before any event can fire.
2184 lines
93 KiB
TypeScript
2184 lines
93 KiB
TypeScript
import { Router, type Request, type Response, type NextFunction } from 'express';
|
|
import { z } from 'zod';
|
|
import path from 'path';
|
|
import { inspect } from 'node:util';
|
|
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, type StackDossierFields } 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 { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
|
|
import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerService';
|
|
import { ComposeDoctorService } from '../services/ComposeDoctorService';
|
|
import { requirePermission, checkPermission } from '../middleware/permissions';
|
|
import { NotificationService, type NotificationCategory } from '../services/NotificationService';
|
|
import { StackOpLockService, type StackOpAction } from '../services/StackOpLockService';
|
|
import { StackOpMetricsService, type StackOpAction as StackMetricAction } from '../services/StackOpMetricsService';
|
|
import { FileExplorerMetricsService, type FileExplorerOp } from '../services/FileExplorerMetricsService';
|
|
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 { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
|
|
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
|
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
|
|
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
|
|
|
|
// Authenticated users with edit permission can write arbitrarily large compose
|
|
// files. Refuse to YAML.parse anything beyond this bound so a malformed (or
|
|
// adversarial) file cannot exhaust heap during an env or service lookup.
|
|
const MAX_COMPOSE_PARSE_BYTES = 1_048_576; // 1 MiB
|
|
|
|
/**
|
|
* Diagnostic-mode log helper. Wraps console.log so non-essential informational
|
|
* lines (deploys completed, files saved, stacks created) stay silent in
|
|
* production and only appear when developer_mode is on. console.warn and
|
|
* console.error remain unconditional - they are operational signal, not noise.
|
|
*/
|
|
function dlog(...args: Parameters<typeof console.log>): void {
|
|
if (isDebugEnabled()) console.log(...args);
|
|
}
|
|
function notifyActionFailure(action: string, stackName: string, error: unknown, actor: string): void {
|
|
const message = getErrorMessage(error, `Failed to ${action} stack`);
|
|
NotificationService.getInstance()
|
|
.dispatchAlert('error', 'deploy_failure', message, { stackName, actor })
|
|
.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));
|
|
}
|
|
|
|
const STACK_OP_PRESENT_PARTICIPLE: Record<StackOpAction, string> = {
|
|
deploy: 'deploying',
|
|
down: 'stopping',
|
|
restart: 'restarting',
|
|
stop: 'stopping',
|
|
start: 'starting',
|
|
update: 'updating',
|
|
rollback: 'rolling back',
|
|
backup: 'backing up',
|
|
};
|
|
|
|
function tryAcquireStackOpLock(
|
|
req: Request,
|
|
res: Response,
|
|
stackName: string,
|
|
action: StackOpAction,
|
|
): boolean {
|
|
const user = req.user?.username ?? 'system';
|
|
const result = StackOpLockService.getInstance().tryAcquire(req.nodeId, stackName, action, user);
|
|
if (!result.acquired) {
|
|
res.status(409).json({
|
|
error: `${stackName} is already ${STACK_OP_PRESENT_PARTICIPLE[result.existing.action]}`,
|
|
code: 'stack_op_in_progress',
|
|
inProgress: {
|
|
action: result.existing.action,
|
|
startedAt: result.existing.startedAt,
|
|
user: result.existing.user,
|
|
},
|
|
});
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function releaseStackOpLock(req: Request, stackName: string): void {
|
|
StackOpLockService.getInstance().release(req.nodeId, stackName);
|
|
}
|
|
|
|
function stackFileEtag(mtimeMs: number): string {
|
|
return `W/"${Math.floor(mtimeMs)}"`;
|
|
}
|
|
|
|
function parseIfMatchMtime(raw: string | undefined): number | null {
|
|
if (!raw) return null;
|
|
const m = /(?:W\/)?"(\d+)"/.exec(raw);
|
|
if (!m) return null;
|
|
const value = Number(m[1]);
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
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];
|
|
|
|
if (composeContent.length > MAX_COMPOSE_PARSE_BYTES) {
|
|
console.warn(`[Stacks] Compose for ${sanitizeForLog(stackName)} exceeds ${MAX_COMPOSE_PARSE_BYTES} bytes; skipping env_file resolution`);
|
|
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) => {
|
|
if (!requirePermission(req, res, 'stack:read')) return;
|
|
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) => {
|
|
if (!requirePermission(req, res, 'stack:read')) return;
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// Read-only scan of the compose directory for the guided first-import flow.
|
|
// Surfaces compose files that are not yet stacks (loose at the root, or one
|
|
// directory too deep) with a dry preview so a new user can land their first
|
|
// stack without reading the docs first. Performs no writes.
|
|
stacksRouter.get('/import/scan', async (req: Request, res: Response) => {
|
|
if (!requirePermission(req, res, 'stack:read')) return;
|
|
try {
|
|
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
|
const raw = await fsSvc.findImportCandidates();
|
|
const candidates = raw.map((c) => {
|
|
let preview: ComposePreview;
|
|
if (c.oversized) {
|
|
preview = { services: [], warnings: [], parseError: 'Compose file is too large to preview.' };
|
|
} else if (c.content !== null) {
|
|
preview = parseComposePreview(c.content);
|
|
} else {
|
|
preview = { services: [], warnings: [], parseError: 'Could not read compose file.' };
|
|
}
|
|
return {
|
|
name: c.name,
|
|
composeFile: c.composeFile,
|
|
location: c.location,
|
|
status: c.status,
|
|
services: preview.services,
|
|
warnings: preview.warnings,
|
|
parseError: preview.parseError,
|
|
};
|
|
});
|
|
res.json({ composeDir: fsSvc.getBaseDir(), candidates });
|
|
} catch (error) {
|
|
console.error('Failed to scan compose directory:', error);
|
|
res.status(500).json({ error: 'Failed to scan compose directory' });
|
|
}
|
|
});
|
|
|
|
// Move a discovered import candidate into its own stack directory so Sencho
|
|
// picks it up. The single write path of the guided import flow: it relocates a
|
|
// loose or nested compose file on disk and never captures it into a store. The
|
|
// source is re-derived from a fresh scan and matched by location, so the client
|
|
// cannot point the move at an arbitrary path.
|
|
stacksRouter.post('/import/move', async (req: Request, res: Response) => {
|
|
if (!requirePermission(req, res, 'stack:create')) return;
|
|
const { location, name } = req.body as { location?: unknown; name?: unknown };
|
|
if (typeof location !== 'string' || !location) {
|
|
return res.status(400).json({ error: 'A candidate location is required' });
|
|
}
|
|
if (typeof name !== 'string' || !isValidStackName(name.trim())) {
|
|
return res.status(400).json({ error: 'Name must be alphanumeric, hyphens, or underscores only' });
|
|
}
|
|
const destName = name.trim();
|
|
try {
|
|
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
|
const match = (await fsSvc.findImportCandidates()).find((c) => c.location === location);
|
|
if (!match) {
|
|
return res.status(404).json({ error: 'That compose file was not found. Rescan and try again.' });
|
|
}
|
|
await fsSvc.importCandidateIntoStack(match, destName);
|
|
invalidateNodeCaches(req.nodeId);
|
|
dlog(`[Stacks] Imported compose file into stack: ${sanitizeForLog(destName)}`);
|
|
res.json({ name: destName });
|
|
} catch (error) {
|
|
const code = (error as { code?: string })?.code;
|
|
// A destination that already exists (our own DEST_EXISTS) or that appeared
|
|
// between the existence check and the rename (EEXIST/ENOTEMPTY) is a clean
|
|
// conflict, not a server error.
|
|
if (code === 'DEST_EXISTS' || code === 'EEXIST' || code === 'ENOTEMPTY') {
|
|
return res.status(409).json({ error: `A stack named "${destName}" already exists` });
|
|
}
|
|
if (code === 'INVALID_PATH' || code === 'INVALID_STACK_NAME') {
|
|
return res.status(400).json({ error: 'Invalid path' });
|
|
}
|
|
// The candidate vanished between the scan above and the move (e.g. deleted
|
|
// on disk): treat it like a stale candidate rather than a server fault.
|
|
if (code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'That compose file was not found. Rescan and try again.' });
|
|
}
|
|
console.error('Failed to import compose file into stack:', sanitizeForLog((error as Error)?.message ?? String(error)));
|
|
res.status(500).json({ error: 'Failed to move the compose file into place' });
|
|
}
|
|
});
|
|
|
|
type BulkLifecycleAction = 'start' | 'stop' | 'restart' | 'update';
|
|
const VALID_BULK_ACTIONS: ReadonlySet<BulkLifecycleAction> = new Set(['start', 'stop', 'restart', 'update']);
|
|
const BULK_PARALLELISM = 4;
|
|
const BULK_MAX_STACKS = 100;
|
|
|
|
interface BulkResultItem {
|
|
stackName: string;
|
|
ok: boolean;
|
|
error?: string;
|
|
code?: string;
|
|
}
|
|
|
|
async function runStackBulkOp(
|
|
req: Request,
|
|
stackName: string,
|
|
action: BulkLifecycleAction,
|
|
): Promise<BulkResultItem> {
|
|
if (!isValidStackName(stackName)) {
|
|
return { stackName, ok: false, error: 'Invalid stack name', code: 'invalid_name' };
|
|
}
|
|
if (!checkPermission(req, 'stack:deploy', 'stack', stackName)) {
|
|
return { stackName, ok: false, error: 'Permission denied', code: 'PERMISSION_DENIED' };
|
|
}
|
|
|
|
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
|
const stackDir = path.join(fsSvc.getBaseDir(), stackName);
|
|
try {
|
|
if (!(await fsSvc.hasComposeFile(stackDir))) {
|
|
return { stackName, ok: false, error: 'Stack not found', code: 'not_found' };
|
|
}
|
|
} catch {
|
|
return { stackName, ok: false, error: 'Stack not found', code: 'not_found' };
|
|
}
|
|
|
|
const user = req.user?.username ?? 'system';
|
|
const lockAction: StackOpAction = action;
|
|
const lockResult = StackOpLockService.getInstance().tryAcquire(req.nodeId, stackName, lockAction, user);
|
|
if (!lockResult.acquired) {
|
|
return {
|
|
stackName,
|
|
ok: false,
|
|
error: `${stackName} is already ${STACK_OP_PRESENT_PARTICIPLE[lockResult.existing.action]}`,
|
|
code: 'stack_op_in_progress',
|
|
};
|
|
}
|
|
|
|
try {
|
|
if (action === 'update') {
|
|
const gate = await enforcePolicyPreDeploy(stackName, req.nodeId, buildPolicyGateOptions(req));
|
|
if (!gate.ok) {
|
|
return {
|
|
stackName,
|
|
ok: false,
|
|
error: `Policy "${gate.policy?.name}" blocked update`,
|
|
code: 'policy_blocked',
|
|
};
|
|
}
|
|
const atomic = true;
|
|
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
|
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
|
NotificationService.getInstance().broadcastEvent({
|
|
type: 'state-invalidate',
|
|
scope: 'image-updates',
|
|
nodeId: req.nodeId,
|
|
stackName,
|
|
action: 'stack-updated',
|
|
ts: Date.now(),
|
|
});
|
|
notifyActionSuccess('image_update_applied', `${stackName} updated`, stackName, user);
|
|
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
|
console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err),
|
|
);
|
|
} else {
|
|
const outcome = await containerActionForStack(req.nodeId, stackName, action);
|
|
if (outcome.kind === 'no-containers') {
|
|
return { stackName, ok: false, error: 'No containers found for this stack', code: 'no_containers' };
|
|
}
|
|
if (outcome.kind === 'error') {
|
|
if (action !== 'start') notifyActionFailure(action, stackName, new Error(outcome.message), user);
|
|
return { stackName, ok: false, error: outcome.message, code: 'op_failed' };
|
|
}
|
|
const meta = CONTAINER_ACTION_META[action];
|
|
notifyActionSuccess(meta.category, `${stackName} ${meta.pastTense}`, stackName, user);
|
|
}
|
|
return { stackName, ok: true };
|
|
} catch (err) {
|
|
if (action !== 'start') notifyActionFailure(action, stackName, err, user);
|
|
return { stackName, ok: false, error: getErrorMessage(err, `${action} failed`), code: 'op_failed' };
|
|
} finally {
|
|
StackOpLockService.getInstance().release(req.nodeId, stackName);
|
|
}
|
|
}
|
|
|
|
async function runWithBoundedParallelism<T, R>(
|
|
items: T[],
|
|
limit: number,
|
|
task: (item: T) => Promise<R>,
|
|
): Promise<R[]> {
|
|
const results: R[] = new Array(items.length);
|
|
let next = 0;
|
|
const worker = async (): Promise<void> => {
|
|
while (next < items.length) {
|
|
const idx = next;
|
|
next += 1;
|
|
results[idx] = await task(items[idx]);
|
|
}
|
|
};
|
|
const workerCount = Math.min(limit, items.length);
|
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
return results;
|
|
}
|
|
|
|
stacksRouter.post('/bulk', async (req: Request, res: Response) => {
|
|
const body = req.body as { action?: unknown; stackNames?: unknown } | undefined;
|
|
const action = body?.action;
|
|
const stackNames = body?.stackNames;
|
|
|
|
if (typeof action !== 'string' || !VALID_BULK_ACTIONS.has(action as BulkLifecycleAction)) {
|
|
return res.status(400).json({
|
|
error: `Invalid action. Must be one of: ${[...VALID_BULK_ACTIONS].join(', ')}`,
|
|
});
|
|
}
|
|
if (!Array.isArray(stackNames) || stackNames.length === 0) {
|
|
return res.status(400).json({ error: 'stackNames must be a non-empty array' });
|
|
}
|
|
if (stackNames.length > BULK_MAX_STACKS) {
|
|
return res.status(400).json({ error: `Bulk operations are limited to ${BULK_MAX_STACKS} stacks per request` });
|
|
}
|
|
if (!stackNames.every(s => typeof s === 'string')) {
|
|
return res.status(400).json({ error: 'stackNames must be an array of strings' });
|
|
}
|
|
|
|
const typedAction = action as BulkLifecycleAction;
|
|
const typedNames = Array.from(new Set(stackNames as string[]));
|
|
|
|
const results = await runWithBoundedParallelism(
|
|
typedNames,
|
|
BULK_PARALLELISM,
|
|
name => runStackBulkOp(req, name, typedAction),
|
|
);
|
|
|
|
invalidateNodeCaches(req.nodeId);
|
|
const okCount = results.filter(r => r.ok).length;
|
|
console.log(
|
|
`[Stacks] Bulk ${sanitizeForLog(action)} completed: ${okCount}/${results.length} on node ${req.nodeId}`,
|
|
);
|
|
|
|
res.json({ action: typedAction, results });
|
|
});
|
|
|
|
stacksRouter.get('/:stackName', async (req: Request, res: Response) => {
|
|
try {
|
|
const stackName = req.params.stackName as string;
|
|
const { content, mtimeMs } = await FileSystemService.getInstance(req.nodeId).getStackContentWithMtime(stackName);
|
|
res.setHeader('ETag', stackFileEtag(mtimeMs));
|
|
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' });
|
|
}
|
|
if (isDebugEnabled()) console.debug(`[Stacks:debug] Save starting`, { stackName: sanitizeForLog(stackName), bytes: content.length, nodeId: req.nodeId });
|
|
const expectedMtimeMs = parseIfMatchMtime(req.header('if-match'));
|
|
const result = await FileSystemService.getInstance(req.nodeId)
|
|
.saveStackContentIfUnchanged(stackName, content, expectedMtimeMs);
|
|
if (!result.ok) {
|
|
res.setHeader('ETag', stackFileEtag(result.currentMtimeMs));
|
|
return res.status(412).json({
|
|
error: `${stackName}'s compose file changed since you opened it.`,
|
|
code: 'stack_file_changed',
|
|
currentMtimeMs: result.currentMtimeMs,
|
|
currentContent: result.currentContent,
|
|
});
|
|
}
|
|
invalidateNodeCaches(req.nodeId);
|
|
dlog(`[Stacks] Compose file saved: ${sanitizeForLog(stackName)}`);
|
|
res.setHeader('ETag', stackFileEtag(result.mtimeMs));
|
|
res.json({ message: 'Stack saved successfully', mtimeMs: result.mtimeMs });
|
|
} 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');
|
|
const mtimeMs = await fsService.statMtime(envPath);
|
|
if (mtimeMs !== null) res.setHeader('ETag', stackFileEtag(mtimeMs));
|
|
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);
|
|
const expectedMtimeMs = parseIfMatchMtime(req.header('if-match'));
|
|
const result = await fsService.writeFileIfUnchanged(envPath, content, expectedMtimeMs);
|
|
if (!result.ok) {
|
|
res.setHeader('ETag', stackFileEtag(result.currentMtimeMs));
|
|
return res.status(412).json({
|
|
error: `${stackName}'s env file changed since you opened it.`,
|
|
code: 'stack_file_changed',
|
|
currentMtimeMs: result.currentMtimeMs,
|
|
currentContent: result.currentContent,
|
|
});
|
|
}
|
|
invalidateNodeCaches(req.nodeId);
|
|
const envFileName = path.basename(envPath);
|
|
dlog(`[Stacks] Env file saved: ${sanitizeForLog(stackName)}/${sanitizeForLog(envFileName)}`);
|
|
res.setHeader('ETag', stackFileEtag(result.mtimeMs));
|
|
res.json({ message: 'Env file saved successfully', mtimeMs: result.mtimeMs });
|
|
} catch (error) {
|
|
console.error('[Stacks] Failed to save env file:', error);
|
|
res.status(500).json({ error: 'Failed to save env file' });
|
|
}
|
|
});
|
|
|
|
// Stack Dossier: operator-authored documentation persisted per (node, stack).
|
|
// All fields default to '' so a PUT is a full-document save (an omitted field
|
|
// clears it) and a GET for a stack with no dossier yet returns a clean blank.
|
|
const dossierField = (max: number) => z.string().max(max).default('');
|
|
const StackDossierUpdateSchema = z.object({
|
|
purpose: dossierField(1000),
|
|
owner: dossierField(1000),
|
|
access_urls: dossierField(2000),
|
|
static_ip: dossierField(255),
|
|
vlan: dossierField(255),
|
|
firewall_notes: dossierField(8000),
|
|
reverse_proxy_notes: dossierField(8000),
|
|
backup_notes: dossierField(8000),
|
|
upgrade_notes: dossierField(8000),
|
|
recovery_notes: dossierField(8000),
|
|
custom_notes: dossierField(8000),
|
|
});
|
|
const emptyDossierFields = (): StackDossierFields => StackDossierUpdateSchema.parse({});
|
|
|
|
stacksRouter.get('/:stackName/dossier', async (req: Request, res: Response) => {
|
|
const stackName = req.params.stackName as string;
|
|
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
|
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
|
try {
|
|
const row = DatabaseService.getInstance().getStackDossier(req.nodeId, stackName);
|
|
// No dossier yet: answer 200 with a blank document so the editor loads clean
|
|
// rather than forcing the client to special-case a 404.
|
|
res.json(row ?? { node_id: req.nodeId, stack_name: stackName, ...emptyDossierFields(), created_at: 0, updated_at: 0 });
|
|
} catch (error) {
|
|
console.error('[Stacks] Failed to read dossier:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
|
res.status(500).json({ error: 'Failed to read dossier' });
|
|
}
|
|
});
|
|
|
|
stacksRouter.put('/:stackName/dossier', async (req: Request, res: Response) => {
|
|
const stackName = req.params.stackName as string;
|
|
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
|
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
|
const parsed = StackDossierUpdateSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
return;
|
|
}
|
|
try {
|
|
const row = DatabaseService.getInstance().upsertStackDossier(req.nodeId, stackName, parsed.data);
|
|
res.json(row);
|
|
} catch (error) {
|
|
console.error('[Stacks] Failed to save dossier:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
|
res.status(500).json({ error: 'Failed to save dossier' });
|
|
}
|
|
});
|
|
|
|
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: "Field 'stackName' 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);
|
|
dlog(`[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) {
|
|
dlog(
|
|
`[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);
|
|
}
|
|
}
|
|
}
|
|
|
|
dlog(`[Stacks] Stack created from Git: ${sanitizeForLog(stack_name)} at ${result.commitSha.slice(0, 7)}`);
|
|
if (fromGitDiag) {
|
|
dlog(
|
|
`[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';
|
|
dlog(
|
|
`[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';
|
|
const debug = isDebugEnabled();
|
|
const sanitizedName = sanitizeForLog(stackName);
|
|
if (debug) console.debug(`[Stacks:debug] Delete starting`, { stackName: sanitizedName, pruneVolumes, nodeId: req.nodeId });
|
|
|
|
// Step 1: compose down. Best-effort: a stack with corrupt compose files or
|
|
// a temporarily-unreachable daemon should still be removable. Logged so the
|
|
// operator can investigate orphaned containers separately.
|
|
try {
|
|
await ComposeService.getInstance(req.nodeId).downStack(stackName);
|
|
if (debug) console.debug(`[Stacks:debug] Delete: down OK`, { stackName: sanitizedName });
|
|
} catch (downErr) {
|
|
console.warn('[Stacks] Compose down failed or no-op for %s:', sanitizeForLog(stackName), downErr);
|
|
}
|
|
|
|
// Step 2: volume prune (only if requested). Best-effort.
|
|
if (pruneVolumes) {
|
|
try {
|
|
const result = await DockerController.getInstance().pruneManagedOnly('volumes', [stackName]);
|
|
dlog(`[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);
|
|
}
|
|
}
|
|
|
|
// Step 3: filesystem delete. If this fails the on-disk compose files are
|
|
// still present, so we abort BEFORE touching the database — keeping DB and
|
|
// FS in sync so the operator can retry. Otherwise a half-deleted stack
|
|
// (rows gone, files remain) becomes invisible to the UI.
|
|
try {
|
|
await FileSystemService.getInstance(req.nodeId).deleteStack(stackName);
|
|
if (debug) console.debug(`[Stacks:debug] Delete: fs OK`, { stackName: sanitizedName });
|
|
} catch (fsErr) {
|
|
console.error('[Stacks] File deletion failed for %s; database state untouched:', sanitizeForLog(stackName), fsErr);
|
|
const message = getErrorMessage(fsErr, 'Failed to remove stack files');
|
|
res.status(500).json({
|
|
error: `${message}. Stack containers may have been stopped but on-disk files remain. Retry the delete or clean the files manually.`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Step 4: database cleanup. Per-call idempotent; safe to run sequentially.
|
|
try {
|
|
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
|
DatabaseService.getInstance().clearStackScanAttempts(req.nodeId, stackName);
|
|
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
|
|
DatabaseService.getInstance().deleteGitSource(stackName);
|
|
DatabaseService.getInstance().deleteStackDossier(req.nodeId, stackName);
|
|
DatabaseService.getInstance().deleteStackDriftFindings(req.nodeId, stackName);
|
|
if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName });
|
|
} catch (dbErr) {
|
|
console.error('[Stacks] Database cleanup failed for %s; files already removed:', sanitizeForLog(stackName), dbErr);
|
|
const message = getErrorMessage(dbErr, 'Failed to clear stack database state');
|
|
res.status(500).json({
|
|
error: `${message}. Stack files have been removed; some database rows for this stack may remain. Recreating the stack with the same name will reuse those rows.`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Step 5: mesh opt-out cascade. Idempotent; best-effort cleanup of derived
|
|
// aliases / override files. A mesh failure here must not flip the delete
|
|
// result, since the stack itself is already gone.
|
|
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,
|
|
);
|
|
}
|
|
|
|
invalidateNodeCaches(req.nodeId);
|
|
dlog(`[Stacks] Stack deleted: ${sanitizeForLog(stackName)}`);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
stacksRouter.get('/:stackName/containers', async (req: Request, res: Response) => {
|
|
const stackName = req.params.stackName as string;
|
|
if (!isValidStackName(stackName)) {
|
|
res.status(400).json({ error: 'Invalid stack name' });
|
|
return;
|
|
}
|
|
try {
|
|
const dockerController = DockerController.getInstance(req.nodeId);
|
|
const containers = await dockerController.getContainersByStack(stackName);
|
|
res.json(containers);
|
|
} catch (error) {
|
|
console.error('[Stacks] Failed to fetch containers for %s:', sanitizeForLog(stackName), error);
|
|
res.status(500).json({ error: 'Failed to fetch containers' });
|
|
}
|
|
});
|
|
|
|
stacksRouter.get('/:stackName/services', async (req: Request, res: Response) => {
|
|
const stackName = req.params.stackName as string;
|
|
if (!isValidStackName(stackName)) {
|
|
res.status(400).json({ error: 'Invalid stack name' });
|
|
return;
|
|
}
|
|
try {
|
|
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
|
|
if (content.length > MAX_COMPOSE_PARSE_BYTES) {
|
|
console.warn(`[Stacks] Compose for ${sanitizeForLog(stackName)} exceeds ${MAX_COMPOSE_PARSE_BYTES} bytes; refusing to parse services`);
|
|
res.status(413).json({ error: 'Compose file too large to parse' });
|
|
return;
|
|
}
|
|
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' });
|
|
}
|
|
});
|
|
|
|
/** A persisted finding as the Drift tab consumes it (no internal column names). */
|
|
interface DriftLedgerEntry {
|
|
service: string;
|
|
kind: DriftFindingKind;
|
|
message: string;
|
|
detectedAt: number;
|
|
resolvedAt: number | null;
|
|
}
|
|
|
|
/**
|
|
* Assemble the full Drift tab payload: the spatial report (compose vs runtime),
|
|
* the temporal overlay (source changed since last deploy), and the persisted
|
|
* ledger history. When `reconcile` is set, the current findings are persisted
|
|
* into the ledger (new ones recorded, cleared ones resolved) before the ledger
|
|
* is read back, so the returned history reflects the just-observed state.
|
|
*/
|
|
async function buildDriftPayload(
|
|
nodeId: number,
|
|
stackName: string,
|
|
reconcile: boolean,
|
|
): Promise<StackDriftReport & { temporal: DriftTemporal; ledger: DriftLedgerEntry[] }> {
|
|
const report = await buildStackDriftReport(nodeId, stackName);
|
|
// Only the on-disk read is best-effort: an unreadable compose is already surfaced
|
|
// by the report as a parse error, so temporal degrades to neutral. computeTemporal
|
|
// runs outside the try so a real ledger fault (a DB or hashing error) surfaces as a
|
|
// 500 instead of being hidden behind a misleading "no baseline".
|
|
let content: string | null = null;
|
|
try {
|
|
content = await FileSystemService.getInstance(nodeId).getStackContent(stackName);
|
|
} catch {
|
|
// Unreadable compose: the report carries the parseError; temporal stays neutral.
|
|
}
|
|
const temporal: DriftTemporal = content !== null
|
|
? DriftLedgerService.getInstance().computeTemporal(nodeId, stackName, content)
|
|
: { hasBaseline: false, sourceChanged: false, renderedChanged: false };
|
|
if (reconcile) {
|
|
DriftLedgerService.getInstance().reconcile(nodeId, stackName, report);
|
|
}
|
|
// finding_type is a free-text column, but reconcile only ever writes a DriftFindingKind.
|
|
const ledger: DriftLedgerEntry[] = DatabaseService.getInstance()
|
|
.getRecentDriftFindings(nodeId, stackName, 20)
|
|
.map(r => ({ service: r.service, kind: r.finding_type as DriftFindingKind, message: r.message, detectedAt: r.detected_at, resolvedAt: r.resolved_at }));
|
|
return { ...report, temporal, ledger };
|
|
}
|
|
|
|
stacksRouter.get('/:stackName/drift', async (req: Request, res: Response) => {
|
|
const stackName = req.params.stackName as string;
|
|
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
|
try {
|
|
res.json(await buildDriftPayload(req.nodeId, stackName, false));
|
|
} catch (error) {
|
|
console.error('[Stacks] Failed to build drift report for %s:', sanitizeForLog(stackName),
|
|
sanitizeForLog(inspect(error, { depth: 4 })));
|
|
res.status(500).json({ error: 'Failed to build drift report' });
|
|
}
|
|
});
|
|
|
|
// Re-check is the one place a passive drift view becomes a ledger write: it
|
|
// reconciles the current findings into stack_drift_findings (recording newly
|
|
// detected and newly resolved ones) before returning the fresh payload, so the
|
|
// GET above can stay a side-effect-free read.
|
|
stacksRouter.post('/:stackName/drift/recheck', async (req: Request, res: Response) => {
|
|
const stackName = req.params.stackName as string;
|
|
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
|
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
|
try {
|
|
res.json(await buildDriftPayload(req.nodeId, stackName, true));
|
|
} catch (error) {
|
|
console.error('[Stacks] Failed to re-check drift for %s:', sanitizeForLog(stackName),
|
|
sanitizeForLog(inspect(error, { depth: 4 })));
|
|
res.status(500).json({ error: 'Failed to re-check drift' });
|
|
}
|
|
});
|
|
|
|
// Compose Doctor: GET returns the last stored preflight run (or a never-run
|
|
// sentinel); it is a side-effect-free read. Both routes auto-proxy to the active
|
|
// node, so a remote stack is preflighted on the node that actually owns it.
|
|
stacksRouter.get('/:stackName/preflight', async (req: Request, res: Response) => {
|
|
const stackName = req.params.stackName as string;
|
|
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
|
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
|
try {
|
|
res.json(ComposeDoctorService.getInstance().getLatest(req.nodeId, stackName));
|
|
} catch (error) {
|
|
console.error('[Stacks] Failed to load preflight for %s:', sanitizeForLog(stackName),
|
|
sanitizeForLog(inspect(error, { depth: 4 })));
|
|
res.status(500).json({ error: 'Failed to load preflight report' });
|
|
}
|
|
});
|
|
|
|
// Running preflight renders the effective model and stores the result, replacing
|
|
// any prior run. It is advisory and never blocks a deploy; stack:read is the
|
|
// correct gate since it mutates only the preflight tables, never the stack.
|
|
stacksRouter.post('/:stackName/preflight/run', async (req: Request, res: Response) => {
|
|
const stackName = req.params.stackName as string;
|
|
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
|
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
|
try {
|
|
const report = await ComposeDoctorService.getInstance().runPreflight(req.nodeId, stackName, req.user?.username ?? null);
|
|
res.json(report);
|
|
} catch (error) {
|
|
console.error('[Stacks] Failed to run preflight for %s:', sanitizeForLog(stackName),
|
|
sanitizeForLog(inspect(error, { depth: 4 })));
|
|
res.status(500).json({ error: 'Failed to run preflight' });
|
|
}
|
|
});
|
|
|
|
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;
|
|
// Lock held below. All early-returns must stay inside the try so finally fires.
|
|
if (!tryAcquireStackOpLock(req, res, stackName, 'deploy')) return;
|
|
const t0 = Date.now();
|
|
let ok = false;
|
|
try {
|
|
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
|
const skipScan = req.body?.skip_scan === true;
|
|
const debug = isDebugEnabled();
|
|
const atomic = true;
|
|
if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId });
|
|
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
|
invalidateNodeCaches(req.nodeId);
|
|
dlog(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`);
|
|
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
|
|
ok = true;
|
|
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, req.user?.username ?? 'system');
|
|
if (!res.headersSent) {
|
|
if (isDockerUnavailableError(error)) {
|
|
res.status(503).json({ error: message, code: 'docker_unavailable', rolledBack });
|
|
} else {
|
|
res.status(500).json({ error: message, rolledBack });
|
|
}
|
|
}
|
|
} finally {
|
|
releaseStackOpLock(req, stackName);
|
|
StackOpMetricsService.getInstance().record(req.nodeId, 'deploy', Date.now() - t0, ok);
|
|
}
|
|
});
|
|
|
|
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;
|
|
// Lock held below. All early-returns must stay inside the try so finally fires.
|
|
if (!tryAcquireStackOpLock(req, res, stackName, 'down')) return;
|
|
const t0 = Date.now();
|
|
let ok = false;
|
|
try {
|
|
if (isDebugEnabled()) console.debug(`[Stacks:debug] Down starting`, { stackName: sanitizeForLog(stackName), nodeId: req.nodeId });
|
|
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs(req.get(DEPLOY_SESSION_HEADER)));
|
|
invalidateNodeCaches(req.nodeId);
|
|
dlog(`[Stacks] Down completed: ${sanitizeForLog(stackName)}`);
|
|
ok = true;
|
|
res.json({ status: 'Command started' });
|
|
} catch (error: unknown) {
|
|
console.error('[Stacks] Down failed: %s', sanitizeForLog(stackName), error);
|
|
notifyActionFailure('down', stackName, error, req.user?.username ?? 'system');
|
|
if (!res.headersSent) {
|
|
if (isDockerUnavailableError(error)) {
|
|
res.status(503).json({ error: getErrorMessage(error, 'Docker daemon is unreachable'), code: 'docker_unavailable' });
|
|
} else {
|
|
res.status(500).json({ error: 'Failed to start command' });
|
|
}
|
|
}
|
|
} finally {
|
|
releaseStackOpLock(req, stackName);
|
|
StackOpMetricsService.getInstance().record(req.nodeId, 'down', Date.now() - t0, ok);
|
|
}
|
|
});
|
|
|
|
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: 'docker-unavailable'; message: string }
|
|
| { kind: 'error'; message: string };
|
|
|
|
/**
|
|
* Returns true when the error looks like a Docker-engine reachability
|
|
* failure (socket gone, daemon down, connection refused). The string match
|
|
* is intentionally permissive: Dockerode wraps the underlying Node error
|
|
* differently depending on the transport (unix socket vs tcp vs ssh) and
|
|
* the OS, so a code-only check (`err.code === 'ECONNREFUSED'`) would miss
|
|
* some shapes.
|
|
*/
|
|
export function isDockerUnavailableError(error: unknown): boolean {
|
|
if (!error) return false;
|
|
const err = error as NodeJS.ErrnoException & { errno?: number };
|
|
if (err.code === 'ECONNREFUSED' || err.code === 'ENOENT' && /docker\.sock/i.test(err.message ?? '')) {
|
|
return true;
|
|
}
|
|
const message = String(err.message ?? '').toLowerCase();
|
|
return (
|
|
message.includes('econnrefused') ||
|
|
message.includes('cannot connect to the docker daemon') ||
|
|
message.includes('docker.sock') && (message.includes('connect') || message.includes('no such'))
|
|
);
|
|
}
|
|
|
|
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) {
|
|
if (isDockerUnavailableError(error)) {
|
|
return { kind: 'docker-unavailable', message: getErrorMessage(error, 'Docker daemon is unreachable') };
|
|
}
|
|
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;
|
|
// Lock held below. All early-returns must stay inside the try so finally fires.
|
|
if (!tryAcquireStackOpLock(req, res, stackName, action)) return;
|
|
const t0 = Date.now();
|
|
let ok = false;
|
|
try {
|
|
const titleCase = action.charAt(0).toUpperCase() + action.slice(1);
|
|
if (isDebugEnabled()) console.debug(`[Stacks:debug] ${titleCase} starting`, { stackName: sanitizeForLog(stackName), nodeId: req.nodeId });
|
|
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 === 'docker-unavailable') {
|
|
console.error('[Stacks] %s failed: docker unavailable for %s', sanitizeForLog(titleCase), sanitizeForLog(stackName));
|
|
if (action !== 'start') notifyActionFailure(action, stackName, new Error(outcome.message), req.user?.username ?? 'system');
|
|
res.status(503).json({ error: outcome.message, code: 'docker_unavailable' });
|
|
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), req.user?.username ?? 'system');
|
|
res.status(500).json({ error: outcome.message });
|
|
return;
|
|
}
|
|
|
|
invalidateNodeCaches(req.nodeId);
|
|
dlog(`[Stacks] ${titleCase} completed: ${sanitizeForLog(stackName)} (${outcome.count} containers)`);
|
|
ok = true;
|
|
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');
|
|
} catch (error: unknown) {
|
|
console.error('[Stacks] %s threw unexpectedly: %s', sanitizeForLog(action), sanitizeForLog(stackName), error);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ error: getErrorMessage(error, `Failed to ${action} stack`) });
|
|
}
|
|
} finally {
|
|
releaseStackOpLock(req, stackName);
|
|
StackOpMetricsService.getInstance().record(req.nodeId, action as StackMetricAction, Date.now() - t0, ok);
|
|
}
|
|
}
|
|
|
|
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);
|
|
dlog(
|
|
`[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;
|
|
// Lock held below. All early-returns must stay inside the try so finally fires.
|
|
if (!tryAcquireStackOpLock(req, res, stackName, 'update')) return;
|
|
const t0 = Date.now();
|
|
let ok = false;
|
|
try {
|
|
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
|
const skipScan = req.body?.skip_scan === true;
|
|
const debug = isDebugEnabled();
|
|
const atomic = true;
|
|
if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId });
|
|
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
|
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
|
invalidateNodeCaches(req.nodeId);
|
|
NotificationService.getInstance().broadcastEvent({
|
|
type: 'state-invalidate',
|
|
scope: 'image-updates',
|
|
nodeId: req.nodeId,
|
|
stackName,
|
|
action: 'stack-updated',
|
|
ts: Date.now(),
|
|
});
|
|
dlog(`[Stacks] Update completed: ${sanitizeForLog(stackName)}`);
|
|
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
|
|
ok = true;
|
|
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, req.user?.username ?? 'system');
|
|
if (!res.headersSent) {
|
|
if (isDockerUnavailableError(error)) {
|
|
res.status(503).json({ error: getErrorMessage(error, 'Docker daemon is unreachable'), code: 'docker_unavailable', rolledBack });
|
|
} else {
|
|
res.status(500).json({ error: getErrorMessage(error, 'Failed to update'), rolledBack });
|
|
}
|
|
}
|
|
} finally {
|
|
releaseStackOpLock(req, stackName);
|
|
StackOpMetricsService.getInstance().record(req.nodeId, 'update', Date.now() - t0, ok);
|
|
}
|
|
});
|
|
|
|
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;
|
|
// Rollback restores files and re-deploys, so it must hold the same per-stack
|
|
// lock deploy/update use. Without it a rollback racing an in-flight deploy
|
|
// would mutate the compose files and run a second `docker compose up` against
|
|
// the same project. Lock held below: all early-returns stay inside the try so
|
|
// finally fires.
|
|
if (!tryAcquireStackOpLock(req, res, stackName, 'rollback')) return;
|
|
try {
|
|
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
|
const backupInfo = await fsSvc.getBackupInfo(stackName);
|
|
if (!backupInfo.exists) {
|
|
res.status(404).json({ error: 'No backup available for this stack.' });
|
|
return;
|
|
}
|
|
dlog(`[Stacks] Rollback initiated: ${sanitizeForLog(stackName)}`);
|
|
// Snapshot the current files before restoring so a policy gate that blocks
|
|
// the restored target can be undone: restoreStackFiles commits to disk, and
|
|
// without this a blocked rollback would leave disk rolled back while the
|
|
// deployed state is unchanged.
|
|
const revertRestore = await fsSvc.snapshotStackFiles(stackName);
|
|
await fsSvc.restoreStackFiles(stackName);
|
|
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) {
|
|
try {
|
|
await revertRestore();
|
|
} catch (revertError) {
|
|
console.error('[Stacks] Failed to revert files after a policy-blocked rollback: %s', sanitizeForLog(stackName), revertError);
|
|
// The 409 is already sent and the on-disk config now diverges from the
|
|
// running stack; surface it on the persistent alert feed so the operator
|
|
// can repair it rather than discovering it on the next deploy.
|
|
notifyActionFailure('rollback', stackName, revertError, req.user?.username ?? 'system');
|
|
}
|
|
return;
|
|
}
|
|
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), false);
|
|
invalidateNodeCaches(req.nodeId);
|
|
dlog(`[Stacks] Rollback completed: ${sanitizeForLog(stackName)}`);
|
|
res.json({ message: 'Stack rolled back successfully.' });
|
|
notifyActionSuccess('deploy_success', `${stackName} rolled back`, stackName, req.user?.username ?? 'system');
|
|
} catch (error: unknown) {
|
|
console.error('[Stacks] Rollback failed: %s', sanitizeForLog(stackName), error);
|
|
const message = getErrorMessage(error, 'Rollback failed.');
|
|
notifyActionFailure('rollback', stackName, error, req.user?.username ?? 'system');
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ error: message });
|
|
}
|
|
} finally {
|
|
releaseStackOpLock(req, stackName);
|
|
}
|
|
});
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
stacksRouter.post('/:stackName/backup', async (req: Request, res: Response) => {
|
|
// Triggers a server-side backup of the stack's managed files: the same
|
|
// rollback snapshot a deploy takes. Exposed so a scheduled backup can run on
|
|
// a remote node through the proxy path, and so an operator can capture an
|
|
// on-demand snapshot.
|
|
const stackName = req.params.stackName as string;
|
|
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
|
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
|
// The backup slot is shared with the pre-deploy rollback snapshot, so hold the
|
|
// stack-op lock to keep a backup from interleaving with a concurrent
|
|
// deploy/update/rollback on the same stack. All early-returns stay inside the
|
|
// try so finally always releases.
|
|
if (!tryAcquireStackOpLock(req, res, stackName, 'backup')) return;
|
|
try {
|
|
await FileSystemService.getInstance(req.nodeId).backupStackFiles(stackName);
|
|
dlog(`[Stacks] Backup completed: ${sanitizeForLog(stackName)}`);
|
|
res.json({ success: true });
|
|
} catch (error: unknown) {
|
|
console.error('[Stacks] Backup failed: %s', sanitizeForLog(stackName), error);
|
|
res.status(500).json({ error: getErrorMessage(error, 'Failed to back up stack files') });
|
|
} finally {
|
|
releaseStackOpLock(req, stackName);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Returns the latest post-deploy scan attempt for this stack, or null if
|
|
* no scan has been attempted yet. Used by the editor UI to flag stacks
|
|
* whose latest deploy did not trigger a successful scan (Trivy missing,
|
|
* registry rejection, etc).
|
|
*/
|
|
stacksRouter.get('/:stackName/scan-status', (req: Request, res: Response): void => {
|
|
const stackName = req.params.stackName as string;
|
|
const row = DatabaseService.getInstance().getStackScanAttempt(req.nodeId, stackName);
|
|
if (!row) {
|
|
res.json({ status: null });
|
|
return;
|
|
}
|
|
res.json({
|
|
status: row.status,
|
|
attemptedAt: row.attempted_at,
|
|
errorMessage: row.error_message,
|
|
});
|
|
});
|
|
|
|
// ── File explorer endpoints ──
|
|
|
|
type FsErrorCode =
|
|
| 'INVALID_PATH'
|
|
| 'SYMLINK_ESCAPE'
|
|
| 'IS_DIRECTORY'
|
|
| 'NOT_EMPTY'
|
|
| 'NOT_FOUND'
|
|
| 'TOO_LARGE'
|
|
| 'ALREADY_EXISTS'
|
|
| 'FILE_EXISTS'
|
|
| 'DIR_EXISTS'
|
|
| 'PROTECTED_FILE'
|
|
| 'LINK_CHMOD_UNSUPPORTED';
|
|
|
|
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 === 'PROTECTED_FILE') {
|
|
return res.status(409).json({ error: e.message, code: 'PROTECTED_FILE' satisfies FsErrorCode });
|
|
}
|
|
if (e.code === 'LINK_CHMOD_UNSUPPORTED') {
|
|
return res.status(409).json({ error: e.message, code: 'LINK_CHMOD_UNSUPPORTED' satisfies 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);
|
|
}
|
|
|
|
/**
|
|
* Records one file-explorer op into the in-process metrics service. Always
|
|
* called once per request from the route layer, regardless of success or
|
|
* failure, so the counts in the `/api/file-explorer-metrics` snapshot stay
|
|
* in step with the INFO log line emitted in the same handler.
|
|
*/
|
|
function recordFileOp(nodeId: number, op: FileExplorerOp, startedAt: number, ok: boolean): void {
|
|
FileExplorerMetricsService.getInstance().record(nodeId, op, Date.now() - startedAt, ok);
|
|
}
|
|
|
|
/**
|
|
* Emits the warn log, records the metric, and sends the JSON response for a
|
|
* mutation rejected with a known error code (e.g. DIR_EXISTS, FILE_EXISTS,
|
|
* PRECONDITION_FAILED). Centralizes the three-step shape so a future rejection
|
|
* site cannot skip the metric and drift from the log.
|
|
*/
|
|
function rejectFileMutation(
|
|
req: Request,
|
|
res: Response,
|
|
args: {
|
|
op: FileExplorerOp;
|
|
stack: string;
|
|
path: string;
|
|
startedAt: number;
|
|
status: number;
|
|
code: string;
|
|
body: Record<string, unknown>;
|
|
},
|
|
): Response {
|
|
logFileOperation('warn', 'mutate rejected', {
|
|
nodeId: req.nodeId,
|
|
op: args.op,
|
|
stack: args.stack,
|
|
path: args.path,
|
|
errorCode: args.code,
|
|
});
|
|
recordFileOp(req.nodeId, args.op, args.startedAt, false);
|
|
return res.status(args.status).json({ ...args.body, code: args.code });
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const DIR_LIST_LIMIT = 1000;
|
|
|
|
stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
|
|
const stackName = req.params.stackName as string;
|
|
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
|
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 result = await FileSystemService.getInstance(req.nodeId).listStackDirectoryPage(stackName, relPath, { limit: DIR_LIST_LIMIT });
|
|
// Expose pagination context via headers; the JSON body stays
|
|
// FileEntry[] for backward compatibility with any direct API caller.
|
|
res.setHeader('X-Total-Count', String(result.total));
|
|
res.setHeader('X-Returned-Count', String(result.entries.length));
|
|
if (result.truncated) res.setHeader('X-Truncated', 'true');
|
|
logFileDiag('list complete', {
|
|
stackName,
|
|
relPath,
|
|
nodeId: req.nodeId,
|
|
returned: result.entries.length,
|
|
total: result.total,
|
|
truncated: result.truncated,
|
|
elapsedMs: Date.now() - startedAt,
|
|
});
|
|
recordFileOp(req.nodeId, 'list', startedAt, true);
|
|
return res.json(result.entries);
|
|
} catch (err: unknown) {
|
|
logFileOperation('warn', 'list failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
|
recordFileOp(req.nodeId, 'list', startedAt, false);
|
|
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;
|
|
if (!requirePermission(req, res, 'stack:read', '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 forceText = req.query.force === 'text';
|
|
const startedAt = Date.now();
|
|
logFileDiag('read start', { stackName, relPath, nodeId: req.nodeId, forceText });
|
|
try {
|
|
const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath, undefined, { forceText });
|
|
// ETag is the integer mtimeMs the file was stat'd with, so the matching
|
|
// PUT can compare millisecond-equal even though some filesystems return
|
|
// float mtimeMs.
|
|
res.setHeader('ETag', stackFileEtag(result.mtimeMs));
|
|
logFileDiag('read complete', {
|
|
stackName,
|
|
relPath,
|
|
nodeId: req.nodeId,
|
|
binary: result.binary,
|
|
oversized: result.oversized,
|
|
size: result.size,
|
|
elapsedMs: Date.now() - startedAt,
|
|
});
|
|
recordFileOp(req.nodeId, 'read', startedAt, true);
|
|
return res.json(result);
|
|
} catch (err: unknown) {
|
|
logFileOperation('warn', 'read failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
|
recordFileOp(req.nodeId, 'read', startedAt, false);
|
|
return sendFsError(res, err, 'Failed to read file');
|
|
}
|
|
});
|
|
|
|
stacksRouter.get('/:stackName/files/download', async (req: Request, res: Response) => {
|
|
const stackName = req.params.stackName as string;
|
|
if (!requirePermission(req, res, 'stack:read', '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 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}`);
|
|
// Track download completion off both the file stream's lifecycle and the
|
|
// response close. Under the in-process supertest transport, request close
|
|
// events can race ahead of normal stream completion. End/error are the
|
|
// durable source signals; a close (source or response) counts as success
|
|
// only when the stream reports it read the full file, and as an abort
|
|
// otherwise.
|
|
let downloadRecorded = false;
|
|
const streamWithBytes = result.stream as typeof result.stream & { bytesRead?: number };
|
|
const hasReadFullFile = (): boolean => (
|
|
result.size === 0 ||
|
|
(typeof streamWithBytes.bytesRead === 'number' && streamWithBytes.bytesRead >= result.size)
|
|
);
|
|
let abortCleanupHandle: NodeJS.Immediate | null = null;
|
|
const clearAbortCleanup = (): void => {
|
|
if (!abortCleanupHandle) return;
|
|
clearImmediate(abortCleanupHandle);
|
|
abortCleanupHandle = null;
|
|
};
|
|
const recordDownloadOnce = (ok: boolean): void => {
|
|
if (downloadRecorded) return;
|
|
downloadRecorded = true;
|
|
clearAbortCleanup();
|
|
recordFileOp(req.nodeId, 'download', startedAt, ok);
|
|
};
|
|
result.stream.on('error', (streamErr) => {
|
|
console.error('[files] stream error:', sanitizeForLog(getErrorMessage(streamErr, 'unknown')));
|
|
if (!res.headersSent) {
|
|
res.removeHeader('Content-Length');
|
|
res.status(500).end();
|
|
} else {
|
|
res.destroy();
|
|
}
|
|
recordDownloadOnce(false);
|
|
});
|
|
result.stream.on('end', () => recordDownloadOnce(hasReadFullFile()));
|
|
result.stream.on('close', () => recordDownloadOnce(hasReadFullFile()));
|
|
res.on('close', () => {
|
|
if (downloadRecorded) return;
|
|
// This op measures a server-side file read, so once the source has
|
|
// streamed the whole file a response close is a successful completion.
|
|
// Record it here rather than waiting on the source stream's end/close,
|
|
// which can be dropped once the response consumer is gone, leaving the
|
|
// op unrecorded.
|
|
if (hasReadFullFile()) { recordDownloadOnce(true); return; }
|
|
// Otherwise response close is an abort signal. It can beat the source
|
|
// stream's final events in the in-process test transport, so give a
|
|
// same-turn clean source completion a chance to win before cleanup.
|
|
abortCleanupHandle = setImmediate(() => {
|
|
abortCleanupHandle = null;
|
|
if (downloadRecorded) return;
|
|
if (hasReadFullFile()) { recordDownloadOnce(true); return; }
|
|
recordDownloadOnce(false);
|
|
result.stream.destroy();
|
|
});
|
|
abortCleanupHandle.unref?.();
|
|
});
|
|
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) });
|
|
recordFileOp(req.nodeId, 'download', startedAt, false);
|
|
return sendFsError(res, err, 'Failed to download file');
|
|
}
|
|
});
|
|
|
|
type UploadStartedReq = Request & { _fileUploadStartedAt?: number };
|
|
|
|
stacksRouter.post(
|
|
'/:stackName/files/upload',
|
|
(req: Request, res: Response, next: NextFunction) => {
|
|
// Capture the time the upload entered the route so every downstream
|
|
// metric reports the same latency window: the body-transfer +
|
|
// parser-buffer time on success, plus the multer rejection branches
|
|
// below. Reading a fresh Date.now() after multer would hide the
|
|
// multipart upload's network and buffering cost from the histogram.
|
|
const startedAt = Date.now();
|
|
(req as UploadStartedReq)._fileUploadStartedAt = startedAt;
|
|
upload.single('file')(req, res, (err) => {
|
|
if (err && (err as multer.MulterError).code === 'LIMIT_FILE_SIZE') {
|
|
logFileOperation('warn', 'mutate rejected', {
|
|
nodeId: req.nodeId,
|
|
op: 'upload',
|
|
stack: req.params.stackName,
|
|
errorCode: 'TOO_LARGE',
|
|
});
|
|
recordFileOp(req.nodeId, 'upload', startedAt, false);
|
|
return res.status(413).json({ error: 'File exceeds 25 MB limit', code: 'TOO_LARGE' });
|
|
}
|
|
if (err) {
|
|
logFileOperation('warn', 'upload failed', {
|
|
nodeId: req.nodeId,
|
|
op: 'upload',
|
|
stack: req.params.stackName,
|
|
errorCode: 'MULTER_ERROR',
|
|
});
|
|
recordFileOp(req.nodeId, 'upload', startedAt, false);
|
|
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 overwrite = String(req.query.overwrite) === '1';
|
|
// The multer wrapper stashed the route-entry timestamp on the request so
|
|
// the success path and the rejection paths share one window. Fall back to
|
|
// Date.now() defensively in case the wrapper was bypassed in a test.
|
|
const startedAt = (req as UploadStartedReq)._fileUploadStartedAt ?? Date.now();
|
|
logFileDiag('upload start', { stackName, relPath: targetRelPath, nodeId: req.nodeId, size: req.file.size, overwrite });
|
|
try {
|
|
const existing = await FileSystemService.getInstance(req.nodeId).pathKind(stackName, targetRelPath);
|
|
if (existing === 'directory') {
|
|
// A directory can never be replaced by an upload; surface a distinct code
|
|
// so the UI does not offer a useless "Replace" button.
|
|
return rejectFileMutation(req, res, {
|
|
op: 'upload',
|
|
stack: stackName,
|
|
path: targetRelPath,
|
|
startedAt,
|
|
status: 409,
|
|
code: 'DIR_EXISTS',
|
|
body: {
|
|
error: `A folder named ${originalName} already exists in this folder. Rename the upload or remove the folder first.`,
|
|
},
|
|
});
|
|
}
|
|
if (existing === 'file' && !overwrite) {
|
|
// Real FS work ran (pathKind) and the operator was rejected; surface
|
|
// the conflict in metrics so a node with a hot overwrite-confirm
|
|
// pattern shows up in the snapshot rather than disappearing.
|
|
return rejectFileMutation(req, res, {
|
|
op: 'upload',
|
|
stack: stackName,
|
|
path: targetRelPath,
|
|
startedAt,
|
|
status: 409,
|
|
code: 'FILE_EXISTS',
|
|
body: {
|
|
error: `${originalName} already exists in this folder. Confirm to replace.`,
|
|
},
|
|
});
|
|
}
|
|
await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer);
|
|
logFileOperation('info', 'mutate', {
|
|
nodeId: req.nodeId,
|
|
op: 'upload',
|
|
stack: stackName,
|
|
path: targetRelPath,
|
|
bytes: req.file.size,
|
|
overwrite,
|
|
});
|
|
logFileDiag('upload timing', { stackName, relPath: targetRelPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
|
recordFileOp(req.nodeId, 'upload', startedAt, true);
|
|
FileExplorerMetricsService.getInstance().recordUploadBytes(req.nodeId, req.file.size);
|
|
return res.status(204).send();
|
|
} catch (err: unknown) {
|
|
logFileOperation('warn', 'upload failed', {
|
|
nodeId: req.nodeId,
|
|
op: 'upload',
|
|
stack: stackName,
|
|
path: targetRelPath,
|
|
errorCode: fsErrorCode(err),
|
|
});
|
|
recordFileOp(req.nodeId, 'upload', startedAt, false);
|
|
return sendFsError(res, err, 'Failed to upload file', { notFoundMessage: 'Target directory not found' });
|
|
}
|
|
},
|
|
);
|
|
|
|
stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response) => {
|
|
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 expectedMtimeMs = parseIfMatchMtime(req.header('if-match'));
|
|
const startedAt = Date.now();
|
|
logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8'), hasIfMatch: expectedMtimeMs !== null });
|
|
try {
|
|
const result = await FileSystemService.getInstance(req.nodeId).writeStackFileIfUnchanged(
|
|
stackName,
|
|
relPath,
|
|
content,
|
|
expectedMtimeMs,
|
|
);
|
|
if (!result.ok) {
|
|
// Stale ETag: surface the current content + mtime so the client can
|
|
// show a "file changed elsewhere" diff and let the user reconcile.
|
|
// Real FS work ran (the if-unchanged stat compare); record the
|
|
// attempted-and-rejected write so operators chasing concurrent-edit
|
|
// patterns can see them in the snapshot.
|
|
res.setHeader('ETag', stackFileEtag(result.currentMtimeMs));
|
|
return rejectFileMutation(req, res, {
|
|
op: 'write',
|
|
stack: stackName,
|
|
path: relPath,
|
|
startedAt,
|
|
status: 412,
|
|
code: 'PRECONDITION_FAILED',
|
|
body: {
|
|
error: 'File has been modified since you last read it. Reload to see the current version.',
|
|
currentMtimeMs: result.currentMtimeMs,
|
|
currentContent: result.currentContent,
|
|
},
|
|
});
|
|
}
|
|
res.setHeader('ETag', stackFileEtag(result.mtimeMs));
|
|
logFileOperation('info', 'mutate', {
|
|
nodeId: req.nodeId,
|
|
op: 'write',
|
|
stack: stackName,
|
|
path: relPath,
|
|
bytes: Buffer.byteLength(content, 'utf-8'),
|
|
});
|
|
logFileDiag('write timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
|
recordFileOp(req.nodeId, 'write', startedAt, true);
|
|
return res.status(204).send();
|
|
} catch (err: unknown) {
|
|
logFileOperation('warn', 'write failed', {
|
|
nodeId: req.nodeId,
|
|
op: 'write',
|
|
stack: stackName,
|
|
path: relPath,
|
|
errorCode: fsErrorCode(err),
|
|
});
|
|
recordFileOp(req.nodeId, 'write', startedAt, false);
|
|
return sendFsError(res, err, 'Failed to write file');
|
|
}
|
|
});
|
|
|
|
stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) => {
|
|
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', 'mutate', {
|
|
nodeId: req.nodeId,
|
|
op: 'delete',
|
|
stack: stackName,
|
|
path: relPath,
|
|
recursive,
|
|
});
|
|
logFileDiag('delete timing', { stackName, relPath, recursive, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
|
recordFileOp(req.nodeId, 'delete', startedAt, true);
|
|
return res.status(204).send();
|
|
} catch (err: unknown) {
|
|
logFileOperation('warn', 'delete failed', {
|
|
nodeId: req.nodeId,
|
|
op: 'delete',
|
|
stack: stackName,
|
|
path: relPath,
|
|
recursive,
|
|
errorCode: fsErrorCode(err),
|
|
});
|
|
recordFileOp(req.nodeId, 'delete', startedAt, false);
|
|
return sendFsError(res, err, 'Failed to delete path');
|
|
}
|
|
});
|
|
|
|
stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response) => {
|
|
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', 'mutate', {
|
|
nodeId: req.nodeId,
|
|
op: 'mkdir',
|
|
stack: stackName,
|
|
path: relPath,
|
|
});
|
|
logFileDiag('mkdir timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
|
recordFileOp(req.nodeId, 'mkdir', startedAt, true);
|
|
return res.status(204).send();
|
|
} catch (err: unknown) {
|
|
logFileOperation('warn', 'mkdir failed', {
|
|
nodeId: req.nodeId,
|
|
op: 'mkdir',
|
|
stack: stackName,
|
|
path: relPath,
|
|
errorCode: fsErrorCode(err),
|
|
});
|
|
recordFileOp(req.nodeId, 'mkdir', startedAt, false);
|
|
return sendFsError(res, err, 'Failed to create folder');
|
|
}
|
|
});
|
|
|
|
stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Response) => {
|
|
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', 'mutate', {
|
|
nodeId: req.nodeId,
|
|
op: 'rename',
|
|
stack: stackName,
|
|
path: from,
|
|
toPath: to,
|
|
});
|
|
logFileDiag('rename timing', { stackName, from, to, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
|
recordFileOp(req.nodeId, 'rename', startedAt, true);
|
|
return res.status(204).send();
|
|
} catch (err: unknown) {
|
|
logFileOperation('warn', 'rename failed', {
|
|
nodeId: req.nodeId,
|
|
op: 'rename',
|
|
stack: stackName,
|
|
path: from,
|
|
toPath: to,
|
|
errorCode: fsErrorCode(err),
|
|
});
|
|
recordFileOp(req.nodeId, 'rename', startedAt, false);
|
|
return sendFsError(res, err, 'Failed to rename');
|
|
}
|
|
});
|
|
|
|
stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Response) => {
|
|
const stackName = req.params.stackName as string;
|
|
if (!requirePermission(req, res, 'stack:read', '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 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 });
|
|
recordFileOp(req.nodeId, 'permissionsRead', startedAt, true);
|
|
return res.json(result);
|
|
} catch (err: unknown) {
|
|
logFileOperation('warn', 'permissions read failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
|
recordFileOp(req.nodeId, 'permissionsRead', startedAt, false);
|
|
return sendFsError(res, err, 'Failed to read permissions');
|
|
}
|
|
});
|
|
|
|
stacksRouter.put('/:stackName/files/permissions', async (req: Request, res: Response) => {
|
|
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', 'mutate', {
|
|
nodeId: req.nodeId,
|
|
op: 'chmod',
|
|
stack: stackName,
|
|
path: relPath,
|
|
mode: mode.toString(8).padStart(3, '0'),
|
|
});
|
|
logFileDiag('chmod timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
|
recordFileOp(req.nodeId, 'chmod', startedAt, true);
|
|
return res.status(204).send();
|
|
} catch (err: unknown) {
|
|
logFileOperation('warn', 'chmod failed', {
|
|
nodeId: req.nodeId,
|
|
op: 'chmod',
|
|
stack: stackName,
|
|
path: relPath,
|
|
mode: mode.toString(8).padStart(3, '0'),
|
|
errorCode: fsErrorCode(err),
|
|
});
|
|
recordFileOp(req.nodeId, 'chmod', startedAt, false);
|
|
return sendFsError(res, err, 'Failed to set permissions');
|
|
}
|
|
});
|
|
|