/** * Docker Maintenance Module * Scheduled cleanup to prevent Docker disk bloat: * - Prunes dangling images * - Prunes stopped non-managed containers * - Clears build cache * - Monitors disk usage and warns when thresholds exceeded */ const Docker = require('dockerode'); const EventEmitter = require('events'); const { DOCKER } = require('./constants'); const docker = new Docker(); class DockerMaintenance extends EventEmitter { constructor() { super(); this.interval = null; this.running = false; this.lastRun = null; this.lastResult = null; } start() { if (this.running) return; this.running = true; // Run first maintenance 5 minutes after startup (let everything settle) setTimeout(() => { if (!this.running) return; this.runMaintenance().catch(() => {}); }, 5 * 60 * 1000); // Then run on the configured interval (default 24h) this.interval = setInterval(() => { this.runMaintenance().catch(() => {}); }, DOCKER.MAINTENANCE.INTERVAL); } stop() { if (!this.running) return; this.running = false; if (this.interval) { clearInterval(this.interval); this.interval = null; } } async runMaintenance() { const startTime = Date.now(); const result = { timestamp: new Date().toISOString(), pruned: { images: 0, containers: 0, buildCache: 0 }, spaceReclaimed: { images: 0, containers: 0, buildCache: 0, total: 0 }, diskUsage: null, warnings: [], containersWithoutLogLimits: [] }; try { // 1. Prune dangling images try { const imgResult = await docker.pruneImages({ filters: { dangling: { true: true } } }); result.pruned.images = (imgResult.ImagesDeleted || []).length; result.spaceReclaimed.images = imgResult.SpaceReclaimed || 0; } catch (e) { result.warnings.push(`Image prune failed: ${e.message}`); } // 2. Prune stopped containers (only non-managed ones) try { const stopped = await docker.listContainers({ all: true, filters: { status: ['exited', 'dead'] } }); for (const c of stopped) { // Skip DashCaddy-managed containers — user may want to restart them if (c.Labels?.['sami.managed'] === 'true') continue; // Skip containers stopped less than 24h ago const stoppedAge = Date.now() / 1000 - c.Created; if (stoppedAge < 86400) continue; try { const container = docker.getContainer(c.Id); await container.remove({ force: true }); result.pruned.containers++; } catch (e) { // Container may have been removed between list and remove } } } catch (e) { result.warnings.push(`Container prune failed: ${e.message}`); } // 3. Prune build cache try { const cacheResult = await docker.pruneBuilder(); result.spaceReclaimed.buildCache = cacheResult.SpaceReclaimed || 0; result.pruned.buildCache = (cacheResult.CachesDeleted || []).length; } catch (e) { // Build cache prune may not be available on all Docker versions result.warnings.push(`Build cache prune failed: ${e.message}`); } // 4. Get disk usage try { const df = await docker.df(); result.diskUsage = { images: { count: (df.Images || []).length, sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0) }, containers: { count: (df.Containers || []).length, sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0) }, volumes: { count: (df.Volumes?.Volumes || []).length, sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0) }, buildCache: { count: (df.BuildCache || []).length, sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0) } }; result.diskUsage.totalBytes = result.diskUsage.images.sizeBytes + result.diskUsage.containers.sizeBytes + result.diskUsage.volumes.sizeBytes + result.diskUsage.buildCache.sizeBytes; result.diskUsage.totalGB = +(result.diskUsage.totalBytes / (1024 ** 3)).toFixed(2); if (result.diskUsage.totalGB > DOCKER.MAINTENANCE.DISK_WARN_GB) { result.warnings.push(`Docker disk usage is ${result.diskUsage.totalGB}GB (threshold: ${DOCKER.MAINTENANCE.DISK_WARN_GB}GB)`); } } catch (e) { result.warnings.push(`Disk usage check failed: ${e.message}`); } // 5. Check for containers without log rotation try { const running = await docker.listContainers({ all: false }); for (const c of running) { if (c.Labels?.['sami.managed'] !== 'true') continue; try { const container = docker.getContainer(c.Id); const info = await container.inspect(); const logConfig = info.HostConfig?.LogConfig; if (!logConfig?.Config?.['max-size']) { result.containersWithoutLogLimits.push({ name: c.Names[0]?.replace(/^\//, '') || c.Id.slice(0, 12), id: c.Id.slice(0, 12) }); } } catch (e) { // Container may have stopped between list and inspect } } if (result.containersWithoutLogLimits.length > 0) { result.warnings.push( `${result.containersWithoutLogLimits.length} container(s) have no log rotation — restart or update them to apply log limits: ${result.containersWithoutLogLimits.map(c => c.name).join(', ')}` ); } } catch (e) { result.warnings.push(`Log config check failed: ${e.message}`); } result.spaceReclaimed.total = result.spaceReclaimed.images + result.spaceReclaimed.containers + result.spaceReclaimed.buildCache; result.duration = Date.now() - startTime; this.lastRun = new Date().toISOString(); this.lastResult = result; this.emit('maintenance-complete', result); return result; } catch (error) { result.error = error.message; result.duration = Date.now() - startTime; this.lastResult = result; this.emit('maintenance-failed', result); throw error; } } /** Get Docker disk usage snapshot (callable on demand) */ async getDiskUsage() { try { const df = await docker.df(); const images = { count: (df.Images || []).length, sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0) }; const containers = { count: (df.Containers || []).length, sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0) }; const volumes = { count: (df.Volumes?.Volumes || []).length, sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0) }; const buildCache = { count: (df.BuildCache || []).length, sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0) }; const totalBytes = images.sizeBytes + containers.sizeBytes + volumes.sizeBytes + buildCache.sizeBytes; return { images, containers, volumes, buildCache, totalBytes, totalGB: +(totalBytes / (1024 ** 3)).toFixed(2) }; } catch (e) { return null; } } getStatus() { return { running: this.running, lastRun: this.lastRun, lastResult: this.lastResult }; } } module.exports = new DockerMaintenance();