Prevents Docker disk bloat by adding log rotation (10MB max, 3 files) to all container creation and update paths, auto-pruning dangling images after deploy/remove/update, and a daily maintenance module that cleans build cache and warns on disk thresholds. Saves a deployment manifest in services.json at deploy time so users can restore all their apps after a Docker purge. Adds restore-all and restore-single endpoints that recreate containers, Caddy config, and DNS records from the saved manifests. Adds an hourly log collector and daily digest generator that summarizes errors, warnings, and events across all services into a single human-readable report with guidance on where to investigate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
218 lines
8.1 KiB
JavaScript
218 lines
8.1 KiB
JavaScript
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,
|
|
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
|
|
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();
|
|
|
|
// Prune dangling images after update
|
|
try {
|
|
const pruneResult = await ctx.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' });
|
|
}
|
|
} catch (pruneErr) {
|
|
ctx.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 (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;
|
|
};
|