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
+140
View File
@@ -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