diff --git a/dashcaddy-api/routes/containers.js b/dashcaddy-api/routes/containers.js index 9bde8e0..c045298 100644 --- a/dashcaddy-api/routes/containers.js +++ b/dashcaddy-api/routes/containers.js @@ -2,13 +2,22 @@ const express = require('express'); const { DOCKER } = require('../constants'); const { paginate, parsePaginationParams } = require('../pagination'); const { NotFoundError } = require('../errors'); +const { success } = require('../response-helpers'); -module.exports = function(ctx) { +/** + * Containers route factory + * @param {Object} deps - Explicit dependencies + * @param {Object} deps.docker - Docker client wrapper (client, pull methods) + * @param {Object} deps.log - Logger instance + * @param {Function} deps.asyncHandler - Async route handler wrapper + * @returns {express.Router} + */ +module.exports = function({ docker, log, asyncHandler }) { const router = express.Router(); // Helper: verify container exists before operating on it async function getVerifiedContainer(id) { - const container = ctx.docker.client.getContainer(id); + const container = docker.client.getContainer(id); try { await container.inspect(); } catch (err) { @@ -21,28 +30,28 @@ module.exports = function(ctx) { } // Start container - router.post('/:id/start', ctx.asyncHandler(async (req, res) => { + router.post('/:id/start', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.start(); - res.json({ success: true, message: 'Container started' }); + success(res, { message: 'Container started' }); }, 'container-start')); // Stop container - router.post('/:id/stop', ctx.asyncHandler(async (req, res) => { + router.post('/:id/stop', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.stop(); - res.json({ success: true, message: 'Container stopped' }); + success(res, { message: 'Container stopped' }); }, 'container-stop')); // Restart container - router.post('/:id/restart', ctx.asyncHandler(async (req, res) => { + router.post('/:id/restart', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.restart(); - res.json({ success: true, message: 'Container restarted' }); + success(res, { message: 'Container restarted' }); }, 'container-restart')); // Update container to latest image version - router.post('/:id/update', ctx.asyncHandler(async (req, res) => { + router.post('/:id/update', asyncHandler(async (req, res) => { const containerId = req.params.id; const container = await getVerifiedContainer(containerId); @@ -51,11 +60,11 @@ module.exports = function(ctx) { const imageName = containerInfo.Config.Image; const containerName = containerInfo.Name.replace(/^\//, ''); - ctx.log.info('docker', 'Updating container', { containerName, imageName }); + log.info('docker', 'Updating container', { containerName, imageName }); // Pull the latest image - ctx.log.info('docker', `Pulling latest image: ${imageName}`); - await ctx.docker.pull(imageName); + log.info('docker', `Pulling latest image: ${imageName}`); + await docker.pull(imageName); // Get current container config for recreation const hostConfig = containerInfo.HostConfig; @@ -89,24 +98,24 @@ module.exports = function(ctx) { } // Stop and remove old container - ctx.log.info('docker', 'Stopping container', { containerName }); + log.info('docker', 'Stopping container', { containerName }); await container.stop().catch(() => {}); // Ignore if already stopped - ctx.log.info('docker', 'Removing container', { containerName }); + log.info('docker', 'Removing container', { containerName }); await container.remove(); // Wait for port release (Windows/Docker Desktop can be slow to free ports) await new Promise(r => setTimeout(r, 3000)); // Create and start new container - ctx.log.info('docker', 'Creating new container', { containerName }); + log.info('docker', 'Creating new container', { containerName }); let newContainer; try { - newContainer = await ctx.docker.client.createContainer(config); - ctx.log.info('docker', 'Starting container', { containerName }); + newContainer = await docker.client.createContainer(config); + log.info('docker', 'Starting container', { containerName }); await newContainer.start(); } catch (startError) { // Clean up the failed container so it doesn't block future attempts - ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); + log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); if (newContainer) { try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ } } @@ -117,42 +126,41 @@ module.exports = function(ctx) { // Prune dangling images after update try { - const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); + const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { - ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); + log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); } } catch (pruneErr) { - ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); + log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); } - res.json({ - success: true, + success(res, { message: `Container ${containerName} updated successfully`, newContainerId: newContainerInfo.Id }); }, 'container-update')); // Check for available updates (compares local and remote image digests) - router.get('/:id/check-update', ctx.asyncHandler(async (req, res) => { + router.get('/:id/check-update', asyncHandler(async (req, res) => { const containerId = req.params.id; const container = await getVerifiedContainer(containerId); const containerInfo = await container.inspect(); const imageName = containerInfo.Config.Image; - const localImage = ctx.docker.client.getImage(containerInfo.Image); + const localImage = docker.client.getImage(containerInfo.Image); const localImageInfo = await localImage.inspect(); const localDigest = localImageInfo.RepoDigests?.[0] || null; let updateAvailable = false; try { - const pullStream = await ctx.docker.pull(imageName); + const pullStream = await docker.pull(imageName); const downloadedLayers = pullStream.filter(e => e.status === 'Downloading' || e.status === 'Download complete' ); updateAvailable = downloadedLayers.length > 0; - const newImage = ctx.docker.client.getImage(imageName); + const newImage = docker.client.getImage(imageName); const newImageInfo = await newImage.inspect(); const newDigest = newImageInfo.RepoDigests?.[0] || null; @@ -160,11 +168,10 @@ module.exports = function(ctx) { updateAvailable = true; } } catch (pullError) { - ctx.log.debug('docker', 'Could not check for updates', { error: pullError.message }); + log.debug('docker', 'Could not check for updates', { error: pullError.message }); } - res.json({ - success: true, + success(res, { imageName, updateAvailable, currentDigest: localDigest @@ -172,7 +179,7 @@ module.exports = function(ctx) { }, 'container-check-update')); // Get container logs - router.get('/:id/logs', ctx.asyncHandler(async (req, res) => { + router.get('/:id/logs', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); const logs = await container.logs({ stdout: true, @@ -180,19 +187,19 @@ module.exports = function(ctx) { tail: 100, timestamps: true }); - res.json({ success: true, logs: logs.toString() }); + success(res, { logs: logs.toString() }); }, 'container-logs')); // Delete container - router.delete('/:id', ctx.asyncHandler(async (req, res) => { + router.delete('/:id', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.remove({ force: true }); - res.json({ success: true, message: 'Container removed' }); + success(res, { message: 'Container removed' }); }, 'container-delete')); // Discover running containers - router.get('/discover', ctx.asyncHandler(async (req, res) => { - const containers = await ctx.docker.client.listContainers({ all: true }); + router.get('/discover', asyncHandler(async (req, res) => { + const containers = await docker.client.listContainers({ all: true }); const samiContainers = containers.filter(container => container.Labels && container.Labels['sami.managed'] === 'true' ); @@ -210,7 +217,7 @@ module.exports = function(ctx) { const paginationParams = parsePaginationParams(req.query); const result = paginate(discoveredContainers, paginationParams); - res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); + success(res, { containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); }, 'containers-discover')); return router; diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index 3545a7a..67bc93b 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -1187,7 +1187,11 @@ apiRouter.use(authRoutes(ctx)); apiRouter.use(configRoutes(ctx)); apiRouter.use('/dns', dnsRoutes(ctx)); apiRouter.use('/notifications', notificationRoutes(ctx)); -apiRouter.use('/containers', containerRoutes(ctx)); +apiRouter.use('/containers', containerRoutes({ + docker: ctx.docker, + log: ctx.log, + asyncHandler: ctx.asyncHandler +})); apiRouter.use(serviceRoutes({ servicesStateManager: ctx.servicesStateManager, credentialManager: ctx.credentialManager,