Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 27 additions & 28 deletions services/orchestrator/capabilities/src/evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

//! Evidence reading: the boot-liveness vocabulary and the reader that
//! resolves a board-defined signal id to it.
//! resolves a board-defined probe to it.

/// Liveness of a managed device's boot: Boot Confirmation only — whether
/// the device came up, never what booted (that is attestation).
Expand Down Expand Up @@ -30,63 +30,62 @@ pub enum BootStatus {
FailedFatal,
}

/// Reads a device's boot evidence, one signal at a time.
/// Reads a device's boot evidence, one probe at a time.
///
/// Implemented by board wiring — typically once per managed device, so
/// each device's boot walk borrows only its own reader. `G` is the
/// board's signal vocabulary; an exhaustive `match` on it keeps dispatch
/// direct and makes a forgotten signal a compile error, not a runtime
/// Implemented by board wiring, typically once per managed device, so
/// each device's boot walk borrows only its own reader. `P` is the
/// board's probe vocabulary; an exhaustive `match` on it keeps dispatch
/// direct and makes a forgotten probe a compile error, not a runtime
/// hole.
///
/// The status must describe the **current** boot cycle — see
/// The status must describe the *current* boot cycle, see
/// [`BootStatus`] for the latching contract (evidence is cleared by the
/// reset path, never by the reader).
///
/// # Wiring a concrete reader
///
/// Concrete readers (e.g. `GpioBootMonitor` in `orchestrator-hal-adapters`)
/// stay signal-agnostic — an adapter crate cannot know a board's `G`.
/// stay probe-agnostic, an adapter crate cannot know a board's `P`.
/// The board impl owns the match; the hardware binding is made once, at
/// construction, and the signal id just proves the right reader was
/// wired:
/// construction, and the probe just proves the right reader was wired:
///
/// ```ignore
/// /// bmc wiring: one ready line behind the board's signal vocabulary.
/// struct BmcReader<'a, P: GpioPort> {
/// /// bmc wiring: one ready line behind the board's probe vocabulary.
/// struct BmcReader<'a, Port: GpioPort> {
/// // (port, pin, polarity) bound at bring-up from the table's Gpio(12).
/// ready: GpioBootMonitor<'a, P>,
/// ready: GpioBootMonitor<'a, Port>,
/// }
///
/// impl<P: GpioPort> EvidenceReader<MockSignal> for BmcReader<'_, P>
/// impl<Port: GpioPort> EvidenceReader<MockProbe> for BmcReader<'_, Port>
/// where
/// P::Error: 'static,
/// Port::Error: 'static,
/// {
/// type Error = MonitorError<P::Error>;
/// type Error = MonitorError<Port::Error>;
///
/// fn read(&mut self, signal: &MockSignal) -> Result<BootStatus, Self::Error> {
/// match signal {
/// MockSignal::Gpio(_) => self.ready.boot_status(),
/// fn read(&mut self, probe: &MockProbe) -> Result<BootStatus, Self::Error> {
/// match probe {
/// MockProbe::Gpio(_) => self.ready.boot_status(),
/// other => unreachable!("bmc reader wired to {other:?}"),
/// }
/// }
/// }
/// ```
pub trait EvidenceReader<G> {
pub trait EvidenceReader<P> {
/// The error type reported by this reader.
///
/// Requires [`core::error::Error`] (in `core` since Rust 1.81) so the
/// orchestrator gets `Display` and a `source()` cause chain, not just
/// a `Debug` dump. Error categories stay implementation-defined —
/// a `Debug` dump. Error categories stay implementation-defined,
/// this crate names no error vocabulary of its own.
type Error: core::error::Error;

/// Returns the current liveness evidence for `signal`.
/// Returns the current liveness evidence for `probe`.
///
/// # Errors
///
/// Returns an error if the evidence channel behind `signal` cannot be
/// Returns an error if the evidence channel behind `probe` cannot be
/// read.
fn read(&mut self, signal: &G) -> Result<BootStatus, Self::Error>;
fn read(&mut self, probe: &P) -> Result<BootStatus, Self::Error>;
}

#[cfg(test)]
Expand Down Expand Up @@ -128,11 +127,11 @@ mod tests {
impl EvidenceReader<TestSignal> for SocReader {
type Error = RegFault;

fn read(&mut self, signal: &TestSignal) -> Result<BootStatus, RegFault> {
fn read(&mut self, probe: &TestSignal) -> Result<BootStatus, RegFault> {
if self.fail {
return Err(RegFault);
}
let TestSignal::Progress(threshold) = *signal;
let TestSignal::Progress(threshold) = *probe;
Ok(match self.level {
POISON => BootStatus::FailedFatal,
TRANSIENT => BootStatus::FailedRetriable,
Expand Down Expand Up @@ -269,11 +268,11 @@ mod tests {
impl EvidenceReader<NicSignal> for MockNicEndpoint {
type Error = MctpFault;

fn read(&mut self, signal: &NicSignal) -> Result<BootStatus, MctpFault> {
fn read(&mut self, probe: &NicSignal) -> Result<BootStatus, MctpFault> {
if self.bus_fault {
return Err(MctpFault);
}
match signal {
match probe {
NicSignal::MctpReady => {
if let Some(code) = self.fault_code {
return Ok(match code {
Expand Down
86 changes: 58 additions & 28 deletions services/orchestrator/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,66 +12,96 @@

#![cfg_attr(not(test), no_std)]

/// One boot checkpoint: a signal the orchestrator waits for, and how long
/// One boot checkpoint: a probe the orchestrator evaluates, and how long
/// it waits. Retry policy is deliberately not table data: a retry
/// re-resets the device and re-runs the whole walk, so budgets are
/// per boot attempt and owned by the orchestrator state machine.
///
/// The signal is a board-defined id — the schema attaches no meaning to
/// it and names no signal kinds. Each board defines its own vocabulary (a
/// small enum: a GPIO line, a progress-register threshold, a message-path
/// readiness) and gives it meaning in its `EvidenceReader`. The id is a
/// defunctionalized evidence check: data in the table instead of a
/// function, so the table stays printable, comparable, const-checkable —
/// The probe is board-defined, the schema attaches no meaning to it and
/// names no probe kinds. Each board defines its own vocabulary (a small
/// enum: a GPIO line, a progress-register threshold, a message-path
/// readiness) and gives it meaning in its `EvidenceReader`. The probe is
/// a defunctionalized evidence check: data in the table instead of a
/// function, so the table stays printable, comparable, const-checkable,
/// and could one day be generated instead of written.
///
/// Fields are private so a checkpoint that violates the schema is
/// unrepresentable: [`new`](Self::new) is the only way in, and it checks.
///
/// # Example: three GPIO checkpoints
///
/// A BMC behind three GPIO ready lines (bl1 on pin 4, kernel on pin 5,
/// service on pin 6), all on the same SGPIOM bank. Each probe variant
/// maps to one `GpioBootMonitor` in the board's `EvidenceReader`, and
/// the walker (`CheckpointWalk`) walks them in declaration order.
///
/// ```ignore
/// #[derive(Debug, Clone, Copy)]
/// enum BmcProbe { Bl1, Kernel, Service }
///
/// const BMC: DeviceConfig<u8, BmcProbe> = DeviceConfig::new(
/// "bmc", 0,
/// &[
/// BootCheckpoint::new("bl1", BmcProbe::Bl1, Duration::from_millis(500)),
/// BootCheckpoint::new("kernel", BmcProbe::Kernel, Duration::from_secs(5)),
/// BootCheckpoint::new("service", BmcProbe::Service, Duration::from_secs(30)),
/// ],
/// );
///
/// // Pin binding at bring-up: one GpioBootMonitor per probe.
/// let bl1 = GpioBootMonitor::new(&sgpiom, Mask(1 << 4), ActivePolarity::ActiveHigh);
/// let kernel = GpioBootMonitor::new(&sgpiom, Mask(1 << 5), ActivePolarity::ActiveHigh);
/// let service = GpioBootMonitor::new(&sgpiom, Mask(1 << 6), ActivePolarity::ActiveHigh);
///
/// // The board's EvidenceReader dispatches probe to monitor.
/// // See EvidenceReader's docs for the full impl pattern.
/// let bmc_walk = CheckpointWalk::new(bmc_reader, BMC.checkpoints());
/// ```
#[derive(Debug, Clone, Copy)]
pub struct BootCheckpoint<G> {
pub struct BootCheckpoint<P> {
name: &'static str,
signal: G,
probe: P,
timeout: core::time::Duration,
}

impl<G> BootCheckpoint<G> {
impl<P> BootCheckpoint<P> {
/// Declares a checkpoint. `const`, so board tables run the checks at
/// build time.
///
/// # Panics
///
/// Panics — a build error in const context — if `name` is empty or
/// Panics, a build error in const context, if `name` is empty or
/// `timeout` is zero.
#[must_use]
pub const fn new(name: &'static str, signal: G, timeout: core::time::Duration) -> Self {
pub const fn new(name: &'static str, probe: P, timeout: core::time::Duration) -> Self {
assert!(!name.is_empty(), "checkpoint name must not be empty");
assert!(!timeout.is_zero(), "checkpoint timeout must not be zero");
Self {
name,
signal,
probe,
timeout,
}
}

/// Names the checkpoint in failure reports ("bl1", "kernel", …).
/// Names the checkpoint in failure reports ("bl1", "kernel", ...).
/// Unique within a device's checkpoint list.
#[must_use]
pub const fn name(&self) -> &'static str {
self.name
}

/// Board-defined signal id, resolved by the board's `EvidenceReader`
/// (in `orchestrator-capabilities`). An id rather than a function, so
/// the table stays pure data — the type-level docs say why.
/// Board-defined probe, resolved by the board's `EvidenceReader`
/// (in `orchestrator-capabilities`). Data rather than a function, so
/// the table stays pure data, the type-level docs say why.
#[must_use]
pub const fn signal(&self) -> &G {
&self.signal
pub const fn probe(&self) -> &P {
&self.probe
}

/// Window for one attempt at this checkpoint. Expiry is the boot
/// walk's own judgment; hung devices report nothing.
///
/// The orchestrator state machine never sees this value — it is
/// The orchestrator state machine never sees this value, it is
/// clockless. The walk consumes the windows and reports expiry as a
/// failed attempt; a component's whole boot timeout is nothing more
/// than its walk over these windows, in order.
Expand All @@ -85,8 +115,8 @@ impl<G> BootCheckpoint<G> {
///
/// Generic over the board's reset signal type `R` (which must match the
/// `ResetId` of the reset controller behind the board's `BootControl`
/// implementation) and its boot-signal vocabulary `G`, for the same
/// reason: signal ids are board-specific.
/// implementation) and its boot-probe vocabulary `P`, for the same
/// reason: probes are board-specific.
///
/// Deliberately says nothing about attestation or commit requirements:
/// those follow from what kind of device this is (iRoT-backed or
Expand All @@ -96,13 +126,13 @@ impl<G> BootCheckpoint<G> {
/// Fields are private so a device entry that violates the schema is
/// unrepresentable: [`new`](Self::new) is the only way in, and it checks.
#[derive(Debug, Clone, Copy)]
pub struct DeviceConfig<R, G: 'static> {
pub struct DeviceConfig<R, P: 'static> {
name: &'static str,
reset_signal: R,
checkpoints: &'static [BootCheckpoint<G>],
checkpoints: &'static [BootCheckpoint<P>],
}

impl<R, G> DeviceConfig<R, G> {
impl<R, P> DeviceConfig<R, P> {
/// Declares a managed device. `const`, so board tables run the checks
/// at build time.
///
Expand All @@ -116,7 +146,7 @@ impl<R, G> DeviceConfig<R, G> {
pub const fn new(
name: &'static str,
reset_signal: R,
checkpoints: &'static [BootCheckpoint<G>],
checkpoints: &'static [BootCheckpoint<P>],
) -> Self {
assert!(!name.is_empty(), "device name must not be empty");
assert!(
Expand Down Expand Up @@ -159,7 +189,7 @@ impl<R, G> DeviceConfig<R, G> {
/// window expires fails the attempt — whether to retry or recover is
/// the orchestrator's decision, not table data.
#[must_use]
pub const fn checkpoints(&self) -> &'static [BootCheckpoint<G>] {
pub const fn checkpoints(&self) -> &'static [BootCheckpoint<P>] {
self.checkpoints
}
}
Expand Down Expand Up @@ -204,7 +234,7 @@ mod tests {
assert_eq!(*device.reset_signal(), 0);
assert_eq!(device.checkpoints().len(), 1);
assert_eq!(device.checkpoints()[0].name(), "boot-complete");
assert_eq!(*device.checkpoints()[0].signal(), 0);
assert_eq!(*device.checkpoints()[0].probe(), 0);
assert_eq!(device.checkpoints()[0].timeout(), Duration::from_secs(1));
}

Expand Down
22 changes: 11 additions & 11 deletions services/orchestrator/test/devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ use core::time::Duration;

use orchestrator_config::{BootCheckpoint, DeviceConfig};

/// The mock board's boot-signal vocabulary. The schema carries these
/// The mock board's boot-probe vocabulary. The schema carries these
/// opaquely; only this board's `EvidenceReader` gives them meaning.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MockSignal {
pub enum MockProbe {
/// A boot-complete GPIO line, by index.
Gpio(u8),
/// The device's MCTP endpoint answers as ready.
Expand All @@ -30,15 +30,15 @@ pub enum MockSignal {
///
/// The mock board's reset controller addresses reset lines by plain index,
/// so the reset id type is `u8`.
pub const MANAGED_DEVICES: &[DeviceConfig<u8, MockSignal>] = &[
pub const MANAGED_DEVICES: &[DeviceConfig<u8, MockProbe>] = &[
// Direct-flash SPI device (BMC archetype): the eRoT fronts its flash.
// Single checkpoint: it raises a boot-complete GPIO.
DeviceConfig::new(
"bmc",
7,
&[BootCheckpoint::new(
"boot-complete",
MockSignal::Gpio(12),
MockProbe::Gpio(12),
Duration::from_secs(90),
)],
),
Expand All @@ -49,29 +49,29 @@ pub const MANAGED_DEVICES: &[DeviceConfig<u8, MockSignal>] = &[
"nic",
3,
&[
BootCheckpoint::new("mctp-ready", MockSignal::MctpReady, Duration::from_secs(20)),
BootCheckpoint::new("heartbeat", MockSignal::Heartbeat, Duration::from_secs(10)),
BootCheckpoint::new("mctp-ready", MockProbe::MctpReady, Duration::from_secs(20)),
BootCheckpoint::new("heartbeat", MockProbe::Heartbeat, Duration::from_secs(10)),
],
),
];

/// Board-local checks the schema constructors cannot do — they know the
/// schema's shape, not this board's meanings. Const-fence pattern: a bad
/// signal fails the build.
const fn validate_signals(devices: &[DeviceConfig<u8, MockSignal>]) {
/// probe fails the build.
const fn validate_probes(devices: &[DeviceConfig<u8, MockProbe>]) {
let mut i = 0;
while i < devices.len() {
let checkpoints = devices[i].checkpoints();
let mut c = 0;
while c < checkpoints.len() {
if let MockSignal::Gpio(line) = *checkpoints[c].signal() {
if let MockProbe::Gpio(line) = *checkpoints[c].probe() {
// The mock ready-line bank packs 32 lines, SGPIO-style.
assert!(line < 32, "gpio signal names a line outside the bank");
assert!(line < 32, "gpio probe names a line outside the bank");
}
c += 1;
}
i += 1;
}
}

const _: () = validate_signals(MANAGED_DEVICES);
const _: () = validate_probes(MANAGED_DEVICES);
Loading