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:
@@ -3,7 +3,18 @@ const { ValidationError } = require('../../errors');
|
||||
const crypto = require('crypto');
|
||||
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();
|
||||
|
||||
/**
|
||||
@@ -12,14 +23,14 @@ module.exports = function(ctx) {
|
||||
* POST /api/recipes/deploy
|
||||
* 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 { RECIPE_TEMPLATES } = require('../../recipe-templates');
|
||||
|
||||
const recipe = RECIPE_TEMPLATES[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
|
||||
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) {
|
||||
networkName = recipe.network.name;
|
||||
try {
|
||||
await ctx.docker.client.createNetwork({
|
||||
await docker.client.createNetwork({
|
||||
Name: networkName,
|
||||
Driver: recipe.network.driver || 'bridge',
|
||||
Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId }
|
||||
});
|
||||
ctx.log.info('recipe', 'Created Docker network', { networkName });
|
||||
log.info('recipe', 'Created Docker network', { networkName });
|
||||
} catch (e) {
|
||||
// Network might already exist
|
||||
if (!e.message.includes('already exists')) {
|
||||
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 {
|
||||
for (const component of componentsToDeploy) {
|
||||
try {
|
||||
ctx.log.info('recipe', `Deploying component: ${component.id}`, {
|
||||
log.info('recipe', `Deploying component: ${component.id}`, {
|
||||
role: component.role,
|
||||
internal: component.internal || false
|
||||
});
|
||||
@@ -69,11 +80,11 @@ module.exports = function(ctx) {
|
||||
const result = await deployComponent(component, recipe, config, generatedPasswords, networkName);
|
||||
deployedComponents.push(result);
|
||||
|
||||
ctx.log.info('recipe', `Component deployed: ${component.id}`, {
|
||||
log.info('recipe', `Component deployed: ${component.id}`, {
|
||||
containerId: result.containerId?.substring(0, 12)
|
||||
});
|
||||
} catch (componentError) {
|
||||
ctx.log.error('recipe', `Component failed: ${component.id}`, {
|
||||
log.error('recipe', `Component failed: ${component.id}`, {
|
||||
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
|
||||
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
|
||||
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);
|
||||
} 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
|
||||
for (const deployed of deployedComponents) {
|
||||
try {
|
||||
if (deployed.containerId) {
|
||||
const container = ctx.docker.client.getContainer(deployed.containerId);
|
||||
const container = docker.client.getContainer(deployed.containerId);
|
||||
await container.remove({ force: true });
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
ctx.log.warn('recipe', 'Cleanup failed for component', {
|
||||
log.warn('recipe', 'Cleanup failed for component', {
|
||||
componentId: deployed.id, error: cleanupError.message
|
||||
});
|
||||
}
|
||||
@@ -155,10 +166,10 @@ module.exports = function(ctx) {
|
||||
// Cleanup network
|
||||
if (networkName) {
|
||||
try {
|
||||
const network = ctx.docker.client.getNetwork(networkName);
|
||||
const network = docker.client.getNetwork(networkName);
|
||||
await network.remove();
|
||||
} 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
|
||||
try {
|
||||
ctx.log.info('recipe', `Pulling image: ${dockerConfig.image}`);
|
||||
await ctx.docker.pull(dockerConfig.image);
|
||||
log.info('recipe', `Pulling image: ${dockerConfig.image}`);
|
||||
await docker.pull(dockerConfig.image);
|
||||
} catch (e) {
|
||||
ctx.log.warn('recipe', `Pull failed, checking local: ${dockerConfig.image}`);
|
||||
const images = await ctx.docker.client.listImages({
|
||||
log.warn('recipe', `Pull failed, checking local: ${dockerConfig.image}`);
|
||||
const images = await docker.client.listImages({
|
||||
filters: { reference: [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
|
||||
try {
|
||||
const existing = ctx.docker.client.getContainer(containerName);
|
||||
const existing = docker.client.getContainer(containerName);
|
||||
await existing.inspect();
|
||||
await existing.remove({ force: true });
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
@@ -305,17 +316,17 @@ module.exports = function(ctx) {
|
||||
}
|
||||
|
||||
// Create and start container
|
||||
const container = await ctx.docker.client.createContainer(containerConfig);
|
||||
const container = await docker.client.createContainer(containerConfig);
|
||||
await container.start();
|
||||
|
||||
// Connect to recipe network
|
||||
if (networkName) {
|
||||
try {
|
||||
const network = ctx.docker.client.getNetwork(networkName);
|
||||
const network = docker.client.getNetwork(networkName);
|
||||
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) {
|
||||
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);
|
||||
url = `https://${ctx.buildDomain(subdomain)}`;
|
||||
} 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) {
|
||||
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
|
||||
// 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) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,28 @@ const deployRoutes = require('./deploy');
|
||||
const manageRoutes = require('./manage');
|
||||
const { NotFoundError } = require('../../errors');
|
||||
|
||||
/**
|
||||
* Recipes routes aggregator
|
||||
* @param {Object} ctx - Application context (for backward compatibility)
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function(ctx) {
|
||||
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
|
||||
router.use(ctx.licenseManager.requirePremium('recipes'));
|
||||
|
||||
// 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 templates = Object.entries(RECIPE_TEMPLATES).map(([id, recipe]) => ({
|
||||
id,
|
||||
@@ -39,7 +53,7 @@ module.exports = function(ctx) {
|
||||
}, 'recipe-templates'));
|
||||
|
||||
// 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 = RECIPE_TEMPLATES[req.params.recipeId];
|
||||
if (!recipe) throw new NotFoundError(`Recipe template ${req.params.recipeId}`);
|
||||
@@ -48,8 +62,8 @@ module.exports = function(ctx) {
|
||||
}, 'recipe-template-detail'));
|
||||
|
||||
// Mount deploy and manage sub-routes
|
||||
router.use(deployRoutes(ctx));
|
||||
router.use(manageRoutes(ctx));
|
||||
router.use(deployRoutes(deps));
|
||||
router.use(manageRoutes(deps));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -2,14 +2,22 @@ const express = require('express');
|
||||
const { DOCKER } = require('../../constants');
|
||||
const { NotFoundError } = require('../../errors');
|
||||
|
||||
module.exports = function(ctx) {
|
||||
module.exports = function({ servicesStateManager, asyncHandler, log }) {
|
||||
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)
|
||||
*/
|
||||
router.get('/deployed', ctx.asyncHandler(async (req, res) => {
|
||||
const services = await ctx.servicesStateManager.read();
|
||||
router.get('/deployed', asyncHandler(async (req, res) => {
|
||||
const services = await servicesStateManager.read();
|
||||
const recipeGroups = {};
|
||||
|
||||
for (const service of services) {
|
||||
@@ -64,7 +72,7 @@ module.exports = function(ctx) {
|
||||
});
|
||||
}
|
||||
} 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
|
||||
@@ -92,7 +100,7 @@ module.exports = function(ctx) {
|
||||
/**
|
||||
* 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 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 });
|
||||
}, 'recipe-start'));
|
||||
|
||||
/**
|
||||
* 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 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 });
|
||||
}, 'recipe-stop'));
|
||||
|
||||
/**
|
||||
* 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 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 });
|
||||
}, 'recipe-restart'));
|
||||
|
||||
/**
|
||||
* 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 containers = await findRecipeContainers(recipeId);
|
||||
|
||||
@@ -189,7 +197,7 @@ module.exports = function(ctx) {
|
||||
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 networkNames = new Set();
|
||||
@@ -213,7 +221,7 @@ module.exports = function(ctx) {
|
||||
try {
|
||||
await removeCaddyBlock(subdomain);
|
||||
} 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
|
||||
await ctx.servicesStateManager.update(services => {
|
||||
await servicesStateManager.update(services => {
|
||||
return services.filter(s => s.recipeId !== recipeId);
|
||||
});
|
||||
|
||||
@@ -235,9 +243,9 @@ module.exports = function(ctx) {
|
||||
try {
|
||||
const network = ctx.docker.client.getNetwork(netName);
|
||||
await network.remove();
|
||||
ctx.log.info('recipe', 'Removed Docker network', { netName });
|
||||
log.info('recipe', 'Removed Docker network', { netName });
|
||||
} 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'
|
||||
);
|
||||
|
||||
ctx.log.info('recipe', 'Recipe removed', { recipeId, results });
|
||||
log.info('recipe', 'Recipe removed', { recipeId, results });
|
||||
res.json({ success: true, recipeId, results });
|
||||
}, 'recipe-remove'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user