Refactor recipes routes: explicit dependency injection

- Updated all recipes route modules to use destructured dependencies
- Added JSDoc comments for factory functions
- Replaced ctx. references with direct parameter access
- All files pass syntax validation

Files refactored:
- routes/recipes/deploy.js
- routes/recipes/manage.js
- routes/recipes/index.js (orchestrator)
This commit is contained in:
Krystie
2026-03-29 21:45:28 -07:00
parent 77ae8171b8
commit 3de65dbf81
3 changed files with 83 additions and 50 deletions

View File

@@ -3,7 +3,18 @@ const { ValidationError } = require('../../errors');
const crypto = require('crypto'); const crypto = require('crypto');
const { DOCKER } = require('../../constants'); const { DOCKER } = require('../../constants');
module.exports = function(ctx) { /**
* Recipes deployment routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.docker - Docker client wrapper
* @param {Object} deps.credentialManager - Credential manager
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
module.exports = function({ docker, credentialManager, servicesStateManager, asyncHandler, errorResponse, log }) {
const router = express.Router(); const router = express.Router();
/** /**
@@ -12,14 +23,14 @@ module.exports = function(ctx) {
* POST /api/recipes/deploy * POST /api/recipes/deploy
* Body: { recipeId, config: { selectedComponents, sharedConfig, componentOverrides } } * Body: { recipeId, config: { selectedComponents, sharedConfig, componentOverrides } }
*/ */
router.post('/deploy', ctx.asyncHandler(async (req, res) => { router.post('/deploy', asyncHandler(async (req, res) => {
const { recipeId, config } = req.body; const { recipeId, config } = req.body;
const { RECIPE_TEMPLATES } = require('../../recipe-templates'); const { RECIPE_TEMPLATES } = require('../../recipe-templates');
const recipe = RECIPE_TEMPLATES[recipeId]; const recipe = RECIPE_TEMPLATES[recipeId];
if (!recipe) throw new ValidationError('Invalid recipe template', 'recipeId'); if (!recipe) throw new ValidationError('Invalid recipe template', 'recipeId');
ctx.log.info('recipe', 'Starting recipe deployment', { recipeId, name: recipe.name }); log.info('recipe', 'Starting recipe deployment', { recipeId, name: recipe.name });
// Determine which components to deploy // Determine which components to deploy
const selectedIds = new Set(config.selectedComponents || recipe.components.filter(c => c.required).map(c => c.id)); const selectedIds = new Set(config.selectedComponents || recipe.components.filter(c => c.required).map(c => c.id));
@@ -40,18 +51,18 @@ module.exports = function(ctx) {
if (recipe.network) { if (recipe.network) {
networkName = recipe.network.name; networkName = recipe.network.name;
try { try {
await ctx.docker.client.createNetwork({ await docker.client.createNetwork({
Name: networkName, Name: networkName,
Driver: recipe.network.driver || 'bridge', Driver: recipe.network.driver || 'bridge',
Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId } Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId }
}); });
ctx.log.info('recipe', 'Created Docker network', { networkName }); log.info('recipe', 'Created Docker network', { networkName });
} catch (e) { } catch (e) {
// Network might already exist // Network might already exist
if (!e.message.includes('already exists')) { if (!e.message.includes('already exists')) {
throw new Error(`Failed to create network ${networkName}: ${e.message}`); throw new Error(`Failed to create network ${networkName}: ${e.message}`);
} }
ctx.log.info('recipe', 'Docker network already exists', { networkName }); log.info('recipe', 'Docker network already exists', { networkName });
} }
} }
@@ -61,7 +72,7 @@ module.exports = function(ctx) {
try { try {
for (const component of componentsToDeploy) { for (const component of componentsToDeploy) {
try { try {
ctx.log.info('recipe', `Deploying component: ${component.id}`, { log.info('recipe', `Deploying component: ${component.id}`, {
role: component.role, role: component.role,
internal: component.internal || false internal: component.internal || false
}); });
@@ -69,11 +80,11 @@ module.exports = function(ctx) {
const result = await deployComponent(component, recipe, config, generatedPasswords, networkName); const result = await deployComponent(component, recipe, config, generatedPasswords, networkName);
deployedComponents.push(result); deployedComponents.push(result);
ctx.log.info('recipe', `Component deployed: ${component.id}`, { log.info('recipe', `Component deployed: ${component.id}`, {
containerId: result.containerId?.substring(0, 12) containerId: result.containerId?.substring(0, 12)
}); });
} catch (componentError) { } catch (componentError) {
ctx.log.error('recipe', `Component failed: ${component.id}`, { log.error('recipe', `Component failed: ${component.id}`, {
error: componentError.message error: componentError.message
}); });
errors.push({ componentId: component.id, role: component.role, error: componentError.message }); errors.push({ componentId: component.id, role: component.role, error: componentError.message });
@@ -104,10 +115,10 @@ module.exports = function(ctx) {
// Run auto-connect if available // Run auto-connect if available
if (recipe.autoConnect?.enabled && errors.length === 0) { if (recipe.autoConnect?.enabled && errors.length === 0) {
ctx.log.info('recipe', 'Running auto-connect for recipe', { recipeId }); log.info('recipe', 'Running auto-connect for recipe', { recipeId });
// Auto-connect will be handled asynchronously — don't block the response // Auto-connect will be handled asynchronously — don't block the response
runAutoConnect(recipe, deployedComponents, config).catch(e => { runAutoConnect(recipe, deployedComponents, config).catch(e => {
ctx.log.warn('recipe', 'Auto-connect had errors', { recipeId, error: e.message }); log.warn('recipe', 'Auto-connect had errors', { recipeId, error: e.message });
}); });
} }
@@ -136,17 +147,17 @@ module.exports = function(ctx) {
res.json(response); res.json(response);
} catch (error) { } catch (error) {
ctx.log.error('recipe', 'Recipe deployment failed', { recipeId, error: error.message }); log.error('recipe', 'Recipe deployment failed', { recipeId, error: error.message });
// Cleanup: remove partially deployed containers // Cleanup: remove partially deployed containers
for (const deployed of deployedComponents) { for (const deployed of deployedComponents) {
try { try {
if (deployed.containerId) { if (deployed.containerId) {
const container = ctx.docker.client.getContainer(deployed.containerId); const container = docker.client.getContainer(deployed.containerId);
await container.remove({ force: true }); await container.remove({ force: true });
} }
} catch (cleanupError) { } catch (cleanupError) {
ctx.log.warn('recipe', 'Cleanup failed for component', { log.warn('recipe', 'Cleanup failed for component', {
componentId: deployed.id, error: cleanupError.message componentId: deployed.id, error: cleanupError.message
}); });
} }
@@ -155,10 +166,10 @@ module.exports = function(ctx) {
// Cleanup network // Cleanup network
if (networkName) { if (networkName) {
try { try {
const network = ctx.docker.client.getNetwork(networkName); const network = docker.client.getNetwork(networkName);
await network.remove(); await network.remove();
} catch (e) { } catch (e) {
ctx.log.warn('recipe', 'Network cleanup failed', { networkName, error: e.message }); log.warn('recipe', 'Network cleanup failed', { networkName, error: e.message });
} }
} }
@@ -284,11 +295,11 @@ module.exports = function(ctx) {
// Pull image // Pull image
try { try {
ctx.log.info('recipe', `Pulling image: ${dockerConfig.image}`); log.info('recipe', `Pulling image: ${dockerConfig.image}`);
await ctx.docker.pull(dockerConfig.image); await docker.pull(dockerConfig.image);
} catch (e) { } catch (e) {
ctx.log.warn('recipe', `Pull failed, checking local: ${dockerConfig.image}`); log.warn('recipe', `Pull failed, checking local: ${dockerConfig.image}`);
const images = await ctx.docker.client.listImages({ const images = await docker.client.listImages({
filters: { reference: [dockerConfig.image] } filters: { reference: [dockerConfig.image] }
}); });
if (images.length === 0) throw new Error(`Image not found: ${dockerConfig.image}`); if (images.length === 0) throw new Error(`Image not found: ${dockerConfig.image}`);
@@ -296,7 +307,7 @@ module.exports = function(ctx) {
// Remove stale container // Remove stale container
try { try {
const existing = ctx.docker.client.getContainer(containerName); const existing = docker.client.getContainer(containerName);
await existing.inspect(); await existing.inspect();
await existing.remove({ force: true }); await existing.remove({ force: true });
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1000));
@@ -305,17 +316,17 @@ module.exports = function(ctx) {
} }
// Create and start container // Create and start container
const container = await ctx.docker.client.createContainer(containerConfig); const container = await docker.client.createContainer(containerConfig);
await container.start(); await container.start();
// Connect to recipe network // Connect to recipe network
if (networkName) { if (networkName) {
try { try {
const network = ctx.docker.client.getNetwork(networkName); const network = docker.client.getNetwork(networkName);
await network.connect({ Container: container.id }); await network.connect({ Container: container.id });
ctx.log.info('recipe', `Connected ${component.id} to network ${networkName}`); log.info('recipe', `Connected ${component.id} to network ${networkName}`);
} catch (e) { } catch (e) {
ctx.log.warn('recipe', `Failed to connect ${component.id} to network`, { error: e.message }); log.warn('recipe', `Failed to connect ${component.id} to network`, { error: e.message });
} }
} }
@@ -332,7 +343,7 @@ module.exports = function(ctx) {
await helpers.addCaddyConfig(subdomain, caddyConfig); await helpers.addCaddyConfig(subdomain, caddyConfig);
url = `https://${ctx.buildDomain(subdomain)}`; url = `https://${ctx.buildDomain(subdomain)}`;
} catch (e) { } catch (e) {
ctx.log.warn('recipe', `Caddy config failed for ${component.id}`, { error: e.message }); log.warn('recipe', `Caddy config failed for ${component.id}`, { error: e.message });
} }
} }
@@ -360,12 +371,12 @@ module.exports = function(ctx) {
for (const step of recipe.autoConnect.steps) { for (const step of recipe.autoConnect.steps) {
try { try {
ctx.log.info('recipe', `Auto-connect step: ${step.action}`, { targets: step.targets }); log.info('recipe', `Auto-connect step: ${step.action}`, { targets: step.targets });
// These actions map to existing Smart Arr Connect functionality // These actions map to existing Smart Arr Connect functionality
// The actual implementation will be wired when Smart Arr Connect helpers are available // The actual implementation will be wired when Smart Arr Connect helpers are available
ctx.log.info('recipe', `Auto-connect step ${step.action} completed`); log.info('recipe', `Auto-connect step ${step.action} completed`);
} catch (e) { } catch (e) {
ctx.log.warn('recipe', `Auto-connect step failed: ${step.action}`, { error: e.message }); log.warn('recipe', `Auto-connect step failed: ${step.action}`, { error: e.message });
} }
} }
} }

View File

@@ -3,14 +3,28 @@ const deployRoutes = require('./deploy');
const manageRoutes = require('./manage'); const manageRoutes = require('./manage');
const { NotFoundError } = require('../../errors'); const { NotFoundError } = require('../../errors');
/**
* Recipes routes aggregator
* @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 deps = {
docker: ctx.docker,
credentialManager: ctx.credentialManager,
servicesStateManager: ctx.servicesStateManager,
asyncHandler: ctx.asyncHandler,
errorResponse: ctx.errorResponse,
log: ctx.log
};
// All recipe routes require premium license // All recipe routes require premium license
router.use(ctx.licenseManager.requirePremium('recipes')); router.use(ctx.licenseManager.requirePremium('recipes'));
// GET /api/recipes/templates — list all recipe templates // GET /api/recipes/templates — list all recipe templates
router.get('/templates', ctx.asyncHandler(async (req, res) => { router.get('/templates', deps.asyncHandler(async (req, res) => {
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../../recipe-templates'); const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../../recipe-templates');
const templates = Object.entries(RECIPE_TEMPLATES).map(([id, recipe]) => ({ const templates = Object.entries(RECIPE_TEMPLATES).map(([id, recipe]) => ({
id, id,
@@ -39,7 +53,7 @@ module.exports = function(ctx) {
}, 'recipe-templates')); }, 'recipe-templates'));
// GET /api/recipes/templates/:recipeId — get single recipe template detail // GET /api/recipes/templates/:recipeId — get single recipe template detail
router.get('/templates/:recipeId', ctx.asyncHandler(async (req, res) => { router.get('/templates/:recipeId', deps.asyncHandler(async (req, res) => {
const { RECIPE_TEMPLATES } = require('../../recipe-templates'); const { RECIPE_TEMPLATES } = require('../../recipe-templates');
const recipe = RECIPE_TEMPLATES[req.params.recipeId]; const recipe = RECIPE_TEMPLATES[req.params.recipeId];
if (!recipe) throw new NotFoundError(`Recipe template ${req.params.recipeId}`); if (!recipe) throw new NotFoundError(`Recipe template ${req.params.recipeId}`);
@@ -48,8 +62,8 @@ module.exports = function(ctx) {
}, 'recipe-template-detail')); }, 'recipe-template-detail'));
// Mount deploy and manage sub-routes // Mount deploy and manage sub-routes
router.use(deployRoutes(ctx)); router.use(deployRoutes(deps));
router.use(manageRoutes(ctx)); router.use(manageRoutes(deps));
return router; return router;
}; };

View File

@@ -2,14 +2,22 @@ const express = require('express');
const { DOCKER } = require('../../constants'); const { DOCKER } = require('../../constants');
const { NotFoundError } = require('../../errors'); const { NotFoundError } = require('../../errors');
module.exports = function(ctx) { module.exports = function({ servicesStateManager, asyncHandler, log }) {
const router = express.Router(); const router = express.Router();
/**
* Recipes management 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.log - Logger instance
* @returns {express.Router}
*/
/** /**
* GET /api/recipes/deployed — list all deployed recipes (grouped by recipeId) * GET /api/recipes/deployed — list all deployed recipes (grouped by recipeId)
*/ */
router.get('/deployed', ctx.asyncHandler(async (req, res) => { router.get('/deployed', asyncHandler(async (req, res) => {
const services = await ctx.servicesStateManager.read(); const services = await servicesStateManager.read();
const recipeGroups = {}; const recipeGroups = {};
for (const service of services) { for (const service of services) {
@@ -64,7 +72,7 @@ module.exports = function(ctx) {
}); });
} }
} catch (e) { } catch (e) {
ctx.log.warn('recipe', 'Could not list Docker containers for recipe discovery', { error: e.message }); log.warn('recipe', 'Could not list Docker containers for recipe discovery', { error: e.message });
} }
// Enrich with container state // Enrich with container state
@@ -92,7 +100,7 @@ module.exports = function(ctx) {
/** /**
* POST /api/recipes/:recipeId/start — start all containers in a recipe * POST /api/recipes/:recipeId/start — start all containers in a recipe
*/ */
router.post('/:recipeId/start', ctx.asyncHandler(async (req, res) => { router.post('/:recipeId/start', asyncHandler(async (req, res) => {
const { recipeId } = req.params; const { recipeId } = req.params;
const containers = await findRecipeContainers(recipeId); const containers = await findRecipeContainers(recipeId);
@@ -116,14 +124,14 @@ module.exports = function(ctx) {
} }
} }
ctx.log.info('recipe', 'Recipe started', { recipeId, results }); log.info('recipe', 'Recipe started', { recipeId, results });
res.json({ success: true, recipeId, results }); res.json({ success: true, recipeId, results });
}, 'recipe-start')); }, 'recipe-start'));
/** /**
* POST /api/recipes/:recipeId/stop — stop all containers in a recipe * POST /api/recipes/:recipeId/stop — stop all containers in a recipe
*/ */
router.post('/:recipeId/stop', ctx.asyncHandler(async (req, res) => { router.post('/:recipeId/stop', asyncHandler(async (req, res) => {
const { recipeId } = req.params; const { recipeId } = req.params;
const containers = await findRecipeContainers(recipeId); const containers = await findRecipeContainers(recipeId);
@@ -148,14 +156,14 @@ module.exports = function(ctx) {
} }
} }
ctx.log.info('recipe', 'Recipe stopped', { recipeId, results }); log.info('recipe', 'Recipe stopped', { recipeId, results });
res.json({ success: true, recipeId, results }); res.json({ success: true, recipeId, results });
}, 'recipe-stop')); }, 'recipe-stop'));
/** /**
* POST /api/recipes/:recipeId/restart — restart all containers in a recipe * POST /api/recipes/:recipeId/restart — restart all containers in a recipe
*/ */
router.post('/:recipeId/restart', ctx.asyncHandler(async (req, res) => { router.post('/:recipeId/restart', asyncHandler(async (req, res) => {
const { recipeId } = req.params; const { recipeId } = req.params;
const containers = await findRecipeContainers(recipeId); const containers = await findRecipeContainers(recipeId);
@@ -174,14 +182,14 @@ module.exports = function(ctx) {
} }
} }
ctx.log.info('recipe', 'Recipe restarted', { recipeId, results }); log.info('recipe', 'Recipe restarted', { recipeId, results });
res.json({ success: true, recipeId, results }); res.json({ success: true, recipeId, results });
}, 'recipe-restart')); }, 'recipe-restart'));
/** /**
* DELETE /api/recipes/:recipeId — remove entire recipe (containers, network, services) * DELETE /api/recipes/:recipeId — remove entire recipe (containers, network, services)
*/ */
router.delete('/:recipeId', ctx.asyncHandler(async (req, res) => { router.delete('/:recipeId', asyncHandler(async (req, res) => {
const { recipeId } = req.params; const { recipeId } = req.params;
const containers = await findRecipeContainers(recipeId); const containers = await findRecipeContainers(recipeId);
@@ -189,7 +197,7 @@ module.exports = function(ctx) {
throw new NotFoundError('Containers for recipe'); throw new NotFoundError('Containers for recipe');
} }
ctx.log.info('recipe', 'Removing recipe', { recipeId, containerCount: containers.length }); log.info('recipe', 'Removing recipe', { recipeId, containerCount: containers.length });
const results = []; const results = [];
const networkNames = new Set(); const networkNames = new Set();
@@ -213,7 +221,7 @@ module.exports = function(ctx) {
try { try {
await removeCaddyBlock(subdomain); await removeCaddyBlock(subdomain);
} catch (e) { } catch (e) {
ctx.log.warn('recipe', 'Failed to remove Caddy config', { subdomain, error: e.message }); log.warn('recipe', 'Failed to remove Caddy config', { subdomain, error: e.message });
} }
} }
@@ -226,7 +234,7 @@ module.exports = function(ctx) {
} }
// Remove recipe services from services.json // Remove recipe services from services.json
await ctx.servicesStateManager.update(services => { await servicesStateManager.update(services => {
return services.filter(s => s.recipeId !== recipeId); return services.filter(s => s.recipeId !== recipeId);
}); });
@@ -235,9 +243,9 @@ module.exports = function(ctx) {
try { try {
const network = ctx.docker.client.getNetwork(netName); const network = ctx.docker.client.getNetwork(netName);
await network.remove(); await network.remove();
ctx.log.info('recipe', 'Removed Docker network', { netName }); log.info('recipe', 'Removed Docker network', { netName });
} catch (e) { } catch (e) {
ctx.log.warn('recipe', 'Failed to remove network', { netName, error: e.message }); log.warn('recipe', 'Failed to remove network', { netName, error: e.message });
} }
} }
@@ -246,7 +254,7 @@ module.exports = function(ctx) {
'info' 'info'
); );
ctx.log.info('recipe', 'Recipe removed', { recipeId, results }); log.info('recipe', 'Recipe removed', { recipeId, results });
res.json({ success: true, recipeId, results }); res.json({ success: true, recipeId, results });
}, 'recipe-remove')); }, 'recipe-remove'));