diff --git a/dashcaddy-api/app-templates.js b/dashcaddy-api/app-templates.js index e5b13b8..533bd86 100644 --- a/dashcaddy-api/app-templates.js +++ b/dashcaddy-api/app-templates.js @@ -158,6 +158,7 @@ const APP_TEMPLATES = { healthCheck: "/api/v3/system/status", subpathSupport: 'native', urlBaseEnv: 'URL_BASE', + dependsOn: ["prowlarr", "qbittorrent"], setupInstructions: [ "Configure download clients (qBittorrent, etc.)", "Add indexers for content discovery", @@ -190,7 +191,8 @@ const APP_TEMPLATES = { defaultPort: 7878, healthCheck: "/api/v3/system/status", subpathSupport: 'native', - urlBaseEnv: 'URL_BASE' + urlBaseEnv: 'URL_BASE', + dependsOn: ["prowlarr", "qbittorrent"] }, "prowlarr": { @@ -1172,6 +1174,7 @@ const APP_TEMPLATES = { healthCheck: "/api/v1/system/status", subpathSupport: 'native', urlBaseEnv: 'URL_BASE', + dependsOn: ["prowlarr", "qbittorrent"], setupInstructions: [ "Configure download clients", "Add indexers", @@ -1205,6 +1208,7 @@ const APP_TEMPLATES = { healthCheck: "/api/v1/system/status", subpathSupport: 'native', urlBaseEnv: 'URL_BASE', + dependsOn: ["prowlarr", "qbittorrent"], setupInstructions: [ "Configure download clients", "Add indexers for books", @@ -1238,6 +1242,7 @@ const APP_TEMPLATES = { healthCheck: "/", subpathSupport: 'native', urlBaseEnv: 'BASE_URL', + dependsOn: ["sonarr", "radarr"], setupInstructions: [ "Connect to Sonarr and Radarr", "Configure subtitle providers", @@ -1266,6 +1271,7 @@ const APP_TEMPLATES = { healthCheck: "/api/v1/status", subpathSupport: 'native', urlBaseEnv: 'BASE_PATH', + dependsOn: ["sonarr", "radarr"], setupInstructions: [ "Connect to Plex, Jellyfin, or Emby server", "Link Sonarr and Radarr", @@ -1295,6 +1301,7 @@ const APP_TEMPLATES = { healthCheck: "/", subpathSupport: 'native', urlBaseEnv: 'TAUTULLI_HTTP_ROOT', + dependsOn: ["plex"], setupInstructions: [ "Connect to Plex server", "Configure notifications", diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index b419b17..1fd71be 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -226,6 +226,198 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag } } + /** + * Check which template-declared dependencies are missing from running services. + * Returns null if the template has no dependsOn field. + * @param {Object} template - App template with optional `dependsOn: string[]` + * @param {Array} runningServices - Array of services from services.json + * @returns {{ dependsOn: string[], missing: string[], satisfied: string[] } | null} + */ + function checkDependencies(template, runningServices) { + if (!template || !Array.isArray(template.dependsOn) || template.dependsOn.length === 0) { + return null; + } + const deployedTemplateIds = new Set( + (runningServices || []) + .map(s => s.appTemplate || s.deploymentManifest?.templateId) + .filter(Boolean) + ); + const satisfied = []; + const missing = []; + for (const depId of template.dependsOn) { + if (deployedTemplateIds.has(depId)) satisfied.push(depId); + else missing.push(depId); + } + return { dependsOn: [...template.dependsOn], missing, satisfied }; + } + + /** + * Topologically sort a list of template IDs by their dependsOn relationships. + * Dependencies come before dependents. Skips IDs not present in templates. + * Detects cycles and breaks them by emitting in DFS finish order. + * @param {string[]} ids + * @returns {string[]} + */ + function topologicalSortTemplates(ids) { + const idSet = new Set(ids); + const visited = new Set(); + const visiting = new Set(); + const result = []; + + function visit(id) { + if (visited.has(id)) return; + if (visiting.has(id)) return; // cycle — break here + visiting.add(id); + const tpl = ctx.APP_TEMPLATES[id]; + if (tpl && Array.isArray(tpl.dependsOn)) { + for (const dep of tpl.dependsOn) { + if (idSet.has(dep)) visit(dep); + } + } + visiting.delete(id); + visited.add(id); + result.push(id); + } + + for (const id of ids) visit(id); + return result; + } + + /** + * Build a default deploy config for a dependency template. + * Uses the template's defaults: subdomain, port, IP from siteConfig. + */ + function buildDefaultDepConfig(template) { + return { + subdomain: template.subdomain, + port: template.defaultPort, + ip: ctx.siteConfig.dnsServerIp || 'localhost', + createDns: true, + tailscaleOnly: false, + allowedIPs: [] + }; + } + + /** + * Deploy a list of dependency apps in topological order, waiting for each + * to become healthy before continuing. Skips deps already running. + * Throws on first failure. + */ + async function deployDependencyChain(depIds) { + const ordered = topologicalSortTemplates(depIds); + const services = await servicesStateManager.read(); + const deployedTemplateIds = new Set( + services.map(s => s.appTemplate || s.deploymentManifest?.templateId).filter(Boolean) + ); + + const deployed = []; + for (const depId of ordered) { + if (deployedTemplateIds.has(depId)) { + log.info('deploy', 'Dependency already running, skipping', { depId }); + continue; + } + const depTemplate = ctx.APP_TEMPLATES[depId]; + if (!depTemplate) { + throw new Error(`[DC-205] Dependency "${depId}" has no template`); + } + const depConfig = buildDefaultDepConfig(depTemplate); + log.info('deploy', 'Deploying dependency', { depId, subdomain: depConfig.subdomain }); + + const containerId = await deployContainer(depId, depConfig, depTemplate); + await helpers.waitForHealthCheck(containerId, depTemplate.healthCheck, depConfig.port); + log.info('deploy', 'Dependency healthy', { depId, containerId }); + + // Generate Caddy config + DNS for the dep so it's actually reachable + const caddyOptions = { + tailscaleOnly: false, + allowedIPs: [], + subpathSupport: depTemplate.subpathSupport || 'strip' + }; + const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain; + const depCaddyConfig = caddy.generateConfig(depConfig.subdomain, depConfig.ip, depConfig.port, caddyOptions); + if (isSubdirectoryMode) { + await helpers.ensureMainDomainBlock(); + await helpers.addSubpathConfig(depConfig.subdomain, depCaddyConfig); + } else { + await helpers.addCaddyConfig(depConfig.subdomain, depCaddyConfig); + } + if (!isSubdirectoryMode) { + try { + await ctx.dns.createRecord(depConfig.subdomain, depConfig.ip); + } catch (dnsErr) { + log.warn('deploy', 'DNS creation failed for dependency', { depId, error: dnsErr.message }); + } + } + + // Build a minimal manifest for the dep so it's restorable later + const processedDep = helpers.processTemplateVariables(depTemplate, depConfig); + const depManifest = { + templateId: depId, + config: { + subdomain: depConfig.subdomain, + port: depConfig.port, + ip: depConfig.ip, + createDns: depConfig.createDns, + tailscaleOnly: false, + allowedIPs: [], + useExisting: false + }, + container: { + image: processedDep.docker.image, + ports: processedDep.docker.ports, + volumes: processedDep.docker.volumes || [], + environment: { ...processedDep.docker.environment }, + capabilities: processedDep.docker.capabilities || undefined + }, + caddy: { + tailscaleOnly: false, + allowedIPs: [], + subpathSupport: depTemplate.subpathSupport || 'strip', + routingMode: ctx.siteConfig.routingMode + } + }; + + await ctx.addServiceToConfig({ + id: depConfig.subdomain, + name: depTemplate.name, + logo: depTemplate.logo || `/assets/${depId}.png`, + url: ctx.buildServiceUrl(depConfig.subdomain), + containerId, + appTemplate: depId, + tailscaleOnly: false, + routingMode: ctx.siteConfig.routingMode, + deployedAt: new Date().toISOString(), + deploymentManifest: depManifest + }); + + deployed.push({ depId, containerId, subdomain: depConfig.subdomain }); + } + return deployed; + } + + // Check dependencies for an app (frontend calls this before opening deploy modal) + router.post('/apps/check-dependencies', asyncHandler(async (req, res) => { + const { appId } = req.body; + if (!appId || typeof appId !== 'string') { + throw new ValidationError('appId is required'); + } + const template = ctx.APP_TEMPLATES[appId]; + if (!template) throw new ValidationError('Invalid app template'); + + const services = await servicesStateManager.read(); + const result = checkDependencies(template, services); + if (!result) { + return res.json({ appId, dependsOn: [], missing: [], satisfied: [] }); + } + // Enrich missing deps with friendly names for the UI + const missingDetails = result.missing.map(id => ({ + id, + name: ctx.APP_TEMPLATES[id]?.name || id, + icon: ctx.APP_TEMPLATES[id]?.icon || '📦' + })); + res.json({ appId, ...result, missingDetails }); + }, 'apps-check-dependencies')); + // Check for existing container before deployment router.post('/apps/check-existing', asyncHandler(async (req, res) => { const { appId } = req.body; @@ -241,7 +433,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag // Deploy new app router.post('/apps/deploy', asyncHandler(async (req, res) => { - const { appId, config } = req.body; + const { appId, config, deployDependencies } = req.body; if (!appId || typeof appId !== 'string') { throw new ValidationError('appId is required'); } @@ -251,6 +443,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag if (!config.subdomain || typeof config.subdomain !== 'string') { throw new ValidationError('config.subdomain is required'); } + let deployedDeps = []; try { log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain }); const template = ctx.APP_TEMPLATES[appId]; @@ -259,6 +452,13 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag throw new ValidationError('Invalid app template'); } + // Deploy declared dependencies first if requested + if (Array.isArray(deployDependencies) && deployDependencies.length > 0) { + log.info('deploy', 'Deploying dependencies first', { appId, deps: deployDependencies }); + deployedDeps = await deployDependencyChain(deployDependencies); + log.info('deploy', 'Dependencies deployed', { count: deployedDeps.length }); + } + if (config.subdomain) { if (!REGEX.SUBDOMAIN.test(config.subdomain)) { throw new ValidationError('[DC-301] Invalid subdomain format'); @@ -409,7 +609,8 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag success: true, containerId, usedExisting, url: serviceUrl, message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`, - setupInstructions: template.setupInstructions || [] + setupInstructions: template.setupInstructions || [], + deployedDependencies: deployedDeps }; if (dnsWarning) response.warning = dnsWarning; diff --git a/dashcaddy-api/routes/recipes/deploy.js b/dashcaddy-api/routes/recipes/deploy.js index 79c6faa..1db14d4 100644 --- a/dashcaddy-api/routes/recipes/deploy.js +++ b/dashcaddy-api/routes/recipes/deploy.js @@ -70,6 +70,9 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi const deployedComponents = []; const errors = []; + // Lazy-load apps helpers once for health waits between components + const appsHelpers = require('../apps/helpers')(ctx); + try { for (const component of componentsToDeploy) { try { @@ -84,6 +87,18 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi log.info('recipe', `Component deployed: ${component.id}`, { containerId: result.containerId?.substring(0, 12) }); + + // Wait for the component to become healthy before deploying the next one + // (so dependent components — Sonarr after Prowlarr — see a working dep) + if (result.containerId && result.healthPort) { + try { + await appsHelpers.waitForHealthCheck(result.containerId, result.healthPath, result.healthPort); + log.info('recipe', `Component healthy: ${component.id}`); + } catch (healthErr) { + log.warn('recipe', `Component health wait failed: ${component.id}`, { error: healthErr.message }); + // Don't abort the recipe — continue with the next component + } + } } catch (componentError) { log.error('recipe', `Component failed: ${component.id}`, { error: componentError.message @@ -349,6 +364,17 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi } } + // Resolve health check path + port for the waiter + let healthPath = null; + let healthPort = null; + if (component.templateRef) { + const tpl = ctx.APP_TEMPLATES[component.templateRef]; + healthPath = tpl?.healthCheck || null; + healthPort = port || tpl?.defaultPort || null; + } else if (dockerConfig.ports?.length > 0) { + healthPort = port || dockerConfig.ports[0].split(/[:/]/)[0]; + } + return { id: component.id, role: component.role, @@ -358,7 +384,9 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi internal: component.internal || false, templateRef: component.templateRef, logo, - url + url, + healthPath, + healthPort }; } diff --git a/status/js/app-selector.js b/status/js/app-selector.js index 60c7c4a..aafb3aa 100644 --- a/status/js/app-selector.js +++ b/status/js/app-selector.js @@ -10,6 +10,22 @@ `); + injectModal('dep-warning-modal', `