mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 05:58:37 +00:00
96c5f05cdc
* fix(app-store): harden template deploy, registry fetch, and catalogue refresh Serialize generated compose through the YAML emitter so registry-supplied values are escaped correctly instead of interpolated into hand-built lines. Cap the registry response size so an oversized or runaway catalogue cannot exhaust backend memory, and surface the fetch failure to the caller. Reload the catalogue when the active node changes, since the registry is node-scoped. Add developer-mode deploy diagnostics (counts only, no values) and extend the unit tests with YAML round-trip, LinuxServer.io mapping, and size-cap coverage. * fix(app-store): reset node-scoped catalogue state on fetch and bound deploy diagnostics Clear the templates list and Trivy availability at the start of each catalogue load so a failed fetch after a node switch shows the new node's empty state instead of the previous node's catalogue or scan toggle. Bound the developer-mode diagnostic template title/source length, and document that the registry cache serves the last-known-good catalogue on a transient fetch failure (the size cap still protects memory in every case).
188 lines
7.5 KiB
TypeScript
188 lines
7.5 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 { effectiveTier, 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 = effectiveTier(req) === 'paid';
|
|
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
|
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 });
|
|
}
|
|
});
|