From 0171bf70b61b4e34df50c2f811bcca7e852e6d78 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Fri, 24 Jul 2026 17:44:34 +0200 Subject: [PATCH 1/5] fwmanager: Add device-table schema; declare a mock board in target/mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fwmanager-api gains the schema for the per-board device table: DeviceConfig (name, reset line, boot timeout), CommitPolicy, and a const validate() so a bad table fails the build. All values are board config and live in the new target/mock/devices.rs — a mock board with one device per archetype (direct-flash SPI "bmc", PLDM "nic") whose declaration order is the boot order; hardware targets declare their own tables later. Kept out of config.rs, which belongs to the kernel. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast Refs: 9elements/openprot#1 --- services/fwmanager/api/BUILD.bazel | 1 + services/fwmanager/api/src/config.rs | 41 ++++++++++++++++++++++++++++ services/fwmanager/api/src/lib.rs | 15 ++++++---- target/mock/BUILD.bazel | 14 ++++++++++ target/mock/devices.rs | 31 +++++++++++++++++++++ 5 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 services/fwmanager/api/src/config.rs create mode 100644 target/mock/BUILD.bazel create mode 100644 target/mock/devices.rs 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..b60edd67 --- /dev/null +++ b/services/fwmanager/api/src/config.rs @@ -0,0 +1,41 @@ +// 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. +#[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, +} + +/// One managed downstream device, as declared by the board config. +#[derive(Debug, Clone, Copy)] +pub struct DeviceConfig { + pub name: &'static str, + /// Reset line id, passed to HalBootControl::new. + pub reset_line: u8, + /// How long the orchestrator waits for this device to report Booted + /// before it declares a timeout. + pub boot_timeout: core::time::Duration, + 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].boot_timeout.is_zero(), + "boot timeout must not be zero" + ); + i += 1; + } +} 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..7e9309b7 --- /dev/null +++ b/target/mock/devices.rs @@ -0,0 +1,31 @@ +// 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 fwmanager_api::config::{CommitPolicy, DeviceConfig}; + +/// Declaration order is the boot order: the orchestrator releases devices +/// top to bottom, one at a time. +pub const MANAGED_DEVICES: &[DeviceConfig] = &[ + // Direct-flash SPI device (BMC archetype): the eRoT fronts its flash. + DeviceConfig { + name: "bmc", + reset_line: 7, + boot_timeout: core::time::Duration::from_secs(90), + commit_policy: CommitPolicy::Liveness, + }, + // PLDM device (NIC archetype): self-updating, SPDM-capable. + DeviceConfig { + name: "nic", + reset_line: 3, + boot_timeout: core::time::Duration::from_secs(30), + commit_policy: CommitPolicy::LivenessAndAttestation, + }, +]; + +const _: () = fwmanager_api::config::validate(MANAGED_DEVICES); From 04a30f31f28bffb77569a5a4d1a0b4150808738b Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Tue, 28 Jul 2026 11:38:16 +0200 Subject: [PATCH 2/5] fwmanager: make DeviceConfig generic over the reset signal type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HalBootControl is generic over its controller's ResetId, so a hardcoded u8 only fits controllers whose id type happens to be u8 — and reset may not even be line-addressed. Parameterize the table over the board's reset signal type so a mismatch fails to compile, and call the field reset_signal rather than reset_line. The mock board uses u8. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast Refs: 9elements/openprot#1 --- services/fwmanager/api/src/config.rs | 13 +++++++++---- target/mock/devices.rs | 9 ++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/services/fwmanager/api/src/config.rs b/services/fwmanager/api/src/config.rs index b60edd67..f0b8d91a 100644 --- a/services/fwmanager/api/src/config.rs +++ b/services/fwmanager/api/src/config.rs @@ -15,11 +15,16 @@ pub enum CommitPolicy { } /// 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. #[derive(Debug, Clone, Copy)] -pub struct DeviceConfig { +pub struct DeviceConfig { pub name: &'static str, - /// Reset line id, passed to HalBootControl::new. - pub reset_line: u8, + /// Reset signal id, passed to HalBootControl::new. + pub reset_signal: R, /// How long the orchestrator waits for this device to report Booted /// before it declares a timeout. pub boot_timeout: core::time::Duration, @@ -28,7 +33,7 @@ pub struct DeviceConfig { /// 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]) { +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"); diff --git a/target/mock/devices.rs b/target/mock/devices.rs index 7e9309b7..5f9fa179 100644 --- a/target/mock/devices.rs +++ b/target/mock/devices.rs @@ -11,18 +11,21 @@ use fwmanager_api::config::{CommitPolicy, DeviceConfig}; /// Declaration order is the boot order: the orchestrator releases devices /// top to bottom, one at a time. -pub const MANAGED_DEVICES: &[DeviceConfig] = &[ +/// +/// The mock board's reset controller addresses lines by plain index, so its +/// reset id type is `u8`. +pub const MANAGED_DEVICES: &[DeviceConfig] = &[ // Direct-flash SPI device (BMC archetype): the eRoT fronts its flash. DeviceConfig { name: "bmc", - reset_line: 7, + reset_signal: 7, boot_timeout: core::time::Duration::from_secs(90), commit_policy: CommitPolicy::Liveness, }, // PLDM device (NIC archetype): self-updating, SPDM-capable. DeviceConfig { name: "nic", - reset_line: 3, + reset_signal: 3, boot_timeout: core::time::Duration::from_secs(30), commit_policy: CommitPolicy::LivenessAndAttestation, }, From 145b279890e7cdbd9a9ab0483b8f0164a4c3f17e Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Tue, 28 Jul 2026 11:44:49 +0200 Subject: [PATCH 3/5] fwmanager: document why the schema types are intentionally exhaustive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything builds in one tree, so a new CommitPolicy variant should break the orchestrator's match instead of falling into a wildcard arm — and board tables construct DeviceConfig by literal, which #[non_exhaustive] would forbid. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast Refs: 9elements/openprot#1 --- services/fwmanager/api/src/config.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/services/fwmanager/api/src/config.rs b/services/fwmanager/api/src/config.rs index f0b8d91a..71494657 100644 --- a/services/fwmanager/api/src/config.rs +++ b/services/fwmanager/api/src/config.rs @@ -6,6 +6,11 @@ //! 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. @@ -20,6 +25,10 @@ pub enum CommitPolicy { /// `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, From bcd47b76e973cbe3604f9d03929632eced09ee64 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Tue, 28 Jul 2026 11:58:18 +0200 Subject: [PATCH 4/5] fwmanager: encode boot-progress checkpointing in the device table Liveness can be more than one timeout: a device may pass several boot checkpoints, and the signal may be polled or queried rather than device-pushed. Replace boot_timeout with an ordered checkpoint list, each pairing a BootSignal (boot-complete GPIO, heartbeat, MCTP ready, version query) with its own window: last checkpoint reached means booted, an expired window fails the boot. BootSignal is generic over the id its boot monitor reads a boot-complete line by, as DeviceConfig is over its reset signal. The mock nic exercises the multi-checkpoint path. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast Refs: 9elements/openprot#1 --- services/fwmanager/api/src/config.rs | 61 ++++++++++++++++++++++++---- target/mock/devices.rs | 33 +++++++++++---- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/services/fwmanager/api/src/config.rs b/services/fwmanager/api/src/config.rs index 71494657..0a946cd8 100644 --- a/services/fwmanager/api/src/config.rs +++ b/services/fwmanager/api/src/config.rs @@ -19,6 +19,40 @@ pub enum CommitPolicy { 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 @@ -30,26 +64,39 @@ pub enum CommitPolicy { /// 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 struct DeviceConfig { pub name: &'static str, /// Reset signal id, passed to HalBootControl::new. pub reset_signal: R, - /// How long the orchestrator waits for this device to report Booted - /// before it declares a timeout. - pub boot_timeout: core::time::Duration, + /// 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]) { +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].boot_timeout.is_zero(), - "boot timeout must not be zero" + !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; } } diff --git a/target/mock/devices.rs b/target/mock/devices.rs index 5f9fa179..ea5b7dfe 100644 --- a/target/mock/devices.rs +++ b/target/mock/devices.rs @@ -7,26 +7,45 @@ #![no_std] -use fwmanager_api::config::{CommitPolicy, DeviceConfig}; +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 addresses lines by plain index, so its -/// reset id type is `u8`. -pub const MANAGED_DEVICES: &[DeviceConfig] = &[ +/// 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, - boot_timeout: core::time::Duration::from_secs(90), + 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. + // PLDM device (NIC archetype): self-updating, SPDM-capable. Two + // checkpoints, exercising the multi-checkpoint path. DeviceConfig { name: "nic", reset_signal: 3, - boot_timeout: core::time::Duration::from_secs(30), + 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, }, ]; From 0c503b64073265cf8e8bb2998c0a510c0aff8c0d Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Tue, 28 Jul 2026 12:10:28 +0200 Subject: [PATCH 5/5] fwmanager: test validate()'s reject paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board tables run validate() at compile time, where a rejection is a build error nobody can assert on — a vacuous loop would pass every const check silently. Call it at runtime instead and prove each reject path fires: empty device name, empty checkpoint list, empty checkpoint name, zero window. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast Refs: 9elements/openprot#1 --- services/fwmanager/api/src/config.rs | 71 ++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/services/fwmanager/api/src/config.rs b/services/fwmanager/api/src/config.rs index 0a946cd8..43e3ed8b 100644 --- a/services/fwmanager/api/src/config.rs +++ b/services/fwmanager/api/src/config.rs @@ -100,3 +100,74 @@ pub const fn validate(devices: &[DeviceConfig]) { 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 + }]); + } +}