Files
sencho/backend/src/services/ComposeDoctorService.ts
T
Anso 78475d96ef fix(compose-doctor): resolve effective healthcheck coverage (#1713)
* fix(compose-doctor): resolve effective healthcheck coverage

Compose Doctor now classifies healthcheck coverage from the Compose model, running containers, and local images so image-provided HEALTHCHECKs are not false positives. Update Guard shares the same presence helper so test NONE is not treated as active.

* fix(compose-doctor): fix healthcheck project label and empty compose HC

Use the Compose project name for runtime container listing so stacks whose name: differs from the directory still get runtime evidence. Treat empty or timing-only healthcheck objects as absent rather than active.

* fix(compose-doctor): treat inherited healthcheck as All Clear note

Inherited image healthchecks no longer block All Clear; they surface under a notes section and cannot be acknowledged.
2026-07-28 14:26:00 -04:00

429 lines
18 KiB
TypeScript

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<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> {
// 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<string, string> = {};
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, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'>,
): 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<PreflightReport> {
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<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> = {
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<PreflightContext> {
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<ReturnType<typeof resolveStackEnvSources>> | 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<string, ServiceHealthcheckEvidence>),
]);
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<string, ExposureIntent>;
accessUrlPorts: Set<number>;
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<string>;
existingVolumeNames: Set<string>;
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<BindCheck[]> {
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')));
}
}
}