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>
122 lines
5.7 KiB
JavaScript
122 lines
5.7 KiB
JavaScript
const express = require('express');
|
|
const { exists } = require('../../fs-helpers');
|
|
|
|
module.exports = function(ctx, helpers) {
|
|
const router = express.Router();
|
|
|
|
// Remove deployed app
|
|
router.delete('/apps/:appId', ctx.asyncHandler(async (req, res) => {
|
|
const { appId } = req.params;
|
|
const { containerId, subdomain, ip, deleteContainer } = req.query;
|
|
const shouldDeleteContainer = deleteContainer === 'true';
|
|
const results = { container: null, dns: null, caddy: null, service: null };
|
|
|
|
try {
|
|
ctx.log.info('deploy', 'Removing app', { appId, containerId, subdomain, deleteContainer: shouldDeleteContainer });
|
|
|
|
if (containerId && shouldDeleteContainer) {
|
|
try {
|
|
const container = ctx.docker.client.getContainer(containerId);
|
|
try { await container.stop(); ctx.log.info('docker', 'Container stopped', { containerId }); }
|
|
catch (stopError) { ctx.log.debug('docker', 'Container stop note', { containerId, note: stopError.message }); }
|
|
await container.remove({ force: true });
|
|
results.container = 'removed';
|
|
ctx.log.info('docker', 'Container removed', { containerId });
|
|
// Prune dangling images after removal
|
|
try {
|
|
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
|
|
if (pruneResult.SpaceReclaimed > 0) {
|
|
ctx.log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
|
|
}
|
|
} catch (pruneErr) {
|
|
ctx.log.debug('docker', 'Image prune after removal failed', { error: pruneErr.message });
|
|
}
|
|
} catch (error) {
|
|
results.container = error.message.includes('no such container') ? 'already removed' : error.message;
|
|
}
|
|
} else if (containerId && !shouldDeleteContainer) {
|
|
results.container = 'kept (user choice)';
|
|
}
|
|
|
|
if (shouldDeleteContainer && subdomain && ctx.dns.getToken()) {
|
|
try {
|
|
const domain = ctx.buildDomain(subdomain);
|
|
const getResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/get', {
|
|
token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true'
|
|
});
|
|
let recordIp = ip || 'localhost';
|
|
if (getResult.status === 'ok' && getResult.response?.records) {
|
|
const aRecord = getResult.response.records.find(r => r.type === 'A');
|
|
if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress;
|
|
}
|
|
const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
|
|
token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp
|
|
});
|
|
results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed');
|
|
ctx.log.info('dns', 'DNS record removal', { result: results.dns });
|
|
} catch (error) {
|
|
results.dns = error.message;
|
|
}
|
|
} else if (!shouldDeleteContainer) {
|
|
results.dns = 'kept (user choice)';
|
|
} else {
|
|
results.dns = 'skipped (no subdomain or token)';
|
|
}
|
|
|
|
if (shouldDeleteContainer && subdomain) {
|
|
try {
|
|
// Check if this service was deployed in subdirectory mode
|
|
const services = await ctx.servicesStateManager.read();
|
|
const serviceList = Array.isArray(services) ? services : [];
|
|
const service = serviceList.find(s => s.id === subdomain);
|
|
|
|
if (service?.routingMode === 'subdirectory') {
|
|
// Subdirectory mode: remove handle block from inside main domain block
|
|
const subResult = await helpers.removeSubpathConfig(subdomain);
|
|
results.caddy = subResult?.success ? 'removed' : 'not found';
|
|
} else {
|
|
// Subdomain mode: remove standalone domain block
|
|
const domain = ctx.buildDomain(subdomain);
|
|
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g');
|
|
const caddyResult = await ctx.caddy.modify(currentContent => {
|
|
const replaced = currentContent.replace(siteBlockRegex, '\n');
|
|
if (replaced.length === currentContent.length) return null;
|
|
return replaced.replace(/\n{3,}/g, '\n\n');
|
|
});
|
|
results.caddy = caddyResult.success ? 'removed' : (caddyResult.rolledBack ? 'removed (reload failed)' : 'not found');
|
|
}
|
|
ctx.log.info('caddy', 'Caddy config removal', { result: results.caddy });
|
|
} catch (error) {
|
|
results.caddy = error.message;
|
|
}
|
|
} else if (!shouldDeleteContainer) {
|
|
results.caddy = 'kept (user choice)';
|
|
}
|
|
|
|
try {
|
|
if (await exists(ctx.SERVICES_FILE)) {
|
|
let removed = false;
|
|
await ctx.servicesStateManager.update(services => {
|
|
const initialLength = services.length;
|
|
const filtered = services.filter(s => s.id !== subdomain);
|
|
removed = filtered.length !== initialLength;
|
|
return filtered;
|
|
});
|
|
results.service = removed ? 'removed' : 'not found';
|
|
}
|
|
ctx.log.info('deploy', 'Service config removal', { result: results.service });
|
|
} catch (error) {
|
|
results.service = error.message;
|
|
}
|
|
|
|
res.json({ success: true, message: `App ${appId} removal completed`, results });
|
|
} catch (error) {
|
|
await ctx.logError('app-removal', error);
|
|
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error), { results });
|
|
}
|
|
}, 'apps-delete'));
|
|
|
|
return router;
|
|
};
|