diff --git a/services/fwmanager/api/BUILD.bazel b/services/fwmanager/api/BUILD.bazel index e5653a61..7cec74f9 100644 --- a/services/fwmanager/api/BUILD.bazel +++ b/services/fwmanager/api/BUILD.bazel @@ -7,6 +7,7 @@ rust_library( name = "fwmanager_api", srcs = [ "src/boot_control.rs", + "src/config.rs", "src/lib.rs", ], edition = "2024", diff --git a/services/fwmanager/api/src/config.rs b/services/fwmanager/api/src/config.rs new file mode 100644 index 00000000..43e3ed8b --- /dev/null +++ b/services/fwmanager/api/src/config.rs @@ -0,0 +1,173 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Schema for the per-board device table. Board device tables +//! (`target//devices.rs`) declare the values; no concrete line or +//! device is named here. + +/// What the orchestrator requires before it commits a staged image. +/// +/// Intentionally exhaustive (not `#[non_exhaustive]`): adding a variant is +/// a breaking change, so the compiler forces every match on the policy — +/// in particular the orchestrator's commit decision — to handle the new +/// variant explicitly instead of falling into a wildcard arm. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommitPolicy { + /// The device reports it came up. + Liveness, + /// Liveness plus SPDM re-attestation of the running image. + LivenessAndAttestation, +} + +/// How the orchestrator observes a device's boot-progress signal. +/// +/// Generic over the id type `G` the board's boot monitor uses to read a +/// boot-complete line, for the same reason `DeviceConfig` is generic over +/// its reset signal: signal ids are board-specific. +/// +/// Intentionally exhaustive (not `#[non_exhaustive]`): adding a signal +/// kind is a breaking change, so every consumer that dispatches on it is +/// forced to handle the new kind explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootSignal { + /// The device raises a boot-complete GPIO line. + GpioBootComplete(G), + /// The device sends a heartbeat message. + Heartbeat, + /// The device's MCTP endpoint answers as ready. + MctpReady, + /// The device answers a firmware version query. + VersionQuery, +} + +/// One boot-progress checkpoint: a signal the orchestrator waits for, and +/// how long it waits. +#[derive(Debug, Clone, Copy)] +pub struct BootCheckpoint { + /// Names the checkpoint in timeout reports. + pub name: &'static str, + pub signal: BootSignal, + /// How long the orchestrator waits for `signal` before it declares the + /// checkpoint — and the device's boot — failed. Expiry is the + /// orchestrator's own judgment; hung devices report nothing. + pub window: core::time::Duration, +} + +/// One managed downstream device, as declared by the board config. +/// +/// Generic over the board's reset signal type `R`, which must match the +/// `ResetId` of the reset controller behind the board's `BootControl` +/// implementation — the compiler rejects a table whose ids the controller +/// cannot accept. +/// +/// Intentionally exhaustive (not `#[non_exhaustive]`): board tables +/// construct this struct by literal, which the attribute would forbid. +/// Adding a field is a breaking change that updates every board table. +#[derive(Debug, Clone, Copy)] +pub struct DeviceConfig { + pub name: &'static str, + /// Reset signal id, passed to HalBootControl::new. + pub reset_signal: R, + /// Boot-progress checkpoints, in the order the device passes them. + /// The device counts as booted when the last one is reached; a + /// checkpoint whose window expires fails the boot. + pub checkpoints: &'static [BootCheckpoint], + pub commit_policy: CommitPolicy, +} + +/// Checks a device table. Board configs call this in a const context so a +/// bad table fails the build. +pub const fn validate(devices: &[DeviceConfig]) { + let mut i = 0; + while i < devices.len() { + assert!(!devices[i].name.is_empty(), "device name must not be empty"); + assert!( + !devices[i].checkpoints.is_empty(), + "device must declare at least one boot checkpoint" + ); + let mut c = 0; + while c < devices[i].checkpoints.len() { + assert!( + !devices[i].checkpoints[c].name.is_empty(), + "checkpoint name must not be empty" + ); + assert!( + !devices[i].checkpoints[c].window.is_zero(), + "checkpoint window must not be zero" + ); + c += 1; + } + i += 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::time::Duration; + + // Board tables run validate() at compile time, where a rejection is a + // build error nobody can assert on. These tests call it at runtime to + // prove the reject paths actually fire — a vacuous loop would pass + // every `const _` check silently. + + const CHECKPOINT: BootCheckpoint = BootCheckpoint { + name: "boot-complete", + signal: BootSignal::GpioBootComplete(0), + window: Duration::from_secs(1), + }; + + const DEVICE: DeviceConfig = DeviceConfig { + name: "dev", + reset_signal: 0, + checkpoints: &[CHECKPOINT], + commit_policy: CommitPolicy::Liveness, + }; + + #[test] + fn accepts_a_valid_table() { + validate(&[DEVICE]); + } + + #[test] + #[should_panic(expected = "device name must not be empty")] + fn rejects_an_empty_device_name() { + validate(&[DEVICE, DeviceConfig { name: "", ..DEVICE }]); + } + + #[test] + #[should_panic(expected = "at least one boot checkpoint")] + fn rejects_an_empty_checkpoint_list() { + validate(&[DeviceConfig { + checkpoints: &[], + ..DEVICE + }]); + } + + #[test] + #[should_panic(expected = "checkpoint name must not be empty")] + fn rejects_an_empty_checkpoint_name() { + validate(&[DeviceConfig { + checkpoints: &[BootCheckpoint { + name: "", + ..CHECKPOINT + }], + ..DEVICE + }]); + } + + #[test] + #[should_panic(expected = "checkpoint window must not be zero")] + fn rejects_a_zero_checkpoint_window() { + validate(&[DeviceConfig { + checkpoints: &[ + CHECKPOINT, + BootCheckpoint { + window: Duration::ZERO, + ..CHECKPOINT + }, + ], + ..DEVICE + }]); + } +} diff --git a/services/fwmanager/api/src/lib.rs b/services/fwmanager/api/src/lib.rs index 8d577b97..72d53ef0 100644 --- a/services/fwmanager/api/src/lib.rs +++ b/services/fwmanager/api/src/lib.rs @@ -7,15 +7,18 @@ //! single managed device's reset without knowing which controller line it //! maps to. //! -//! This crate is a dependency-free leaf: it holds only the capability -//! contracts, and everything depends downward on it. Concrete adapters bind a -//! trait to a signal source and live in their own crates, so naming a -//! capability never drags in the stack behind it — the HAL-backed -//! `HalBootControl` is in `fwmanager-hal-adapters`; other backends implement -//! the same trait from their own transport crate. +//! This crate is a dependency-free leaf: it holds the capability contracts +//! and the schema for the per-board device table, and everything depends +//! downward on it. Concrete adapters bind a trait to a signal source and +//! live in their own crates, so naming a capability never drags in the stack +//! behind it — the HAL-backed `HalBootControl` is in +//! `fwmanager-hal-adapters`; other backends implement the same trait from +//! their own transport crate. Config values live in the board device tables +//! (`target//devices.rs`). #![cfg_attr(not(test), no_std)] mod boot_control; +pub mod config; pub use boot_control::BootControl; diff --git a/target/mock/BUILD.bazel b/target/mock/BUILD.bazel new file mode 100644 index 00000000..ddf861bd --- /dev/null +++ b/target/mock/BUILD.bazel @@ -0,0 +1,14 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_library") + +package(default_visibility = ["//visibility:public"]) + +rust_library( + name = "devices", + srcs = ["devices.rs"], + crate_name = "board_devices", + edition = "2024", + deps = ["//services/fwmanager/api:fwmanager_api"], +) diff --git a/target/mock/devices.rs b/target/mock/devices.rs new file mode 100644 index 00000000..ea5b7dfe --- /dev/null +++ b/target/mock/devices.rs @@ -0,0 +1,53 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Mock board: a device table exercising every device archetype the +//! orchestrator manages. Not a real board — consumed by host tests and QEMU +//! runs until a hardware target declares its own table. + +#![no_std] + +use core::time::Duration; + +use fwmanager_api::config::{BootCheckpoint, BootSignal, CommitPolicy, DeviceConfig}; + +/// Declaration order is the boot order: the orchestrator releases devices +/// top to bottom, one at a time. +/// +/// The mock board's reset controller and boot monitor both address +/// signals by plain index, so both id types are `u8`. +pub const MANAGED_DEVICES: &[DeviceConfig] = &[ + // Direct-flash SPI device (BMC archetype): the eRoT fronts its flash. + // Single checkpoint: it raises a boot-complete GPIO. + DeviceConfig { + name: "bmc", + reset_signal: 7, + checkpoints: &[BootCheckpoint { + name: "boot-complete", + signal: BootSignal::GpioBootComplete(12), + window: Duration::from_secs(90), + }], + commit_policy: CommitPolicy::Liveness, + }, + // PLDM device (NIC archetype): self-updating, SPDM-capable. Two + // checkpoints, exercising the multi-checkpoint path. + DeviceConfig { + name: "nic", + reset_signal: 3, + checkpoints: &[ + BootCheckpoint { + name: "mctp-ready", + signal: BootSignal::MctpReady, + window: Duration::from_secs(20), + }, + BootCheckpoint { + name: "heartbeat", + signal: BootSignal::Heartbeat, + window: Duration::from_secs(10), + }, + ], + commit_policy: CommitPolicy::LivenessAndAttestation, + }, +]; + +const _: () = fwmanager_api::config::validate(MANAGED_DEVICES);