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
+60 -5
View File
@@ -11,10 +11,15 @@ import type { EffService, EffectiveModel } from '../services/preflight/effective
import type { PreflightContext, PreflightFinding } from '../services/preflight/types';
function svc(over: Partial<EffService> = {}): EffService {
const hasHealthcheck = over.hasHealthcheck ?? true;
const composeHealthcheck = over.composeHealthcheck ?? (hasHealthcheck ? 'active' : 'absent');
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [],
networks: [], extraHosts: [], labelKeys: [], ...over,
privileged: false, restart: 'unless-stopped', envKeys: [],
networks: [], extraHosts: [], labelKeys: [],
...over,
hasHealthcheck,
composeHealthcheck,
};
}
@@ -33,7 +38,9 @@ function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
existingContainers: [], nodeStateAvailable: true, bindChecks: [],
stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false,
exposureAvailable: true,
isSelfStack: false, ...over,
isSelfStack: false,
healthchecks: {},
...over,
};
}
@@ -217,10 +224,56 @@ describe('hygiene rules', () => {
expect(restartFindings[0].remediation).toMatch(/one-shot|init jobs/i);
expect(restartFindings[0].remediation).toMatch(/restart: "no"/);
expect(restartFindings[0].remediation).toMatch(/unless-stopped/);
expect(ids(runRules(ctx({ model: bare })), 'no-healthcheck')).toHaveLength(1);
expect(ids(runRules(ctx({
model: bare,
healthchecks: { web: { state: 'absent', origin: 'local-image', consistentReplicas: null } },
})), 'no-healthcheck')).toHaveLength(1);
const withDeployRestart = model([svc({ restart: undefined, deploy: { restart_policy: { condition: 'any' } }})]);
expect(ids(runRules(ctx({ model: withDeployRestart })), 'no-restart-policy')).toHaveLength(0);
});
it('emits the healthcheck evidence rule family', () => {
const bare = model([svc({ hasHealthcheck: false })]);
expect(ids(runRules(ctx({
model: bare,
healthchecks: { web: { state: 'explicitly-disabled', origin: 'compose', consistentReplicas: null } },
})), 'healthcheck-disabled')).toHaveLength(1);
expect(ids(runRules(ctx({
model: bare,
healthchecks: { web: { state: 'runtime-inherited', origin: 'runtime', consistentReplicas: true } },
})), 'healthcheck-inherited')[0]).toMatchObject({
severity: 'info',
title: 'Healthcheck inherited from image',
});
expect(ids(runRules(ctx({
model: bare,
healthchecks: { web: { state: 'local-image-inherited', origin: 'local-image', consistentReplicas: null } },
})), 'healthcheck-inherited')[0].remediation).toMatch(/Optionally declare/);
expect(ids(runRules(ctx({
model: bare,
healthchecks: { web: { state: 'unverifiable', origin: 'none', consistentReplicas: null } },
})), 'healthcheck-unverifiable')[0].severity).toBe('info');
expect(ids(runRules(ctx({
model: bare,
healthchecks: { web: { state: 'inconsistent-replicas', origin: 'runtime', consistentReplicas: false } },
})), 'healthcheck-inconsistent')).toHaveLength(1);
expect(ids(runRules(ctx({
model: bare,
healthchecks: { web: { state: 'compose-declared', origin: 'compose', consistentReplicas: null } },
})), 'no-healthcheck')).toHaveLength(0);
});
it('never embeds healthcheck Test command text in findings', () => {
const bare = model([svc({ hasHealthcheck: false })]);
const findings = runRules(ctx({
model: bare,
healthchecks: { web: { state: 'runtime-inherited', origin: 'runtime', consistentReplicas: true } },
}));
const blob = findings.map(f => `${f.title}\n${f.message}\n${f.remediation ?? ''}`).join('\n');
expect(blob).not.toMatch(/\bCMD\b/);
expect(blob).not.toMatch(/CMD-SHELL/);
expect(blob).not.toContain('secret-token');
});
it('flags swarm-only deploy fields but not honored ones', () => {
expect(ids(runRules(ctx({ model: model([svc({ deploy: { placement: {} }})]) })), 'deploy-swarm-only')).toHaveLength(1);
expect(ids(runRules(ctx({ model: model([svc({ deploy: { replicas: 3 }})]) })), 'deploy-swarm-only')).toHaveLength(0);
@@ -491,7 +544,9 @@ describe('rule registry completeness', () => {
const EXPECTED_RULE_IDS = [
'render-failed', 'env-unset', 'env-literal-dollar', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host',
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only',
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck',
'healthcheck-disabled', 'healthcheck-inherited', 'healthcheck-unverifiable', 'healthcheck-inconsistent',
'deploy-swarm-only',
'node-state-unavailable',
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume', 'anonymous-volume',
'container-name-internal-dup', 'container-name-collision',