Refactor apps routes: explicit dependency injection

- Updated all apps route modules to use destructured dependencies
- Added JSDoc comments for factory functions
- Replaced ctx. references with direct parameter access
- Updated apps/index.js to extract and pass explicit dependencies
- All files pass syntax validation

Files refactored:
- routes/apps/deploy.js (18k lines)
- routes/apps/helpers.js (17k lines)
- routes/apps/removal.js
- routes/apps/restore.js
- routes/apps/templates.js
- routes/apps/index.js (orchestrator)
This commit is contained in:
Krystie
2026-03-29 21:36:15 -07:00
parent 6bde2eb62e
commit a4788c3f28
6 changed files with 229 additions and 153 deletions

View File

@@ -7,29 +7,43 @@ const { isValidPort } = require('../../input-validator');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const platformPaths = require('../../platform-paths'); const platformPaths = require('../../platform-paths');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/**
* Apps deployment routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.docker - Docker client wrapper
* @param {Object} deps.caddy - Caddy client
* @param {Object} deps.credentialManager - Credential manager
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Object} deps.portLockManager - Port lock manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Apps helpers module
* @returns {express.Router}
*/
module.exports = function(ctx, helpers) { module.exports = function({ docker, caddy, credentialManager, servicesStateManager, portLockManager, asyncHandler, errorResponse, log, helpers }) {
const router = express.Router(); const router = express.Router();
async function deployDashCAStaticSite(template, userConfig) { async function deployDashCAStaticSite(template, userConfig) {
const destPath = platformPaths.caCertDir; const destPath = platformPaths.caCertDir;
try { try {
ctx.log.info('deploy', 'DashCA: Starting static site deployment'); log.info('deploy', 'DashCA: Starting static site deployment');
if (!await exists(destPath)) { if (!await exists(destPath)) {
await fsp.mkdir(destPath, { recursive: true }); await fsp.mkdir(destPath, { recursive: true });
ctx.log.info('deploy', 'DashCA: Created destination directory', { path: destPath }); log.info('deploy', 'DashCA: Created destination directory', { path: destPath });
} }
ctx.log.info('deploy', 'DashCA: Verifying certificate files'); log.info('deploy', 'DashCA: Verifying certificate files');
const rootCertExists = await exists(`${destPath}/root.crt`); const rootCertExists = await exists(`${destPath}/root.crt`);
const intermediateCertExists = await exists(`${destPath}/intermediate.crt`); const intermediateCertExists = await exists(`${destPath}/intermediate.crt`);
if (rootCertExists) ctx.log.info('deploy', 'DashCA: Root certificate found'); if (rootCertExists) log.info('deploy', 'DashCA: Root certificate found');
else ctx.log.warn('deploy', 'DashCA: Root certificate not found', { expected: path.join(destPath, 'root.crt') }); else log.warn('deploy', 'DashCA: Root certificate not found', { expected: path.join(destPath, 'root.crt') });
if (intermediateCertExists) ctx.log.info('deploy', 'DashCA: Intermediate certificate found'); if (intermediateCertExists) log.info('deploy', 'DashCA: Intermediate certificate found');
const indexPath = path.join(destPath, 'index.html'); const indexPath = path.join(destPath, 'index.html');
if (!await exists(indexPath)) { if (!await exists(indexPath)) {
ctx.log.info('deploy', 'DashCA: Creating minimal landing page'); log.info('deploy', 'DashCA: Creating minimal landing page');
const minimalHtml = `<!DOCTYPE html> const minimalHtml = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -58,15 +72,15 @@ module.exports = function(ctx, helpers) {
</body> </body>
</html>`; </html>`;
await fsp.writeFile(indexPath, minimalHtml); await fsp.writeFile(indexPath, minimalHtml);
ctx.log.info('deploy', 'DashCA: Created minimal landing page'); log.info('deploy', 'DashCA: Created minimal landing page');
} else { } else {
ctx.log.info('deploy', 'DashCA: Using existing index.html'); log.info('deploy', 'DashCA: Using existing index.html');
} }
ctx.log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath); log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath);
ctx.log.info('deploy', 'DashCA: Static site deployment completed successfully'); log.info('deploy', 'DashCA: Static site deployment completed successfully');
} catch (error) { } catch (error) {
ctx.log.error('deploy', 'DashCA deployment error', { error: error.message }); log.error('deploy', 'DashCA deployment error', { error: error.message });
throw new Error(`DashCA deployment failed: ${error.message}`); throw new Error(`DashCA deployment failed: ${error.message}`);
} }
} }
@@ -82,9 +96,9 @@ module.exports = function(ctx, helpers) {
let lockId = null; let lockId = null;
try { try {
ctx.log.info('deploy', 'Acquiring port locks', { ports: requestedPorts }); log.info('deploy', 'Acquiring port locks', { ports: requestedPorts });
lockId = await ctx.portLockManager.acquirePorts(requestedPorts); lockId = await portLockManager.acquirePorts(requestedPorts);
ctx.log.info('deploy', 'Port locks acquired', { lockId }); log.info('deploy', 'Port locks acquired', { lockId });
} catch (lockError) { } catch (lockError) {
throw new Error(`Failed to acquire port locks: ${lockError.message}`); throw new Error(`Failed to acquire port locks: ${lockError.message}`);
} }
@@ -92,9 +106,9 @@ module.exports = function(ctx, helpers) {
try { try {
// Remove stale container with same name // Remove stale container with same name
try { try {
const existingContainer = ctx.docker.client.getContainer(containerName); const existingContainer = docker.client.getContainer(containerName);
const info = await existingContainer.inspect(); const info = await existingContainer.inspect();
ctx.log.info('docker', 'Removing stale container', { containerName, status: info.State.Status }); log.info('docker', 'Removing stale container', { containerName, status: info.State.Status });
await existingContainer.remove({ force: true }); await existingContainer.remove({ force: true });
await new Promise(r => setTimeout(r, 2000)); await new Promise(r => setTimeout(r, 2000));
} catch (e) { } catch (e) {
@@ -144,43 +158,43 @@ module.exports = function(ctx, helpers) {
} }
try { try {
ctx.log.info('docker', 'Pulling image', { image: processedTemplate.docker.image }); log.info('docker', 'Pulling image', { image: processedTemplate.docker.image });
await ctx.docker.pull(processedTemplate.docker.image); await docker.pull(processedTemplate.docker.image);
ctx.log.info('docker', 'Image pulled successfully', { image: processedTemplate.docker.image }); log.info('docker', 'Image pulled successfully', { image: processedTemplate.docker.image });
} catch (e) { } catch (e) {
ctx.log.warn('docker', 'Image pull failed, checking if local image exists', { image: processedTemplate.docker.image, error: e.message }); log.warn('docker', 'Image pull failed, checking if local image exists', { image: processedTemplate.docker.image, error: e.message });
try { try {
const images = await ctx.docker.client.listImages({ filters: { reference: [processedTemplate.docker.image] } }); const images = await docker.client.listImages({ filters: { reference: [processedTemplate.docker.image] } });
if (images.length === 0) throw new Error(`[DC-201] Image ${processedTemplate.docker.image} not found locally and pull failed: ${e.message}`); if (images.length === 0) throw new Error(`[DC-201] Image ${processedTemplate.docker.image} not found locally and pull failed: ${e.message}`);
ctx.log.info('docker', 'Using existing local image', { image: processedTemplate.docker.image }); log.info('docker', 'Using existing local image', { image: processedTemplate.docker.image });
} catch (listError) { } catch (listError) {
throw new Error(`[DC-201] Failed to pull or find image ${processedTemplate.docker.image}: ${e.message}`); throw new Error(`[DC-201] Failed to pull or find image ${processedTemplate.docker.image}: ${e.message}`);
} }
} }
const container = await ctx.docker.client.createContainer(containerConfig); const container = await docker.client.createContainer(containerConfig);
await container.start(); await container.start();
// Prune dangling images to prevent disk bloat // Prune dangling images to prevent disk bloat
try { try {
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) { if (pruneResult.SpaceReclaimed > 0) {
ctx.log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
} }
} catch (pruneErr) { } catch (pruneErr) {
ctx.log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message }); log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message });
} }
await ctx.portLockManager.releasePorts(lockId); await portLockManager.releasePorts(lockId);
ctx.log.info('deploy', 'Port locks released', { lockId }); log.info('deploy', 'Port locks released', { lockId });
return container.id; return container.id;
} catch (deployError) { } catch (deployError) {
if (lockId) { if (lockId) {
try { try {
await ctx.portLockManager.releasePorts(lockId); await portLockManager.releasePorts(lockId);
ctx.log.info('deploy', 'Port locks released after error', { lockId }); log.info('deploy', 'Port locks released after error', { lockId });
} catch (releaseError) { } catch (releaseError) {
ctx.log.error('deploy', 'Failed to release port locks', { lockId, error: releaseError.message }); log.error('deploy', 'Failed to release port locks', { lockId, error: releaseError.message });
} }
} }
throw deployError; throw deployError;
@@ -188,7 +202,7 @@ module.exports = function(ctx, helpers) {
} }
// Check for existing container before deployment // Check for existing container before deployment
router.post('/apps/check-existing', ctx.asyncHandler(async (req, res) => { router.post('/apps/check-existing', asyncHandler(async (req, res) => {
const { appId } = req.body; const { appId } = req.body;
const template = ctx.APP_TEMPLATES[appId]; const template = ctx.APP_TEMPLATES[appId];
if (!template) throw new ValidationError('Invalid app template'); if (!template) throw new ValidationError('Invalid app template');
@@ -201,7 +215,7 @@ module.exports = function(ctx, helpers) {
}, 'check-existing')); }, 'check-existing'));
// Deploy new app // Deploy new app
router.post('/apps/deploy', ctx.asyncHandler(async (req, res) => { router.post('/apps/deploy', asyncHandler(async (req, res) => {
const { appId, config } = req.body; const { appId, config } = req.body;
if (!appId || typeof appId !== 'string') { if (!appId || typeof appId !== 'string') {
throw new ValidationError('appId is required'); throw new ValidationError('appId is required');
@@ -213,10 +227,10 @@ module.exports = function(ctx, helpers) {
throw new ValidationError('config.subdomain is required'); throw new ValidationError('config.subdomain is required');
} }
try { try {
ctx.log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain }); log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain });
const template = ctx.APP_TEMPLATES[appId]; const template = ctx.APP_TEMPLATES[appId];
if (!template) { if (!template) {
await ctx.logError('app-deploy', new Error('Invalid app template'), { appId, config }); await logError('app-deploy', new Error('Invalid app template'), { appId, config });
throw new ValidationError('Invalid app template'); throw new ValidationError('Invalid app template');
} }
@@ -226,7 +240,7 @@ module.exports = function(ctx, helpers) {
} }
// Block reserved path names in subdirectory mode // Block reserved path names in subdirectory mode
if (ctx.siteConfig.routingMode === 'subdirectory' && helpers.RESERVED_SUBPATHS.includes(config.subdomain)) { if (ctx.siteConfig.routingMode === 'subdirectory' && helpers.RESERVED_SUBPATHS.includes(config.subdomain)) {
return ctx.errorResponse(res, 400, `[DC-301] "${config.subdomain}" is a reserved path and cannot be used in subdirectory mode`); return errorResponse(res, 400, `[DC-301] "${config.subdomain}" is a reserved path and cannot be used in subdirectory mode`);
} }
} }
if (config.port && !isValidPort(config.port)) { if (config.port && !isValidPort(config.port)) {
@@ -236,7 +250,7 @@ module.exports = function(ctx, helpers) {
if (!template.isStaticSite) { if (!template.isStaticSite) {
const allowedHostnames = ['localhost', 'host.docker.internal']; const allowedHostnames = ['localhost', 'host.docker.internal'];
if (config.ip && !validatorLib.isIP(config.ip) && !allowedHostnames.includes(config.ip)) { if (config.ip && !validatorLib.isIP(config.ip) && !allowedHostnames.includes(config.ip)) {
return ctx.errorResponse(res, 400, '[DC-210] Invalid IP address. Use a valid IP (e.g., 192.168.x.x) or "localhost".'); return errorResponse(res, 400, '[DC-210] Invalid IP address. Use a valid IP (e.g., 192.168.x.x) or "localhost".');
} }
if (!config.ip) config.ip = ctx.siteConfig.dnsServerIp || 'localhost'; if (!config.ip) config.ip = ctx.siteConfig.dnsServerIp || 'localhost';
} else { } else {
@@ -248,24 +262,24 @@ module.exports = function(ctx, helpers) {
let usedExisting = false; let usedExisting = false;
if (template.isStaticSite) { if (template.isStaticSite) {
ctx.log.info('deploy', 'Deploying static site', { appId }); log.info('deploy', 'Deploying static site', { appId });
if (appId === 'dashca') { if (appId === 'dashca') {
await deployDashCAStaticSite(template, config); await deployDashCAStaticSite(template, config);
containerId = null; containerId = null;
ctx.log.info('deploy', 'Static site deployed', { appId }); log.info('deploy', 'Static site deployed', { appId });
} else { } else {
throw new Error(`Unknown static site type: ${appId}`); throw new Error(`Unknown static site type: ${appId}`);
} }
} else if (config.useExisting && config.existingContainerId) { } else if (config.useExisting && config.existingContainerId) {
containerId = config.existingContainerId; containerId = config.existingContainerId;
usedExisting = true; usedExisting = true;
ctx.log.info('deploy', 'Using existing container', { containerId }); log.info('deploy', 'Using existing container', { containerId });
if (config.existingPort && !config.port) config.port = config.existingPort; if (config.existingPort && !config.port) config.port = config.existingPort;
} else { } else {
containerId = await deployContainer(appId, config, template); containerId = await deployContainer(appId, config, template);
ctx.log.info('deploy', 'Container deployed', { containerId }); log.info('deploy', 'Container deployed', { containerId });
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort); await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort);
ctx.log.info('deploy', 'Container is healthy', { containerId }); log.info('deploy', 'Container is healthy', { containerId });
} }
const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain; const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain;
@@ -275,11 +289,11 @@ module.exports = function(ctx, helpers) {
if (config.createDns && !isSubdirectoryMode) { if (config.createDns && !isSubdirectoryMode) {
try { try {
await ctx.dns.createRecord(config.subdomain, config.ip); await ctx.dns.createRecord(config.subdomain, config.ip);
ctx.log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip }); log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip });
} catch (dnsError) { } catch (dnsError) {
await ctx.logError('app-deploy-dns', dnsError, { appId, subdomain: config.subdomain, ip: config.ip }); await logError('app-deploy-dns', dnsError, { appId, subdomain: config.subdomain, ip: config.ip });
dnsWarning = `DNS creation failed: ${dnsError.message}. You may need to create the DNS record manually.`; dnsWarning = `DNS creation failed: ${dnsError.message}. You may need to create the DNS record manually.`;
ctx.log.warn('deploy', 'DNS creation failed during deploy', { error: dnsError.message }); log.warn('deploy', 'DNS creation failed during deploy', { error: dnsError.message });
} }
} }
@@ -298,7 +312,7 @@ module.exports = function(ctx, helpers) {
} }
caddyConfig = helpers.generateStaticSiteConfig(config.subdomain, sitePath, caddyOptions); caddyConfig = helpers.generateStaticSiteConfig(config.subdomain, sitePath, caddyOptions);
} else { } else {
caddyConfig = ctx.caddy.generateConfig(config.subdomain, config.ip, config.port || template.defaultPort, caddyOptions); caddyConfig = caddy.generateConfig(config.subdomain, config.ip, config.port || template.defaultPort, caddyOptions);
} }
// Write Caddy config (subdirectory: inject into main block; subdomain: append as new block) // Write Caddy config (subdirectory: inject into main block; subdomain: append as new block)
@@ -308,7 +322,7 @@ module.exports = function(ctx, helpers) {
} else { } else {
await helpers.addCaddyConfig(config.subdomain, caddyConfig); await helpers.addCaddyConfig(config.subdomain, caddyConfig);
} }
ctx.log.info('deploy', 'Caddy config added', { domain: ctx.buildDomain(config.subdomain), routingMode: ctx.siteConfig.routingMode, tailscaleOnly: config.tailscaleOnly || false }); log.info('deploy', 'Caddy config added', { domain: ctx.buildDomain(config.subdomain), routingMode: ctx.siteConfig.routingMode, tailscaleOnly: config.tailscaleOnly || false });
// Build service URL based on routing mode // Build service URL based on routing mode
const serviceUrl = ctx.buildServiceUrl(config.subdomain); const serviceUrl = ctx.buildServiceUrl(config.subdomain);
@@ -361,7 +375,7 @@ module.exports = function(ctx, helpers) {
deployedAt: new Date().toISOString(), deployedAt: new Date().toISOString(),
deploymentManifest deploymentManifest
}); });
ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain }); log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain });
const response = { const response = {
success: true, containerId, usedExisting, success: true, containerId, usedExisting,
@@ -378,11 +392,11 @@ module.exports = function(ctx, helpers) {
res.json(response); res.json(response);
} catch (error) { } catch (error) {
await ctx.logError('app-deploy', error, { appId, config }); await logError('app-deploy', error, { appId, config });
ctx.log.error('deploy', 'Deployment failed', { appId, error: error.message }); log.error('deploy', 'Deployment failed', { appId, error: error.message });
const template = ctx.APP_TEMPLATES[appId]; const template = ctx.APP_TEMPLATES[appId];
ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error'); ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error');
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error)); errorResponse(res, 500, ctx.safeErrorMessage(error));
} }
}, 'apps-deploy')); }, 'apps-deploy'));

View File

@@ -6,12 +6,23 @@ const { REGEX, DOCKER } = require('../../constants');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const platformPaths = require('../../platform-paths'); const platformPaths = require('../../platform-paths');
module.exports = function(ctx) { /**
* Apps helpers factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.docker - Docker client wrapper
* @param {Object} deps.caddy - Caddy client
* @param {Object} deps.credentialManager - Credential manager
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.fetchT - Timeout-wrapped fetch
* @param {Object} deps.log - Logger instance
* @returns {Object} Helper functions
*/
module.exports = function({ docker, caddy, credentialManager, servicesStateManager, fetchT, log }) {
async function checkPortConflicts(ports, excludeContainerName = null) { async function checkPortConflicts(ports, excludeContainerName = null) {
const conflicts = []; const conflicts = [];
try { try {
const containers = await ctx.docker.client.listContainers({ all: true }); const containers = await docker.client.listContainers({ all: true });
for (const container of containers) { for (const container of containers) {
if (excludeContainerName && container.Names.some(n => n === `/${excludeContainerName}`)) continue; if (excludeContainerName && container.Names.some(n => n === `/${excludeContainerName}`)) continue;
if (container.State !== 'running') continue; if (container.State !== 'running') continue;
@@ -27,14 +38,14 @@ module.exports = function(ctx) {
} }
} }
} catch (e) { } catch (e) {
ctx.log.warn('docker', 'Could not check port conflicts', { error: e.message }); log.warn('docker', 'Could not check port conflicts', { error: e.message });
} }
return conflicts; return conflicts;
} }
async function findExistingContainerByImage(template) { async function findExistingContainerByImage(template) {
try { try {
const containers = await ctx.docker.client.listContainers({ all: false }); const containers = await docker.client.listContainers({ all: false });
const templateImage = template.docker.image.split(':')[0]; const templateImage = template.docker.image.split(':')[0];
for (const container of containers) { for (const container of containers) {
const containerImage = container.Image.split(':')[0]; const containerImage = container.Image.split(':')[0];
@@ -53,7 +64,7 @@ module.exports = function(ctx) {
} }
return null; return null;
} catch (e) { } catch (e) {
ctx.log.warn('docker', 'Could not check for existing containers', { error: e.message }); log.warn('docker', 'Could not check for existing containers', { error: e.message });
return null; return null;
} }
} }
@@ -140,7 +151,7 @@ module.exports = function(ctx) {
normalizedHost === root || normalizedHost.startsWith(root + path.sep) normalizedHost === root || normalizedHost.startsWith(root + path.sep)
); );
if (!isAllowed) { if (!isAllowed) {
ctx.log.warn('deploy', 'Custom volume host path rejected', { hostPath: override.hostPath, allowed: allowedRoots }); log.warn('deploy', 'Custom volume host path rejected', { hostPath: override.hostPath, allowed: allowedRoots });
return vol; // Keep original volume, don't apply unsafe override return vol; // Keep original volume, don't apply unsafe override
} }
return `${toDockerDesktopPath(override.hostPath)}:${containerPath}`; return `${toDockerDesktopPath(override.hostPath)}:${containerPath}`;
@@ -243,39 +254,39 @@ module.exports = function(ctx) {
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
try { try {
const container = ctx.docker.client.getContainer(containerId); const container = docker.client.getContainer(containerId);
const info = await container.inspect(); const info = await container.inspect();
if (info.State.Running) { if (info.State.Running) {
if (info.State.Health) { if (info.State.Health) {
if (info.State.Health.Status === 'healthy') { if (info.State.Health.Status === 'healthy') {
ctx.log.info('docker', 'Container is healthy (Docker health check)', { containerId }); log.info('docker', 'Container is healthy (Docker health check)', { containerId });
return true; return true;
} }
} else if (healthPath && port && httpCheckFailed < 5) { } else if (healthPath && port && httpCheckFailed < 5) {
try { try {
const response = await ctx.fetchT(`http://localhost:${port}${healthPath}`, { const response = await fetchT(`http://localhost:${port}${healthPath}`, {
signal: AbortSignal.timeout(3000), redirect: 'manual' signal: AbortSignal.timeout(3000), redirect: 'manual'
}); });
if (response.ok || (response.status >= 300 && response.status < 400)) { if (response.ok || (response.status >= 300 && response.status < 400)) {
ctx.log.info('docker', 'Health check passed', { containerId, status: response.status }); log.info('docker', 'Health check passed', { containerId, status: response.status });
return true; return true;
} }
} catch (e) { } catch (e) {
httpCheckFailed++; httpCheckFailed++;
ctx.log.debug('docker', 'HTTP health check failed', { attempt: httpCheckFailed, error: e.message }); log.debug('docker', 'HTTP health check failed', { attempt: httpCheckFailed, error: e.message });
} }
} else { } else {
if (i >= 5) { if (i >= 5) {
ctx.log.info('docker', 'Container is running', { containerId, waitedSeconds: i * delay / 1000 }); log.info('docker', 'Container is running', { containerId, waitedSeconds: i * delay / 1000 });
return true; return true;
} }
} }
} }
} catch (e) { } catch (e) {
ctx.log.debug('docker', 'Health check attempt failed', { attempt: i + 1, error: e.message }); log.debug('docker', 'Health check attempt failed', { attempt: i + 1, error: e.message });
} }
if (i < maxAttempts - 1) { if (i < maxAttempts - 1) {
ctx.log.debug('docker', 'Waiting for container to be healthy', { attempt: i + 1, maxAttempts }); log.debug('docker', 'Waiting for container to be healthy', { attempt: i + 1, maxAttempts });
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
} }
} }
@@ -284,15 +295,15 @@ module.exports = function(ctx) {
async function addCaddyConfig(subdomain, config) { async function addCaddyConfig(subdomain, config) {
const domain = ctx.buildDomain(subdomain); const domain = ctx.buildDomain(subdomain);
const existing = await ctx.caddy.read(); const existing = await caddy.read();
if (existing.includes(`${domain} {`)) { if (existing.includes(`${domain} {`)) {
ctx.log.info('caddy', 'Caddy config already exists, skipping add', { domain }); log.info('caddy', 'Caddy config already exists, skipping add', { domain });
await ctx.caddy.reload(existing); await caddy.reload(existing);
return; return;
} }
const result = await ctx.caddy.modify(c => c + `\n${config}\n`); const result = await caddy.modify(c => c + `\n${config}\n`);
if (!result.success) throw new Error(`[DC-303] Failed to add Caddy config for ${domain}: ${result.error}`); if (!result.success) throw new Error(`[DC-303] Failed to add Caddy config for ${domain}: ${result.error}`);
await ctx.caddy.verifySite(domain); await caddy.verifySite(domain);
} }
// Reserved paths that cannot be used as subpath names in subdirectory mode // Reserved paths that cannot be used as subpath names in subdirectory mode
@@ -303,7 +314,7 @@ module.exports = function(ctx) {
async function ensureMainDomainBlock() { async function ensureMainDomainBlock() {
if (ctx.siteConfig.routingMode !== 'subdirectory' || !ctx.siteConfig.domain) return; if (ctx.siteConfig.routingMode !== 'subdirectory' || !ctx.siteConfig.domain) return;
const content = await ctx.caddy.read(); const content = await caddy.read();
const domain = ctx.siteConfig.domain; const domain = ctx.siteConfig.domain;
const ROUTE_MARKER = '# === DashCaddy App Routes ==='; const ROUTE_MARKER = '# === DashCaddy App Routes ===';
@@ -312,7 +323,7 @@ module.exports = function(ctx) {
// Domain block exists but lacks markers — inject them // Domain block exists but lacks markers — inject them
if (content.includes(`${domain} {`)) { if (content.includes(`${domain} {`)) {
const result = await ctx.caddy.modify(c => { const result = await caddy.modify(c => {
// Insert markers before the final catch-all handle block inside the domain block // Insert markers before the final catch-all handle block inside the domain block
const domainStart = c.indexOf(`${domain} {`); const domainStart = c.indexOf(`${domain} {`);
// Find standalone "handle {" (catch-all SPA fallback) — match tabs or spaces // Find standalone "handle {" (catch-all SPA fallback) — match tabs or spaces
@@ -325,7 +336,7 @@ module.exports = function(ctx) {
return c.slice(0, handleIdx) + markerBlock + c.slice(handleIdx); return c.slice(0, handleIdx) + markerBlock + c.slice(handleIdx);
}); });
if (result.success) { if (result.success) {
ctx.log.info('caddy', 'Injected route markers into existing domain block', { domain }); log.info('caddy', 'Injected route markers into existing domain block', { domain });
} }
return; return;
} }
@@ -335,9 +346,9 @@ module.exports = function(ctx) {
const apiPort = process.env.PORT || 3001; const apiPort = process.env.PORT || 3001;
const block = `\n${domain} {\n root * ${dashboardRoot}\n encode gzip\n\n handle /api/* {\n reverse_proxy localhost:${apiPort}\n }\n\n handle /probe/* {\n reverse_proxy localhost:${apiPort}\n }\n\n ${ROUTE_MARKER}\n # === End App Routes ===\n\n handle {\n @notFile not file {path}\n rewrite @notFile /index.html\n file_server\n }\n}\n`; const block = `\n${domain} {\n root * ${dashboardRoot}\n encode gzip\n\n handle /api/* {\n reverse_proxy localhost:${apiPort}\n }\n\n handle /probe/* {\n reverse_proxy localhost:${apiPort}\n }\n\n ${ROUTE_MARKER}\n # === End App Routes ===\n\n handle {\n @notFile not file {path}\n rewrite @notFile /index.html\n file_server\n }\n}\n`;
const result = await ctx.caddy.modify(c => c + block); const result = await caddy.modify(c => c + block);
if (result.success) { if (result.success) {
ctx.log.info('caddy', 'Created main domain block with route markers', { domain }); log.info('caddy', 'Created main domain block with route markers', { domain });
} else { } else {
throw new Error(`[DC-303] Failed to create main domain block for ${domain}: ${result.error}`); throw new Error(`[DC-303] Failed to create main domain block for ${domain}: ${result.error}`);
} }
@@ -349,9 +360,9 @@ module.exports = function(ctx) {
const endMarker = `# --- End: ${subdomain} ---`; const endMarker = `# --- End: ${subdomain} ---`;
const END_ROUTE_MARKER = '# === End App Routes ==='; const END_ROUTE_MARKER = '# === End App Routes ===';
const result = await ctx.caddy.modify(content => { const result = await caddy.modify(content => {
if (content.includes(marker)) { if (content.includes(marker)) {
ctx.log.info('caddy', 'Subpath config already exists, skipping', { subdomain }); log.info('caddy', 'Subpath config already exists, skipping', { subdomain });
return null; return null;
} }
@@ -378,7 +389,7 @@ module.exports = function(ctx) {
const marker = `# --- DashCaddy: ${subdomain} ---`; const marker = `# --- DashCaddy: ${subdomain} ---`;
const endMarker = `# --- End: ${subdomain} ---`; const endMarker = `# --- End: ${subdomain} ---`;
return await ctx.caddy.modify(content => { return await caddy.modify(content => {
const startIdx = content.indexOf(marker); const startIdx = content.indexOf(marker);
if (startIdx === -1) return null; if (startIdx === -1) return null;

View File

@@ -5,14 +5,35 @@ const initRemoval = require('./removal');
const initTemplates = require('./templates'); const initTemplates = require('./templates');
const initRestore = require('./restore'); const initRestore = require('./restore');
/**
* Apps routes aggregator
* Assembles all apps sub-routes with their dependencies
* @param {Object} ctx - Application context (for backward compatibility)
* @returns {express.Router}
*/
module.exports = function(ctx) { module.exports = function(ctx) {
const router = express.Router(); const router = express.Router();
const helpers = initHelpers(ctx);
router.use(initDeploy(ctx, helpers)); // Extract dependencies from context
router.use(initRemoval(ctx, helpers)); const deps = {
router.use(initTemplates(ctx, helpers)); docker: ctx.docker,
router.use(initRestore(ctx, helpers)); caddy: ctx.caddy,
credentialManager: ctx.credentialManager,
servicesStateManager: ctx.servicesStateManager,
portLockManager: ctx.portLockManager,
asyncHandler: ctx.asyncHandler,
errorResponse: ctx.errorResponse,
log: ctx.log
};
// Initialize helpers with dependencies
const helpers = initHelpers(deps);
// Mount sub-routes with explicit dependencies
router.use(initDeploy({ ...deps, helpers }));
router.use(initRemoval({ ...deps, helpers }));
router.use(initTemplates({ ...deps, helpers }));
router.use(initRestore({ ...deps, helpers }));
return router; return router;
}; };

View File

@@ -1,35 +1,46 @@
const express = require('express'); const express = require('express');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
module.exports = function(ctx, helpers) { module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, log, helpers }) {
const router = express.Router(); const router = express.Router();
// Remove deployed app // Remove deployed app
router.delete('/apps/:appId', ctx.asyncHandler(async (req, res) => { /**
* Apps removal routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.docker - Docker client wrapper
* @param {Object} deps.caddy - Caddy client
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Apps helpers module
* @returns {express.Router}
*/
router.delete('/apps/:appId', asyncHandler(async (req, res) => {
const { appId } = req.params; const { appId } = req.params;
const { containerId, subdomain, ip, deleteContainer } = req.query; const { containerId, subdomain, ip, deleteContainer } = req.query;
const shouldDeleteContainer = deleteContainer === 'true'; const shouldDeleteContainer = deleteContainer === 'true';
const results = { container: null, dns: null, caddy: null, service: null }; const results = { container: null, dns: null, caddy: null, service: null };
try { try {
ctx.log.info('deploy', 'Removing app', { appId, containerId, subdomain, deleteContainer: shouldDeleteContainer }); log.info('deploy', 'Removing app', { appId, containerId, subdomain, deleteContainer: shouldDeleteContainer });
if (containerId && shouldDeleteContainer) { if (containerId && shouldDeleteContainer) {
try { try {
const container = ctx.docker.client.getContainer(containerId); const container = docker.client.getContainer(containerId);
try { await container.stop(); ctx.log.info('docker', 'Container stopped', { containerId }); } try { await container.stop(); log.info('docker', 'Container stopped', { containerId }); }
catch (stopError) { ctx.log.debug('docker', 'Container stop note', { containerId, note: stopError.message }); } catch (stopError) { log.debug('docker', 'Container stop note', { containerId, note: stopError.message }); }
await container.remove({ force: true }); await container.remove({ force: true });
results.container = 'removed'; results.container = 'removed';
ctx.log.info('docker', 'Container removed', { containerId }); log.info('docker', 'Container removed', { containerId });
// Prune dangling images after removal // Prune dangling images after removal
try { try {
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) { if (pruneResult.SpaceReclaimed > 0) {
ctx.log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
} }
} catch (pruneErr) { } catch (pruneErr) {
ctx.log.debug('docker', 'Image prune after removal failed', { error: pruneErr.message }); log.debug('docker', 'Image prune after removal failed', { error: pruneErr.message });
} }
} catch (error) { } catch (error) {
results.container = error.message.includes('no such container') ? 'already removed' : error.message; results.container = error.message.includes('no such container') ? 'already removed' : error.message;
@@ -53,7 +64,7 @@ module.exports = function(ctx, helpers) {
token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp
}); });
results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed'); results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed');
ctx.log.info('dns', 'DNS record removal', { result: results.dns }); log.info('dns', 'DNS record removal', { result: results.dns });
} catch (error) { } catch (error) {
results.dns = error.message; results.dns = error.message;
} }
@@ -66,7 +77,7 @@ module.exports = function(ctx, helpers) {
if (shouldDeleteContainer && subdomain) { if (shouldDeleteContainer && subdomain) {
try { try {
// Check if this service was deployed in subdirectory mode // Check if this service was deployed in subdirectory mode
const services = await ctx.servicesStateManager.read(); const services = await servicesStateManager.read();
const serviceList = Array.isArray(services) ? services : []; const serviceList = Array.isArray(services) ? services : [];
const service = serviceList.find(s => s.id === subdomain); const service = serviceList.find(s => s.id === subdomain);
@@ -79,14 +90,14 @@ module.exports = function(ctx, helpers) {
const domain = ctx.buildDomain(subdomain); const domain = ctx.buildDomain(subdomain);
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g'); const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g');
const caddyResult = await ctx.caddy.modify(currentContent => { const caddyResult = await caddy.modify(currentContent => {
const replaced = currentContent.replace(siteBlockRegex, '\n'); const replaced = currentContent.replace(siteBlockRegex, '\n');
if (replaced.length === currentContent.length) return null; if (replaced.length === currentContent.length) return null;
return replaced.replace(/\n{3,}/g, '\n\n'); return replaced.replace(/\n{3,}/g, '\n\n');
}); });
results.caddy = caddyResult.success ? 'removed' : (caddyResult.rolledBack ? 'removed (reload failed)' : 'not found'); results.caddy = caddyResult.success ? 'removed' : (caddyResult.rolledBack ? 'removed (reload failed)' : 'not found');
} }
ctx.log.info('caddy', 'Caddy config removal', { result: results.caddy }); log.info('caddy', 'Caddy config removal', { result: results.caddy });
} catch (error) { } catch (error) {
results.caddy = error.message; results.caddy = error.message;
} }
@@ -97,7 +108,7 @@ module.exports = function(ctx, helpers) {
try { try {
if (await exists(ctx.SERVICES_FILE)) { if (await exists(ctx.SERVICES_FILE)) {
let removed = false; let removed = false;
await ctx.servicesStateManager.update(services => { await servicesStateManager.update(services => {
const initialLength = services.length; const initialLength = services.length;
const filtered = services.filter(s => s.id !== subdomain); const filtered = services.filter(s => s.id !== subdomain);
removed = filtered.length !== initialLength; removed = filtered.length !== initialLength;
@@ -105,15 +116,15 @@ module.exports = function(ctx, helpers) {
}); });
results.service = removed ? 'removed' : 'not found'; results.service = removed ? 'removed' : 'not found';
} }
ctx.log.info('deploy', 'Service config removal', { result: results.service }); log.info('deploy', 'Service config removal', { result: results.service });
} catch (error) { } catch (error) {
results.service = error.message; results.service = error.message;
} }
res.json({ success: true, message: `App ${appId} removal completed`, results }); res.json({ success: true, message: `App ${appId} removal completed`, results });
} catch (error) { } catch (error) {
await ctx.logError('app-removal', error); await logError('app-removal', error);
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error), { results }); errorResponse(res, 500, ctx.safeErrorMessage(error), { results });
} }
}, 'apps-delete')); }, 'apps-delete'));

View File

@@ -1,7 +1,18 @@
const express = require('express'); const express = require('express');
const { DOCKER } = require('../../constants'); const { DOCKER } = require('../../constants');
module.exports = function(ctx, helpers) { /**
* Apps restore routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.docker - Docker client wrapper
* @param {Object} deps.caddy - Caddy client
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Apps helpers module
* @returns {express.Router}
*/
module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, log, helpers }) {
const router = express.Router(); const router = express.Router();
/** /**
@@ -9,16 +20,16 @@ module.exports = function(ctx, helpers) {
* Pulls image, creates container, starts it, recreates Caddy config. * Pulls image, creates container, starts it, recreates Caddy config.
* Skips if container is already running. * Skips if container is already running.
*/ */
router.post('/apps/:appId/restore', ctx.asyncHandler(async (req, res) => { router.post('/apps/:appId/restore', asyncHandler(async (req, res) => {
const { appId } = req.params; const { appId } = req.params;
const services = await ctx.servicesStateManager.read(); const services = await servicesStateManager.read();
const service = services.find(s => s.id === appId); const service = services.find(s => s.id === appId);
if (!service) { if (!service) {
return ctx.errorResponse(res, 404, `Service "${appId}" not found in services.json`); return errorResponse(res, 404, `Service "${appId}" not found in services.json`);
} }
if (!service.deploymentManifest) { if (!service.deploymentManifest) {
return ctx.errorResponse(res, 400, `Service "${appId}" has no deployment manifest — it was deployed before the manifest feature was added. Redeploy it manually to create a manifest.`); return errorResponse(res, 400, `Service "${appId}" has no deployment manifest — it was deployed before the manifest feature was added. Redeploy it manually to create a manifest.`);
} }
const result = await restoreService(service); const result = await restoreService(service);
@@ -29,8 +40,8 @@ module.exports = function(ctx, helpers) {
* Restore all services that have deployment manifests. * Restore all services that have deployment manifests.
* Returns per-service results. * Returns per-service results.
*/ */
router.post('/apps/restore-all', ctx.asyncHandler(async (req, res) => { router.post('/apps/restore-all', asyncHandler(async (req, res) => {
const services = await ctx.servicesStateManager.read(); const services = await servicesStateManager.read();
const restoreable = services.filter(s => s.deploymentManifest); const restoreable = services.filter(s => s.deploymentManifest);
if (restoreable.length === 0) { if (restoreable.length === 0) {
@@ -70,8 +81,8 @@ module.exports = function(ctx, helpers) {
/** /**
* List all services and their restore status. * List all services and their restore status.
*/ */
router.get('/apps/restore-status', ctx.asyncHandler(async (req, res) => { router.get('/apps/restore-status', asyncHandler(async (req, res) => {
const services = await ctx.servicesStateManager.read(); const services = await servicesStateManager.read();
const status = []; const status = [];
for (const service of services) { for (const service of services) {
@@ -87,7 +98,7 @@ module.exports = function(ctx, helpers) {
// Check if container is currently running // Check if container is currently running
if (service.containerId) { if (service.containerId) {
try { try {
const container = ctx.docker.client.getContainer(service.containerId); const container = docker.client.getContainer(service.containerId);
const info = await container.inspect(); const info = await container.inspect();
entry.containerRunning = info.State.Running; entry.containerRunning = info.State.Running;
} catch (e) { } catch (e) {
@@ -108,11 +119,11 @@ module.exports = function(ctx, helpers) {
const manifest = service.deploymentManifest; const manifest = service.deploymentManifest;
const template = ctx.APP_TEMPLATES[manifest.templateId]; const template = ctx.APP_TEMPLATES[manifest.templateId];
ctx.log.info('restore', `Restoring service: ${service.name}`, { id: service.id, templateId: manifest.templateId }); log.info('restore', `Restoring service: ${service.name}`, { id: service.id, templateId: manifest.templateId });
// Static sites: just recreate Caddy config // Static sites: just recreate Caddy config
if (template?.isStaticSite) { if (template?.isStaticSite) {
ctx.log.info('restore', `Restoring static site Caddy config: ${service.name}`); log.info('restore', `Restoring static site Caddy config: ${service.name}`);
const caddyOptions = { const caddyOptions = {
tailscaleOnly: manifest.caddy.tailscaleOnly, tailscaleOnly: manifest.caddy.tailscaleOnly,
allowedIPs: manifest.caddy.allowedIPs, allowedIPs: manifest.caddy.allowedIPs,
@@ -132,10 +143,10 @@ module.exports = function(ctx, helpers) {
// Docker container: check if already running // Docker container: check if already running
if (service.containerId) { if (service.containerId) {
try { try {
const existing = ctx.docker.client.getContainer(service.containerId); const existing = docker.client.getContainer(service.containerId);
const info = await existing.inspect(); const info = await existing.inspect();
if (info.State.Running) { if (info.State.Running) {
ctx.log.info('restore', `Container already running, skipping: ${service.name}`); log.info('restore', `Container already running, skipping: ${service.name}`);
return { return {
id: service.id, id: service.id,
name: service.name, name: service.name,
@@ -151,11 +162,11 @@ module.exports = function(ctx, helpers) {
// Also check by name (container ID may have changed) // Also check by name (container ID may have changed)
const containerName = `${DOCKER.CONTAINER_PREFIX}${manifest.config.subdomain}`; const containerName = `${DOCKER.CONTAINER_PREFIX}${manifest.config.subdomain}`;
try { try {
const byName = ctx.docker.client.getContainer(containerName); const byName = docker.client.getContainer(containerName);
const info = await byName.inspect(); const info = await byName.inspect();
if (info.State.Running) { if (info.State.Running) {
// Update the service entry with the current container ID // Update the service entry with the current container ID
await ctx.servicesStateManager.update(services => { await servicesStateManager.update(services => {
const svc = services.find(s => s.id === service.id); const svc = services.find(s => s.id === service.id);
if (svc) svc.containerId = info.Id; if (svc) svc.containerId = info.Id;
return services; return services;
@@ -183,18 +194,18 @@ module.exports = function(ctx, helpers) {
} }
// Pull image // Pull image
ctx.log.info('restore', `Pulling image: ${manifest.container.image}`); log.info('restore', `Pulling image: ${manifest.container.image}`);
try { try {
await ctx.docker.pull(manifest.container.image); await docker.pull(manifest.container.image);
} catch (e) { } catch (e) {
// Check if image exists locally // Check if image exists locally
const images = await ctx.docker.client.listImages({ const images = await docker.client.listImages({
filters: { reference: [manifest.container.image] } filters: { reference: [manifest.container.image] }
}); });
if (images.length === 0) { if (images.length === 0) {
throw new Error(`Failed to pull image ${manifest.container.image}: ${e.message}`); throw new Error(`Failed to pull image ${manifest.container.image}: ${e.message}`);
} }
ctx.log.warn('restore', `Pull failed, using local image: ${manifest.container.image}`); log.warn('restore', `Pull failed, using local image: ${manifest.container.image}`);
} }
// Build container config from manifest // Build container config from manifest
@@ -231,10 +242,10 @@ module.exports = function(ctx, helpers) {
} }
// Create and start container // Create and start container
ctx.log.info('restore', `Creating container: ${containerName}`); log.info('restore', `Creating container: ${containerName}`);
const container = await ctx.docker.client.createContainer(containerConfig); const container = await docker.client.createContainer(containerConfig);
await container.start(); await container.start();
ctx.log.info('restore', `Container started: ${containerName}`); log.info('restore', `Container started: ${containerName}`);
// Recreate Caddy config // Recreate Caddy config
const port = manifest.config.port; const port = manifest.config.port;
@@ -245,19 +256,19 @@ module.exports = function(ctx, helpers) {
}; };
if (manifest.caddy.routingMode === 'subdirectory') { if (manifest.caddy.routingMode === 'subdirectory') {
const caddyConfig = ctx.caddy.generateConfig(manifest.config.subdomain, manifest.config.ip, port, caddyOptions); const caddyConfig = caddy.generateConfig(manifest.config.subdomain, manifest.config.ip, port, caddyOptions);
try { try {
await helpers.ensureMainDomainBlock(); await helpers.ensureMainDomainBlock();
await helpers.addSubpathConfig(manifest.config.subdomain, caddyConfig); await helpers.addSubpathConfig(manifest.config.subdomain, caddyConfig);
} catch (e) { } catch (e) {
ctx.log.warn('restore', `Caddy config may already exist: ${e.message}`); log.warn('restore', `Caddy config may already exist: ${e.message}`);
} }
} else { } else {
const caddyConfig = ctx.caddy.generateConfig(manifest.config.subdomain, manifest.config.ip, port, caddyOptions); const caddyConfig = caddy.generateConfig(manifest.config.subdomain, manifest.config.ip, port, caddyOptions);
try { try {
await helpers.addCaddyConfig(manifest.config.subdomain, caddyConfig); await helpers.addCaddyConfig(manifest.config.subdomain, caddyConfig);
} catch (e) { } catch (e) {
ctx.log.warn('restore', `Caddy config may already exist: ${e.message}`); log.warn('restore', `Caddy config may already exist: ${e.message}`);
} }
} }
@@ -265,14 +276,14 @@ module.exports = function(ctx, helpers) {
if (manifest.config.createDns && manifest.caddy.routingMode !== 'subdirectory') { if (manifest.config.createDns && manifest.caddy.routingMode !== 'subdirectory') {
try { try {
await ctx.dns.createRecord(manifest.config.subdomain, manifest.config.ip); await ctx.dns.createRecord(manifest.config.subdomain, manifest.config.ip);
ctx.log.info('restore', 'DNS record recreated', { subdomain: manifest.config.subdomain }); log.info('restore', 'DNS record recreated', { subdomain: manifest.config.subdomain });
} catch (e) { } catch (e) {
ctx.log.warn('restore', `DNS recreation failed: ${e.message}`); log.warn('restore', `DNS recreation failed: ${e.message}`);
} }
} }
// Update the service entry with the new container ID // Update the service entry with the new container ID
await ctx.servicesStateManager.update(services => { await servicesStateManager.update(services => {
const svc = services.find(s => s.id === service.id); const svc = services.find(s => s.id === service.id);
if (svc) { if (svc) {
svc.containerId = container.id; svc.containerId = container.id;

View File

@@ -1,12 +1,20 @@
const express = require('express'); const express = require('express');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
/**
* Apps templates routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.helpers - Apps helpers module
* @returns {express.Router}
*/
const { REGEX } = require('../../constants'); const { REGEX } = require('../../constants');
module.exports = function(ctx, helpers) { module.exports = function({ servicesStateManager, asyncHandler, helpers }) {
const router = express.Router(); const router = express.Router();
// Get available app templates // Get available app templates
router.get('/apps/templates', ctx.asyncHandler(async (req, res) => { router.get('/apps/templates', asyncHandler(async (req, res) => {
res.json({ res.json({
success: true, success: true,
templates: ctx.APP_TEMPLATES, templates: ctx.APP_TEMPLATES,
@@ -16,7 +24,7 @@ module.exports = function(ctx, helpers) {
}, 'apps-templates')); }, 'apps-templates'));
// Get specific app template // Get specific app template
router.get('/apps/templates/:appId', ctx.asyncHandler(async (req, res) => { router.get('/apps/templates/:appId', asyncHandler(async (req, res) => {
const { appId } = req.params; const { appId } = req.params;
const template = ctx.APP_TEMPLATES[appId]; const template = ctx.APP_TEMPLATES[appId];
if (!template) { if (!template) {
@@ -27,7 +35,7 @@ module.exports = function(ctx, helpers) {
}, 'apps-template-detail')); }, 'apps-template-detail'));
// Check port availability // Check port availability
router.get('/apps/ports/:port/check', ctx.asyncHandler(async (req, res) => { router.get('/apps/ports/:port/check', asyncHandler(async (req, res) => {
const port = req.params.port; const port = req.params.port;
const conflicts = await helpers.checkPortConflicts([port]); const conflicts = await helpers.checkPortConflicts([port]);
if (conflicts.length > 0) { if (conflicts.length > 0) {
@@ -39,21 +47,21 @@ module.exports = function(ctx, helpers) {
}, 'check-port')); }, 'check-port'));
// Get suggested available port // Get suggested available port
router.get('/apps/ports/:basePort/suggest', ctx.asyncHandler(async (req, res) => { router.get('/apps/ports/:basePort/suggest', asyncHandler(async (req, res) => {
const basePort = parseInt(req.params.basePort) || 8080; const basePort = parseInt(req.params.basePort) || 8080;
const maxAttempts = 100; const maxAttempts = 100;
const usedPorts = await ctx.docker.getUsedPorts(); const usedPorts = await docker.getUsedPorts();
for (let port = basePort; port < basePort + maxAttempts; port++) { for (let port = basePort; port < basePort + maxAttempts; port++) {
if (!usedPorts.has(port)) { if (!usedPorts.has(port)) {
res.json({ success: true, suggestedPort: port, basePort }); res.json({ success: true, suggestedPort: port, basePort });
return; return;
} }
} }
ctx.errorResponse(res, 400, `No available ports found in range ${basePort}-${basePort + maxAttempts}`); errorResponse(res, 400, `No available ports found in range ${basePort}-${basePort + maxAttempts}`);
}, 'suggest-port')); }, 'suggest-port'));
// Update subdomain for deployed app // Update subdomain for deployed app
router.post('/apps/update-subdomain', ctx.asyncHandler(async (req, res) => { router.post('/apps/update-subdomain', asyncHandler(async (req, res) => {
const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body; const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body;
if (!oldSubdomain || typeof oldSubdomain !== 'string') { if (!oldSubdomain || typeof oldSubdomain !== 'string') {
throw new ValidationError('oldSubdomain is required'); throw new ValidationError('oldSubdomain is required');
@@ -64,7 +72,7 @@ module.exports = function(ctx, helpers) {
if (!REGEX.SUBDOMAIN.test(newSubdomain)) { if (!REGEX.SUBDOMAIN.test(newSubdomain)) {
throw new ValidationError('[DC-301] Invalid subdomain format for newSubdomain'); throw new ValidationError('[DC-301] Invalid subdomain format for newSubdomain');
} }
ctx.log.info('deploy', 'Updating subdomain', { oldSubdomain, newSubdomain }); log.info('deploy', 'Updating subdomain', { oldSubdomain, newSubdomain });
const results = { oldDns: null, newDns: null, caddy: null, service: null }; const results = { oldDns: null, newDns: null, caddy: null, service: null };
if (oldSubdomain && ctx.dns.getToken()) { if (oldSubdomain && ctx.dns.getToken()) {
@@ -74,10 +82,10 @@ module.exports = function(ctx, helpers) {
token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost' token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost'
}); });
results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage; results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage;
ctx.log.info('dns', 'Old DNS record deleted', { domain: oldDomain }); log.info('dns', 'Old DNS record deleted', { domain: oldDomain });
} catch (error) { } catch (error) {
results.oldDns = `failed: ${error.message}`; results.oldDns = `failed: ${error.message}`;
ctx.log.warn('dns', 'Old DNS deletion warning', { error: error.message }); log.warn('dns', 'Old DNS deletion warning', { error: error.message });
} }
} }
@@ -85,22 +93,22 @@ module.exports = function(ctx, helpers) {
try { try {
await ctx.dns.createRecord(newSubdomain, ip || 'localhost'); await ctx.dns.createRecord(newSubdomain, ip || 'localhost');
results.newDns = 'created'; results.newDns = 'created';
ctx.log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) }); log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) });
} catch (error) { } catch (error) {
results.newDns = `failed: ${error.message}`; results.newDns = `failed: ${error.message}`;
ctx.log.warn('dns', 'New DNS creation warning', { error: error.message }); log.warn('dns', 'New DNS creation warning', { error: error.message });
} }
} }
try { try {
if (await exists(ctx.caddy.filePath)) { if (await exists(caddy.filePath)) {
const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain); const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain);
const newDomain = newSubdomain.includes('.') ? newSubdomain : ctx.buildDomain(newSubdomain); const newDomain = newSubdomain.includes('.') ? newSubdomain : ctx.buildDomain(newSubdomain);
const escapedOld = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedOld = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const oldBlockRegex = new RegExp(`${escapedOld}(?::\\d+)?\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, 'g'); const oldBlockRegex = new RegExp(`${escapedOld}(?::\\d+)?\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, 'g');
const content = await ctx.caddy.read(); const content = await caddy.read();
if (oldBlockRegex.test(content)) { if (oldBlockRegex.test(content)) {
const caddyResult = await ctx.caddy.modify(c => { const caddyResult = await caddy.modify(c => {
const re = new RegExp(`${escapedOld}(?::\\d+)?\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, 'g'); const re = new RegExp(`${escapedOld}(?::\\d+)?\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, 'g');
return c.replace(re, match => match.replace(oldDomain, newDomain)); return c.replace(re, match => match.replace(oldDomain, newDomain));
}); });
@@ -113,17 +121,17 @@ module.exports = function(ctx, helpers) {
} }
} catch (error) { } catch (error) {
results.caddy = `failed: ${error.message}`; results.caddy = `failed: ${error.message}`;
ctx.log.error('caddy', 'Caddy update error', { error: error.message }); log.error('caddy', 'Caddy update error', { error: error.message });
} }
try { try {
if (await exists(ctx.SERVICES_FILE)) { if (await exists(ctx.SERVICES_FILE)) {
await ctx.servicesStateManager.update(services => { await servicesStateManager.update(services => {
const serviceIndex = services.findIndex(s => s.id === oldSubdomain || s.id === serviceId); const serviceIndex = services.findIndex(s => s.id === oldSubdomain || s.id === serviceId);
if (serviceIndex !== -1) { if (serviceIndex !== -1) {
services[serviceIndex].id = newSubdomain; services[serviceIndex].id = newSubdomain;
results.service = 'updated'; results.service = 'updated';
ctx.log.info('deploy', 'Service config updated in services.json'); log.info('deploy', 'Service config updated in services.json');
} else { } else {
results.service = 'not found'; results.service = 'not found';
} }
@@ -132,7 +140,7 @@ module.exports = function(ctx, helpers) {
} }
} catch (error) { } catch (error) {
results.service = `failed: ${error.message}`; results.service = `failed: ${error.message}`;
ctx.log.warn('deploy', 'Service update warning', { error: error.message || String(error) }); log.warn('deploy', 'Service update warning', { error: error.message || String(error) });
} }
res.json({ res.json({