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:
Anso
2026-07-05 04:12:03 -04:00
committed by GitHub
parent 122c1b8073
commit 4077546492
16 changed files with 1002 additions and 54 deletions
+62 -4
View File
@@ -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,
+98 -3
View File
@@ -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);
+10
View File
@@ -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':