322 lines
11 KiB
JavaScript
322 lines
11 KiB
JavaScript
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;
|
|
};
|