mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-03 15:37:44 +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>;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Run a preflight check on a stack before you deploy. Compose Doctor
|
||||
|
||||
The **Doctor** tab in the right-hand **Anatomy** panel answers one question before you apply a change: *what will Docker actually run, and is it safe on this node?* Compose Doctor renders the effective Compose model (the fully resolved result after interpolation, includes, profiles, `.env`, and `env_file` are applied) and then runs a set of deterministic checks against it and the live Docker state on the node it would deploy to.
|
||||
|
||||
The check is advisory: on its own it never blocks a deploy or changes a stack. (One rule is the exception: see [Self-management](#self-management) below for the one case where Sencho actively blocks an action, independent of this report.) It runs on demand: press **run preflight** and Sencho renders the model, runs all 32 checks, and stores the result so the tab still shows it the next time you open the stack.
|
||||
The check is advisory: on its own it never blocks a deploy or changes a stack. (One rule is the exception: see [Self-management](#self-management) below for the one case where Sencho actively blocks an action, independent of this report.) It runs on demand: press **run preflight** and Sencho renders the model, runs all 36 checks, and stores the result so the tab still shows it the next time you open the stack.
|
||||
|
||||
## Where to find it
|
||||
|
||||
@@ -24,7 +24,7 @@ Every preflight run follows three steps:
|
||||
|
||||
1. **Render** the effective model. Sencho calls `docker compose config` on the stack, which resolves all variable interpolation, `include` directives, profile overrides, and `env_file` references into a single, normalized model.
|
||||
2. **Snapshot** live Docker state. Sencho reads which host ports are in use, which containers are running, and which named networks and volumes exist on the target node.
|
||||
3. **Run 32 deterministic rules** against the combination. Each rule is pure and produces zero or more findings with a severity, a message, and a suggested fix.
|
||||
3. **Run 36 deterministic rules** against the combination. Each rule is pure and produces zero or more findings with a severity, a message, and a suggested fix.
|
||||
|
||||
Sencho stores exactly one run per stack per node, so a new run immediately overwrites the previous one; there is no history.
|
||||
|
||||
@@ -73,7 +73,7 @@ A small colored dot appears on the **Doctor** tab label when the last run's acti
|
||||
|
||||
## What it checks
|
||||
|
||||
All 32 rules are listed below, organized by topic.
|
||||
All 36 rules are listed below, organized by topic.
|
||||
|
||||
### Model rendering
|
||||
|
||||
@@ -119,7 +119,11 @@ All 32 rules are listed below, organized by topic.
|
||||
|------|----------|----------------|
|
||||
| Image uses a moving tag | Warning | A service uses `:latest` or a tag-less image reference, making deploys non-reproducible and subject to unexpected changes. |
|
||||
| No restart policy | Warning | A service has no restart policy and will not come back after a crash or host reboot. |
|
||||
| No healthcheck | Warning | A service declares no healthcheck in the Compose model. The image itself may define one, but Sencho cannot see it from the model alone. |
|
||||
| No effective healthcheck detected | Warning | Sencho verified that the service has no effective healthcheck in the Compose model, running containers, or a locally available image. |
|
||||
| Healthcheck explicitly disabled | Warning | The Compose model disables the healthcheck (`disable: true` or `test: NONE`). |
|
||||
| Healthcheck inherited from image | Note | The Compose model does not declare a healthcheck, but the running container or a local image provides one. Coverage is treated as satisfied (All Clear); the note explains the origin. |
|
||||
| Healthcheck inheritance could not be verified | Info | No Compose healthcheck is declared, and Sencho could not verify inheritance (Docker unreachable, image not present locally, or incomplete inspection). Doctor never pulls images. |
|
||||
| Replica healthcheck coverage is inconsistent | Warning | Running replicas for the service disagree on effective healthcheck coverage. |
|
||||
|
||||
### Compose semantics
|
||||
|
||||
@@ -163,7 +167,7 @@ These rules activate when the stack publishes at least one host port. They use t
|
||||
|
||||
## Exposure intent checks
|
||||
|
||||
Five of the 32 rules cross-reference the stack's exposure intent and the access URLs documented in the Stack Dossier. These rules only fire when the stack publishes at least one host port.
|
||||
Five of the 36 rules cross-reference the stack's exposure intent and the access URLs documented in the Stack Dossier. These rules only fire when the stack publishes at least one host port.
|
||||
|
||||
To resolve exposure-related findings:
|
||||
|
||||
@@ -186,7 +190,7 @@ The networking-relevant rules on this page (host mode, exposure intent, port con
|
||||
|
||||
## Node-state checks and graceful degradation
|
||||
|
||||
Six of the 32 rules require live Docker state to run: five are in the Node state category (external networks and volumes, new-resource notices, and container_name collision) and one is "Host port is already in use" in Port conflicts. All six are skipped when the Docker daemon is unreachable.
|
||||
Six of the 36 rules require live Docker state to run: five are in the Node state category (external networks and volumes, new-resource notices, and container_name collision) and one is "Host port is already in use" in Port conflicts. All six are skipped when the Docker daemon is unreachable. Healthcheck inheritance checks also read Docker when Compose does not declare a healthcheck; those degrade to an Info finding when the daemon or image is unavailable.
|
||||
|
||||
When the daemon is unreachable:
|
||||
|
||||
@@ -260,7 +264,7 @@ There is no tier gate: Compose Doctor is available on all plans.
|
||||
- **Advisory only, with one exception.** Compose Doctor's report never blocks a deploy or changes any stack configuration; act on findings or ignore them. The one exception is the self-management guard: Sencho refuses generic deploy, update, stop, down, and delete actions on its own compose project regardless of whether that finding is acknowledged. See [Self-management](#self-management).
|
||||
- **Dismissing the summary is local to your browser.** The **X** on the summary card and the Doctor tab dot share a per-stack, per-node dismissal stored in your browser's local storage. It is not synced across devices or between teammates, so a dismissal you make is invisible to anyone else looking at the same stack.
|
||||
- **Bind-mount checks are scoped.** Only paths that resolve inside the node's Compose base directory can be checked for existence and ownership. Absolute host paths outside that directory (such as `/mnt/media`) are not reported as missing.
|
||||
- **Healthcheck rule cannot see image-level healthchecks.** The no-healthcheck rule fires when the Compose model does not declare a healthcheck. Many images define one internally that Sencho cannot see from the rendered model; treat the finding as a prompt to confirm the image provides one.
|
||||
- **Healthcheck coverage uses Compose, runtime, and local image evidence.** Doctor checks the rendered Compose model first, then running containers for the service, then the locally available declared image. It never pulls an image. When evidence is incomplete, it reports that inheritance could not be verified instead of claiming a definitive absence.
|
||||
- **One run stored per node.** There is no history. Each new run overwrites the previous one for that stack on that node.
|
||||
- **Node-state rules require a reachable Docker daemon.** See [Node-state checks and graceful degradation](#node-state-checks-and-graceful-degradation).
|
||||
- **Port conflict check excludes the checked stack.** Host ports already held by the stack being checked are ignored, so redeploying a running stack does not generate a false conflict with itself.
|
||||
@@ -276,7 +280,7 @@ There is no tier gate: Compose Doctor is available on all plans.
|
||||
Compose Doctor can only check paths that resolve inside the node's Compose base directory, such as a relative `./data` mount. An absolute host path like `/mnt/media` is outside what Sencho can see from inside its container and is never reported as missing. A missing relative path is a real finding: Docker would create it as a root-owned directory on deploy.
|
||||
</Accordion>
|
||||
<Accordion title="Every service is flagged for no healthcheck">
|
||||
The no-healthcheck rule fires when the Compose model does not declare a healthcheck. Many images define their own healthcheck internally, which Sencho cannot see from the model alone. Treat these findings as a prompt to confirm the image provides a healthcheck rather than a hard problem.
|
||||
Doctor reports missing healthcheck coverage only after checking the Compose model, running containers for the service, and any locally available declared image. An Info finding means inheritance could not be verified (for example the image is not present locally). Doctor does not pull images during a run. Add an explicit Compose healthcheck, or make sure the image is available on the node and run preflight again.
|
||||
</Accordion>
|
||||
<Accordion title="A port conflict is flagged for a port my own stack uses">
|
||||
Preflight ignores ports already held by the stack being checked, so redeploying a running stack does not flag its own bindings. A conflict finding means a different stack or an unmanaged container holds that host port on this node.
|
||||
|
||||
@@ -26,6 +26,7 @@ import EnvironmentPanel from './stack/EnvironmentPanel';
|
||||
import ComposeLabelsPanel from './stack/ComposeLabelsPanel';
|
||||
import StackNetworkingPanel from './stack/StackNetworkingPanel';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { isPreflightNoteFinding } from '@/lib/preflightNotes';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
|
||||
interface StackAnatomyPanelProps {
|
||||
@@ -184,7 +185,9 @@ export default function StackAnatomyPanel({
|
||||
if (!cancelled) {
|
||||
setPreflightSeverity(typeof data?.activeHighestSeverity === 'string' ? data.activeHighestSeverity : null);
|
||||
const findings = Array.isArray(data?.findings) ? data.findings : undefined;
|
||||
setPreflightFindings(findings?.filter((f: { acknowledged?: boolean }) => !f.acknowledged));
|
||||
// Notes do not drive the Doctor tab dismiss fingerprint.
|
||||
setPreflightFindings(findings?.filter((f: { acknowledged?: boolean; ruleId?: string }) =>
|
||||
!f.acknowledged && !isPreflightNoteFinding(f.ruleId)));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) { setPreflightSeverity(null); setPreflightFindings(undefined); }
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { isPreflightNoteFinding } from '@/lib/preflightNotes';
|
||||
import PreflightPanel from './PreflightPanel';
|
||||
|
||||
interface Finding {
|
||||
@@ -54,7 +55,11 @@ function report(partial: Partial<Report>): Report {
|
||||
// every call site listing the new field names).
|
||||
if (partial.status !== undefined && partial.activeStatus === undefined) merged.activeStatus = merged.status;
|
||||
if (partial.highestSeverity !== undefined && partial.activeHighestSeverity === undefined) merged.activeHighestSeverity = merged.highestSeverity;
|
||||
if (partial.findings !== undefined && partial.activeCount === undefined) merged.activeCount = merged.findings.filter(f => !f.acknowledged).length;
|
||||
if (partial.findings !== undefined && partial.activeCount === undefined) {
|
||||
merged.activeCount = merged.findings.filter(
|
||||
f => !f.acknowledged && !isPreflightNoteFinding(f.ruleId),
|
||||
).length;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -79,6 +84,61 @@ describe('PreflightPanel', () => {
|
||||
expect(status).toHaveTextContent(/all clear/i);
|
||||
});
|
||||
|
||||
it('keeps All Clear when only inherited-healthcheck notes remain', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'pass',
|
||||
activeStatus: 'pass',
|
||||
activeCount: 0,
|
||||
findings: [{
|
||||
ruleId: 'healthcheck-inherited',
|
||||
severity: 'info',
|
||||
title: 'Healthcheck inherited from image',
|
||||
message: 'Service "web" does not declare a healthcheck in Compose.',
|
||||
service: 'web',
|
||||
}],
|
||||
})));
|
||||
render(<PreflightPanel stackName="web" canEdit />);
|
||||
const status = await screen.findByTestId('preflight-status');
|
||||
expect(status).toHaveAttribute('data-status', 'pass');
|
||||
expect(status).toHaveTextContent(/all clear/i);
|
||||
expect(screen.getByTestId('preflight-notes-section')).toHaveTextContent(/Healthcheck inherited from image/i);
|
||||
expect(screen.queryByTestId('preflight-ack-btn-healthcheck-inherited-web')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes notes from the graded summary line when issue findings remain', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'warning',
|
||||
highestSeverity: 'warning',
|
||||
activeStatus: 'warning',
|
||||
activeHighestSeverity: 'warning',
|
||||
activeCount: 1,
|
||||
findings: [
|
||||
{
|
||||
ruleId: 'image-latest',
|
||||
severity: 'warning',
|
||||
title: 'Image uses a moving tag',
|
||||
message: 'latest tag',
|
||||
service: 'web',
|
||||
},
|
||||
{
|
||||
ruleId: 'healthcheck-inherited',
|
||||
severity: 'info',
|
||||
title: 'Healthcheck inherited from image',
|
||||
message: 'Service "web" does not declare a healthcheck in Compose.',
|
||||
service: 'web',
|
||||
},
|
||||
],
|
||||
})));
|
||||
render(<PreflightPanel stackName="web" canEdit />);
|
||||
const status = await screen.findByTestId('preflight-status');
|
||||
expect(status).toHaveAttribute('data-status', 'warning');
|
||||
expect(status).toHaveTextContent(/1 warning/i);
|
||||
expect(status).not.toHaveTextContent(/info/i);
|
||||
expect(screen.getByTestId('preflight-notes-section')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('preflight-ack-btn-healthcheck-inherited-web')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('preflight-ack-btn-image-latest-web')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('groups findings and reflects the highest severity', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'high',
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
import { isPreflightNoteFinding } from '@/lib/preflightNotes';
|
||||
|
||||
type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
|
||||
type PreflightStatus = 'never-run' | 'pass' | 'unrenderable' | PreflightSeverity;
|
||||
@@ -70,7 +71,10 @@ const EXPIRY_LABELS: Record<PreflightAckExpiryMode, string> = {
|
||||
until_image_change: 'Until image changes',
|
||||
};
|
||||
|
||||
function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon; tone: string; line: string } {
|
||||
function summaryMeta(
|
||||
report: PreflightReport,
|
||||
activeFindings: PreflightFinding[],
|
||||
): { label: string; icon: LucideIcon; tone: string; line: string } {
|
||||
if (!report.renderable) {
|
||||
return {
|
||||
label: 'cannot render',
|
||||
@@ -84,7 +88,7 @@ function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon
|
||||
}
|
||||
const meta = SEVERITY_META[report.activeHighestSeverity ?? 'info'];
|
||||
const activeParts = GROUP_ORDER
|
||||
.map(sev => ({ sev, n: report.findings.filter(f => !f.acknowledged && f.severity === sev).length }))
|
||||
.map(sev => ({ sev, n: activeFindings.filter(f => f.severity === sev).length }))
|
||||
.filter(c => c.n > 0)
|
||||
.map(c => `${c.n} ${SEVERITY_META[c.sev].label}`)
|
||||
.join(' · ');
|
||||
@@ -267,16 +271,19 @@ export default function PreflightPanel({ stackName, canEdit = false }: { stackNa
|
||||
}
|
||||
};
|
||||
|
||||
const activeFindings = useMemo(
|
||||
() => report?.findings.filter(f => !f.acknowledged) ?? [],
|
||||
[report?.findings],
|
||||
);
|
||||
const acknowledgedFindings = useMemo(
|
||||
() => report?.findings.filter(f => f.acknowledged) ?? [],
|
||||
[report?.findings],
|
||||
);
|
||||
const { activeFindings, noteFindings, acknowledgedFindings } = useMemo(() => {
|
||||
const notes: PreflightFinding[] = [];
|
||||
const active: PreflightFinding[] = [];
|
||||
const acknowledged: PreflightFinding[] = [];
|
||||
for (const f of report?.findings ?? []) {
|
||||
if (isPreflightNoteFinding(f.ruleId)) notes.push(f);
|
||||
else if (f.acknowledged) acknowledged.push(f);
|
||||
else active.push(f);
|
||||
}
|
||||
return { activeFindings: active, noteFindings: notes, acknowledgedFindings: acknowledged };
|
||||
}, [report?.findings]);
|
||||
|
||||
const summary = report && report.status !== 'never-run' ? summaryMeta(report) : null;
|
||||
const summary = report && report.status !== 'never-run' ? summaryMeta(report, activeFindings) : null;
|
||||
const SummaryIcon = summary?.icon;
|
||||
const busy = loading || running;
|
||||
|
||||
@@ -418,6 +425,21 @@ export default function PreflightPanel({ stackName, canEdit = false }: { stackNa
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noteFindings.length > 0 && (
|
||||
<section data-testid="preflight-notes-section">
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>notes · {noteFindings.length}</div>
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
{noteFindings.map((f, i) => (
|
||||
<FindingRow
|
||||
key={`note-${f.ruleId}-${f.service ?? ''}-${i}`}
|
||||
finding={f}
|
||||
canEdit={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{GROUP_ORDER.map(sev => {
|
||||
const items = activeFindings.filter(f => f.severity === sev);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Informational Compose Doctor notes (excluded from All Clear, severity
|
||||
* summary, and dismiss fingerprint). Keep in sync with backend PREFLIGHT_NOTE_RULE_IDS.
|
||||
*/
|
||||
const PREFLIGHT_NOTE_RULE_IDS = new Set(['healthcheck-inherited']);
|
||||
|
||||
export function isPreflightNoteFinding(ruleId: string | undefined): boolean {
|
||||
return !!ruleId && PREFLIGHT_NOTE_RULE_IDS.has(ruleId);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface EffectiveServiceSpec {
|
||||
/** May be 0 (explicit `scale: 0` or `deploy.replicas: 0`); defaults to 1 when neither is set. */
|
||||
expectedReplicas: number;
|
||||
dependsOn: string[];
|
||||
/** False when Compose omits the healthcheck, sets disable: true, or uses test NONE. */
|
||||
hasHealthcheck: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user