import fs from 'fs'; import path from 'path'; import { randomUUID } from 'crypto'; import DockerController from './DockerController'; import { ComposeService } from './ComposeService'; import { FileSystemService } from './FileSystemService'; import { DatabaseService } from './DatabaseService'; import { computeStackHashes } from './DriftLedgerService'; import { parseComposeDependencies } from '../helpers/composeDependencyParse'; import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel'; import { getExposureContext } from './network/exposureContext'; import type { ExposureIntent } from './network/types'; import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID, isPreflightNoteFinding } from './preflight/rules'; import type { BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus, MissingEnvFile, ServiceHealthcheckEvidence, } from './preflight/types'; import { collectServiceHealthcheckEvidence } from './healthcheck/collectServiceHealthcheckEvidence'; import { applyPreflightAcknowledgements, parseServiceImages } from '../utils/preflight-ack-filter'; import { isPathWithinBase } from '../utils/validation'; import { getErrorMessage } from '../utils/errors'; import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog'; import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse'; import { resolveStackEnvSources } from '../helpers/envFileResolution'; import { isSelfStack } from '../helpers/selfStackGuard'; import { classifyUnsetEnvVars, type LiteralDollarWarning } from '../helpers/unsetEnvClassification'; const MAX_RENDER_ERROR = 600; // chars kept from a (redacted) render error const ruleOrder = new Map(RULE_IDS.map((id, i) => [id, i])); /** Severity descending, then registry order, so output is deterministic. */ function sortFindings(findings: PreflightFinding[]): PreflightFinding[] { return [...findings].sort((a, b) => (SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]) || ((ruleOrder.get(a.ruleId) ?? 0) - (ruleOrder.get(b.ruleId) ?? 0))); } function highestOf(findings: PreflightFinding[]): PreflightSeverity | null { let best: PreflightSeverity | null = null; for (const f of findings) { if (isPreflightNoteFinding(f.ruleId)) continue; if (best === null || SEVERITY_RANK[f.severity] > SEVERITY_RANK[best]) best = f.severity; } return best; } function activeFields( renderable: boolean, findings: PreflightFinding[], ): Pick { // Notes stay in `findings` for display but do not affect All Clear or active severity. const issueFindings = findings.filter(f => !isPreflightNoteFinding(f.ruleId)); const active = issueFindings.filter(f => !f.acknowledged); const acknowledgedCount = issueFindings.length - active.length; const activeHighestSeverity = highestOf(active); const activeStatus: PreflightStatus = !renderable ? 'unrenderable' : (activeHighestSeverity ?? 'pass'); return { activeStatus, activeHighestSeverity, activeCount: active.length, acknowledgedCount, }; } function buildServiceImages(model: EffectiveModel | null): string | null { if (!model) return null; const map: Record = {}; for (const svc of model.services) { if (svc.image) map[svc.name] = svc.image; } return Object.keys(map).length > 0 ? JSON.stringify(map) : null; } function enrichReport( nodeId: number, stackName: string, report: Omit, ): PreflightReport { const db = DatabaseService.getInstance(); const acks = db.getPreflightAcknowledgements(nodeId, stackName); const serviceImages = parseServiceImages( db.getLatestPreflightRun(nodeId, stackName)?.service_images ?? null, ); const findings = applyPreflightAcknowledgements( report.findings, { renderedHash: report.renderedHash, serviceImages }, acks, ); return { ...report, findings, ...activeFields(report.renderable, findings), }; } /** * Compose Doctor: renders the effective model and runs the deterministic * preflight rule registry against the active node. Advisory only (it never * blocks a deploy), read-only with respect to the stack, and node-scoped. It * never stores, returns, or logs an environment value. */ export class ComposeDoctorService { private static instance: ComposeDoctorService | null = null; static getInstance(): ComposeDoctorService { if (!ComposeDoctorService.instance) ComposeDoctorService.instance = new ComposeDoctorService(); return ComposeDoctorService.instance; } private constructor() { /* singleton */ } /** Run all checks, persist the result (replacing any prior run), return the report. */ async runPreflight(nodeId: number, stackName: string, ranBy: string | null): Promise { const fsSvc = FileSystemService.getInstance(nodeId); let source: string | null = null; try { source = await fsSvc.getStackContent(stackName); } catch (err) { // An unreadable source is logged here (not silently swallowed) so a later // skipped hash or source comparison is traceable. console.warn('[ComposeDoctor] Source unreadable for %s; source-derived checks skipped:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown'))); } const sourceReadable = source !== null; // renderedHash is the parsed-SOURCE-model hash (the same `rendered_hash` // meaning the drift ledger uses), deliberately not a hash of docker's // rendered output, which inlines resolved env values and must never be hashed. const hashes = source !== null ? computeStackHashes(source) : { sourceHash: null as string | null, renderedHash: null as string | null }; const sourceServiceNames = source !== null ? parseComposeDependencies(source).services.map(s => s.name) : []; const ctx = await this.buildContext(nodeId, stackName, sourceServiceNames, sourceReadable); const findings = sortFindings(runRules(ctx)); const highestSeverity = highestOf(findings); const status: PreflightStatus = !ctx.renderable ? 'unrenderable' : (highestSeverity ?? 'pass'); const serviceImages = buildServiceImages(ctx.model); const report: PreflightReport = { stack: stackName, ranAt: Date.now(), ranBy, renderable: ctx.renderable, renderError: ctx.renderError, status, highestSeverity, sourceHash: hashes.sourceHash, renderedHash: hashes.renderedHash, findings, activeStatus: status, activeHighestSeverity: highestSeverity, activeCount: findings.length, acknowledgedCount: 0, }; this.persist(nodeId, report, serviceImages); return enrichReport(nodeId, stackName, report); } /** Read the last stored run for a stack, mapped to the report shape. */ getLatest(nodeId: number, stackName: string): PreflightReport { const db = DatabaseService.getInstance(); const run = db.getLatestPreflightRun(nodeId, stackName); if (!run) { return { stack: stackName, ranAt: null, ranBy: null, renderable: true, renderError: null, status: 'never-run', highestSeverity: null, sourceHash: null, renderedHash: null, findings: [], activeStatus: 'never-run', activeHighestSeverity: null, activeCount: 0, acknowledgedCount: 0, }; } const findings = sortFindings(db.getPreflightFindings(run.id).map(r => ({ ruleId: r.rule_id, severity: r.severity as PreflightSeverity, title: r.title, message: r.message, sourcePath: r.source_path ?? undefined, remediation: r.remediation ?? undefined, service: r.service ?? undefined, }))); const renderable = run.status !== 'unrenderable'; // The render error is carried by the render-failed finding, not a column. const renderError = renderable ? null : (findings.find(f => f.ruleId === RENDER_FAILED_RULE_ID)?.message ?? null); const base: Omit = { stack: stackName, ranAt: run.created_at, ranBy: run.created_by, renderable, renderError, status: run.status as PreflightStatus, highestSeverity: (run.highest_severity as PreflightSeverity | null) ?? null, sourceHash: run.source_hash, renderedHash: run.rendered_hash, findings, }; return enrichReport(nodeId, stackName, base); } private async buildContext(nodeId: number, stackName: string, sourceServiceNames: string[], sourceReadable: boolean): Promise { const fsSvc = FileSystemService.getInstance(nodeId); const baseDir = fsSvc.getBaseDir(); let renderable = false; let renderError: string | null = null; let model: EffectiveModel | null = null; let unsetEnvVars: string[] = []; let literalDollarWarnings: LiteralDollarWarning[] = []; let missingEnvFiles: MissingEnvFile[] = []; let envSources: Awaited> | null = null; try { envSources = await resolveStackEnvSources(nodeId, stackName); missingEnvFiles = envSources.envFiles .filter(f => f.isInjectionSource && f.required && f.existence === 'missing') .map(f => ({ rawPath: f.rawPaths[0], services: f.declaringServices })); } catch (err) { console.warn('[ComposeDoctor] env-file resolution failed for %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown'))); } try { const result = await ComposeService.getInstance(nodeId).renderConfig(stackName); if (result.rendered !== null) { // Unset-variable warnings come from stderr and do not depend on the // model parsing, so capture them before attempting the parse, so a parse // failure does not also suppress the env-unset findings. const rawUnset = parseUnsetEnvVars(result.stderr); if (envSources) { const envFileKeys: string[] = []; for (const file of envSources.envFiles) { if (!file.resolvedPath || file.existence !== 'present') continue; const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, baseDir); if (!unverifiable) envFileKeys.push(...keys); } const classified = classifyUnsetEnvVars(rawUnset, envSources, envFileKeys); unsetEnvVars = classified.intentional; literalDollarWarnings = classified.literalDollar; } else { // Without authored env context, never surface raw stderr names as unset // variables; they may be literal-dollar fragments from secret values. unsetEnvVars = []; literalDollarWarnings = rawUnset.length > 0 ? [{ likelySecret: false }] : []; } try { model = parseEffectiveModel(JSON.parse(result.rendered), stackName); renderable = true; } catch (parseErr) { // JSON.parse errors carry no file content, so the message is safe to log. console.warn('[ComposeDoctor] Effective model parse failed for %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown'))); renderError = 'Sencho could not parse the rendered Compose model.'; } } else { // The raw stderr from `docker compose config` can echo file content // (and therefore secrets), so it is never stored. We surface only safe, // structural signals: the names of any required variables Compose // reported as missing, otherwise a generic nudge. const missing = parseMissingRequiredVars(result.stderr); renderError = 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, then re-run.'; } } catch (err) { // Spawn failure (docker unavailable). Spawn errors carry no file content; // redact defensively anyway. renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim() || 'Sencho could not run docker compose on this node.'; } const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers, nodeStateAvailable } = await this.nodeState(nodeId, fsSvc, stackName); const [bindChecks, healthchecks] = await Promise.all([ model ? this.resolveBindChecks(model, baseDir) : Promise.resolve([] as BindCheck[]), model ? collectServiceHealthcheckEvidence(nodeId, stackName, model, nodeStateAvailable) : Promise.resolve({} as Record), ]); const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls, exposureAvailable } = this.exposureState(nodeId, stackName); const selfStack = await isSelfStack(stackName); return { stackName, platform: process.platform, model, renderable, renderError, unsetEnvVars, literalDollarWarnings, missingEnvFiles, sourceServiceNames, sourceReadable, nodePorts, existingNetworkNames, existingVolumeNames, existingContainers, nodeStateAvailable, bindChecks, stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls, exposureAvailable, isSelfStack: selfStack, healthchecks, }; } /** * The user's stored exposure intent (resolved into stack-level + per-service) * and the dossier's documented access-URL ports, for the exposure rules. * Delegates to the shared exposure-context helper (also used by the live * Networking findings engine) so both engines can never diverge on severity. * Fail-soft: a read error defaults to unset/empty so the rules simply do not * fire rather than the whole preflight failing. */ private exposureState(nodeId: number, stackName: string): { stackIntent: ExposureIntent | null; serviceIntents: Record; accessUrlPorts: Set; hasAccessUrls: boolean; exposureAvailable: boolean; } { const context = getExposureContext(nodeId, stackName); if (!context.available) { return { stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false, exposureAvailable: false }; } return { stackIntent: context.stackIntent, serviceIntents: context.serviceIntents, accessUrlPorts: context.accessUrlPorts, hasAccessUrls: context.hasAccessUrls, exposureAvailable: true, }; } /** Snapshot the node's ports/networks/volumes/containers. Degrades to empty if Docker is unreachable. */ private async nodeState(nodeId: number, fsSvc: FileSystemService, stackName: string): Promise<{ nodePorts: NodePortBinding[]; existingNetworkNames: Set; existingVolumeNames: Set; existingContainers: { name: string; stack: string | null }[]; nodeStateAvailable: boolean; }> { try { const knownStacks = await fsSvc.getStacks(); const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks); const nodePorts = snapshot.containers.flatMap(c => c.ports.map(p => ({ publishedPort: p.publishedPort, protocol: p.protocol, ip: p.ip, stack: c.stack }))); return { nodePorts, existingNetworkNames: new Set(snapshot.networks.map(n => n.name)), existingVolumeNames: new Set(snapshot.volumes.map(v => v.name)), existingContainers: snapshot.containers.map(c => ({ name: c.name, stack: c.stack })), nodeStateAvailable: true, }; } catch (error) { console.warn('[ComposeDoctor] Node snapshot unavailable for %s; node-state checks skipped:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown'))); return { nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(), existingContainers: [], nodeStateAvailable: false }; } } /** * Stat each bind-mount source. Existence/ownership is probed ONLY for sources * that resolve inside the node's compose base dir (relative binds); absolute * host paths are outside Sencho's filesystem view and are left unverified. */ private async resolveBindChecks(model: EffectiveModel, baseDir: string): Promise { const resolvedBase = path.resolve(baseDir); const checks: BindCheck[] = []; for (const svc of model.services) { for (const bind of svc.binds) { const withinBase = isPathWithinBase(path.resolve(bind.source), resolvedBase); let exists = false; let ownerUid: number | null = null; if (withinBase) { try { const st = await fs.promises.stat(bind.source); exists = true; ownerUid = typeof st.uid === 'number' ? st.uid : null; } catch { exists = false; } } checks.push({ service: svc.name, source: bind.source, target: bind.target, withinBase, exists, ownerUid }); } } return checks; } /** Persist the run, replacing any prior run for this stack. Best-effort. */ private persist(nodeId: number, report: PreflightReport, serviceImages: string | null): void { if (report.ranAt === null) return; try { const runId = randomUUID(); DatabaseService.getInstance().replacePreflightRun( { id: runId, node_id: nodeId, stack_name: report.stack, source_hash: report.sourceHash, rendered_hash: report.renderedHash, service_images: serviceImages, status: report.status, highest_severity: report.highestSeverity, created_at: report.ranAt, created_by: report.ranBy, }, report.findings.map(f => ({ id: randomUUID(), run_id: runId, rule_id: f.ruleId, severity: f.severity, title: f.title, message: f.message, source_path: f.sourcePath ?? null, remediation: f.remediation ?? null, service: f.service ?? null, created_at: report.ranAt!, })), ); } catch (error) { console.error('[ComposeDoctor] Failed to persist preflight run for %s:', sanitizeForLog(report.stack), sanitizeForLog(getErrorMessage(error, 'unknown'))); } } }