Files
sencho/backend/src/routes/templates.ts
T
Anso 35bb74425b feat: guide missing external network creation during deploy (#1645)
* feat: guide missing external network creation during deploy

Detect missing external networks before Compose runs, prompt or auto-create safe bridge networks, and keep unsupported declarations blocked with trusted deploy provenance.

* test: align deploy context and settings fixtures with missing-network gate

Update caller spies, EffResource expectations, StacksSection save keys, and git-source spy cleanup so CI matches the new deployStack context and auto-create setting.

* fix: drop unused renderError binding in missing-network resolver

Satisfies no-unused-vars so backend ESLint CI passes; callers already key only on model presence.

* fix: use HTTP-safe clipboard helper in missing-network dialog

navigator.clipboard fails on plain HTTP LAN hosts; route copy actions through copyToClipboard so Docker and Compose copy buttons work on self-hosted instances.

* fix: simplify missing-network dialog actions and copy label

Drop the Compose snippet escape hatch, move secondary actions under More, and rename the terminal copy action to Copy create command so the footer is a clear Cancel / Create decision.
2026-07-17 14:51:00 -04:00

193 lines
7.6 KiB
TypeScript

import { Router, type Request, type Response } from 'express';
import path from 'path';
import { promises as fsPromises } from 'fs';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { templateService } from '../services/TemplateService';
import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import { DatabaseService } from '../services/DatabaseService';
import { ErrorParser } from '../utils/ErrorParser';
import { isValidStackName, isPathWithinBase } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
export const templatesRouter = Router();
templatesRouter.get('/', authMiddleware, async (req: Request, res: Response) => {
try {
const templates = await templateService.getTemplates();
const imageRefs = templates.map(t => t.image).filter((i): i is string => !!i);
const scanSummary = DatabaseService.getInstance().getLatestScanSummaryByImageRefs(req.nodeId, imageRefs);
const topCandidates = templates
.map((t, i) => ({ t, i }))
.filter(({ t }) => (t.stars ?? 0) > 0)
.sort((a, b) => (b.t.stars ?? 0) - (a.t.stars ?? 0))
.slice(0, 5);
const weekIndex = Math.floor(Date.now() / (7 * 86_400_000));
const featuredIndex = topCandidates.length > 0
? topCandidates[weekIndex % topCandidates.length].i
: -1;
const enriched = templates.map((t, i) => {
const summary = t.image ? scanSummary.get(t.image) : undefined;
const scan_status: 'clean' | 'vulnerable' | 'unscanned' = summary
? (summary.total === 0 ? 'clean' : 'vulnerable')
: 'unscanned';
return {
...t,
scan_status,
scan_cve_count: summary?.total ?? 0,
scan_critical_count: summary?.critical ?? 0,
scan_high_count: summary?.high ?? 0,
featured: i === featuredIndex,
};
});
res.json(enriched);
} catch (error) {
console.error('[Templates] Failed to fetch:', error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to fetch templates') });
}
});
templatesRouter.post('/refresh-cache', authMiddleware, (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
templateService.clearCache();
console.log('[Templates] Cache cleared by', req.user?.username || 'unknown');
res.json({ success: true });
});
templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:create')) return;
try {
const { stackName, template, envVars, skip_scan } = req.body;
if (!stackName || !template) {
return res.status(400).json({ error: 'stackName and template are required' });
}
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' });
}
const fsService = FileSystemService.getInstance(req.nodeId);
const baseDir = fsService.getBaseDir();
const stackPath = path.join(baseDir, stackName);
if (!isPathWithinBase(stackPath, baseDir)) {
return res.status(400).json({ error: 'Invalid stack path' });
}
try {
await fsPromises.access(stackPath);
if (await fsService.hasComposeFile(stackPath)) {
return res.status(409).json({
error: `A stack directory named '${stackName}' already exists. Please choose a different Stack Name.`,
rolledBack: false
});
}
console.log(`[Templates] Cleaned up orphaned stack directory: ${stackName}`);
await fsService.deleteStack(stackName);
} catch {
// Directory does not exist; proceed with deploy
}
const debug = isDebugEnabled();
const deployStartedAt = Date.now();
console.log(`[Templates] Deploy started: ${stackName}`);
if (debug) console.debug('[Templates:debug] Deploy payload', {
stackName,
// title/source are registry/client-supplied; bound the title so a long
// value cannot bloat the log line. No env values or YAML are logged.
templateTitle: String(template.title ?? '').slice(0, 80),
source: String(template.source ?? '').slice(0, 40),
portCount: Array.isArray(template.ports) ? template.ports.length : 0,
volumeCount: Array.isArray(template.volumes) ? template.volumes.length : 0,
envVarCount: envVars ? Object.keys(envVars).length : 0,
});
await fsService.createStack(stackName);
const composeYaml = templateService.generateComposeFromTemplate(template, stackName);
await fsService.saveStackContent(stackName, composeYaml);
if (envVars && Object.keys(envVars).length > 0) {
const envString = templateService.generateEnvString(envVars);
const defaultEnvPath = path.join(stackPath, '.env');
await fsPromises.writeFile(defaultEnvPath, envString, 'utf-8');
}
try {
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) {
try {
await fsService.deleteStack(stackName);
} catch (cleanupErr) {
console.error(`[Templates] Cleanup after policy block failed for ${stackName}:`, cleanupErr);
}
return;
}
const atomic = true;
await ComposeService.getInstance(req.nodeId).deployStack(
stackName,
getTerminalWs(req.get(DEPLOY_SESSION_HEADER)),
atomic,
req.deployContext ?? { source: 'template', actor: req.user?.username ?? null },
);
invalidateNodeCaches(req.nodeId);
console.log(`[Templates] Deploy completed: ${stackName}`);
if (debug) console.debug(`[Templates:debug] Deploy timing: ${stackName} took ${Date.now() - deployStartedAt}ms`);
res.json({ success: true, message: 'Template deployed successfully' });
if (!skip_scan) {
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
);
}
} catch (deployError: unknown) {
const rawError = getErrorMessage(deployError, String(deployError));
console.error(`[Templates] Deploy failed: ${stackName} -`, rawError);
const parsed = ErrorParser.parse(rawError);
const shouldRollback = parsed.rule ? parsed.rule.canSilentlyRollback : true;
let rolledBack = false;
if (shouldRollback) {
let dockerDownCompleted = true;
let fileDeleteCompleted = true;
try {
await ComposeService.getInstance(req.nodeId).downStack(stackName);
} catch (downErr) {
dockerDownCompleted = false;
console.error("[Templates] Rollback Stage 1 (Docker down) failed:", downErr);
}
try {
await fsService.deleteStack(stackName);
} catch (fsErr) {
fileDeleteCompleted = false;
console.error("[Templates] Rollback Stage 2 (File deletion) failed:", fsErr);
}
rolledBack = dockerDownCompleted && fileDeleteCompleted;
}
invalidateNodeCaches(req.nodeId);
res.status(500).json({
error: parsed.message,
rolledBack,
ruleId: parsed.rule?.id || 'UNKNOWN'
});
}
} catch (error: unknown) {
const message = getErrorMessage(error, 'Failed to deploy template');
console.error('[Templates] Deploy error:', message);
res.status(500).json({ error: message });
}
});