Files
dashcaddy/dashcaddy-api/routes/containers.js
Krystie 81f778df72 Phase 3 (WIP): Refactor containers & monitoring routes
- routes/containers.js: Explicit deps (docker, asyncHandler, log)
- routes/monitoring.js: Explicit deps (docker, resourceMonitor)
- Pattern established: factory function with destructured deps
2026-03-22 11:09:16 +01:00

234 lines
8.0 KiB
JavaScript

/**
* 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;
};