const express = require('express'); const { DOCKER } = require('../../constants'); module.exports = function(ctx) { const router = express.Router(); /** * GET /api/recipes/deployed — list all deployed recipes (grouped by recipeId) */ router.get('/deployed', ctx.asyncHandler(async (req, res) => { const services = await ctx.servicesStateManager.read(); const recipeGroups = {}; for (const service of services) { if (!service.recipeId) continue; if (!recipeGroups[service.recipeId]) { recipeGroups[service.recipeId] = { recipeId: service.recipeId, components: [], }; } recipeGroups[service.recipeId].components.push({ id: service.id, name: service.name, logo: service.logo, containerId: service.containerId, recipeRole: service.recipeRole, deployedAt: service.deployedAt, }); } // Also find internal containers (not in services.json) by Docker labels try { const containers = await ctx.docker.client.listContainers({ all: true }); for (const containerInfo of containers) { const labels = containerInfo.Labels || {}; if (labels['sami.managed'] !== 'true') continue; const recipeLabel = labels['sami.recipe']; if (!recipeLabel) continue; // Map recipe label back to recipe ID const recipeId = findRecipeIdByLabel(recipeLabel); if (!recipeId) continue; if (!recipeGroups[recipeId]) { recipeGroups[recipeId] = { recipeId, components: [] }; } // Check if this container is already listed (by containerId) const existing = recipeGroups[recipeId].components.find( c => c.containerId === containerInfo.Id, ); if (existing) continue; recipeGroups[recipeId].components.push({ id: labels['sami.recipe.component'] || containerInfo.Names[0]?.replace('/', ''), name: labels['sami.recipe.role'] || labels['sami.app'] || 'Unknown', containerId: containerInfo.Id, recipeRole: labels['sami.recipe.role'] || 'Unknown', internal: true, state: containerInfo.State, status: containerInfo.Status, }); } } catch (e) { ctx.log.warn('recipe', 'Could not list Docker containers for recipe discovery', { error: e.message }); } // Enrich with container state for (const group of Object.values(recipeGroups)) { for (const component of group.components) { if (component.containerId && !component.state) { try { const container = ctx.docker.client.getContainer(component.containerId); const info = await container.inspect(); component.state = info.State.Status; component.status = info.State.Status === 'running' ? `Up ${formatUptime(info.State.StartedAt)}` : info.State.Status; } catch (e) { component.state = 'removed'; component.status = 'Container not found'; } } } } res.json({ success: true, recipes: Object.values(recipeGroups) }); }, 'recipe-deployed')); /** * POST /api/recipes/:recipeId/start — start all containers in a recipe */ router.post('/:recipeId/start', ctx.asyncHandler(async (req, res) => { const { recipeId } = req.params; const containers = await findRecipeContainers(recipeId); if (containers.length === 0) { return ctx.errorResponse(res, 404, 'No containers found for this recipe'); } const results = []; for (const containerInfo of containers) { try { const container = ctx.docker.client.getContainer(containerInfo.Id); const info = await container.inspect(); if (info.State.Status !== 'running') { await container.start(); results.push({ id: containerInfo.component, status: 'started' }); } else { results.push({ id: containerInfo.component, status: 'already running' }); } } catch (e) { results.push({ id: containerInfo.component, status: 'failed', error: e.message }); } } ctx.log.info('recipe', 'Recipe started', { recipeId, results }); res.json({ success: true, recipeId, results }); }, 'recipe-start')); /** * POST /api/recipes/:recipeId/stop — stop all containers in a recipe */ router.post('/:recipeId/stop', ctx.asyncHandler(async (req, res) => { const { recipeId } = req.params; const containers = await findRecipeContainers(recipeId); if (containers.length === 0) { return ctx.errorResponse(res, 404, 'No containers found for this recipe'); } const results = []; // Stop in reverse order (apps first, then infrastructure) for (const containerInfo of containers.reverse()) { try { const container = ctx.docker.client.getContainer(containerInfo.Id); const info = await container.inspect(); if (info.State.Status === 'running') { await container.stop(); results.push({ id: containerInfo.component, status: 'stopped' }); } else { results.push({ id: containerInfo.component, status: 'already stopped' }); } } catch (e) { results.push({ id: containerInfo.component, status: 'failed', error: e.message }); } } ctx.log.info('recipe', 'Recipe stopped', { recipeId, results }); res.json({ success: true, recipeId, results }); }, 'recipe-stop')); /** * POST /api/recipes/:recipeId/restart — restart all containers in a recipe */ router.post('/:recipeId/restart', ctx.asyncHandler(async (req, res) => { const { recipeId } = req.params; const containers = await findRecipeContainers(recipeId); if (containers.length === 0) { return ctx.errorResponse(res, 404, 'No containers found for this recipe'); } const results = []; for (const containerInfo of containers) { try { const container = ctx.docker.client.getContainer(containerInfo.Id); await container.restart(); results.push({ id: containerInfo.component, status: 'restarted' }); } catch (e) { results.push({ id: containerInfo.component, status: 'failed', error: e.message }); } } ctx.log.info('recipe', 'Recipe restarted', { recipeId, results }); res.json({ success: true, recipeId, results }); }, 'recipe-restart')); /** * DELETE /api/recipes/:recipeId — remove entire recipe (containers, network, services) */ router.delete('/:recipeId', ctx.asyncHandler(async (req, res) => { const { recipeId } = req.params; const containers = await findRecipeContainers(recipeId); if (containers.length === 0) { return ctx.errorResponse(res, 404, 'No containers found for this recipe'); } ctx.log.info('recipe', 'Removing recipe', { recipeId, containerCount: containers.length }); const results = []; const networkNames = new Set(); // Remove containers (reverse order: apps first, then infrastructure) for (const containerInfo of containers.reverse()) { try { const container = ctx.docker.client.getContainer(containerInfo.Id); const info = await container.inspect(); // Collect network names for cleanup for (const netName of Object.keys(info.NetworkSettings.Networks || {})) { if (netName.startsWith('dashcaddy-')) { networkNames.add(netName); } } // Remove Caddy config for this subdomain const subdomain = info.Config?.Labels?.['sami.subdomain']; if (subdomain) { try { await removeCaddyBlock(subdomain); } catch (e) { ctx.log.warn('recipe', 'Failed to remove Caddy config', { subdomain, error: e.message }); } } // Force remove container await container.remove({ force: true }); results.push({ id: containerInfo.component, status: 'removed' }); } catch (e) { results.push({ id: containerInfo.component, status: 'failed', error: e.message }); } } // Remove recipe services from services.json await ctx.servicesStateManager.update(services => { return services.filter(s => s.recipeId !== recipeId); }); // Remove Docker networks for (const netName of networkNames) { try { const network = ctx.docker.client.getNetwork(netName); await network.remove(); ctx.log.info('recipe', 'Removed Docker network', { netName }); } catch (e) { ctx.log.warn('recipe', 'Failed to remove network', { netName, error: e.message }); } } ctx.notification.send('recipeRemoved', 'Recipe Removed', `Removed **${recipeId}** recipe (${results.filter(r => r.status === 'removed').length} containers).`, 'info', ); ctx.log.info('recipe', 'Recipe removed', { recipeId, results }); res.json({ success: true, recipeId, results }); }, 'recipe-remove')); // === Helper functions === /** * Find all Docker containers belonging to a recipe by label */ async function findRecipeContainers(recipeId) { const { RECIPE_TEMPLATES } = require('../../recipe-templates'); const recipe = RECIPE_TEMPLATES[recipeId]; const recipeLabel = recipe ? recipe.name.toLowerCase().replace(/\s+/g, '-') : recipeId; const containers = await ctx.docker.client.listContainers({ all: true }); return containers .filter(c => { const labels = c.Labels || {}; return labels['sami.managed'] === 'true' && labels['sami.recipe'] === recipeLabel; }) .map(c => ({ Id: c.Id, component: c.Labels['sami.recipe.component'] || c.Names[0]?.replace('/', ''), role: c.Labels['sami.recipe.role'] || 'Unknown', state: c.State, })); } /** * Find recipe ID by its label (name slug) */ function findRecipeIdByLabel(label) { const { RECIPE_TEMPLATES } = require('../../recipe-templates'); for (const [id, recipe] of Object.entries(RECIPE_TEMPLATES)) { if (recipe.name.toLowerCase().replace(/\s+/g, '-') === label) { return id; } } return null; } /** * Remove a Caddy block for a subdomain from the Caddyfile */ async function removeCaddyBlock(subdomain) { const domain = ctx.buildDomain(subdomain); const content = await ctx.caddy.read(); // Find and remove the block for this domain const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const blockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^}]*(?:\\{[^}]*\\}[^}]*)*\\}`, 'g'); const newContent = content.replace(blockRegex, ''); if (newContent !== content) { await ctx.caddy.write(newContent); await ctx.caddy.reload(); } } /** * Format uptime from start time */ function formatUptime(startedAt) { const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000); if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; return `${Math.floor(seconds / 86400)}d`; } return router; };