import { INTENTIONAL_KILL_WINDOW_MS } from './ContainerLifecycleClassifier'; import { DatabaseService, AutoHealPolicy, AutoHealHistoryEntry } from './DatabaseService'; import DockerController from './DockerController'; import { DockerEventManager } from './DockerEventManager'; import { ContainerHealthSnapshot } from './DockerEventService'; import { LicenseService } from './LicenseService'; import { NodeRegistry } from './NodeRegistry'; import { NotificationService } from './NotificationService'; import { PROXY_TIER_HEADER } from './license-headers'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; // Dockerode listContainers shape (subset used here) type ContainerInfo = { Id: string; Names?: string[]; Labels?: Record; State?: string; Status?: string; }; /** Why a container is eligible for healing. */ type HealReason = 'unhealthy' | 'crashed'; /** * Reasons shouldHeal declines to act. The `skipped_*` members are the subset * recorded to history (the matching values exist in AutoHealHistoryEntry['action']); * `not_unhealthy` and `duration_not_met` are internal-only and filtered out before * any history write. */ type SkipReason = | 'not_unhealthy' | 'duration_not_met' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit'; /** * Normalized heal trigger for a single container. `reason`/`downSince` are set * only when there is something to act on; the safety rails (kill window, * cooldown, hourly cap) are evaluated separately in shouldHeal. */ interface HealSignal { reason?: HealReason; /** When the heal-worthy condition began (unhealthy-since or crashed-at). */ downSince?: number; /** Last operator kill/stop, used to suppress healing right after a manual action. */ lastKillAt?: number; } const EVAL_INTERVAL_MS = 30_000; const INITIAL_DELAY_MS = 10_000; const RATE_LIMIT_WINDOW_MS = 60 * 60_000; // 1 hour const HISTORY_THROTTLE_MS = 5 * 60_000; // Refresh proxied remotes' entitlement well under the 5-minute lease the remote // route grants, so a remote node's policies keep evaluating between operator visits. const LEASE_REFRESH_INTERVAL_MS = 2 * 60_000; const LEASE_REFRESH_TIMEOUT_MS = 10_000; // Consecutive failed lease refreshes for one node before we surface a WARN. At the // 2-minute cadence this is ~6 minutes of a node being unreachable, by which point // its entitlement lease has lapsed and its auto-heal has stopped. const LEASE_REFRESH_FAILURE_WARN_THRESHOLD = 3; export class AutoHealService { private static instance: AutoHealService; private intervalId: NodeJS.Timeout | null = null; private initialTimer: NodeJS.Timeout | null = null; private leaseRefreshTimer: NodeJS.Timeout | null = null; private isProcessing = false; private restartTimestamps = new Map(); private observedUnhealthySince = new Map(); private historyTimestamps = new Map(); private leaseRefreshFailures = new Map(); /** * Ref-counted suppressions for service-scoped updates/restores. While a * service is being recreated and its post-mutation health gate observes it, * auto-heal must not restart the churning containers out from under the gate. * Keyed `${nodeId}:${stackName}:${serviceName}`. Nested owners (overlapping * same-service ops, or a gate that outlives the orchestrator finally) each * increment; clearSuppress only unmutes at zero. Process death clears it. */ private suppressedServices = new Map(); private constructor() {} static getInstance(): AutoHealService { if (!AutoHealService.instance) { AutoHealService.instance = new AutoHealService(); } return AutoHealService.instance; } start(): void { if (this.initialTimer || this.intervalId) return; this.initialTimer = setTimeout(() => { void this.evaluate(); this.intervalId = setInterval(() => void this.evaluate(), EVAL_INTERVAL_MS); }, INITIAL_DELAY_MS); // Keep proxied remotes' auto-heal entitlement alive without depending on // operator UI traffic (which would otherwise let the lease lapse). this.leaseRefreshTimer = setInterval( () => void this.refreshRemoteLeases(), LEASE_REFRESH_INTERVAL_MS, ); } stop(): void { if (this.initialTimer) { clearTimeout(this.initialTimer); this.initialTimer = null; } if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } if (this.leaseRefreshTimer) { clearInterval(this.leaseRefreshTimer); this.leaseRefreshTimer = null; } this.suppressedServices.clear(); } private suppressKey(nodeId: number, stackName: string, serviceName: string): string { return `${nodeId}:${stackName}:${serviceName}`; } /** Suppress auto-heal for one service while a service-scoped update/restore + its health gate run. */ suppress(nodeId: number, stackName: string, serviceName: string): void { const key = this.suppressKey(nodeId, stackName, serviceName); this.suppressedServices.set(key, (this.suppressedServices.get(key) ?? 0) + 1); } /** Release one suppression owner; Auto-Heal resumes only when the count hits zero. */ clearSuppress(nodeId: number, stackName: string, serviceName: string): void { const key = this.suppressKey(nodeId, stackName, serviceName); const current = this.suppressedServices.get(key) ?? 0; if (current <= 1) { this.suppressedServices.delete(key); return; } this.suppressedServices.set(key, current - 1); } /** True while the given service is suppressed. A stack-wide policy still heals other services. */ isSuppressed(nodeId: number, stackName: string, serviceName: string): boolean { return (this.suppressedServices.get(this.suppressKey(nodeId, stackName, serviceName)) ?? 0) > 0; } /** * Ping each enrolled remote node's auto-heal list endpoint so the remote * renews its proxy entitlement lease, keeping its policies evaluating * between operator visits to the Auto-Heal sheet. Best-effort and per-node * isolated: a single unreachable node never blocks the others or throws. */ private async refreshRemoteLeases(): Promise { const remotes = DatabaseService.getInstance().getNodes().filter(n => n.type === 'remote'); if (remotes.length === 0) return; const registry = NodeRegistry.getInstance(); const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); // Drop failure counters for nodes that are no longer enrolled. const remoteIds = new Set(remotes.map(n => n.id)); for (const id of this.leaseRefreshFailures.keys()) { if (!remoteIds.has(id)) this.leaseRefreshFailures.delete(id); } await Promise.allSettled(remotes.map(async (node) => { if (node.id === undefined) return; const nodeId = node.id; const target = registry.getProxyTarget(nodeId); if (!target) { // No reachable proxy target (e.g. a pilot-agent tunnel that is down // or a remote missing its URL/token): count it so a persistently // untargetable node is surfaced rather than silently skipped. this.noteLeaseRefreshFailure(nodeId, 'no proxy target'); return; } const baseUrl = target.apiUrl.replace(/\/$/, ''); try { const res = await fetch(`${baseUrl}/api/auto-heal/policies`, { method: 'GET', headers: { 'Authorization': `Bearer ${target.apiToken}`, [PROXY_TIER_HEADER]: proxyHeaders.tier, }, signal: AbortSignal.timeout(LEASE_REFRESH_TIMEOUT_MS), }); if (res.ok) { this.leaseRefreshFailures.delete(nodeId); } else { this.noteLeaseRefreshFailure(nodeId, `HTTP ${res.status}`); } } catch (err) { this.noteLeaseRefreshFailure(nodeId, getErrorMessage(err, 'unknown')); } })); } /** * Record a failed lease refresh for a node and surface a one-time WARN once it * has failed enough times in a row that its auto-heal has almost certainly * stopped, so a revoked token or a long-unreachable node is not silently lost. */ private noteLeaseRefreshFailure(nodeId: number, detail: string): void { const count = (this.leaseRefreshFailures.get(nodeId) ?? 0) + 1; this.leaseRefreshFailures.set(nodeId, count); if (count === LEASE_REFRESH_FAILURE_WARN_THRESHOLD) { console.warn( `[AutoHeal] Could not refresh auto-heal entitlement for node ${nodeId} ` + `after ${count} consecutive attempts (${detail}). Auto-heal on that node ` + `will stop until it is reachable again.`, ); } else if (isDebugEnabled()) { console.log(`[AutoHeal:diag] lease refresh for node ${nodeId} failed (attempt ${count}): ${detail}`); } } async evaluate(): Promise { if (this.isProcessing) return; this.isProcessing = true; try { const db = DatabaseService.getInstance(); // Evaluate only on local nodes (remote nodes self-monitor via their own instance) const nodes = db.getNodes().filter(n => n.type === 'local'); if (isDebugEnabled()) { console.log(`[AutoHeal:diag] evaluate: ${nodes.length} local node(s)`); } for (const node of nodes) { const policies = db.getAutoHealPolicies(undefined, node.id).filter(p => p.enabled === 1); this.pruneInactivePolicyHistory(node.id, policies); if (policies.length === 0) continue; if (isDebugEnabled()) { console.log(`[AutoHeal:diag] node ${node.id}: ${policies.length} active policy(ies)`); } await this.evaluateForNode(node.id, policies); } } catch (err) { console.error('[AutoHeal] evaluate error:', err instanceof Error ? err.message : err); } finally { this.isProcessing = false; } } private async evaluateForNode(nodeId: number, policies: AutoHealPolicy[]): Promise { let containers: ContainerInfo[]; try { // all:true so exited/crashed containers are visible too, not just // running-but-unhealthy ones. containers = await DockerController.getInstance(nodeId).getAllContainers(); } catch (err) { const now = Date.now(); const errorMsg = err instanceof Error ? err.message : String(err); console.error( `[AutoHeal] failed to list containers on node ${nodeId}:`, errorMsg, ); for (const policy of policies) { if (policy.id === undefined) continue; this.recordThrottledHistory(policy, { policy_id: policy.id!, stack_name: policy.stack_name, service_name: policy.service_name, container_name: `node-${nodeId}`, container_id: `node-${nodeId}`, action: 'docker_unavailable', reason: 'Skipped: Docker daemon is unavailable for this node.', success: 0, error: errorMsg, timestamp: now, }, nodeId); } return; } const eventSvc = DockerEventManager.getInstance().getService(nodeId); const now = Date.now(); // Prune stale entries for containers no longer running on this node const liveIds = new Set(containers.map(c => c.Id)); const liveKeys = new Set(containers.map(c => this.containerKey(nodeId, c.Id))); for (const [key, timestamps] of this.restartTimestamps.entries()) { if (!key.startsWith(`${nodeId}:`)) continue; const containerId = key.slice(String(nodeId).length + 1); const recent = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS); if (recent.length === 0 || !liveIds.has(containerId)) { this.restartTimestamps.delete(key); } else { this.restartTimestamps.set(key, recent); } } for (const key of this.observedUnhealthySince.keys()) { if (key.startsWith(`${nodeId}:`) && !liveKeys.has(key)) { this.observedUnhealthySince.delete(key); } } this.pruneInactivePolicyHistory(nodeId, policies); // Containers already restarted in this pass, so overlapping policies (an // all-services policy plus a service-specific one) can't double-restart // the same container and halve its hourly cap. const healedThisPass = new Set(); for (const policy of policies) { if (policy.id === undefined) { console.warn('[AutoHeal] skipping policy without id:', policy.stack_name); continue; } const candidates = containers.filter(c => { const labels = c.Labels ?? {}; if (labels['com.docker.compose.project'] !== policy.stack_name) return false; if (policy.service_name) { return labels['com.docker.compose.service'] === policy.service_name; } return true; }); for (const container of candidates) { const containerName = container.Names?.[0]?.replace(/^\//, '') ?? container.Id.slice(0, 12); const serviceOverride = container.Labels?.['com.docker.compose.service'] ?? null; // Skip containers whose service is suppressed by an in-flight // service-scoped update/restore, so the post-mutation health gate // owns the churn without auto-heal racing it. if (serviceOverride && this.isSuppressed(nodeId, policy.stack_name, serviceOverride)) { continue; } const signal = this.getHealSignal(nodeId, container, eventSvc?.getContainerState(container.Id), now); const decision = this.shouldHeal(signal, policy, this.containerKey(nodeId, container.Id), now); if (!decision.heal) { if ( decision.skipReason && decision.skipReason !== 'not_unhealthy' && decision.skipReason !== 'duration_not_met' ) { this.recordThrottledHistory(policy, { policy_id: policy.id!, stack_name: policy.stack_name, service_name: policy.service_name ?? serviceOverride, container_name: containerName, container_id: container.Id, action: decision.skipReason, reason: this.skipReasonText(decision.skipReason), success: 0, error: null, timestamp: now, }, nodeId, container.Id); } continue; } if (healedThisPass.has(container.Id)) continue; healedThisPass.add(container.Id); if (isDebugEnabled()) { const downForSec = signal.downSince ? Math.round((now - signal.downSince) / 1000) : 0; console.log(`[AutoHeal:diag] healing ${containerName} on node ${nodeId}: reason=${decision.reason} downFor=${downForSec}s`); } await this.executeHeal( policy, nodeId, container.Id, containerName, policy.service_name ?? serviceOverride, decision.reason ?? 'unhealthy', ); } } } private getHealSignal( nodeId: number, container: ContainerInfo, eventState: ContainerHealthSnapshot | undefined, now: number, ): HealSignal { const key = this.containerKey(nodeId, container.Id); // Stopped: heal only when the event stream classified the exit as a crash // or OOM kill. crashedAt is never stamped for operator stops or clean // exits, so intentionally-stopped containers are not resurrected. Checked // before any health-text parsing so an exited container can never fall into // the healthcheck path. const rawState = (container.State ?? '').toLowerCase(); if (rawState === 'exited' || rawState === 'dead') { this.observedUnhealthySince.delete(key); if (eventState?.crashedAt) { return { reason: 'crashed', downSince: eventState.crashedAt, lastKillAt: eventState.lastKillAt }; } return { lastKillAt: eventState?.lastKillAt }; } const statusText = `${container.State ?? ''} ${container.Status ?? ''}`.toLowerCase(); const dockerHealth = statusText.includes('unhealthy') ? 'unhealthy' : statusText.includes('healthy') ? 'healthy' : statusText.includes('starting') ? 'starting' : undefined; // Running but failing its healthcheck. if (dockerHealth === 'unhealthy') { const since = eventState?.healthStatus === 'unhealthy' && eventState.unhealthySince ? eventState.unhealthySince : this.observedUnhealthySince.get(key) ?? now; this.observedUnhealthySince.set(key, since); return { reason: 'unhealthy', downSince: since, lastKillAt: eventState?.lastKillAt }; } // Running and healthy (or warming up): clear unhealthy tracking, nothing to heal. if (dockerHealth === 'healthy' || dockerHealth === 'starting') { this.observedUnhealthySince.delete(key); return { lastKillAt: eventState?.lastKillAt }; } // Running without a Docker healthcheck (or freshly 'created'): fall back to // any health the event stream recorded, otherwise no trigger. if (eventState?.healthStatus === 'unhealthy' && eventState.unhealthySince) { this.observedUnhealthySince.set(key, eventState.unhealthySince); return { reason: 'unhealthy', downSince: eventState.unhealthySince, lastKillAt: eventState.lastKillAt }; } this.observedUnhealthySince.delete(key); return { lastKillAt: eventState?.lastKillAt }; } private shouldHeal( signal: HealSignal, policy: AutoHealPolicy, containerKey: string, now: number, ): { heal: boolean; skipReason?: SkipReason; reason?: HealReason } { // No heal-worthy condition (healthy, clean exit, or unclassified stop) if (!signal.reason || !signal.downSince) { return { heal: false, skipReason: 'not_unhealthy' }; } // Duration threshold not yet met if (now - signal.downSince < policy.unhealthy_duration_mins * 60_000) { return { heal: false, skipReason: 'duration_not_met' }; } // Suppress if user recently killed the container if (signal.lastKillAt !== undefined && now - signal.lastKillAt < INTENTIONAL_KILL_WINDOW_MS) { return { heal: false, skipReason: 'skipped_user_action' }; } // Cooldown: respect last_fired_at (set on every attempt, success or failure) if (policy.last_fired_at > 0 && now - policy.last_fired_at < policy.cooldown_mins * 60_000) { return { heal: false, skipReason: 'skipped_cooldown' }; } // Rate limit: max restart attempts per hour const recentRestarts = (this.restartTimestamps.get(containerKey) ?? []).filter( t => now - t < RATE_LIMIT_WINDOW_MS, ); if (recentRestarts.length >= policy.max_restarts_per_hour) { return { heal: false, skipReason: 'skipped_rate_limit' }; } return { heal: true, reason: signal.reason }; } private async executeHeal( policy: AutoHealPolicy, nodeId: number, containerId: string, containerName: string, serviceName: string | null, reason: HealReason, ): Promise { const db = DatabaseService.getInstance(); const now = Date.now(); const baseEntry = { policy_id: policy.id!, stack_name: policy.stack_name, service_name: serviceName, container_name: containerName, container_id: containerId, timestamp: now, }; const condition = reason === 'crashed' ? 'crashed and stayed down' : 'unhealthy'; // Stamp the attempt up front so the cooldown and hourly cap apply to a // failed restart too. Otherwise a container that keeps failing to restart // would be retried on every poll (every 30s) until auto-disable. db.updateAutoHealPolicy(policy.id!, { last_fired_at: now }); const restartKey = this.containerKey(nodeId, containerId); const timestamps = (this.restartTimestamps.get(restartKey) ?? []).filter( t => now - t < RATE_LIMIT_WINDOW_MS, ); timestamps.push(now); this.restartTimestamps.set(restartKey, timestamps); try { await DockerController.getInstance(nodeId).restartContainer(containerId); if (isDebugEnabled()) { console.log(`[AutoHeal:diag] restart of ${containerName} on node ${nodeId} took ${Date.now() - now}ms`); } db.resetConsecutiveFailures(policy.id!); db.recordAutoHealHistory({ ...baseEntry, action: 'restarted', reason: `Container ${condition} for ${policy.unhealthy_duration_mins} minute(s); auto-restarted.`, success: 1, error: null, }); db.insertAuditLog({ timestamp: now, username: 'system', method: 'POST', path: '/system/auto-heal', status_code: 200, node_id: nodeId, ip_address: '127.0.0.1', summary: `Auto-healed container ${containerName} on stack ${policy.stack_name}`, }); NotificationService.getInstance() .dispatchAlert( 'info', 'autoheal_triggered', `Auto-Heal: Restarted ${containerName} on stack ${policy.stack_name} after being ${condition} for ${policy.unhealthy_duration_mins} minute(s).`, { stackName: policy.stack_name, containerName, actor: 'system:autoheal' }, ) .catch(err => console.error('[AutoHeal] notification dispatch failed:', err)); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); db.incrementConsecutiveFailures(policy.id!); // Re-read to get updated consecutive_failures count const updated = db.getAutoHealPolicy(policy.id!); const failures = updated?.consecutive_failures ?? policy.consecutive_failures + 1; db.recordAutoHealHistory({ ...baseEntry, action: 'failed', reason: `Restart failed: ${errorMsg}`, success: 0, error: errorMsg, }); NotificationService.getInstance() .dispatchAlert( 'warning', 'autoheal_triggered', `Auto-Heal: Failed to restart ${containerName} on stack ${policy.stack_name}. Error: ${errorMsg}`, { stackName: policy.stack_name, containerName, actor: 'system:autoheal' }, ) .catch(e => console.error('[AutoHeal] notification dispatch failed:', e)); // Auto-disable if failure threshold reached if (failures >= policy.auto_disable_after_failures) { this.handleAutoDisable(policy.id!, policy, baseEntry, failures); } console.error(`[AutoHeal] restart failed for ${containerName} (${containerId}):`, errorMsg); } } private handleAutoDisable( policyId: number, policy: AutoHealPolicy, baseEntry: Omit[0], 'action' | 'reason' | 'success' | 'error'>, failures: number, ): void { const db = DatabaseService.getInstance(); db.setPolicyEnabled(policyId, false); db.recordAutoHealHistory({ ...baseEntry, action: 'policy_auto_disabled', reason: `Policy disabled after ${failures} consecutive restart failures. Check container logs and re-enable when resolved.`, success: 0, error: null, }); NotificationService.getInstance() .dispatchAlert( 'warning', 'autoheal_triggered', `Auto-Heal: Policy for ${policy.stack_name}${policy.service_name ? '/' + policy.service_name : ''} has been auto-disabled after ${failures} consecutive failures.`, { stackName: policy.stack_name, actor: 'system:autoheal' }, ) .catch(e => console.error('[AutoHeal] notification dispatch failed:', e)); } private recordThrottledHistory( policy: AutoHealPolicy, entry: Omit, nodeId: number, containerId = entry.container_id, ): void { if (policy.id === undefined) return; const key = `${nodeId}:${policy.id}:${containerId}:${entry.action}`; const lastRecorded = this.historyTimestamps.get(key) ?? 0; if (entry.timestamp - lastRecorded < HISTORY_THROTTLE_MS) return; this.historyTimestamps.set(key, entry.timestamp); DatabaseService.getInstance().recordAutoHealHistory(entry); } private pruneInactivePolicyHistory(nodeId: number, policies: AutoHealPolicy[]): void { const activePolicyIds = new Set(policies.map(p => p.id).filter((id): id is number => id !== undefined)); for (const key of this.historyTimestamps.keys()) { const [keyNodeId, policyId] = key.split(':'); if (keyNodeId === String(nodeId) && !activePolicyIds.has(Number(policyId))) { this.historyTimestamps.delete(key); } } } private containerKey(nodeId: number, containerId: string): string { return `${nodeId}:${containerId}`; } private skipReasonText(reason: SkipReason): string { switch (reason) { case 'skipped_user_action': return 'Skipped: recent user action detected on this container.'; case 'skipped_cooldown': return 'Skipped: cooldown period has not elapsed since last restart.'; case 'skipped_rate_limit': return 'Skipped: hourly restart limit reached for this container.'; default: return reason; } } }