import DockerController from './DockerController'; import type { DependencyContainer, DependencyNetwork } from './DockerController'; import { FileSystemService } from './FileSystemService'; import { declaredFromEffectiveModel } from '../helpers/effectiveToDeclaredCompose'; import type { DeclaredCompose, DeclaredService } from '../helpers/composeDependencyParse'; import { parseMissingRequiredVars } from '../helpers/envVarParse'; import { parseEffectiveModel } from './preflight/effectiveModel'; import { compareStackNetworks, fromDeclaredCompose } from './network/normalize'; import { sanitizeForLog, redactSensitiveText } from '../utils/safeLog'; import { getErrorMessage } from '../utils/errors'; const MAX_RENDER_ERROR = 600; /** * Spatial drift engine: compares a stack's effective compose model (from * `docker compose config`) against the live Docker runtime and reports where * the two diverge. Read-only and stateless. It does NOT persist findings, track change-over-time, or compare * against a last-applied hash: temporal drift ("the file changed since you * deployed"), env-key/value comparison, the cross-fleet rollup, and * unknown-source (orphan containers with no on-disk stack) belong to the * persistence-backed Drift Ledger that builds on this engine. The pure * assembleStackDrift step is exported so that layer can call it per stack. */ /** High-level alignment of a stack's runtime against its compose source. */ export type StackDriftStatus = 'in-sync' | 'drifted' | 'missing-runtime' | 'unreachable'; /** A specific, service-scoped reason a stack is drifted. */ export type DriftFindingKind = | 'service-missing' | 'service-undeclared' | 'image-mismatch' | 'ports-mismatch' | 'network-undeclared' | 'network-missing'; export interface StackDriftFinding { kind: DriftFindingKind; /** Compose service (or runtime service identity) the finding applies to. */ service: string; /** Specific, actionable description of the divergence. */ detail: string; /** Declared/expected value, when the finding compares two values. */ expected?: string; /** Observed runtime value, when the finding compares two values. */ actual?: string; } export interface StackDriftReport { stack: string; status: StackDriftStatus; /** True when a parseable compose file is present (false on a parse failure). */ hasComposeFile: boolean; /** True when the stack has at least one running container. */ hasContainers: boolean; findings: StackDriftFinding[]; /** Set when the compose file could not be parsed; status is then 'drifted'. */ parseError?: string; } // Container states that count as actually deployed. 'restarting' is included // deliberately: a crash-looping container is still the live deployment attempt, // so excluding it would falsely read the stack as missing-runtime. const RUNNING_STATES = new Set(['running', 'restarting']); /** * Normalizes an image reference so equivalent forms compare equal: the implicit * Docker Hub registry (`docker.io/`, plus its `library/` namespace for official * images) is stripped and a missing tag defaults to `:latest`. A digest-pinned * reference is left intact, so a digest runtime vs a tag-only declaration reads * as a mismatch. That is intentional: the engine prefers reporting an actionable * difference over hiding one, and never reports a false in-sync. */ export function normalizeImageRef(ref: string): string { let s = ref.trim(); if (!s) return s; if (s.startsWith('docker.io/')) { s = s.slice('docker.io/'.length); if (s.startsWith('library/')) s = s.slice('library/'.length); } if (s.includes('@')) return s; // digest-pinned: an exact ref, no :latest defaulting applies const lastSlash = s.lastIndexOf('/'); const lastColon = s.lastIndexOf(':'); const hasTag = lastColon > lastSlash; // a ':' after the last '/' is the tag return hasTag ? s : `${s}:latest`; } const portKey = (publishedPort: number, protocol: string): string => `${publishedPort}/${protocol}`; function setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false; for (const v of a) if (!b.has(v)) return false; return true; } function formatPorts(ports: Set): string { return ports.size ? [...ports].sort().join(', ') : 'none'; } /** Runtime aggregate for one service across its (possibly replicated) containers. */ interface RuntimeService { images: Set; ports: Set; } export interface AssembleStackDriftInput { stack: string; declared: DeclaredCompose; /** All runtime containers belonging to this stack (any state). */ containers: DependencyContainer[]; /** Every network on the node (for resolving foreign vs stack-owned attachments). */ networks?: DependencyNetwork[]; /** Set when the compose file could not be parsed. */ parseError?: string; } /** Add a network to a service's accumulated undeclared-attachment set. */ function addNetwork(map: Map>, service: string, network: string): void { let set = map.get(service); if (!set) { set = new Set(); map.set(service, set); } set.add(network); } /** * Network-level drift: a service attached to a network not declared in compose * (stack-owned-undeclared or owned by another stack) is one finding per service; * declared networks that no running service uses or that are absent from the * runtime are one stack-level finding. Reuses the same comparison the Network * Inspector uses, so the two surfaces never disagree. */ function networkDriftFindings( stack: string, declared: DeclaredCompose, containers: DependencyContainer[], networks: DependencyNetwork[], ): StackDriftFinding[] { // Runtime resource names use the Compose project (top-level `name:` when set), // not the stack directory, so a stack with `name:` resolves its networks the // same way Docker does. Containers are still attributed to the stack directory. const normalized = fromDeclaredCompose(declared, declared.projectName ?? stack); const facts = compareStackNetworks(normalized, { containers, networks, volumes: [] }, stack); const findings: StackDriftFinding[] = []; const serviceByContainer = new Map(containers.map(c => [c.name, c.service ?? c.name])); const undeclaredByService = new Map>(); for (const a of facts.runtimeOnlyAttachments) addNetwork(undeclaredByService, a.service ?? a.container, a.network); for (const a of facts.foreignNetworkAttachments) addNetwork(undeclaredByService, serviceByContainer.get(a.container) ?? a.container, a.network); for (const [service, nets] of undeclaredByService) { const list = [...nets].sort(); const joined = list.join(', '); findings.push({ kind: 'network-undeclared', service, detail: `Service "${service}" is attached to ${list.length > 1 ? 'networks' : 'a network'} not declared in compose: ${joined}.`, actual: joined, }); } // declaredButUnused holds compose keys; resolve them to runtime names so the // finding lists every missing network in one consistent namespace. const unusedNames = facts.declaredButUnused.map(key => normalized.networks[key]?.runtimeName ?? key); const missing = [...new Set([...unusedNames, ...facts.missingFromRuntime])].sort(); if (missing.length > 0) { const joined = missing.join(', '); findings.push({ kind: 'network-missing', service: '', detail: `Declared ${missing.length > 1 ? 'networks are' : 'network is'} unused by running services or absent from the runtime: ${joined}.`, expected: joined, }); } return findings; } /** * Pure diff step (no Docker / FS access) so it is directly unit-testable. Only * running containers are compared, since a stopped container publishes no ports * and is not "deployed": a declared service with no running container is * service-missing, a running container with no matching service is * service-undeclared, and image / port differences are checked only for services * present on both sides so a missing/undeclared service is not double-reported. */ export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftReport { const { stack, declared, containers, parseError } = input; const networks = input.networks ?? []; const hasContainers = containers.some((c) => RUNNING_STATES.has(c.state)); // A parse failure means the declared model is untrustworthy: report drift // rather than risk a false in-sync. hasContainers still reflects runtime. if (parseError) { return { stack, status: 'drifted', hasComposeFile: false, hasContainers, findings: [], parseError }; } const runtimeByService = new Map(); for (const c of containers) { if (!RUNNING_STATES.has(c.state)) continue; const name = c.service ?? c.name; const agg = runtimeByService.get(name) ?? { images: new Set(), ports: new Set() }; if (c.image) agg.images.add(normalizeImageRef(c.image)); for (const p of c.ports) agg.ports.add(portKey(p.publishedPort, p.protocol)); runtimeByService.set(name, agg); } // Nothing running: the stack is defined on disk but not deployed. One status // conveys this; per-service findings would just be noise. if (!hasContainers) { return { stack, status: 'missing-runtime', hasComposeFile: true, hasContainers: false, findings: [] }; } const declaredByName = new Map(); for (const svc of declared.services) declaredByName.set(svc.name, svc); const findings: StackDriftFinding[] = []; // Declared service with no running container. for (const svc of declared.services) { if (!runtimeByService.has(svc.name)) { findings.push({ kind: 'service-missing', service: svc.name, detail: `Service "${svc.name}" is declared in compose but is not running.`, }); } } // Running container with no matching declared service. for (const name of runtimeByService.keys()) { if (!declaredByName.has(name)) { findings.push({ kind: 'service-undeclared', service: name, detail: `Service "${name}" is running but is not declared in compose.`, }); } } // Image / port divergence for services present on both sides. for (const [name, svc] of declaredByName) { const runtime = runtimeByService.get(name); if (!runtime) continue; if (svc.image && runtime.images.size > 0) { const declaredImage = normalizeImageRef(svc.image); const runtimeImages = [...runtime.images]; // Any running image that differs from the declared one is drift, so a // replica left on an old image is caught even when a sibling matches. if (runtimeImages.some((img) => img !== declaredImage)) { findings.push({ kind: 'image-mismatch', service: name, detail: `Service "${name}" runs a different image than compose declares.`, expected: declaredImage, actual: runtimeImages.sort().join(', '), }); } } // Ports compare as exact sets. The compose parser collapses a published // range (e.g. "8000-8002:80") to its first port, while the runtime reports // every port in the range, so a range can read as a mismatch. That errs // toward reporting drift rather than hiding it, consistent with the engine's // philosophy. const declaredPorts = new Set(svc.ports.map((p) => portKey(p.publishedPort, p.protocol))); if (!setsEqual(declaredPorts, runtime.ports)) { findings.push({ kind: 'ports-mismatch', service: name, detail: `Service "${name}" publishes different ports than compose declares.`, expected: formatPorts(declaredPorts), actual: formatPorts(runtime.ports), }); } } findings.push(...networkDriftFindings(stack, declared, containers, networks)); const status: StackDriftStatus = findings.length > 0 ? 'drifted' : 'in-sync'; return { stack, status, hasComposeFile: true, hasContainers, findings }; } type RenderDeclaredResult = | { ok: true; declared: DeclaredCompose } | { ok: false; parseError: string }; async function renderDeclaredCompose(nodeId: number, stackName: string): Promise { try { const { ComposeService } = await import('./ComposeService'); const result = await ComposeService.getInstance(nodeId).renderConfig(stackName); if (result.rendered === null) { const missing = parseMissingRequiredVars(result.stderr); return { ok: false, parseError: missing.length ? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.` : 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value.', }; } try { const model = parseEffectiveModel(JSON.parse(result.rendered), stackName); return { ok: true, declared: declaredFromEffectiveModel(model) }; } catch (parseErr) { console.warn('[Drift] Effective model parse failed for %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown'))); return { ok: false, parseError: 'Sencho could not parse the rendered Compose model.' }; } } catch (err) { console.error('[Drift] Effective model render failed for stack %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'render failed'))); const message = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')) .slice(0, MAX_RENDER_ERROR) .trim(); return { ok: false, parseError: message || 'Sencho could not run docker compose on this node.' }; } } /** * Builds the drift report for one stack on one node: renders the effective * compose model, takes a Docker snapshot, and diffs them. Fails closed at each * boundary: a render failure is reported as a parse error (drifted, never * in-sync), and a Docker failure is reported as 'unreachable' rather than crashing. */ export async function buildStackDriftReport(nodeId: number, stackName: string): Promise { const fs = FileSystemService.getInstance(nodeId); const render = await renderDeclaredCompose(nodeId, stackName); if (!render.ok) { let hasContainers = false; try { const stacks = await fs.getStacks(); const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks); hasContainers = snapshot.containers.some((c) => c.stack === stackName && RUNNING_STATES.has(c.state)); } catch { // Docker unreachable: hasContainers stays false; render error is still the headline. } return { stack: stackName, status: 'drifted', hasComposeFile: false, hasContainers, findings: [], parseError: render.parseError, }; } let containers: DependencyContainer[]; let networks: DependencyNetwork[] = []; try { // The snapshot needs the full known-stacks set to resolve each container to // its stack; we then filter to this one. Do not narrow to [stackName] or // resolution breaks. const stacks = await fs.getStacks(); const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks); containers = snapshot.containers.filter((c) => c.stack === stackName); networks = snapshot.networks; } catch (error) { console.error('[Drift] Docker snapshot failed for stack %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'snapshot failed'))); return { stack: stackName, status: 'unreachable', hasComposeFile: true, hasContainers: false, findings: [], }; } return assembleStackDrift({ stack: stackName, declared: render.declared, containers, networks }); }