const express = require('express'); const { DOCKER } = require('../constants'); const { paginate, parsePaginationParams } = require('../pagination'); const { NotFoundError } = require('../errors'); module.exports = function(ctx) { const router = express.Router(); // Helper: verify container exists before operating on it async function getVerifiedContainer(id) { const container = ctx.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', ctx.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', ctx.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', ctx.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', ctx.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(/^\//, ''); ctx.log.info('docker', 'Updating container', { containerName, imageName }); // Pull the latest image ctx.log.info('docker', `Pulling latest image: ${imageName}`); await ctx.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 }, 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 ctx.log.info('docker', 'Stopping container', { containerName }); await container.stop().catch(() => {}); // Ignore if already stopped ctx.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 }); let newContainer; try { newContainer = await ctx.docker.client.createContainer(config); ctx.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 }); if (newContainer) { try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ } } throw startError; } const newContainerInfo = await newContainer.inspect(); res.json({ success: true, 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) => { 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 localImageInfo = await localImage.inspect(); const localDigest = localImageInfo.RepoDigests?.[0] || null; let updateAvailable = false; try { const pullStream = await ctx.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 newImageInfo = await newImage.inspect(); const newDigest = newImageInfo.RepoDigests?.[0] || null; if (localDigest && newDigest && localDigest !== newDigest) { updateAvailable = true; } } catch (pullError) { ctx.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', ctx.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', ctx.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', ctx.asyncHandler(async (req, res) => { const containers = await ctx.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; };