/** * Config Drift Detector - Compares services.json with live Docker state * * Detects discrepancies between the configured service list and what is * actually running in Docker, including missing containers, unknown * containers, port mismatches, state mismatches, and stale records. * * @module config-drift-detector */ const EventEmitter = require('events'); /** * @typedef {Object} DriftReport * @property {string} checkedAt - ISO timestamp of the check * @property {Object[]} missingContainers - Services with containerId but container absent in Docker * @property {Object[]} unknownContainers - Running Docker containers with sami.managed label but not in services.json * @property {Object[]} portMismatch - Service port != container mapped port * @property {Object[]} stateMismatch - Service expected up but container stopped/absent * @property {Object[]} staleRecords - Services with containerId pointing to removed containers * @property {boolean} hasDrift - Whether any drift category is non-empty */ /** * Detects and reports configuration drift between services.json and Docker. * * @extends EventEmitter * * @fires ConfigDriftDetector#drift-detected */ class ConfigDriftDetector extends EventEmitter { /** * @param {Object} ctx - Shared application context * @param {Object} ctx.docker - Docker client wrapper ({ client: Dockerode }) * @param {Object} ctx.servicesStateManager - StateManager for services.json * @param {Object} ctx.notification - NotificationManager instance * @param {Object} ctx.log - Logger instance * @param {Function} ctx.logError - Error logging function */ constructor(ctx) { super(); this.ctx = ctx; this.log = ctx.log || console; this.logError = ctx.logError || ((_c, err) => process.stderr.write(`[config-drift] ${err?.message || err}\n`)); this.docker = ctx.docker; this.servicesStateManager = ctx.servicesStateManager; this.notification = ctx.notification; /** @type {DriftReport|null} Cached report from last detection */ this.lastReport = null; /** @type {NodeJS.Timeout|null} Polling timer reference */ this._pollTimer = null; /** Whether polling is currently active */ this._polling = false; } // ─── Detection ─────────────────────────────────────────────────────── /** * Run a full drift detection and return the report. * * Reads services from servicesStateManager and live containers from Docker, * then compares them across five drift categories. * * @returns {Promise} */ async detect() { const checkedAt = new Date().toISOString(); // Gather configured services let services = []; try { const data = await this.servicesStateManager.read(); services = Array.isArray(data) ? data : (data.services || []); } catch (err) { this.log.error('drift', err, null, { note: 'Failed to read services' }); } // Gather live Docker containers let containers = []; try { containers = await this.docker.client.listContainers({ all: true }); } catch (err) { this.log.error('drift', err, null, { note: 'Failed to list containers' }); } // Build lookup maps const containerById = new Map(); // containerId (short or long) → container info const containerByName = new Map(); // container name → container info for (const c of containers) { // Store by full ID containerById.set(c.Id, c); // Store by short ID (first 12 chars) if (c.Id && c.Id.length >= 12) { containerById.set(c.Id.substring(0, 12), c); } // Store by name (strip leading /) for (const name of (c.Names || [])) { containerByName.set(name.replace(/^\//, ''), c); } } // Build set of service containerIds for reverse lookup const serviceContainerIds = new Set(); const serviceByContainerId = new Map(); for (const svc of services) { if (svc.containerId) { serviceContainerIds.add(svc.containerId); // Index by both full and short ID serviceByContainerId.set(svc.containerId, svc); if (svc.containerId.length >= 12) { serviceByContainerId.set(svc.containerId.substring(0, 12), svc); } } } const missingContainers = []; const portMismatch = []; const stateMismatch = []; const staleRecords = []; for (const svc of services) { if (!svc.containerId) continue; // Look up the container const container = containerById.get(svc.containerId) || containerById.get(svc.containerId.substring(0, 12)); if (!container) { // Container ID referenced but not found in Docker at all staleRecords.push({ serviceId: svc.id, name: svc.name, containerId: svc.containerId, reason: 'Container not found in Docker', }); continue; } // Missing container — service expects it but it's not running if (container.State !== 'running') { missingContainers.push({ serviceId: svc.id, name: svc.name, containerId: svc.containerId, containerState: container.State, containerStatus: container.Status, }); // Also a state mismatch if the service is expected to be up stateMismatch.push({ serviceId: svc.id, name: svc.name, expectedState: 'running', actualState: container.State, containerId: svc.containerId, }); } // Port mismatch detection if (svc.port && container.State === 'running') { const actualPorts = this._extractContainerPorts(container); if (actualPorts.length > 0 && !actualPorts.includes(svc.port)) { portMismatch.push({ serviceId: svc.id, name: svc.name, configuredPort: svc.port, actualPorts, containerId: svc.containerId, }); } } } // Unknown managed containers: Docker containers with sami.managed label // that are NOT in services.json const unknownContainers = []; for (const c of containers) { const isManaged = c.Labels && c.Labels['sami.managed'] === 'true'; if (!isManaged) continue; const isInServices = serviceByContainerId.has(c.Id) || serviceByContainerId.has(c.Id.substring(0, 12)); if (!isInServices) { unknownContainers.push({ containerId: c.Id, name: (c.Names && c.Names[0] || '').replace(/^\//, ''), image: c.Image, state: c.State, status: c.Status, app: c.Labels?.['sami.app'] || null, subdomain: c.Labels?.['sami.subdomain'] || null, }); } } const report = { checkedAt, missingContainers, unknownContainers, portMismatch, stateMismatch, staleRecords, hasDrift: missingContainers.length > 0 || unknownContainers.length > 0 || portMismatch.length > 0 || stateMismatch.length > 0 || staleRecords.length > 0, }; // Cache for quick API access this.lastReport = report; // Emit and notify if drift detected if (report.hasDrift) { /** * @event ConfigDriftDetector#drift-detected * @type {DriftReport} */ this.emit('drift-detected', report); try { await this._sendDriftNotification(report); } catch (notifErr) { this.log.error('drift', 'Failed to send drift notification', { error: notifErr.message, }); } } this.log.info('drift', 'Detection complete', { hasDrift: report.hasDrift, missing: report.missingContainers.length, unknown: report.unknownContainers.length, portMismatch: report.portMismatch.length, stateMismatch: report.stateMismatch.length, stale: report.staleRecords.length, }); return report; } // ─── Auto-fix ──────────────────────────────────────────────────────── /** * Attempt to auto-fix drift: * - Remove stale records (services referencing removed containers) * - Flag unknown containers for review * * @returns {Promise<{ staleRemoved: number, unknownFlagged: number }>} */ async autoFix() { const report = await this.detect(); let staleRemoved = 0; // Remove stale records from services.json if (report.staleRecords.length > 0) { const staleIds = new Set(report.staleRecords.map(r => r.serviceId)); await this.servicesStateManager.update(services => { const before = services.length; const cleaned = services.filter(s => !staleIds.has(s.id)); staleRemoved = before - cleaned.length; return cleaned; }); } const unknownFlagged = report.unknownContainers.length; this.log.info('drift', 'Auto-fix applied', { staleRemoved, unknownFlagged }); return { staleRemoved, unknownFlagged }; } // ─── Polling ───────────────────────────────────────────────────────── /** * Start periodic drift detection. * * @param {number} [intervalMs=300000] - Polling interval in milliseconds (default 5 min) */ startPolling(intervalMs = 300000) { this.stopPolling(); this._polling = true; this._pollTimer = setInterval(async () => { try { await this.detect(); } catch (err) { this.logError('drift-poll', err); } }, intervalMs); this.log.info('drift', 'Polling started', { intervalMs }); } /** * Stop periodic drift detection. */ stopPolling() { if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; } this._polling = false; this.log.info('drift', 'Polling stopped'); } /** * Whether polling is currently active. * @returns {boolean} */ isPolling() { return this._polling; } // ─── Helpers ───────────────────────────────────────────────────────── /** * Extract mapped host ports from a Docker container info object. * * @param {Object} container - Dockerode container info * @returns {number[]} Array of host port numbers * @private */ _extractContainerPorts(container) { const ports = []; if (!container.Ports) return ports; for (const p of container.Ports) { if (p.PublicPort) { ports.push(p.PublicPort); } } return ports; } /** * Send a notification about detected drift. * * @param {DriftReport} report * @returns {Promise} * @private */ async _sendDriftNotification(report) { if (!this.notification?.send) { return { success: false, reason: 'no-notification-manager' }; } const parts = []; if (report.missingContainers.length > 0) { parts.push(`Missing containers: ${report.missingContainers.map(c => c.name).join(', ')}`); } if (report.unknownContainers.length > 0) { parts.push(`Unknown managed containers: ${report.unknownContainers.map(c => c.name).join(', ')}`); } if (report.portMismatch.length > 0) { parts.push(`Port mismatches: ${report.portMismatch.map(c => c.name).join(', ')}`); } if (report.staleRecords.length > 0) { parts.push(`Stale records: ${report.staleRecords.map(c => c.name).join(', ')}`); } return this.notification.send('drift-detected', { text: `⚠️ Configuration drift detected:\n${parts.join('\n')}`, report, }); } } module.exports = { ConfigDriftDetector };