/** * Container management routes * Refactored to use explicit dependencies instead of ctx god object */ const express = require('express'); const { DOCKER } = require('../constants'); const { paginate, parsePaginationParams } = require('../pagination'); const { NotFoundError } = require('../errors'); const asyncHandler = require('../src/utils/async-handler'); const log = require('../src/utils/logger'); module.exports = function({ docker }) { const router = express.Router(); // Helper: verify container exists before operating on it async function getVerifiedContainer(id) { const container = docker.client.getContainer(id); try { await container.inspect(); } catch (err) { if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) { throw new NotFoundError(`Container ${id}`); } throw err; } return container; } // Start container 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' }); }, 'container-start')); // Stop container 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' }); }, 'container-stop')); // Restart container 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' }); }, 'container-restart')); // Update container to latest image version router.post('/:id/update', asyncHandler(async (req, res) => { const containerId = req.params.id; const container = await getVerifiedContainer(containerId); // Get container info const containerInfo = await container.inspect(); const imageName = containerInfo.Config.Image; const containerName = containerInfo.Name.replace(/^\//, ''); log.info('docker', 'Updating container', { containerName, imageName }); // Pull the latest image log.info('docker', `Pulling latest image: ${imageName}`); await docker.pull(imageName); // Get current container config for recreation const hostConfig = containerInfo.HostConfig; const config = { Image: imageName, name: containerName, Env: containerInfo.Config.Env, ExposedPorts: containerInfo.Config.ExposedPorts, Labels: containerInfo.Config.Labels, HostConfig: { Binds: hostConfig.Binds, PortBindings: hostConfig.PortBindings, RestartPolicy: hostConfig.RestartPolicy, NetworkMode: hostConfig.NetworkMode, ExtraHosts: hostConfig.ExtraHosts, Privileged: hostConfig.Privileged, CapAdd: hostConfig.CapAdd, CapDrop: hostConfig.CapDrop, Devices: hostConfig.Devices, LogConfig: DOCKER.LOG_CONFIG, }, NetworkingConfig: {}, }; // Get network settings if using a custom network if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) { const networkName = hostConfig.NetworkMode; config.NetworkingConfig.EndpointsConfig = { [networkName]: containerInfo.NetworkSettings.Networks[networkName], }; } // Stop and remove old container log.info('docker', 'Stopping container', { containerName }); await container.stop().catch(() => {}); // Ignore if already stopped log.info('docker', 'Removing container', { containerName }); await container.remove(); // Wait for port release await new Promise((r) => setTimeout(r, 3000)); // Create and start new container log.info('docker', 'Creating new container', { containerName }); let newContainer; try { newContainer = await docker.client.createContainer(config); log.info('docker', 'Starting container', { containerName }); await newContainer.start(); } catch (startError) { log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); if (newContainer) { try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ } } throw startError; } const newContainerInfo = await newContainer.inspect(); // Prune dangling images try { const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024)}MB`, }); } } catch (pruneErr) { log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); } res.json({ success: true, message: `Container ${containerName} updated successfully`, newContainerId: newContainerInfo.Id, }); }, 'container-update')); // Check for available updates 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 = docker.client.getImage(containerInfo.Image); const localImageInfo = await localImage.inspect(); const localDigest = localImageInfo.RepoDigests?.[0] || null; let updateAvailable = false; try { const pullStream = await docker.pull(imageName); const downloadedLayers = pullStream.filter( (e) => e.status === 'Downloading' || e.status === 'Download complete', ); updateAvailable = downloadedLayers.length > 0; const newImage = docker.client.getImage(imageName); const newImageInfo = await newImage.inspect(); const newDigest = newImageInfo.RepoDigests?.[0] || null; if (localDigest && newDigest && localDigest !== newDigest) { updateAvailable = true; } } catch (pullError) { log.debug('docker', 'Could not check for updates', { error: pullError.message }); } res.json({ success: true, imageName, updateAvailable, currentDigest: localDigest, }); }, 'container-check-update')); // Get container logs router.get('/:id/logs', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); const logs = await container.logs({ stdout: true, stderr: true, tail: 100, timestamps: true, }); res.json({ success: true, logs: logs.toString() }); }, 'container-logs')); // Delete container 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' }); }, 'container-delete')); // Discover running containers 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', ); const discoveredContainers = samiContainers.map((container) => ({ id: container.Id, name: container.Names[0].replace('/', ''), image: container.Image, state: container.State, status: container.Status, appTemplate: container.Labels['sami.app'], subdomain: container.Labels['sami.subdomain'], ports: container.Ports, })); const paginationParams = parsePaginationParams(req.query); const result = paginate(discoveredContainers, paginationParams); res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }), }); }, 'containers-discover')); return router; };