mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +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:
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user