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.
This commit is contained in:
Anso
2026-07-28 14:26:00 -04:00
committed by GitHub
parent c90e9606f1
commit 78475d96ef
27 changed files with 1077 additions and 54 deletions
+15 -4
View File
@@ -11,10 +11,12 @@ 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 } from './preflight/rules';
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';
@@ -38,6 +40,7 @@ function sortFindings(findings: PreflightFinding[]): PreflightFinding[] {
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;
@@ -47,8 +50,10 @@ function activeFields(
renderable: boolean,
findings: PreflightFinding[],
): Pick<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> {
const active = findings.filter(f => !f.acknowledged);
const acknowledgedCount = findings.length - active.length;
// 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'
@@ -265,7 +270,12 @@ export class ComposeDoctorService {
}
const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers, nodeStateAvailable } = await this.nodeState(nodeId, fsSvc, stackName);
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
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);
@@ -292,6 +302,7 @@ export class ComposeDoctorService {
hasAccessUrls,
exposureAvailable,
isSelfStack: selfStack,
healthchecks,
};
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { UpdatePreviewService, isMovingTag, filterPreviewForService, buildDetect
import { ImageUpdateService } from './ImageUpdateService';
import { buildEffectiveServiceModel, type EffectiveServiceModelResult } from './effectiveServiceModel';
import { filterContainersByComposeService } from '../helpers/composeServiceMatch';
import { isDockerHealthcheckActive } from '../helpers/healthcheckPresence';
import { withTimeout } from '../utils/withTimeout';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
@@ -98,7 +99,7 @@ export class UpdateGuardService {
state: inspect.State?.Status ?? info.State ?? 'unknown',
health: inspect.State?.Health?.Status ?? null,
exitCode: typeof inspect.State?.ExitCode === 'number' ? inspect.State.ExitCode : null,
hasHealthcheck: !!inspect.Config?.Healthcheck?.Test?.length,
hasHealthcheck: isDockerHealthcheckActive(inspect.Config?.Healthcheck?.Test),
restartPolicy: inspect.HostConfig?.RestartPolicy?.Name || null,
mounts,
};
@@ -18,6 +18,7 @@
*/
import { ComposeService } from './ComposeService';
import { parseMissingRequiredVars } from '../helpers/envVarParse';
import { isComposeHealthcheckActive } from '../helpers/healthcheckPresence';
import { getErrorMessage } from '../utils/errors';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
@@ -75,9 +76,7 @@ function parseServiceSpec(name: string, raw: unknown): EffectiveServiceSpec {
const svc = (raw ?? {}) as Record<string, unknown>;
const deploy = (svc.deploy && typeof svc.deploy === 'object') ? svc.deploy as Record<string, unknown> : undefined;
const healthcheck = svc.healthcheck;
const hasHealthcheck = !!healthcheck
&& typeof healthcheck === 'object'
&& (healthcheck as Record<string, unknown>).disable !== true;
const hasHealthcheck = isComposeHealthcheckActive(healthcheck);
return {
name,
declaredImage: asString(svc.image) ?? null,
@@ -0,0 +1,210 @@
/**
* Collect per-service effective healthcheck evidence for Compose Doctor.
* Structural facts only: never returns or logs Healthcheck.Test command text.
*/
import DockerController from '../DockerController';
import { filterContainersByComposeService } from '../../helpers/composeServiceMatch';
import { isDockerHealthcheckActive } from '../../helpers/healthcheckPresence';
import type { EffectiveModel } from '../preflight/effectiveModel';
import type { ServiceHealthcheckEvidence } from '../preflight/types';
import { mapWithConcurrency } from '../../utils/mapWithConcurrency';
import { getErrorMessage } from '../../utils/errors';
import { sanitizeForLog } from '../../utils/safeLog';
const INSPECT_CONCURRENCY = 8;
type ListedContainer = {
Id: string;
Names?: string[];
Labels?: Record<string, string>;
Image?: string;
};
type ReplicaProbe = {
hasHealthcheck: boolean;
imageMatches: boolean;
inspectFailed: boolean;
};
type ImageEvidence = 'inherited' | 'absent' | 'unverifiable';
function evidence(
state: ServiceHealthcheckEvidence['state'],
origin: ServiceHealthcheckEvidence['origin'],
consistentReplicas: boolean | null,
): ServiceHealthcheckEvidence {
return { state, origin, consistentReplicas };
}
/**
* Resolve effective healthcheck coverage for each service in the model.
* When `nodeStateAvailable` is false, services that still need Docker evidence
* become unverifiable without listing or inspecting containers/images.
*/
export async function collectServiceHealthcheckEvidence(
nodeId: number,
stackName: string,
model: EffectiveModel,
nodeStateAvailable: boolean,
): Promise<Record<string, ServiceHealthcheckEvidence>> {
const out: Record<string, ServiceHealthcheckEvidence> = {};
const needsDocker = model.services.some(s =>
s.composeHealthcheck !== 'active' && s.composeHealthcheck !== 'disabled');
let listed: ListedContainer[] = [];
let listFailed = false;
// Compose's top-level `name:` becomes com.docker.compose.project; that often
// differs from the Sencho stack directory name used as stackName.
const projectLabel = model.projectName || stackName;
if (nodeStateAvailable && needsDocker) {
try {
const docker = DockerController.getInstance(nodeId).getDocker();
listed = await docker.listContainers({
all: true,
filters: { label: [`com.docker.compose.project=${projectLabel}`] },
}) as ListedContainer[];
} catch (err) {
listFailed = true;
console.warn(
'[ComposeDoctor] Healthcheck container list failed for %s:',
sanitizeForLog(projectLabel),
sanitizeForLog(getErrorMessage(err, 'unknown')),
);
}
}
for (const svc of model.services) {
if (svc.composeHealthcheck === 'active') {
out[svc.name] = evidence('compose-declared', 'compose', null);
continue;
}
if (svc.composeHealthcheck === 'disabled') {
out[svc.name] = evidence('explicitly-disabled', 'compose', null);
continue;
}
if (!nodeStateAvailable) {
out[svc.name] = evidence('unverifiable', 'none', null);
continue;
}
if (listFailed) {
// Container list failed, but a local image inspect may still succeed.
out[svc.name] = await evidenceFromLocalImage(nodeId, svc.image, null);
continue;
}
const scoped = filterContainersByComposeService(listed, svc.name);
if (scoped.length > 0) {
const replicas = await mapWithConcurrency(scoped, INSPECT_CONCURRENCY, (c) =>
probeReplica(nodeId, c, svc.image));
const fromRuntime = await resolveRuntimeEvidence(replicas);
if (fromRuntime) {
out[svc.name] = fromRuntime;
continue;
}
}
// No suitable runtime evidence: local image or unverifiable.
out[svc.name] = await evidenceFromLocalImage(nodeId, svc.image, null);
}
return out;
}
/**
* Decide from inspected, image-matched replicas.
* Returns null when the caller should fall through to a generic local-image lookup
* (all inspects failed, or every replica is a stale/mismatched image).
*/
function resolveRuntimeEvidence(
replicas: ReplicaProbe[],
): ServiceHealthcheckEvidence | null {
const inspected = replicas.filter(r => !r.inspectFailed);
if (inspected.length === 0) return null;
const usable = inspected.filter(r => r.imageMatches);
if (usable.length === 0) return null;
const withHc = usable.filter(r => r.hasHealthcheck).length;
const withoutHc = usable.length - withHc;
const partial = inspected.length < replicas.length;
if (withHc > 0 && withoutHc > 0) {
return evidence('inconsistent-replicas', 'runtime', false);
}
if (withHc === usable.length) {
// Incomplete inspection: do not claim full coverage.
if (partial) return evidence('unverifiable', 'runtime', null);
return evidence('runtime-inherited', 'runtime', true);
}
// All usable replicas lack an effective healthcheck. Do not upgrade a verified
// runtime gap to local-image-inherited; live replicas are authoritative.
if (partial) return evidence('unverifiable', 'runtime', null);
return evidence('absent', 'runtime', true);
}
async function evidenceFromLocalImage(
nodeId: number,
image: string | undefined,
consistentReplicas: boolean | null,
): Promise<ServiceHealthcheckEvidence> {
if (!image) return evidence('unverifiable', 'none', consistentReplicas);
const imageKind = await inspectLocalImage(nodeId, image);
if (imageKind === 'inherited') {
return evidence('local-image-inherited', 'local-image', consistentReplicas);
}
if (imageKind === 'absent') {
return evidence('absent', 'local-image', consistentReplicas);
}
return evidence('unverifiable', 'none', consistentReplicas);
}
async function probeReplica(
nodeId: number,
listed: ListedContainer,
declaredImage: string | undefined,
): Promise<ReplicaProbe> {
try {
const docker = DockerController.getInstance(nodeId).getDocker();
const inspect = await docker.getContainer(listed.Id).inspect();
const test = inspect.Config?.Healthcheck?.Test;
const hasHealthcheck = isDockerHealthcheckActive(test);
const runtimeImage = typeof inspect.Config?.Image === 'string' ? inspect.Config.Image : listed.Image;
const imageMatches = !declaredImage
|| !runtimeImage
|| runtimeImage === declaredImage;
return { hasHealthcheck, imageMatches, inspectFailed: false };
} catch (err) {
if ((err as { statusCode?: number })?.statusCode === 404) {
return { hasHealthcheck: false, imageMatches: false, inspectFailed: true };
}
console.warn(
'[ComposeDoctor] Healthcheck container inspect failed:',
sanitizeForLog(getErrorMessage(err, 'unknown')),
);
return { hasHealthcheck: false, imageMatches: false, inspectFailed: true };
}
}
async function inspectLocalImage(
nodeId: number,
imageRef: string,
): Promise<ImageEvidence> {
try {
const { inspect } = await DockerController.getInstance(nodeId).inspectImage(imageRef);
const test = (inspect as { Config?: { Healthcheck?: { Test?: unknown } } })?.Config?.Healthcheck?.Test;
return isDockerHealthcheckActive(test) ? 'inherited' : 'absent';
} catch (err) {
console.warn(
'[ComposeDoctor] Healthcheck image inspect failed for %s:',
sanitizeForLog(imageRef),
sanitizeForLog(getErrorMessage(err, 'unknown')),
);
return 'unverifiable';
}
}
@@ -6,6 +6,8 @@
* are handled by the caller, not here.
*/
import { classifyComposeHealthcheck } from '../../helpers/healthcheckPresence';
/** A host-published port range declared by a service (start==end for one port). */
export interface EffPortSpec {
startPort: number;
@@ -55,7 +57,13 @@ export interface EffService {
privileged: boolean;
networkMode?: string;
restart?: string;
/**
* True when the rendered Compose model declares an active healthcheck.
* False for absent, `disable: true`, and `test: NONE` / `["NONE"]`.
*/
hasHealthcheck: boolean;
/** Compose-layer classification used by healthcheck evidence collection. */
composeHealthcheck: 'active' | 'disabled' | 'absent';
/** Raw deploy block (preflight uses key presence; Drift also reads restart_policy.condition). Undefined = none. */
deploy?: Record<string, unknown>;
containerName?: string;
@@ -393,10 +401,7 @@ export function parseEffectiveModel(parsed: unknown, fallbackProjectName: string
: [];
const { binds, named } = parseVolumes(svc.volumes);
const storageMounts = parseStorageMounts(svc.volumes, svc.tmpfs);
const healthcheck = svc.healthcheck;
const hasHealthcheck = !!healthcheck
&& typeof healthcheck === 'object'
&& (healthcheck as Record<string, unknown>).disable !== true;
const composeHealthcheck = classifyComposeHealthcheck(svc.healthcheck);
services.push({
name,
image: str(svc.image),
@@ -407,7 +412,8 @@ export function parseEffectiveModel(parsed: unknown, fallbackProjectName: string
privileged: svc.privileged === true,
networkMode: str(svc.network_mode),
restart: str(svc.restart),
hasHealthcheck,
hasHealthcheck: composeHealthcheck === 'active',
composeHealthcheck,
deploy: (svc.deploy && typeof svc.deploy === 'object') ? svc.deploy as Record<string, unknown> : undefined,
containerName: str(svc.container_name),
user: str(svc.user),
+108 -5
View File
@@ -7,6 +7,18 @@ import { classifyMissingExternalNetworks } from '../network/missingExternalNetwo
/** Higher number = more severe. Used to derive a run's overall status. */
export const SEVERITY_RANK: Record<PreflightSeverity, number> = { info: 0, warning: 1, high: 2, blocker: 3 };
/**
* Rule IDs that are informational notes, not issue findings. They appear in the
* report for context but do not affect All Clear, active severity, or Update Guard.
*/
export const PREFLIGHT_NOTE_RULE_IDS: ReadonlySet<string> = new Set([
'healthcheck-inherited',
]);
export function isPreflightNoteFinding(ruleId: string): boolean {
return PREFLIGHT_NOTE_RULE_IDS.has(ruleId);
}
/** The one rule whose message doubles as the report's render error. Shared so the
* service that reconstructs renderError from it cannot drift from the rule id. */
export const RENDER_FAILED_RULE_ID = 'render-failed';
@@ -373,15 +385,102 @@ const noHealthcheck: PreflightRule = {
run(ctx) {
if (!ctx.model) return [];
return ctx.model.services
.filter(s => !s.hasHealthcheck)
.filter(s => ctx.healthchecks[s.name]?.state === 'absent')
.map(s => {
const origin = ctx.healthchecks[s.name]?.origin;
let from = 'available evidence';
if (origin === 'runtime') from = 'currently running containers';
else if (origin === 'local-image') from = 'the locally available image';
return {
ruleId: 'no-healthcheck',
severity: 'warning' as const,
title: 'No effective healthcheck detected',
message: `Service "${s.name}" has no effective healthcheck in ${from}, so Docker and Sencho cannot tell when it is actually ready.`,
sourcePath: s.name,
service: s.name,
remediation: 'Add a healthcheck to the Compose service, or use an image that defines one.',
};
});
},
};
const healthcheckDisabled: PreflightRule = {
id: 'healthcheck-disabled',
run(ctx) {
if (!ctx.model) return [];
return ctx.model.services
.filter(s => ctx.healthchecks[s.name]?.state === 'explicitly-disabled')
.map(s => ({
ruleId: 'no-healthcheck',
ruleId: 'healthcheck-disabled',
severity: 'warning' as const,
title: 'No healthcheck',
message: `Service "${s.name}" declares no healthcheck, so Docker and Sencho cannot tell when it is actually ready (the image may still define one).`,
title: 'Healthcheck explicitly disabled',
message: `Service "${s.name}" disables its healthcheck in the Compose model (disable: true or test: NONE), so Docker will not report readiness for this service.`,
sourcePath: s.name,
service: s.name,
remediation: 'Add a healthcheck, or confirm the image provides one.',
remediation: 'Remove the disablement, or replace it with an active healthcheck if the service should report readiness.',
}));
},
};
const healthcheckInherited: PreflightRule = {
id: 'healthcheck-inherited',
run(ctx) {
if (!ctx.model) return [];
return ctx.model.services
.filter(s => {
const state = ctx.healthchecks[s.name]?.state;
return state === 'runtime-inherited' || state === 'local-image-inherited';
})
.map(s => {
const origin = ctx.healthchecks[s.name]?.origin;
const from = origin === 'runtime'
? 'Docker is using the healthcheck from the currently running container image'
: 'Docker is using the healthcheck defined by the locally available container image';
return {
ruleId: 'healthcheck-inherited',
severity: 'info' as const,
title: 'Healthcheck inherited from image',
message: `Service "${s.name}" does not declare a healthcheck in Compose. ${from}.`,
sourcePath: s.name,
service: s.name,
remediation: 'Optionally declare the healthcheck in Compose so its configuration remains explicit and independently controlled.',
};
});
},
};
const healthcheckUnverifiable: PreflightRule = {
id: 'healthcheck-unverifiable',
run(ctx) {
if (!ctx.model) return [];
return ctx.model.services
.filter(s => ctx.healthchecks[s.name]?.state === 'unverifiable')
.map(s => ({
ruleId: 'healthcheck-unverifiable',
severity: 'info' as const,
title: 'Healthcheck inheritance could not be verified',
message: `Service "${s.name}" has no Compose healthcheck, and Sencho could not verify whether the running container or a local image provides one (Docker unreachable, image missing locally, or incomplete inspection).`,
sourcePath: s.name,
service: s.name,
remediation: 'Ensure the declared image is present locally, or add an explicit Compose healthcheck. Sencho does not pull images during Doctor runs.',
}));
},
};
const healthcheckInconsistent: PreflightRule = {
id: 'healthcheck-inconsistent',
run(ctx) {
if (!ctx.model) return [];
return ctx.model.services
.filter(s => ctx.healthchecks[s.name]?.state === 'inconsistent-replicas')
.map(s => ({
ruleId: 'healthcheck-inconsistent',
severity: 'warning' as const,
title: 'Replica healthcheck coverage is inconsistent',
message: `Service "${s.name}" has running replicas with mixed effective healthcheck coverage, so readiness is not uniform across replicas.`,
sourcePath: s.name,
service: s.name,
remediation: 'Recreate the service so every replica uses the same image and healthcheck configuration.',
}));
},
};
@@ -803,6 +902,10 @@ export const PREFLIGHT_RULES: PreflightRule[] = [
imageLatest,
noRestartPolicy,
noHealthcheck,
healthcheckDisabled,
healthcheckInherited,
healthcheckUnverifiable,
healthcheckInconsistent,
deploySwarmOnly,
nodeStateUnavailable,
externalNetworkMissing,
+33
View File
@@ -84,6 +84,34 @@ export interface BindCheck {
ownerUid: number | null;
}
/** Effective healthcheck coverage state for one Compose service. */
export type HealthcheckEvidenceState =
| 'compose-declared'
| 'explicitly-disabled'
| 'runtime-inherited'
| 'local-image-inherited'
| 'absent'
| 'unverifiable'
| 'inconsistent-replicas';
/** Which layer produced the decisive healthcheck evidence. */
export type HealthcheckEvidenceOrigin =
| 'compose'
| 'runtime'
| 'local-image'
| 'none';
/**
* Structural healthcheck evidence for one service. Never carries Test command
* text (commands can include credentials or interpolated secrets).
*/
export interface ServiceHealthcheckEvidence {
state: HealthcheckEvidenceState;
origin: HealthcheckEvidenceOrigin;
/** null when replica consistency does not apply (no runtime replicas inspected). */
consistentReplicas: boolean | null;
}
/**
* Everything the pure rule functions need, computed once by the service so the
* rules stay synchronous and individually testable. No field ever holds an
@@ -131,4 +159,9 @@ export interface PreflightContext {
exposureAvailable: boolean;
/** True when this stack is the running Sencho instance on the node. */
isSelfStack: boolean;
/**
* Per-service effective healthcheck evidence (Compose, runtime, local image).
* Empty when the model is null. Structural facts only; never Test command text.
*/
healthchecks: Record<string, ServiceHealthcheckEvidence>;
}