377 lines
12 KiB
JavaScript
377 lines
12 KiB
JavaScript
/**
|
|
* 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) => console.error(err));
|
|
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<DriftReport>}
|
|
*/
|
|
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', 'Failed to read services', { error: err.message });
|
|
}
|
|
|
|
// Gather live Docker containers
|
|
let containers = [];
|
|
try {
|
|
containers = await this.docker.client.listContainers({ all: true });
|
|
} catch (err) {
|
|
this.log.error('drift', 'Failed to list containers', { error: err.message });
|
|
}
|
|
|
|
// 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<Object>}
|
|
* @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 };
|