diff --git a/src/lib/advisories/presenter.test.ts b/src/lib/advisories/presenter.test.ts new file mode 100644 index 00000000000..27558471a0f --- /dev/null +++ b/src/lib/advisories/presenter.test.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + assertNoBlocking, + BlockingAdvisoryError, + blockingAdvisories, + formatAdvisories, +} from "./presenter"; +import type { Advisory } from "./types"; + +const warning: Advisory = { + id: "runtime_resources", + severity: "warning", + phase: "preflight.host", + title: "Runtime resources are low", + reason: "The sandbox build may stall.", + commands: ["colima start --cpu 4 --memory 8"], + docsUrl: "https://docs.example.test/runtime-resources", + resumeSafe: false, + kind: "manual", +}; + +const fatal: Advisory = { + id: "docker_unreachable", + severity: "fatal", + phase: "preflight.host", + title: "Docker is unreachable", + reason: "The daemon did not answer.", + resumeSafe: false, +}; + +describe("advisory presenter", () => { + it("formats deterministic console and JSON representations", () => { + expect(formatAdvisories([warning], "console")).toBe( + [ + "[WARNING] Runtime resources are low (runtime_resources)", + " The sandbox build may stall.", + " Run: colima start --cpu 4 --memory 8", + " More: https://docs.example.test/runtime-resources", + ].join("\n"), + ); + expect(JSON.parse(formatAdvisories([warning], "json"))).toEqual([warning]); + }); + + it("classifies only fatal and blocking severities as blockers", () => { + expect(blockingAdvisories([warning, fatal])).toEqual([fatal]); + expect(() => assertNoBlocking([warning])).not.toThrow(); + }); + + it("raises one structured error for all blockers", () => { + expect(() => assertNoBlocking([warning, fatal])).toThrow(BlockingAdvisoryError); + try { + assertNoBlocking([fatal]); + } catch (error) { + expect(error).toBeInstanceOf(BlockingAdvisoryError); + expect((error as BlockingAdvisoryError).advisories).toEqual([fatal]); + } + }); +}); diff --git a/src/lib/advisories/presenter.ts b/src/lib/advisories/presenter.ts new file mode 100644 index 00000000000..fee2917738c --- /dev/null +++ b/src/lib/advisories/presenter.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Advisory } from "./types"; + +/** Supported pure rendering targets. */ +export type AdvisoryFormat = "console" | "json"; + +/** Error raised when a caller attempts to continue past blocking advisories. */ +export class BlockingAdvisoryError extends Error { + readonly advisories: readonly Advisory[]; + + constructor(advisories: readonly Advisory[]) { + super(`Blocked by ${advisories.length} advisory finding${advisories.length === 1 ? "" : "s"}.`); + this.name = "BlockingAdvisoryError"; + this.advisories = advisories; + } +} + +function formatConsoleAdvisory(advisory: Advisory): string { + const lines = [ + `[${advisory.severity.toUpperCase()}] ${advisory.title} (${advisory.id})`, + ` ${advisory.reason}`, + ]; + for (const command of advisory.commands ?? []) lines.push(` Run: ${command}`); + if (advisory.docsUrl) lines.push(` More: ${advisory.docsUrl}`); + return lines.join("\n"); +} + +/** Formats advisories without writing to stdout, stderr, or process state. */ +export function formatAdvisories(advisories: readonly Advisory[], format: AdvisoryFormat): string { + if (format === "json") return JSON.stringify(advisories, null, 2); + return advisories.map(formatConsoleAdvisory).join("\n\n"); +} + +/** Returns the findings that prohibit the caller from continuing. */ +export function blockingAdvisories(advisories: readonly Advisory[]): readonly Advisory[] { + return advisories.filter( + (advisory) => advisory.severity === "fatal" || advisory.severity === "blocking", + ); +} + +/** Throws one structured error when any fatal or blocking finding is present. */ +export function assertNoBlocking(advisories: readonly Advisory[]): void { + const blocking = blockingAdvisories(advisories); + if (blocking.length > 0) throw new BlockingAdvisoryError(blocking); +} diff --git a/src/lib/advisories/registry.test.ts b/src/lib/advisories/registry.test.ts new file mode 100644 index 00000000000..a3754c7e1fb --- /dev/null +++ b/src/lib/advisories/registry.test.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { ADVISORY_CHECKS, defineAdvisoryRegistry } from "./registry"; +import type { AdvisoryCheck } from "./types"; + +function check(id: string): AdvisoryCheck { + return { + id, + phase: "preflight.host", + severity: "info", + resumeSafe: true, + check: () => null, + }; +} + +describe("defineAdvisoryRegistry", () => { + it("preserves explicit order in an immutable registry", () => { + const registry = defineAdvisoryRegistry([check("first"), check("second")]); + + expect(registry.map((entry) => entry.id)).toEqual(["first", "second"]); + expect(Object.isFrozen(registry)).toBe(true); + expect(ADVISORY_CHECKS).toEqual([]); + }); + + it("rejects duplicate stable IDs", () => { + expect(() => defineAdvisoryRegistry([check("duplicate"), check("duplicate")])).toThrow( + "Duplicate advisory check id 'duplicate'.", + ); + }); +}); diff --git a/src/lib/advisories/registry.ts b/src/lib/advisories/registry.ts new file mode 100644 index 00000000000..d948428eb3e --- /dev/null +++ b/src/lib/advisories/registry.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AdvisoryCheck } from "./types"; + +/** Creates an explicit, immutable advisory registry with globally unique IDs. */ +export function defineAdvisoryRegistry( + checks: readonly AdvisoryCheck[], +): readonly AdvisoryCheck[] { + const ids = new Set(); + for (const check of checks) { + if (ids.has(check.id)) throw new Error(`Duplicate advisory check id '${check.id}'.`); + ids.add(check.id); + } + return Object.freeze([...checks]); +} + +/** Checks are imported and registered explicitly as migration slices land (#3213). */ +export const ADVISORY_CHECKS = defineAdvisoryRegistry([]); diff --git a/src/lib/advisories/runner.test.ts b/src/lib/advisories/runner.test.ts new file mode 100644 index 00000000000..bffb79ae63f --- /dev/null +++ b/src/lib/advisories/runner.test.ts @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { runAdvisories } from "./runner"; +import type { Advisory, AdvisoryCheck } from "./types"; + +function advisory(id: string, overrides: Partial = {}): Advisory { + return { + id, + severity: "warning", + phase: "preflight.host", + title: `Finding ${id}`, + reason: `Reason ${id}`, + resumeSafe: true, + ...overrides, + }; +} + +function check( + id: string, + run: AdvisoryCheck<{ enabled: boolean }>["check"], + overrides: Partial> = {}, +): AdvisoryCheck<{ enabled: boolean }> { + return { + id, + severity: "warning", + phase: "preflight.host", + resumeSafe: true, + check: run, + ...overrides, + }; +} + +describe("runAdvisories", () => { + it("filters phases and skipped checks without evaluating them", () => { + const hostRun = vi.fn(() => advisory("host")); + const networkRun = vi.fn(() => advisory("network", { phase: "preflight.network" })); + const skippedRun = vi.fn(() => advisory("skipped")); + + const result = runAdvisories( + [ + check("host", hostRun), + check("network", networkRun, { phase: "preflight.network" }), + check("skipped", skippedRun, { skipIf: (context) => !context.enabled }), + ], + { enabled: false }, + { phase: "preflight.host" }, + ); + + expect(result.advisories.map((item) => item.id)).toEqual(["host"]); + expect(result.executedCheckIds).toEqual(["host"]); + expect(result.results.get("skipped")).toBeNull(); + expect(hostRun).toHaveBeenCalledOnce(); + expect(networkRun).not.toHaveBeenCalled(); + expect(skippedRun).not.toHaveBeenCalled(); + }); + + it("reuses only resume-safe cached results", () => { + const safeRun = vi.fn(() => advisory("safe")); + const unsafeRun = vi.fn(() => advisory("unsafe", { resumeSafe: false })); + const cachedSafe = advisory("safe", { reason: "cached" }); + const cachedUnsafe = advisory("unsafe", { reason: "stale", resumeSafe: false }); + + const result = runAdvisories( + [check("safe", safeRun), check("unsafe", unsafeRun, { resumeSafe: false })], + { enabled: true }, + { + resuming: true, + cachedResults: new Map([ + ["safe", cachedSafe], + ["unsafe", cachedUnsafe], + ]), + }, + ); + + expect(result.reusedCheckIds).toEqual(["safe"]); + expect(result.executedCheckIds).toEqual(["unsafe"]); + expect(result.results.get("safe")).toBe(cachedSafe); + expect(result.results.get("unsafe")?.reason).toBe("Reason unsafe"); + expect(safeRun).not.toHaveBeenCalled(); + expect(unsafeRun).toHaveBeenCalledOnce(); + }); + + it("suppresses presentation without discarding the evaluated result", () => { + const result = runAdvisories( + [check("hidden", () => advisory("hidden"))], + { enabled: true }, + { suppressed: ["hidden"] }, + ); + + expect(result.advisories).toEqual([]); + expect(result.results.get("hidden")?.id).toBe("hidden"); + }); + + it("does not suppress fatal or blocking findings", () => { + const result = runAdvisories( + [ + check("fatal", () => advisory("fatal", { severity: "fatal" }), { + severity: "fatal", + }), + check("blocking", () => advisory("blocking", { severity: "blocking" }), { + severity: "blocking", + }), + ], + { enabled: true }, + { suppressed: ["fatal", "blocking"] }, + ); + + expect(result.advisories.map((item) => item.id)).toEqual(["fatal", "blocking"]); + }); + + it("rejects duplicate IDs before a second check can overwrite the first", () => { + expect(() => + runAdvisories([check("duplicate", () => null), check("duplicate", () => null)], { + enabled: true, + }), + ).toThrow("Duplicate advisory check id 'duplicate'."); + }); + + it("rejects advisory metadata that diverges from its check", () => { + expect(() => + runAdvisories([check("stable-id", () => advisory("different-id", { severity: "fatal" }))], { + enabled: true, + }), + ).toThrow("Advisory check 'stable-id' returned mismatched metadata"); + }); +}); diff --git a/src/lib/advisories/runner.ts b/src/lib/advisories/runner.ts new file mode 100644 index 00000000000..a47daa27b4c --- /dev/null +++ b/src/lib/advisories/runner.ts @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Advisory, AdvisoryCheck, AdvisoryPhase } from "./types"; + +/** Selection, resume, cache, and presentation controls for one run. */ +export interface RunAdvisoriesOptions { + phase?: AdvisoryPhase | readonly AdvisoryPhase[]; + resuming?: boolean; + suppressed?: Iterable; + cachedResults?: ReadonlyMap; +} + +/** Structured output used to persist safe results across resume boundaries. */ +export interface AdvisoryRunResult { + advisories: readonly Advisory[]; + results: ReadonlyMap; + executedCheckIds: readonly string[]; + reusedCheckIds: readonly string[]; +} + +function canSuppress(advisory: Advisory): boolean { + return advisory.severity !== "fatal" && advisory.severity !== "blocking"; +} + +function phaseSet(phase: RunAdvisoriesOptions["phase"]): ReadonlySet | null { + if (!phase) return null; + return new Set(Array.isArray(phase) ? phase : [phase]); +} + +function assertAdvisoryMatchesCheck( + check: AdvisoryCheck, + advisory: Advisory, +): void { + const mismatches: string[] = []; + if (advisory.id !== check.id) mismatches.push(`id '${advisory.id}'`); + if (advisory.phase !== check.phase) mismatches.push(`phase '${advisory.phase}'`); + if (advisory.severity !== check.severity) mismatches.push(`severity '${advisory.severity}'`); + if (advisory.resumeSafe !== check.resumeSafe) { + mismatches.push(`resumeSafe '${String(advisory.resumeSafe)}'`); + } + if (mismatches.length > 0) { + throw new Error( + `Advisory check '${check.id}' returned mismatched metadata: ${mismatches.join(", ")}.`, + ); + } +} + +/** + * Runs checks in registry order. During resume, cached results are reused only + * for checks that explicitly declare their prior verdict safe to reuse. + */ +export function runAdvisories( + checks: readonly AdvisoryCheck[], + context: Context, + options: RunAdvisoriesOptions = {}, +): AdvisoryRunResult { + const phases = phaseSet(options.phase); + const suppressed = new Set(options.suppressed ?? []); + const results = new Map(); + const advisories: Advisory[] = []; + const executedCheckIds: string[] = []; + const reusedCheckIds: string[] = []; + const seenIds = new Set(); + + for (const check of checks) { + if (seenIds.has(check.id)) { + throw new Error(`Duplicate advisory check id '${check.id}'.`); + } + seenIds.add(check.id); + + if (phases && !phases.has(check.phase)) continue; + if (check.skipIf?.(context)) { + results.set(check.id, null); + continue; + } + + const canReuse = + options.resuming === true && + check.resumeSafe && + options.cachedResults?.has(check.id) === true; + const advisory = canReuse + ? (options.cachedResults?.get(check.id) ?? null) + : check.check(context); + + if (canReuse) reusedCheckIds.push(check.id); + else executedCheckIds.push(check.id); + + if (advisory) assertAdvisoryMatchesCheck(check, advisory); + results.set(check.id, advisory); + if (advisory && (!suppressed.has(advisory.id) || !canSuppress(advisory))) { + advisories.push(advisory); + } + } + + return { + advisories: Object.freeze(advisories), + results, + executedCheckIds: Object.freeze(executedCheckIds), + reusedCheckIds: Object.freeze(reusedCheckIds), + }; +} diff --git a/src/lib/advisories/types.ts b/src/lib/advisories/types.ts new file mode 100644 index 00000000000..5729666854f --- /dev/null +++ b/src/lib/advisories/types.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Severity levels shared by advisory producers and presenters. */ +export type AdvisorySeverity = "fatal" | "blocking" | "warning" | "info" | "hint"; + +/** Lifecycle phases in which advisory checks can run. */ +export type AdvisoryPhase = + | "preflight.host" + | "preflight.network" + | "onboard.gateway" + | "onboard.sandbox" + | "onboard.credentials" + | "onboard.inference" + | "runtime.rebuild" + | "runtime.destroy" + | "runtime.shields"; + +/** Remediation interaction required from an operator. */ +export type AdvisoryKind = "manual" | "sudo" | "info"; + +/** A structured, stable diagnostic produced by an advisory check. */ +export interface Advisory { + id: string; + severity: AdvisorySeverity; + phase: AdvisoryPhase; + title: string; + reason: string; + commands?: readonly string[]; + docsUrl?: string; + resumeSafe: boolean; + kind?: AdvisoryKind; +} + +/** A pure advisory check and the metadata that controls its execution. */ +export interface AdvisoryCheck { + id: string; + phase: AdvisoryPhase; + severity: AdvisorySeverity; + resumeSafe: boolean; + skipIf?: (context: Context) => boolean; + check: (context: Context) => Advisory | null; +}