const express = require('express'); const { DOCKER } = require('../constants'); const { paginate, parsePaginationParams } = require('../pagination'); const { NotFoundError } = require('../errors'); const { success } = require('../response-helpers'); /** * 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 = 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(); success(res, { 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(); success(res, { 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(); success(res, { 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 // Ensure log rotation on updated containers }, 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 (Windows/Docker Desktop can be slow to free ports) 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) { // Clean up the failed container so it doesn't block future attempts 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 after update 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 }); } 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', 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 }); } success(res, { 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 }); success(res, { logs: logs.toString() }); }, 'container-logs')); // Update resource limits on a running container router.put('/:id/resources', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); const { memory, cpus } = req.body; const updateConfig = {}; if (memory !== undefined) { updateConfig.Memory = memory > 0 ? Math.round(memory * 1024 * 1024) : 0; // MB to bytes, 0 = unlimited updateConfig.MemoryReservation = memory > 0 ? Math.round(memory * 1024 * 1024 * 0.5) : 0; } if (cpus !== undefined) { updateConfig.NanoCpus = cpus > 0 ? Math.round(cpus * 1e9) : 0; // 0 = unlimited } await container.update(updateConfig); success(res, { message: 'Resource limits updated' }); }, 'container-resources')); // Get resource limits for a container router.get('/:id/resources', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); const info = await container.inspect(); const hc = info.HostConfig; success(res, { memory: hc.Memory ? Math.round(hc.Memory / 1024 / 1024) : 0, // bytes to MB memoryReservation: hc.MemoryReservation ? Math.round(hc.MemoryReservation / 1024 / 1024) : 0, cpus: hc.NanoCpus ? hc.NanoCpus / 1e9 : 0, }); }, 'container-resources-get')); // Delete container router.delete('/:id', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.remove({ force: true }); success(res, { 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); success(res, { containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); }, 'containers-discover')); return router; };