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
@@ -28,6 +28,9 @@ function stubDocker(
rendered: object | null,
stderr = '',
snapshot: { containers: unknown[]; networks: unknown[]; volumes: unknown[] } | 'reject' = { containers: [], networks: [], volumes: [] },
inspectImage: ReturnType<typeof vi.fn> = vi.fn().mockRejectedValue(
Object.assign(new Error('No such image'), { statusCode: 404 }),
),
) {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({
@@ -41,6 +44,13 @@ function stubDocker(
getDependencySnapshot: snapshot === 'reject'
? vi.fn().mockRejectedValue(new Error('docker down'))
: vi.fn().mockResolvedValue(snapshot),
getDocker: vi.fn(() => ({
listContainers: vi.fn().mockResolvedValue([]),
getContainer: vi.fn(() => ({
inspect: vi.fn().mockResolvedValue({ Config: {} }),
})),
})),
inspectImage,
} as unknown as DockerController);
}
@@ -85,7 +95,7 @@ describe('runPreflight', () => {
expect(report.renderable).toBe(true);
expect(report.status).toBe('high'); // env-unset + 0.0.0.0 exposure are high
expect(report.highestSeverity).toBe('high');
expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-literal-dollar', 'port-exposed-all-interfaces', 'image-latest', 'no-healthcheck']));
expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-literal-dollar', 'port-exposed-all-interfaces', 'image-latest', 'healthcheck-unverifiable']));
expect(report.ranBy).toBe('tester');
expect(report.sourceHash).toBeTruthy();
@@ -173,6 +183,30 @@ describe('runPreflight', () => {
expect(doctor().getLatest(nodeId, STACK).status).toBe('pass');
});
it('treats inherited healthcheck as a note that does not block All Clear', async () => {
const model = {
name: STACK,
services: { web: { image: 'nginx:1.27', restart: 'always' } },
networks: {},
volumes: {},
};
stubDocker(
model,
'',
{ containers: [], networks: [], volumes: [] },
vi.fn().mockResolvedValue({
inspect: { Config: { Healthcheck: { Test: ['CMD', 'true'] } } },
history: [],
}),
);
const report = await doctor().runPreflight(nodeId, STACK, 'tester');
expect(report.findings.some(f => f.ruleId === 'healthcheck-inherited')).toBe(true);
expect(report.activeCount).toBe(0);
expect(report.activeStatus).toBe('pass');
expect(report.status).toBe('pass');
});
it('returns an unrenderable report and never stores raw stderr', async () => {
stubDocker(null, `bad yaml near ${SECRET}`); // stderr can echo arbitrary file content
const report = await doctor().runPreflight(nodeId, STACK, null);
@@ -14,10 +14,15 @@ import {
import { assembleStackNetworkFacts } from '../services/network/composeNetworkInspector';
function effSvc(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,
};
}
@@ -43,10 +43,15 @@ function container(p: Partial<DependencyContainer> & { id: string }): Dependency
const findingKinds = (r: { findings: { kind: string }[] }): string[] => r.findings.map((f) => f.kind).sort();
function effSvc(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,
};
}
@@ -79,6 +79,15 @@ describe('buildEffectiveServiceModel', () => {
expect(result.services[0].hasHealthcheck).toBe(false);
});
it('treats test NONE as a disabled healthcheck', async () => {
stubRender(JSON.stringify({
services: { web: { image: 'a', healthcheck: { test: ['NONE'] } } },
}));
const result = await buildEffectiveServiceModel(1, 'mystack');
if (!result.renderable) throw new Error('expected renderable');
expect(result.services[0].hasHealthcheck).toBe(false);
});
it('parses depends_on given in the short list form', async () => {
stubRender(JSON.stringify({
services: { web: { image: 'a', depends_on: ['db', 'cache'] } },
+1
View File
@@ -14,6 +14,7 @@ function svc(overrides: Record<string, unknown>) {
networkMode: undefined as string | undefined,
restart: undefined as string | undefined,
hasHealthcheck: false,
composeHealthcheck: 'absent' as const,
envKeys: [],
networks: [],
extraHosts: [],
@@ -0,0 +1,292 @@
/**
* Unit tests for collectServiceHealthcheckEvidence. Mocks Docker list/inspect
* and image inspect; asserts structural evidence states and that Test command
* text never appears in returned evidence.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { collectServiceHealthcheckEvidence } from '../services/healthcheck/collectServiceHealthcheckEvidence';
import type { EffectiveModel, EffService } from '../services/preflight/effectiveModel';
import DockerController from '../services/DockerController';
function svc(over: Partial<EffService> = {}): EffService {
const hasHealthcheck = over.hasHealthcheck ?? false;
const composeHealthcheck = over.composeHealthcheck ?? (hasHealthcheck ? 'active' : 'absent');
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
privileged: false, restart: 'unless-stopped', envKeys: [],
networks: [], extraHosts: [], labelKeys: [],
...over,
hasHealthcheck,
composeHealthcheck,
};
}
function model(services: EffService[]): EffectiveModel {
return { projectName: 'proj', services, networks: {}, volumes: {} };
}
function mockDocker(opts: {
list?: unknown[];
inspectById?: Record<string, { Config?: { Image?: string; Healthcheck?: { Test?: unknown } } }>;
image?: { Config?: { Healthcheck?: { Test?: unknown } } } | 'missing' | 'error';
}) {
const getContainer = vi.fn((id: string) => ({
inspect: vi.fn(async () => {
const hit = opts.inspectById?.[id];
if (!hit) {
const err = Object.assign(new Error('not found'), { statusCode: 404 });
throw err;
}
return hit;
}),
}));
const listContainers = vi.fn(async () => opts.list ?? []);
const getDocker = vi.fn(() => ({ listContainers, getContainer }));
const inspectImage = vi.fn(async () => {
if (opts.image === 'missing' || opts.image === 'error') {
throw Object.assign(new Error('No such image'), { statusCode: 404 });
}
return { inspect: opts.image ?? { Config: {} }, history: [] };
});
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDocker,
inspectImage,
} as unknown as DockerController);
return { listContainers, getContainer, inspectImage };
}
describe('collectServiceHealthcheckEvidence', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('returns compose-declared for an active Compose healthcheck without Docker calls', async () => {
const { listContainers } = mockDocker({});
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ hasHealthcheck: true, composeHealthcheck: 'active' })]), true,
);
expect(evidence.web).toEqual({ state: 'compose-declared', origin: 'compose', consistentReplicas: null });
expect(listContainers).not.toHaveBeenCalled();
});
it('returns explicitly-disabled for Compose disablement', async () => {
mockDocker({});
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'disabled' })]), true,
);
expect(evidence.web.state).toBe('explicitly-disabled');
});
it('lists containers by Compose projectName, not the stack directory name', async () => {
const { listContainers } = mockDocker({
list: [{
Id: 'c1',
Labels: { 'com.docker.compose.service': 'web' },
Image: 'nginx:1.27',
}],
inspectById: {
c1: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['CMD', 'true'] } } },
},
});
const m: EffectiveModel = {
projectName: 'qa-hc-1713',
services: [svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })],
networks: {},
volumes: {},
};
const evidence = await collectServiceHealthcheckEvidence(1, 'qa-healthcheck', m, true);
expect(listContainers).toHaveBeenCalledWith({
all: true,
filters: { label: ['com.docker.compose.project=qa-hc-1713'] },
});
expect(evidence.web.state).toBe('runtime-inherited');
});
it('recognizes runtime-inherited healthchecks and never returns Test text', async () => {
mockDocker({
list: [{
Id: 'c1',
Names: ['/proj-web-1'],
Labels: { 'com.docker.compose.service': 'web' },
Image: 'nginx:1.27',
}],
inspectById: {
c1: {
Config: {
Image: 'nginx:1.27',
Healthcheck: { Test: ['CMD-SHELL', 'curl -f http://x/?token=secret-token || exit 1'] },
},
},
},
});
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
);
expect(evidence.web).toEqual({
state: 'runtime-inherited',
origin: 'runtime',
consistentReplicas: true,
});
expect(JSON.stringify(evidence)).not.toContain('secret-token');
expect(JSON.stringify(evidence)).not.toContain('CMD-SHELL');
});
it('matches containers via composeServiceMatch when the service label is absent', async () => {
mockDocker({
list: [{ Id: 'c1', Names: ['/web'], Image: 'nginx:1.27' }],
inspectById: {
c1: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['CMD', 'true'] } } },
},
});
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ name: 'web', composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
);
expect(evidence.web.state).toBe('runtime-inherited');
});
it('ignores stale image replicas and uses local-image evidence', async () => {
mockDocker({
list: [{
Id: 'c1',
Labels: { 'com.docker.compose.service': 'web' },
Image: 'nginx:old',
}],
inspectById: {
c1: { Config: { Image: 'nginx:old', Healthcheck: { Test: ['CMD', 'true'] } } },
},
image: { Config: { Healthcheck: { Test: ['CMD', 'true'] } } },
});
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
);
expect(evidence.web.state).toBe('local-image-inherited');
expect(evidence.web.origin).toBe('local-image');
});
it('reports inconsistent-replicas when coverage mixes', async () => {
mockDocker({
list: [
{ Id: 'c1', Labels: { 'com.docker.compose.service': 'web' }, Image: 'nginx:1.27' },
{ Id: 'c2', Labels: { 'com.docker.compose.service': 'web' }, Image: 'nginx:1.27' },
],
inspectById: {
c1: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['CMD', 'true'] } } },
c2: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['NONE'] } } },
},
});
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
);
expect(evidence.web.state).toBe('inconsistent-replicas');
});
it('reports unverifiable when Docker is unavailable', async () => {
mockDocker({});
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'absent' })]), false,
);
expect(evidence.web.state).toBe('unverifiable');
});
it('reports unverifiable for a missing local image', async () => {
mockDocker({ list: [], image: 'missing' });
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
);
expect(evidence.web.state).toBe('unverifiable');
});
it('reports unverifiable for build-only services with no image', async () => {
mockDocker({ list: [] });
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: undefined })]), true,
);
expect(evidence.web.state).toBe('unverifiable');
});
it('reports absent when local image has no healthcheck', async () => {
mockDocker({ list: [], image: { Config: {} } });
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
);
expect(evidence.web).toEqual({
state: 'absent',
origin: 'local-image',
consistentReplicas: null,
});
});
it('reports absent when matching replicas lack a healthcheck even if the local image has one', async () => {
mockDocker({
list: [{
Id: 'c1',
Labels: { 'com.docker.compose.service': 'web' },
Image: 'nginx:1.27',
}],
inspectById: {
c1: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['NONE'] } } },
},
image: { Config: { Healthcheck: { Test: ['CMD', 'true'] } } },
});
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
);
expect(evidence.web).toEqual({
state: 'absent',
origin: 'runtime',
consistentReplicas: true,
});
});
it('reports unverifiable when some replica inspects fail amid otherwise full coverage', async () => {
mockDocker({
list: [
{ Id: 'c1', Labels: { 'com.docker.compose.service': 'web' }, Image: 'nginx:1.27' },
{ Id: 'gone', Labels: { 'com.docker.compose.service': 'web' }, Image: 'nginx:1.27' },
],
inspectById: {
c1: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['CMD', 'true'] } } },
},
});
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
);
expect(evidence.web.state).toBe('unverifiable');
expect(evidence.web.origin).toBe('runtime');
});
it('treats a disappearing container as inspect failure and falls through', async () => {
mockDocker({
list: [{ Id: 'gone', Labels: { 'com.docker.compose.service': 'web' }, Image: 'nginx:1.27' }],
inspectById: {},
image: { Config: { Healthcheck: { Test: ['CMD', 'true'] } } },
});
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
);
expect(evidence.web.state).toBe('local-image-inherited');
});
});
describe('collectServiceHealthcheckEvidence list failure', () => {
beforeEach(() => {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDocker: () => ({
listContainers: vi.fn(async () => { throw new Error('daemon down'); }),
getContainer: vi.fn(),
}),
inspectImage: vi.fn(async () => ({
inspect: { Config: { Healthcheck: { Test: ['CMD', 'true'] } } },
history: [],
})),
} as unknown as DockerController);
});
afterEach(() => vi.restoreAllMocks());
it('falls through to local-image evidence when the container list fails', async () => {
const evidence = await collectServiceHealthcheckEvidence(
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
);
expect(evidence.web.state).toBe('local-image-inherited');
});
});
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import {
classifyComposeHealthcheck,
isComposeHealthcheckActive,
isDockerHealthcheckActive,
} from '../helpers/healthcheckPresence';
describe('classifyComposeHealthcheck', () => {
it('treats a missing or non-object healthcheck as absent', () => {
expect(classifyComposeHealthcheck(undefined)).toBe('absent');
expect(classifyComposeHealthcheck(null)).toBe('absent');
expect(classifyComposeHealthcheck('CMD')).toBe('absent');
});
it('treats disable: true as disabled', () => {
expect(classifyComposeHealthcheck({ disable: true })).toBe('disabled');
expect(isComposeHealthcheckActive({ disable: true })).toBe(false);
});
it('treats test NONE forms as disabled', () => {
expect(classifyComposeHealthcheck({ test: 'NONE' })).toBe('disabled');
expect(classifyComposeHealthcheck({ test: ['NONE'] })).toBe('disabled');
expect(classifyComposeHealthcheck({ test: ['none'] })).toBe('disabled');
});
it('treats an active test as active', () => {
expect(classifyComposeHealthcheck({ test: ['CMD', 'true'] })).toBe('active');
expect(isComposeHealthcheckActive({ test: ['CMD', 'curl', '-f', 'http://localhost'] })).toBe(true);
});
it('treats empty or timing-only healthcheck objects as absent, not active', () => {
expect(classifyComposeHealthcheck({})).toBe('absent');
expect(classifyComposeHealthcheck({ disable: false })).toBe('absent');
expect(classifyComposeHealthcheck({ interval: '30s', timeout: '3s' })).toBe('absent');
expect(isComposeHealthcheckActive({ disable: false })).toBe(false);
});
});
describe('isDockerHealthcheckActive', () => {
it('is false for missing, empty, and NONE', () => {
expect(isDockerHealthcheckActive(undefined)).toBe(false);
expect(isDockerHealthcheckActive([])).toBe(false);
expect(isDockerHealthcheckActive(['NONE'])).toBe(false);
expect(isDockerHealthcheckActive('NONE')).toBe(false);
});
it('is true for a real Test array without exposing its contents', () => {
const secretTest = ['CMD-SHELL', 'curl -f http://x/?token=secret-token || exit 1'];
expect(isDockerHealthcheckActive(secretTest)).toBe(true);
});
});
@@ -157,6 +157,24 @@ describe('parseEffectiveModel', () => {
it('treats a disabled healthcheck as none', () => {
const m = parseEffectiveModel({ services: { web: { healthcheck: { disable: true } } } }, 'fallback');
expect(m.services[0].hasHealthcheck).toBe(false);
expect(m.services[0].composeHealthcheck).toBe('disabled');
});
it('treats test NONE as a disabled healthcheck', () => {
const m = parseEffectiveModel({ services: { web: { healthcheck: { test: ['NONE'] } } } }, 'fallback');
expect(m.services[0].hasHealthcheck).toBe(false);
expect(m.services[0].composeHealthcheck).toBe('disabled');
});
it('treats empty or disable:false healthcheck objects as absent', () => {
expect(parseEffectiveModel({ services: { web: { healthcheck: {} } } }, 'fallback').services[0]).toMatchObject({
hasHealthcheck: false,
composeHealthcheck: 'absent',
});
expect(parseEffectiveModel({ services: { web: { healthcheck: { disable: false } } } }, 'fallback').services[0]).toMatchObject({
hasHealthcheck: false,
composeHealthcheck: 'absent',
});
});
it('falls back to the provided project name and yields an empty model for garbage', () => {
+15 -1
View File
@@ -11,6 +11,7 @@ import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import DockerController from '../services/DockerController';
import { ComposeService } from '../services/ComposeService';
import { isPreflightNoteFinding } from '../services/preflight/rules';
let tmpDir: string;
let app: import('express').Express;
@@ -111,7 +112,10 @@ describe('preflight acknowledgement routes', () => {
const acked = get.body.findings.find((f: { ruleId: string; service?: string }) =>
f.ruleId === target.ruleId && f.service === target.service);
expect(acked?.acknowledged).toBe(true);
expect(get.body.activeCount).toBe(get.body.findings.length - get.body.acknowledgedCount);
expect(get.body.activeCount).toBe(
get.body.findings.filter((f: { ruleId: string; acknowledged?: boolean }) =>
!f.acknowledged && !isPreflightNoteFinding(f.ruleId)).length,
);
});
it('DELETE clears an acknowledgement', async () => {
@@ -140,4 +144,14 @@ describe('preflight acknowledgement routes', () => {
.send({ ruleId: 'port-exposed-all-interfaces', expiryMode: 'until_image_change' });
expect(res.status).toBe(400);
});
it('rejects acknowledging informational note findings', async () => {
await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
const res = await request(app)
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
.set('Authorization', authHeader)
.send({ ruleId: 'healthcheck-inherited', service: 'web', expiryMode: 'forever' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/notes cannot be acknowledged/i);
});
});
+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',
@@ -132,7 +132,7 @@ describe('buildMounts', () => {
{ type: 'bind', source: '/app/stack/conf', target: '/conf', readOnly: true },
{ type: 'named', source: 'shared', target: '/s', readOnly: false },
],
privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [],
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active', envKeys: [], networks: [], extraHosts: [], labelKeys: [],
},
],
networks: {},
@@ -161,7 +161,7 @@ describe('assembleStorageInventory', () => {
projectName: 'a', services: [{
name: 'app', ports: [], binds: [], namedVolumes: [],
storageMounts: [{ type: 'named', source: 'db', target: '/db', readOnly: false }],
privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [],
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active', envKeys: [], networks: [], extraHosts: [], labelKeys: [],
}], networks: {}, volumes: {},
};
expect(assembleStorageInventory('a', stateful, null, new Map()).stateful).toBe(true);
@@ -178,7 +178,7 @@ describe('assembleStorageInventory', () => {
projectName: 'a', services: [{
name: 'app', ports: [], binds: [], namedVolumes: [],
storageMounts: [{ type: 'bind', source: '/var/run/docker.sock', target: '/var/run/docker.sock', readOnly: false }],
privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [],
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active', envKeys: [], networks: [], extraHosts: [], labelKeys: [],
}], networks: {}, volumes: {},
};
expect(assembleStorageInventory('a', socketOnly, null, new Map()).stateful).toBe(false);
@@ -116,6 +116,21 @@ describe('UpdateGuardService.probeContainers', () => {
expect(probes[0].name).toBe('app-web-1');
});
it('treats Test NONE as no effective healthcheck', async () => {
mockListContainers.mockResolvedValue([
{ Id: 'aaa', Names: ['/app-web-1'], State: 'running' },
]);
mockGetContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue(inspectResult({
Config: { Healthcheck: { Test: ['NONE'] } },
})),
});
const probes = await UpdateGuardService.getInstance().probeContainers(0, 'app');
expect(probes).toHaveLength(1);
expect(probes[0].hasHealthcheck).toBe(false);
});
it('propagates non-404 inspect failures so the whole signal degrades honestly', async () => {
mockListContainers.mockResolvedValue([
{ Id: 'aaa', Names: ['/app-web-1'], State: 'running' },