mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
feat: acknowledge Compose Doctor preflight findings per stack (#1560)
* feat: acknowledge Compose Doctor preflight findings per stack Add node-scoped preflight acknowledgements with read-time filtering. Supports four expiry modes and activeStatus for banner, tab dot, and readiness. * fix: align preflight acknowledge UI with design system Use Combobox, modal chrome, mono fields, and non-destructive clear confirm. * fix: update test mocks to match new preflight field names The preflight-acknowledgements feature renamed status-\>activeStatus and highestSeverity-\>activeHighestSeverity in the preflight report shape. The corresponding test mocks in three files still used the old field names, causing 6 test failures across backend and frontend. - backend: update-guard-service mock now passes activeStatus - frontend PreflightPanel: Report interface and report() helper now include activeStatus, activeHighestSeverity, activeCount, acknowledgedCount - frontend StackAnatomyPanel doctor: mock API response now includes activeHighestSeverity and activeStatus
This commit is contained in:
@@ -282,11 +282,45 @@ describe('getLatest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('preflight acknowledgements', () => {
|
||||
const STACK = 'doctorack';
|
||||
beforeEach(() => { writeStack(STACK); });
|
||||
afterEach(() => { fs.rmSync(path.join(process.env.COMPOSE_DIR as string, STACK), { recursive: true, force: true }); });
|
||||
|
||||
it('lowers activeStatus when a finding is acknowledged', async () => {
|
||||
stubDocker(
|
||||
{ name: STACK, services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} },
|
||||
);
|
||||
const report = await doctor().runPreflight(nodeId, STACK, 'tester');
|
||||
expect(report.status).toBe('high');
|
||||
const target = report.findings.find(f => f.ruleId === 'port-exposed-all-interfaces');
|
||||
expect(target).toBeTruthy();
|
||||
db().upsertPreflightAcknowledgement({
|
||||
node_id: nodeId,
|
||||
stack_name: STACK,
|
||||
rule_id: target!.ruleId,
|
||||
service: target!.service ?? null,
|
||||
reason: 'intentional',
|
||||
expiry_mode: 'forever',
|
||||
expires_at: null,
|
||||
anchor_rendered_hash: null,
|
||||
anchor_image_ref: null,
|
||||
created_by: 'tester',
|
||||
created_at: Date.now(),
|
||||
});
|
||||
const latest = doctor().getLatest(nodeId, STACK);
|
||||
expect(latest.acknowledgedCount).toBe(1);
|
||||
expect(latest.activeCount).toBe(latest.findings.length - 1);
|
||||
expect(latest.status).toBe('high');
|
||||
expect(latest.activeStatus).not.toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('node deletion cleanup', () => {
|
||||
it('removes preflight runs and findings for a deleted node', () => {
|
||||
const ghostNode = 987654;
|
||||
db().replacePreflightRun(
|
||||
{ id: 'run-x', node_id: ghostNode, stack_name: 's', source_hash: null, rendered_hash: null, status: 'pass', highest_severity: null, created_at: 1, created_by: null },
|
||||
{ id: 'run-x', node_id: ghostNode, stack_name: 's', source_hash: null, rendered_hash: null, service_images: null, status: 'pass', highest_severity: null, created_at: 1, created_by: null },
|
||||
[{ id: 'find-x', run_id: 'run-x', rule_id: 'privileged', severity: 'high', title: 't', message: 'm', source_path: null, remediation: null, service: 's', created_at: 1 }],
|
||||
);
|
||||
expect(db().getLatestPreflightRun(ghostNode, 's')).toBeDefined();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { PreflightAcknowledgement } from '../services/DatabaseService';
|
||||
import { applyPreflightAcknowledgements, isPreflightAckActive } from '../utils/preflight-ack-filter';
|
||||
import type { PreflightFinding } from '../services/preflight/types';
|
||||
|
||||
const baseFinding = (over: Partial<PreflightFinding> = {}): PreflightFinding => ({
|
||||
ruleId: 'uid-gid-risk',
|
||||
severity: 'warning',
|
||||
title: 'Check UID/GID alignment',
|
||||
message: 'test',
|
||||
service: 'web',
|
||||
...over,
|
||||
});
|
||||
|
||||
const baseAck = (over: Partial<PreflightAcknowledgement> = {}): PreflightAcknowledgement => ({
|
||||
id: 1,
|
||||
node_id: 1,
|
||||
stack_name: 'demo',
|
||||
rule_id: 'uid-gid-risk',
|
||||
service: 'web',
|
||||
reason: 'verified ownership',
|
||||
expiry_mode: 'forever',
|
||||
expires_at: null,
|
||||
anchor_rendered_hash: null,
|
||||
anchor_image_ref: null,
|
||||
created_by: 'admin',
|
||||
created_at: Date.now(),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('applyPreflightAcknowledgements', () => {
|
||||
const ctx = { renderedHash: 'hash-a', serviceImages: { web: 'nginx:1.2' } };
|
||||
|
||||
it('marks a matching service-scoped ack as acknowledged', () => {
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [baseAck()], Date.now());
|
||||
expect(out[0].acknowledged).toBe(true);
|
||||
expect(out[0].acknowledgementId).toBe(1);
|
||||
});
|
||||
|
||||
it('prefers a service-scoped ack over a rule-wide ack', () => {
|
||||
const ruleWide = baseAck({ id: 2, service: null });
|
||||
const serviceScoped = baseAck({ id: 3, service: 'web' });
|
||||
const out = applyPreflightAcknowledgements(
|
||||
[baseFinding()],
|
||||
ctx,
|
||||
[ruleWide, serviceScoped],
|
||||
Date.now(),
|
||||
);
|
||||
expect(out[0].acknowledgementId).toBe(3);
|
||||
});
|
||||
|
||||
it('does not acknowledge when until_compose_change hash differs', () => {
|
||||
const ack = baseAck({ expiry_mode: 'until_compose_change', anchor_rendered_hash: 'old-hash' });
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
|
||||
expect(out[0].acknowledged).toBe(false);
|
||||
});
|
||||
|
||||
it('acknowledges when until_compose_change hash matches', () => {
|
||||
const ack = baseAck({ expiry_mode: 'until_compose_change', anchor_rendered_hash: 'hash-a' });
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
|
||||
expect(out[0].acknowledged).toBe(true);
|
||||
});
|
||||
|
||||
it('expires days mode after expires_at', () => {
|
||||
const now = 1_000_000;
|
||||
const ack = baseAck({ expiry_mode: 'days', expires_at: now - 1 });
|
||||
expect(isPreflightAckActive(ack, ctx, now)).toBe(false);
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], now);
|
||||
expect(out[0].acknowledged).toBe(false);
|
||||
});
|
||||
|
||||
it('honors until_image_change while image ref matches', () => {
|
||||
const ack = baseAck({
|
||||
expiry_mode: 'until_image_change',
|
||||
anchor_image_ref: 'nginx:1.2',
|
||||
});
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
|
||||
expect(out[0].acknowledged).toBe(true);
|
||||
});
|
||||
|
||||
it('re-surfaces until_image_change when image ref changes', () => {
|
||||
const ack = baseAck({
|
||||
expiry_mode: 'until_image_change',
|
||||
anchor_image_ref: 'nginx:1.0',
|
||||
});
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
|
||||
expect(out[0].acknowledged).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -80,3 +80,64 @@ describe('preflight routes', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preflight acknowledgement routes', () => {
|
||||
let stackDir: string;
|
||||
beforeEach(() => {
|
||||
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n ports:\n - "8080:80"\n');
|
||||
stub();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('POST acknowledges a finding and GET preflight reflects activeStatus', async () => {
|
||||
const run = await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
|
||||
expect(run.status).toBe(200);
|
||||
const target = run.body.findings.find((f: { ruleId: string }) => f.ruleId === 'port-exposed-all-interfaces');
|
||||
expect(target).toBeTruthy();
|
||||
|
||||
const ack = await request(app)
|
||||
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ ruleId: target.ruleId, service: target.service ?? null, reason: 'intentional', expiryMode: 'forever' });
|
||||
expect(ack.status).toBe(201);
|
||||
|
||||
const get = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
|
||||
expect(get.body.acknowledgedCount).toBeGreaterThanOrEqual(1);
|
||||
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);
|
||||
});
|
||||
|
||||
it('DELETE clears an acknowledgement', async () => {
|
||||
await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
|
||||
const list = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
|
||||
const target = list.body.findings[0];
|
||||
const ack = await request(app)
|
||||
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ ruleId: target.ruleId, service: target.service ?? null, expiryMode: 'forever' });
|
||||
const del = await request(app)
|
||||
.delete(`/api/stacks/${STACK}/preflight/acknowledgements/${ack.body.id}`)
|
||||
.set('Authorization', authHeader);
|
||||
expect(del.status).toBe(204);
|
||||
const get = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
|
||||
const again = get.body.findings.find((f: { ruleId: string; service?: string }) =>
|
||||
f.ruleId === target.ruleId && f.service === target.service);
|
||||
expect(again?.acknowledged).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects until_image_change without a service', 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: 'port-exposed-all-interfaces', expiryMode: 'until_image_change' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,17 +39,17 @@ const summary = (over: Partial<UpdatePreviewSummary> = {}): UpdatePreviewSummary
|
||||
|
||||
describe('preflightSignal', () => {
|
||||
const cases = [
|
||||
{ status: 'never-run', expected: 'unknown', affects: false },
|
||||
{ status: 'blocker', expected: 'blocked', affects: true },
|
||||
{ status: 'unrenderable', expected: 'attention', affects: true },
|
||||
{ status: 'high', expected: 'attention', affects: true },
|
||||
{ status: 'warning', expected: 'warning', affects: true },
|
||||
{ status: 'pass', expected: 'ok', affects: true },
|
||||
{ status: 'info', expected: 'ok', affects: true },
|
||||
{ activeStatus: 'never-run', expected: 'unknown', affects: false },
|
||||
{ activeStatus: 'blocker', expected: 'blocked', affects: true },
|
||||
{ activeStatus: 'unrenderable', expected: 'attention', affects: true },
|
||||
{ activeStatus: 'high', expected: 'attention', affects: true },
|
||||
{ activeStatus: 'warning', expected: 'warning', affects: true },
|
||||
{ activeStatus: 'pass', expected: 'ok', affects: true },
|
||||
{ activeStatus: 'info', expected: 'ok', affects: true },
|
||||
] as const;
|
||||
|
||||
it.each(cases)('maps preflight status $status to $expected', ({ status, expected, affects }) => {
|
||||
const signal = preflightSignal({ status });
|
||||
it.each(cases)('maps preflight activeStatus $activeStatus to $expected', ({ activeStatus, expected, affects }) => {
|
||||
const signal = preflightSignal({ activeStatus });
|
||||
expect(signal.status).toBe(expected);
|
||||
expect(signal.affectsVerdict).toBe(affects);
|
||||
});
|
||||
@@ -182,10 +182,10 @@ describe('aggregateVerdict', () => {
|
||||
});
|
||||
|
||||
it('reaches every verdict from realistic signal sets', () => {
|
||||
expect(aggregateVerdict([preflightSignal({ status: 'blocker' }), driftSignal(0)])).toBe('blocked');
|
||||
expect(aggregateVerdict([preflightSignal({ status: 'high' }), driftSignal(0)])).toBe('review_required');
|
||||
expect(aggregateVerdict([preflightSignal({ activeStatus: 'blocker' }), driftSignal(0)])).toBe('blocked');
|
||||
expect(aggregateVerdict([preflightSignal({ activeStatus: 'high' }), driftSignal(0)])).toBe('review_required');
|
||||
expect(aggregateVerdict([containersSignal('error'), driftSignal(0)])).toBe('unknown');
|
||||
expect(aggregateVerdict([driftSignal(1), preflightSignal({ status: 'pass' })])).toBe('ready_with_warnings');
|
||||
expect(aggregateVerdict([driftSignal(0), preflightSignal({ status: 'pass' }), healthchecksSignal([probe()])])).toBe('ready');
|
||||
expect(aggregateVerdict([driftSignal(1), preflightSignal({ activeStatus: 'pass' })])).toBe('ready_with_warnings');
|
||||
expect(aggregateVerdict([driftSignal(0), preflightSignal({ activeStatus: 'pass' }), healthchecksSignal([probe()])])).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('UpdateGuardService.computeUpdateReadiness wiring', () => {
|
||||
});
|
||||
|
||||
it('produces a ready verdict from healthy collaborator outputs', async () => {
|
||||
mockGetLatest.mockReturnValue({ status: 'pass' });
|
||||
mockGetLatest.mockReturnValue({ activeStatus: 'pass' });
|
||||
mockGetOpenDriftFindings.mockReturnValue([]);
|
||||
mockListContainers.mockResolvedValue([{ Id: 'aaa', Names: ['/app-web-1'], State: 'running' }]);
|
||||
mockGetContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectResult()) });
|
||||
|
||||
@@ -23,6 +23,9 @@ 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 { parseServiceImages, isPreflightAckActive } from '../utils/preflight-ack-filter';
|
||||
import type { PreflightAckExpiryMode } from '../services/DatabaseService';
|
||||
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
|
||||
import { buildStorageInventory } from '../services/storage/inventory';
|
||||
import { buildEffectiveAnatomy } from '../services/effectiveAnatomy';
|
||||
@@ -1249,6 +1252,143 @@ stacksRouter.post('/:stackName/preflight/run', async (req: Request, res: Respons
|
||||
}
|
||||
});
|
||||
|
||||
const PREFLIGHT_ACK_EXPIRY_MODES = new Set<PreflightAckExpiryMode>([
|
||||
'forever', 'until_compose_change', 'days', 'until_image_change',
|
||||
]);
|
||||
const PREFLIGHT_RULE_ID_SET = new Set(RULE_IDS);
|
||||
|
||||
stacksRouter.get('/:stackName/preflight/acknowledgements', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
try {
|
||||
const run = DatabaseService.getInstance().getLatestPreflightRun(req.nodeId, stackName);
|
||||
const ctx = {
|
||||
renderedHash: run?.rendered_hash ?? null,
|
||||
serviceImages: parseServiceImages(run?.service_images ?? null),
|
||||
};
|
||||
const now = Date.now();
|
||||
const rows = DatabaseService.getInstance().getPreflightAcknowledgements(req.nodeId, stackName).map((ack) => ({
|
||||
...ack,
|
||||
active: isPreflightAckActive(ack, ctx, now),
|
||||
}));
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to list preflight acknowledgements for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(inspect(error, { depth: 4 })));
|
||||
res.status(500).json({ error: 'Failed to load preflight acknowledgements' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/preflight/acknowledgements', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
const body = req.body ?? {};
|
||||
const ruleId = typeof body.ruleId === 'string' ? body.ruleId.trim() : '';
|
||||
if (!PREFLIGHT_RULE_ID_SET.has(ruleId)) {
|
||||
res.status(400).json({ error: 'ruleId must be a known Compose Doctor rule id' });
|
||||
return;
|
||||
}
|
||||
const serviceRaw = body.service == null || body.service === ''
|
||||
? null
|
||||
: String(body.service).trim();
|
||||
if (serviceRaw !== null && !isValidServiceName(serviceRaw)) {
|
||||
res.status(400).json({ error: 'service must be a valid service name' });
|
||||
return;
|
||||
}
|
||||
const expiryMode = typeof body.expiryMode === 'string' ? body.expiryMode.trim() : 'forever';
|
||||
if (!PREFLIGHT_ACK_EXPIRY_MODES.has(expiryMode as PreflightAckExpiryMode)) {
|
||||
res.status(400).json({ error: 'expiryMode must be forever, until_compose_change, days, or until_image_change' });
|
||||
return;
|
||||
}
|
||||
if (expiryMode === 'until_image_change' && !serviceRaw) {
|
||||
res.status(400).json({ error: 'until_image_change requires a specific service' });
|
||||
return;
|
||||
}
|
||||
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
|
||||
if (reason.length > 2000) {
|
||||
res.status(400).json({ error: 'reason is too long' });
|
||||
return;
|
||||
}
|
||||
let expiresAt: number | null = null;
|
||||
if (expiryMode === 'days') {
|
||||
const days = Number(body.expiresInDays ?? 30);
|
||||
if (!Number.isFinite(days) || days <= 0 || days > 3650) {
|
||||
res.status(400).json({ error: 'expiresInDays must be between 1 and 3650' });
|
||||
return;
|
||||
}
|
||||
expiresAt = Date.now() + Math.round(days * 86_400_000);
|
||||
}
|
||||
const run = DatabaseService.getInstance().getLatestPreflightRun(req.nodeId, stackName);
|
||||
if (!run) {
|
||||
res.status(400).json({ error: 'Run Compose Doctor before acknowledging a finding' });
|
||||
return;
|
||||
}
|
||||
const serviceImages = parseServiceImages(run.service_images);
|
||||
let anchorRenderedHash: string | null = null;
|
||||
let anchorImageRef: string | null = null;
|
||||
if (expiryMode === 'until_compose_change') {
|
||||
if (!run.rendered_hash) {
|
||||
res.status(400).json({ error: 'The latest preflight run has no compose fingerprint to anchor against' });
|
||||
return;
|
||||
}
|
||||
anchorRenderedHash = run.rendered_hash;
|
||||
}
|
||||
if (expiryMode === 'until_image_change') {
|
||||
const imageRef = serviceImages[serviceRaw!] ?? null;
|
||||
if (!imageRef) {
|
||||
res.status(400).json({ error: 'The latest preflight run has no image reference for that service' });
|
||||
return;
|
||||
}
|
||||
anchorImageRef = imageRef;
|
||||
}
|
||||
try {
|
||||
const ack = DatabaseService.getInstance().upsertPreflightAcknowledgement({
|
||||
node_id: req.nodeId,
|
||||
stack_name: stackName,
|
||||
rule_id: ruleId,
|
||||
service: serviceRaw,
|
||||
reason,
|
||||
expiry_mode: expiryMode as PreflightAckExpiryMode,
|
||||
expires_at: expiresAt,
|
||||
anchor_rendered_hash: anchorRenderedHash,
|
||||
anchor_image_ref: anchorImageRef,
|
||||
created_by: req.user?.username ?? 'unknown',
|
||||
created_at: Date.now(),
|
||||
});
|
||||
res.status(201).json(ack);
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to create preflight acknowledgement for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(inspect(error, { depth: 4 })));
|
||||
res.status(500).json({ error: 'Failed to create preflight acknowledgement' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.delete('/:stackName/preflight/acknowledgements/:id', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
res.status(400).json({ error: 'Invalid acknowledgement id' });
|
||||
return;
|
||||
}
|
||||
const existing = DatabaseService.getInstance().getPreflightAcknowledgement(id);
|
||||
if (!existing || existing.node_id !== req.nodeId || existing.stack_name !== stackName) {
|
||||
res.status(404).json({ error: 'Acknowledgement not found' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
DatabaseService.getInstance().deletePreflightAcknowledgement(id);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to delete preflight acknowledgement for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(inspect(error, { depth: 4 })));
|
||||
res.status(500).json({ error: 'Failed to delete preflight acknowledgement' });
|
||||
}
|
||||
});
|
||||
|
||||
// Compose Network Inspector: per-stack networking facts (network map, service
|
||||
// membership, published ports/bindings, network_mode, extra_hosts, runtime
|
||||
// drift) derived from the authored effective model + live snapshot. Read-only
|
||||
|
||||
@@ -15,6 +15,7 @@ import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './pref
|
||||
import type {
|
||||
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus, MissingEnvFile,
|
||||
} from './preflight/types';
|
||||
import { applyPreflightAcknowledgements, parseServiceImages } from '../utils/preflight-ack-filter';
|
||||
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
@@ -41,6 +42,55 @@ function highestOf(findings: PreflightFinding[]): PreflightSeverity | null {
|
||||
return best;
|
||||
}
|
||||
|
||||
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;
|
||||
const activeHighestSeverity = highestOf(active);
|
||||
const activeStatus: PreflightStatus = !renderable
|
||||
? 'unrenderable'
|
||||
: (activeHighestSeverity ?? 'pass');
|
||||
return {
|
||||
activeStatus,
|
||||
activeHighestSeverity,
|
||||
activeCount: active.length,
|
||||
acknowledgedCount,
|
||||
};
|
||||
}
|
||||
|
||||
function buildServiceImages(model: EffectiveModel | null): string | null {
|
||||
if (!model) return null;
|
||||
const map: Record<string, string> = {};
|
||||
for (const svc of model.services) {
|
||||
if (svc.image) map[svc.name] = svc.image;
|
||||
}
|
||||
return Object.keys(map).length > 0 ? JSON.stringify(map) : null;
|
||||
}
|
||||
|
||||
function enrichReport(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
report: Omit<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'>,
|
||||
): PreflightReport {
|
||||
const db = DatabaseService.getInstance();
|
||||
const acks = db.getPreflightAcknowledgements(nodeId, stackName);
|
||||
const serviceImages = parseServiceImages(
|
||||
db.getLatestPreflightRun(nodeId, stackName)?.service_images ?? null,
|
||||
);
|
||||
const findings = applyPreflightAcknowledgements(
|
||||
report.findings,
|
||||
{ renderedHash: report.renderedHash, serviceImages },
|
||||
acks,
|
||||
);
|
||||
return {
|
||||
...report,
|
||||
findings,
|
||||
...activeFields(report.renderable, findings),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose Doctor: renders the effective model and runs the deterministic
|
||||
* preflight rule registry against the active node. Advisory only (it never
|
||||
@@ -82,6 +132,7 @@ export class ComposeDoctorService {
|
||||
const findings = sortFindings(runRules(ctx));
|
||||
const highestSeverity = highestOf(findings);
|
||||
const status: PreflightStatus = !ctx.renderable ? 'unrenderable' : (highestSeverity ?? 'pass');
|
||||
const serviceImages = buildServiceImages(ctx.model);
|
||||
|
||||
const report: PreflightReport = {
|
||||
stack: stackName,
|
||||
@@ -94,9 +145,13 @@ export class ComposeDoctorService {
|
||||
sourceHash: hashes.sourceHash,
|
||||
renderedHash: hashes.renderedHash,
|
||||
findings,
|
||||
activeStatus: status,
|
||||
activeHighestSeverity: highestSeverity,
|
||||
activeCount: findings.length,
|
||||
acknowledgedCount: 0,
|
||||
};
|
||||
this.persist(nodeId, report);
|
||||
return report;
|
||||
this.persist(nodeId, report, serviceImages);
|
||||
return enrichReport(nodeId, stackName, report);
|
||||
}
|
||||
|
||||
/** Read the last stored run for a stack, mapped to the report shape. */
|
||||
@@ -107,6 +162,7 @@ export class ComposeDoctorService {
|
||||
return {
|
||||
stack: stackName, ranAt: null, ranBy: null, renderable: true, renderError: null,
|
||||
status: 'never-run', highestSeverity: null, sourceHash: null, renderedHash: null, findings: [],
|
||||
activeStatus: 'never-run', activeHighestSeverity: null, activeCount: 0, acknowledgedCount: 0,
|
||||
};
|
||||
}
|
||||
const findings = sortFindings(db.getPreflightFindings(run.id).map(r => ({
|
||||
@@ -121,7 +177,7 @@ export class ComposeDoctorService {
|
||||
const renderable = run.status !== 'unrenderable';
|
||||
// The render error is carried by the render-failed finding, not a column.
|
||||
const renderError = renderable ? null : (findings.find(f => f.ruleId === RENDER_FAILED_RULE_ID)?.message ?? null);
|
||||
return {
|
||||
const base: Omit<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> = {
|
||||
stack: stackName,
|
||||
ranAt: run.created_at,
|
||||
ranBy: run.created_by,
|
||||
@@ -133,6 +189,7 @@ export class ComposeDoctorService {
|
||||
renderedHash: run.rendered_hash,
|
||||
findings,
|
||||
};
|
||||
return enrichReport(nodeId, stackName, base);
|
||||
}
|
||||
|
||||
private async buildContext(nodeId: number, stackName: string, sourceServiceNames: string[], sourceReadable: boolean): Promise<PreflightContext> {
|
||||
@@ -323,7 +380,7 @@ export class ComposeDoctorService {
|
||||
}
|
||||
|
||||
/** Persist the run, replacing any prior run for this stack. Best-effort. */
|
||||
private persist(nodeId: number, report: PreflightReport): void {
|
||||
private persist(nodeId: number, report: PreflightReport, serviceImages: string | null): void {
|
||||
if (report.ranAt === null) return;
|
||||
try {
|
||||
const runId = randomUUID();
|
||||
@@ -334,6 +391,7 @@ export class ComposeDoctorService {
|
||||
stack_name: report.stack,
|
||||
source_hash: report.sourceHash,
|
||||
rendered_hash: report.renderedHash,
|
||||
service_images: serviceImages,
|
||||
status: report.status,
|
||||
highest_severity: report.highestSeverity,
|
||||
created_at: report.ranAt,
|
||||
|
||||
@@ -122,6 +122,8 @@ export interface PreflightRunRow {
|
||||
stack_name: string;
|
||||
source_hash: string | null;
|
||||
rendered_hash: string | null;
|
||||
/** JSON map of service name to image ref at run time (for until_image_change acks). */
|
||||
service_images: string | null;
|
||||
status: string;
|
||||
highest_severity: string | null;
|
||||
created_at: number;
|
||||
@@ -168,6 +170,24 @@ export interface PreflightFindingRow {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export type PreflightAckExpiryMode = 'forever' | 'until_compose_change' | 'days' | 'until_image_change';
|
||||
|
||||
/** Operator acknowledgement of a specific Compose Doctor finding on a stack. */
|
||||
export interface PreflightAcknowledgement {
|
||||
id: number;
|
||||
node_id: number;
|
||||
stack_name: string;
|
||||
rule_id: string;
|
||||
service: string | null;
|
||||
reason: string;
|
||||
expiry_mode: PreflightAckExpiryMode;
|
||||
expires_at: number | null;
|
||||
anchor_rendered_hash: string | null;
|
||||
anchor_image_ref: string | null;
|
||||
created_by: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/** A persisted drift finding: one service-scoped divergence, open until resolved. */
|
||||
export interface StackDriftFindingRow {
|
||||
id: number;
|
||||
@@ -1470,6 +1490,26 @@ export class DatabaseService {
|
||||
CREATE INDEX IF NOT EXISTS idx_preflight_findings_run
|
||||
ON preflight_findings(run_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS preflight_acknowledgements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
rule_id TEXT NOT NULL,
|
||||
service TEXT,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
expiry_mode TEXT NOT NULL DEFAULT 'forever'
|
||||
CHECK (expiry_mode IN ('forever','until_compose_change','days','until_image_change')),
|
||||
expires_at INTEGER,
|
||||
anchor_rendered_hash TEXT,
|
||||
anchor_image_ref TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_preflight_ack_unique
|
||||
ON preflight_acknowledgements(node_id, stack_name, rule_id, COALESCE(service,''));
|
||||
CREATE INDEX IF NOT EXISTS idx_preflight_ack_stack
|
||||
ON preflight_acknowledgements(node_id, stack_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_exposure (
|
||||
node_id INTEGER NOT NULL DEFAULT 0,
|
||||
stack_name TEXT NOT NULL,
|
||||
@@ -1583,6 +1623,8 @@ export class DatabaseService {
|
||||
maybeAddCol('cve_suppressions', 'status', "TEXT NOT NULL DEFAULT 'accepted'");
|
||||
maybeAddCol('cve_suppressions', 'justification', 'TEXT');
|
||||
|
||||
maybeAddCol('preflight_runs', 'service_images', 'TEXT');
|
||||
|
||||
// Scheduled operations migrations
|
||||
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
|
||||
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
|
||||
@@ -2884,9 +2926,12 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ? AND stack_name = ?').run(run.node_id, run.stack_name);
|
||||
this.db.prepare(
|
||||
`INSERT INTO preflight_runs
|
||||
(id, node_id, stack_name, source_hash, rendered_hash, status, highest_severity, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(run.id, run.node_id, run.stack_name, run.source_hash, run.rendered_hash, run.status, run.highest_severity, run.created_at, run.created_by);
|
||||
(id, node_id, stack_name, source_hash, rendered_hash, service_images, status, highest_severity, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
run.id, run.node_id, run.stack_name, run.source_hash, run.rendered_hash,
|
||||
run.service_images ?? null, run.status, run.highest_severity, run.created_at, run.created_by,
|
||||
);
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO preflight_findings
|
||||
(id, run_id, rule_id, severity, title, message, source_path, remediation, service, created_at)
|
||||
@@ -2912,6 +2957,55 @@ export class DatabaseService {
|
||||
).all(runId) as PreflightFindingRow[];
|
||||
}
|
||||
|
||||
public getPreflightAcknowledgements(nodeId: number, stackName: string): PreflightAcknowledgement[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM preflight_acknowledgements WHERE node_id = ? AND stack_name = ? ORDER BY created_at DESC, id DESC',
|
||||
).all(nodeId, stackName) as PreflightAcknowledgement[];
|
||||
}
|
||||
|
||||
public getPreflightAcknowledgement(id: number): PreflightAcknowledgement | null {
|
||||
return (
|
||||
(this.db.prepare('SELECT * FROM preflight_acknowledgements WHERE id = ?')
|
||||
.get(id) as PreflightAcknowledgement | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public upsertPreflightAcknowledgement(
|
||||
ack: Omit<PreflightAcknowledgement, 'id'>,
|
||||
): PreflightAcknowledgement {
|
||||
const existing = this.db.prepare(
|
||||
`SELECT id FROM preflight_acknowledgements
|
||||
WHERE node_id = ? AND stack_name = ? AND rule_id = ? AND COALESCE(service, '') = COALESCE(?, '')`,
|
||||
).get(ack.node_id, ack.stack_name, ack.rule_id, ack.service) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
this.db.prepare(
|
||||
`UPDATE preflight_acknowledgements
|
||||
SET reason = ?, expiry_mode = ?, expires_at = ?, anchor_rendered_hash = ?,
|
||||
anchor_image_ref = ?, created_by = ?, created_at = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
ack.reason, ack.expiry_mode, ack.expires_at, ack.anchor_rendered_hash,
|
||||
ack.anchor_image_ref, ack.created_by, ack.created_at, existing.id,
|
||||
);
|
||||
return this.getPreflightAcknowledgement(existing.id)!;
|
||||
}
|
||||
const result = this.db.prepare(
|
||||
`INSERT INTO preflight_acknowledgements
|
||||
(node_id, stack_name, rule_id, service, reason, expiry_mode, expires_at,
|
||||
anchor_rendered_hash, anchor_image_ref, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
ack.node_id, ack.stack_name, ack.rule_id, ack.service, ack.reason, ack.expiry_mode,
|
||||
ack.expires_at, ack.anchor_rendered_hash, ack.anchor_image_ref, ack.created_by, ack.created_at,
|
||||
);
|
||||
return { ...ack, id: result.lastInsertRowid as number };
|
||||
}
|
||||
|
||||
public deletePreflightAcknowledgement(id: number): boolean {
|
||||
const result = this.db.prepare('DELETE FROM preflight_acknowledgements WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// --- Health Gate Runs ---
|
||||
|
||||
public insertHealthGateRun(run: HealthGateRunRow): void {
|
||||
@@ -3316,6 +3410,7 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ?)').run(id);
|
||||
this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM preflight_acknowledgements WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_exposure WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM health_gate_runs WHERE node_id = ?').run(id);
|
||||
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
|
||||
|
||||
@@ -26,6 +26,11 @@ export interface PreflightFinding {
|
||||
remediation?: string;
|
||||
/** Service the finding is scoped to, when applicable. */
|
||||
service?: string;
|
||||
/** True when an active acknowledgement covers this finding. */
|
||||
acknowledged?: boolean;
|
||||
acknowledgementId?: number;
|
||||
acknowledgementReason?: string;
|
||||
acknowledgementExpiry?: 'forever' | 'until_compose_change' | 'days' | 'until_image_change';
|
||||
}
|
||||
|
||||
/** The full report returned by both the GET (latest) and POST (run) routes. */
|
||||
@@ -42,6 +47,11 @@ export interface PreflightReport {
|
||||
sourceHash: string | null;
|
||||
renderedHash: string | null;
|
||||
findings: PreflightFinding[];
|
||||
/** Severity/status after filtering acknowledged findings. */
|
||||
activeStatus: PreflightStatus;
|
||||
activeHighestSeverity: PreflightSeverity | null;
|
||||
activeCount: number;
|
||||
acknowledgedCount: number;
|
||||
}
|
||||
|
||||
/** A declared `env_file:` that is required and absent on disk (names only). */
|
||||
|
||||
@@ -27,13 +27,13 @@ const formatAge = (timestamp: number, now: number): string => {
|
||||
};
|
||||
|
||||
export function preflightSignal(
|
||||
input: { status: PreflightStatus } | Errored,
|
||||
input: { activeStatus: PreflightStatus } | Errored,
|
||||
): ReadinessSignal {
|
||||
const base = { id: 'preflight' as const, title: 'Compose Doctor' };
|
||||
if (input === 'error') {
|
||||
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'The stored preflight report could not be read.' };
|
||||
}
|
||||
switch (input.status) {
|
||||
switch (input.activeStatus) {
|
||||
case 'never-run':
|
||||
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'Compose Doctor has not been run for this stack yet. Run it for a deeper pre-update check.' };
|
||||
case 'blocker':
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Read-time Compose Doctor acknowledgement filter.
|
||||
*
|
||||
* Acknowledgements never modify stored finding rows. They are applied at read time
|
||||
* so clearing an ack resurfaces findings without re-running preflight.
|
||||
*/
|
||||
import type { PreflightAckExpiryMode, PreflightAcknowledgement } from '../services/DatabaseService';
|
||||
import type { PreflightFinding } from '../services/preflight/types';
|
||||
|
||||
export interface PreflightAcknowledgementDecision {
|
||||
acknowledged: boolean;
|
||||
acknowledgementId?: number;
|
||||
acknowledgementReason?: string;
|
||||
acknowledgementExpiry?: PreflightAckExpiryMode;
|
||||
}
|
||||
|
||||
export interface PreflightAckFilterContext {
|
||||
renderedHash: string | null;
|
||||
/** Parsed service name to image ref from the latest stored run. */
|
||||
serviceImages: Record<string, string>;
|
||||
}
|
||||
|
||||
function matchesService(ackService: string | null, findingService: string | undefined): boolean {
|
||||
if (ackService === null) return true;
|
||||
return ackService === (findingService ?? null);
|
||||
}
|
||||
|
||||
function isActive(
|
||||
ack: PreflightAcknowledgement,
|
||||
ctx: PreflightAckFilterContext,
|
||||
findingService: string | undefined,
|
||||
now: number,
|
||||
): boolean {
|
||||
switch (ack.expiry_mode) {
|
||||
case 'forever':
|
||||
return true;
|
||||
case 'until_compose_change':
|
||||
return ctx.renderedHash !== null
|
||||
&& ack.anchor_rendered_hash !== null
|
||||
&& ctx.renderedHash === ack.anchor_rendered_hash;
|
||||
case 'days':
|
||||
return ack.expires_at !== null && ack.expires_at > now;
|
||||
case 'until_image_change': {
|
||||
const svc = findingService ?? ack.service ?? null;
|
||||
if (!svc) return false;
|
||||
const current = ctx.serviceImages[svc] ?? null;
|
||||
return current !== null
|
||||
&& ack.anchor_image_ref !== null
|
||||
&& current === ack.anchor_image_ref;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function specificityScore(ack: PreflightAcknowledgement): number {
|
||||
return ack.service ? 1 : 0;
|
||||
}
|
||||
|
||||
function pickFromBucket(
|
||||
bucket: PreflightAcknowledgement[],
|
||||
findingService: string | undefined,
|
||||
ctx: PreflightAckFilterContext,
|
||||
now: number,
|
||||
): PreflightAcknowledgement | null {
|
||||
let best: PreflightAcknowledgement | null = null;
|
||||
let bestScore = -1;
|
||||
for (const ack of bucket) {
|
||||
if (!matchesService(ack.service, findingService)) continue;
|
||||
if (!isActive(ack, ctx, findingService, now)) continue;
|
||||
const score = specificityScore(ack);
|
||||
if (score > bestScore) {
|
||||
best = ack;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function parseServiceImages(json: string | null | undefined): Record<string, string> {
|
||||
if (!json) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === 'string' && value.length > 0) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function applyPreflightAcknowledgements<T extends PreflightFinding>(
|
||||
findings: T[],
|
||||
ctx: PreflightAckFilterContext,
|
||||
acks: PreflightAcknowledgement[],
|
||||
now: number = Date.now(),
|
||||
): Array<T & PreflightAcknowledgementDecision> {
|
||||
if (findings.length === 0) return [];
|
||||
const buckets = new Map<string, PreflightAcknowledgement[]>();
|
||||
for (const ack of acks) {
|
||||
const existing = buckets.get(ack.rule_id);
|
||||
if (existing) {
|
||||
existing.push(ack);
|
||||
} else {
|
||||
buckets.set(ack.rule_id, [ack]);
|
||||
}
|
||||
}
|
||||
return findings.map((f) => {
|
||||
const bucket = buckets.get(f.ruleId);
|
||||
const match = bucket ? pickFromBucket(bucket, f.service, ctx, now) : null;
|
||||
if (!match) return { ...f, acknowledged: false };
|
||||
return {
|
||||
...f,
|
||||
acknowledged: true,
|
||||
acknowledgementId: match.id,
|
||||
acknowledgementReason: match.reason,
|
||||
acknowledgementExpiry: match.expiry_mode,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function isPreflightAckActive(
|
||||
ack: PreflightAcknowledgement,
|
||||
ctx: PreflightAckFilterContext,
|
||||
now: number = Date.now(),
|
||||
): boolean {
|
||||
return isActive(ack, ctx, ack.service ?? undefined, now);
|
||||
}
|
||||
Reference in New Issue
Block a user