wip: app-deploy dependency tracking (incomplete — needs endpoint wiring)
Half-finished feature for declaring and resolving app dependencies
when deploying. Preserved here for later finishing.
What's done:
- app-templates.js: dependsOn declarations on 7 templates
(sonarr, radarr, lidarr, readarr, bazarr, overseerr, tautulli).
- routes/apps/deploy.js: helper functions checkDependencies(),
topologicalSortTemplates(), buildDefaultDepConfig().
- routes/recipes/deploy.js: wait-for-health between recipe components
via appsHelpers.waitForHealthCheck() (verify export exists).
- status/js/app-selector.js: dependency-warning modal injected into
app-selector flow, with a "deploy with deps" checkbox.
What's missing (blockers for merge):
- POST /api/v1/apps/check-dependencies endpoint — frontend calls it
(app-selector.js around line 395) but the route is never registered.
Helper functions exist; just need to expose them. Frontend currently
404s and falls back to plain deploy (line 401), so the dep-aware
flow is non-functional.
- Auto-deploy-with-dependencies handler in the modal — checkbox
exists but nothing wires the "yes deploy them" choice into actually
deploying the listed dependencies before the target app.
- No tests around topological sort behaviour (circular deps,
diamond deps, missing deps).
Lifted out of wip/cloud-backups-and-history when the cloud-backups +
resource-history features were merged to main (commit d81d118).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user