mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
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:
@@ -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'] } },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Structural healthcheck presence classification for Compose YAML objects and
|
||||
* Docker inspect `Config.Healthcheck.Test` arrays. Returns enums/booleans only;
|
||||
* never retains or returns Test command text (commands can carry secrets).
|
||||
*/
|
||||
|
||||
export type ComposeHealthcheckClass = 'active' | 'disabled' | 'absent';
|
||||
|
||||
/**
|
||||
* Classify a Compose `healthcheck:` value from the rendered effective model.
|
||||
* `disable: true` and `test: NONE` / `["NONE"]` are explicit disablement.
|
||||
* An empty object, `disable: false` alone, or timing-only fields without a
|
||||
* `test` are absent (fall through to runtime/image evidence), not active.
|
||||
*/
|
||||
export function classifyComposeHealthcheck(healthcheck: unknown): ComposeHealthcheckClass {
|
||||
if (healthcheck == null) return 'absent';
|
||||
if (typeof healthcheck !== 'object' || Array.isArray(healthcheck)) return 'absent';
|
||||
const hc = healthcheck as Record<string, unknown>;
|
||||
if (hc.disable === true) return 'disabled';
|
||||
if (isNoneTest(hc.test)) return 'disabled';
|
||||
if (hasActiveTest(hc.test)) return 'active';
|
||||
return 'absent';
|
||||
}
|
||||
|
||||
/** True when the Compose healthcheck is an active (non-disabled) declaration. */
|
||||
export function isComposeHealthcheckActive(healthcheck: unknown): boolean {
|
||||
return classifyComposeHealthcheck(healthcheck) === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when Docker's effective healthcheck Test is present and active.
|
||||
* Empty / missing / `NONE` / `["NONE"]` are inactive.
|
||||
*/
|
||||
export function isDockerHealthcheckActive(test: unknown): boolean {
|
||||
return hasActiveTest(test);
|
||||
}
|
||||
|
||||
/** True when `test` is a non-empty, non-NONE healthcheck command. */
|
||||
function hasActiveTest(test: unknown): boolean {
|
||||
if (test == null) return false;
|
||||
if (typeof test === 'string') {
|
||||
const trimmed = test.trim();
|
||||
return trimmed.length > 0 && !isNoneToken(trimmed);
|
||||
}
|
||||
if (!Array.isArray(test) || test.length === 0) return false;
|
||||
if (isNoneTest(test)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isNoneTest(test: unknown): boolean {
|
||||
if (typeof test === 'string') return isNoneToken(test);
|
||||
if (!Array.isArray(test) || test.length === 0) return false;
|
||||
return test.length === 1 && typeof test[0] === 'string' && isNoneToken(test[0]);
|
||||
}
|
||||
|
||||
function isNoneToken(value: string): boolean {
|
||||
return value.trim().toUpperCase() === 'NONE';
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
|
||||
import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerService';
|
||||
import { ComposeDoctorService } from '../services/ComposeDoctorService';
|
||||
import { RULE_IDS } from '../services/preflight/rules';
|
||||
import { RULE_IDS, isPreflightNoteFinding } from '../services/preflight/rules';
|
||||
import { parseServiceImages, isPreflightAckActive } from '../utils/preflight-ack-filter';
|
||||
import type { PreflightAckExpiryMode } from '../services/DatabaseService';
|
||||
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
|
||||
@@ -1417,6 +1417,10 @@ stacksRouter.post('/:stackName/preflight/acknowledgements', async (req: Request,
|
||||
res.status(400).json({ error: 'ruleId must be a known Compose Doctor rule id' });
|
||||
return;
|
||||
}
|
||||
if (isPreflightNoteFinding(ruleId)) {
|
||||
res.status(400).json({ error: 'Informational notes cannot be acknowledged' });
|
||||
return;
|
||||
}
|
||||
const serviceRaw = body.service == null || body.service === ''
|
||||
? null
|
||||
: String(body.service).trim();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user