diff --git a/services/orchestrator/adapters/walk/BUILD.bazel b/services/orchestrator/adapters/walk/BUILD.bazel index cf86946ef..919bff503 100644 --- a/services/orchestrator/adapters/walk/BUILD.bazel +++ b/services/orchestrator/adapters/walk/BUILD.bazel @@ -14,6 +14,7 @@ rust_library( deps = [ "//services/orchestrator/capabilities:orchestrator_capabilities", "//services/orchestrator/config:orchestrator_config", + "//services/orchestrator/sm:orchestrator_sm", ], ) diff --git a/services/orchestrator/adapters/walk/src/walk.rs b/services/orchestrator/adapters/walk/src/walk.rs index e19e4422f..0d1a762e8 100644 --- a/services/orchestrator/adapters/walk/src/walk.rs +++ b/services/orchestrator/adapters/walk/src/walk.rs @@ -130,14 +130,19 @@ impl, P> BootWatch for CheckpointWalk { mod tests { use super::*; use core::time::Duration; + use openprot_orchestrator_sm::ComponentAttrs; use orchestrator_config::DeviceConfig; + const ATTRS: ComponentAttrs = ComponentAttrs::passive_required(); + const BL1: BootCheckpoint = BootCheckpoint::new("bl1", 1, Duration::from_millis(100)); const KERNEL: BootCheckpoint = BootCheckpoint::new("kernel", 2, Duration::from_millis(200)); const CHECKPOINTS: &[BootCheckpoint] = &[BL1, KERNEL]; - static DEVICE: DeviceConfig = DeviceConfig::new("test-dev", 0, CHECKPOINTS, None); - static ONE_CP_DEVICE: DeviceConfig = DeviceConfig::new("one-cp-dev", 0, &[BL1], None); + static DEVICE: DeviceConfig = + DeviceConfig::new("test-dev", 0, CHECKPOINTS, None, ATTRS); + static ONE_CP_DEVICE: DeviceConfig = + DeviceConfig::new("one-cp-dev", 0, &[BL1], None, ATTRS); // A progress-register reader: probe N is Booted once progress >= N. // Mirrors the SocReader archetype in the evidence tests. diff --git a/services/orchestrator/config/BUILD.bazel b/services/orchestrator/config/BUILD.bazel index 5628f96d5..830a5040a 100644 --- a/services/orchestrator/config/BUILD.bazel +++ b/services/orchestrator/config/BUILD.bazel @@ -6,6 +6,7 @@ load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") rust_library( name = "orchestrator_config", srcs = [ + "src/chain.rs", "src/checkpoint.rs", "src/device.rs", "src/layout.rs", @@ -14,10 +15,16 @@ rust_library( ], edition = "2024", visibility = ["//visibility:public"], + deps = [ + "//services/orchestrator/sm:orchestrator_sm", + ], ) # Host tests: build on the host platform, no kernel/QEMU. rust_test( name = "orchestrator_config_test", crate = ":orchestrator_config", + deps = [ + "@rust_crates//:heapless", + ], ) diff --git a/services/orchestrator/config/src/chain.rs b/services/orchestrator/config/src/chain.rs new file mode 100644 index 000000000..4c97484e9 --- /dev/null +++ b/services/orchestrator/config/src/chain.rs @@ -0,0 +1,138 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The views the orchestrator and the platform driver take of the device +//! table. Both are derived here, so the table is the only place a board +//! states what its components are. + +use crate::device::DeviceConfig; +use openprot_orchestrator_sm::{ComponentAttrs, ComponentId}; + +/// The entries `chain_of` validated. The field is private, so the only +/// constructor is the const-validated path and holders can trust the +/// invariants without rechecking. +pub struct ChainEntries([(ComponentId, ComponentAttrs); N]); + +impl ChainEntries { + /// The validated entries, one per device in table order. + pub const fn entries(&self) -> &[(ComponentId, ComponentAttrs); N] { + &self.0 + } +} + +/// Table order assigns the ids: `devices[i]` is `ComponentId::new(i)`. The +/// board's per-component arrays are indexed the same way, so the table is +/// also what keeps those arrays lined up with the chain. +/// +/// # Panics +/// +/// Panics, a build error in const context, if the table is empty or holds +/// more than `u8::MAX` devices (`Chain` takes neither), or if a `depends_on` +/// names anything other than an earlier device in the table. +#[must_use] +pub const fn chain_of(devices: &[DeviceConfig; N]) -> ChainEntries { + assert!(N > 0, "a device table needs at least one device"); + assert!( + N <= u8::MAX as usize, + "a device table holds at most u8::MAX devices" + ); + let mut i = 0; + while i < N { + if let Some(on) = devices[i].attrs().depends_on { + let on = on.get() as usize; + assert!( + on < i, + "depends_on must name an earlier device in the table" + ); + } + i += 1; + } + + // Const arrays need a default; the loop below overwrites every element. + let mut chain = [(ComponentId::new(0), ComponentAttrs::passive_required()); N]; + let mut i = 0; + while i < N { + chain[i] = (ComponentId::new(i as u8), devices[i].attrs()); + i += 1; + } + ChainEntries(chain) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::checkpoint::BootCheckpoint; + use core::time::Duration; + use openprot_orchestrator_sm::{Chain, FailurePolicy}; + + const CP: BootCheckpoint = BootCheckpoint::new("up", 1, Duration::from_millis(10)); + + const fn dev(attrs: ComponentAttrs) -> DeviceConfig { + DeviceConfig::new("dev", 0, &[CP], None, attrs) + } + + #[test] + fn ids_come_from_table_order() { + let table = [ + dev(ComponentAttrs::passive_required()), + dev(ComponentAttrs::active_isolable()), + ]; + let validated = chain_of(&table); + let entries = validated.entries(); + + assert_eq!(entries[0].0, ComponentId::new(0)); + assert_eq!(entries[1].0, ComponentId::new(1)); + assert_eq!(entries[1].1.failure_policy, FailurePolicy::Isolable); + } + + #[test] + fn the_derived_chain_is_one_the_machine_accepts() { + let table = [ + dev(ComponentAttrs::passive_required()), + dev(ComponentAttrs::active_isolable()), + ]; + let validated = chain_of(&table); + let entries: heapless::Vec<_, 2> = + heapless::Vec::from_slice(validated.entries()).expect("same length as the table"); + + assert!(Chain::<2>::try_from(entries).is_ok()); + } + + #[test] + #[should_panic(expected = "at least one device")] + fn rejects_an_empty_table() { + let table: [DeviceConfig; 0] = []; + let _ = chain_of(&table); + } + + #[test] + #[should_panic(expected = "at most u8::MAX")] + fn rejects_a_table_longer_than_the_chain_takes() { + let table = [dev(ComponentAttrs::passive_required()); 256]; + let _ = chain_of(&table); + } + + #[test] + #[should_panic(expected = "depends_on must name an earlier device")] + fn rejects_a_dependency_outside_the_table() { + let mut attrs = ComponentAttrs::passive_required(); + attrs.depends_on = Some(ComponentId::new(4)); + let _ = chain_of(&[dev(attrs)]); + } + + #[test] + #[should_panic(expected = "depends_on must name an earlier device")] + fn rejects_a_self_dependency() { + let mut attrs = ComponentAttrs::passive_required(); + attrs.depends_on = Some(ComponentId::new(0)); + let _ = chain_of(&[dev(attrs)]); + } + + #[test] + #[should_panic(expected = "depends_on must name an earlier device")] + fn rejects_a_forward_dependency() { + let mut first = ComponentAttrs::passive_required(); + first.depends_on = Some(ComponentId::new(1)); + let _ = chain_of(&[dev(first), dev(ComponentAttrs::passive_required())]); + } +} diff --git a/services/orchestrator/config/src/device.rs b/services/orchestrator/config/src/device.rs index cbad32a39..997ddc5ce 100644 --- a/services/orchestrator/config/src/device.rs +++ b/services/orchestrator/config/src/device.rs @@ -5,6 +5,7 @@ use crate::checkpoint::BootCheckpoint; use crate::layout::ImageLayout; +use openprot_orchestrator_sm::ComponentAttrs; /// Fails the build if `max_retry` is too small for a device to boot every /// image. Recovery restores one image per attempt, and the orchestrator @@ -48,10 +49,9 @@ pub const fn assert_retry_reaches_every_image(max_retry: u8, devices: &[De /// 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 -/// symbiont, the orchestrator's `ComponentKind`), not from a table -/// setting — a second knob would only let the two disagree. +/// Says nothing about attestation or commit requirements: those follow from +/// what kind of device this is, which `attrs` carries, not from a second +/// table setting that could disagree with it. /// /// Fields are private so a device entry that violates the schema is /// unrepresentable: [`new`](Self::new) is the only way in, and it checks. @@ -61,6 +61,7 @@ pub struct DeviceConfig { reset_signal: R, checkpoints: &'static [BootCheckpoint

], layout: Option, + attrs: ComponentAttrs, } impl DeviceConfig { @@ -79,6 +80,7 @@ impl DeviceConfig { reset_signal: R, checkpoints: &'static [BootCheckpoint

], layout: Option, + attrs: ComponentAttrs, ) -> Self { assert!(!name.is_empty(), "device name must not be empty"); assert!( @@ -102,9 +104,16 @@ impl DeviceConfig { reset_signal, checkpoints, layout, + attrs, } } + /// The orchestrator's per-component policy for this device. + #[must_use] + pub const fn attrs(&self) -> ComponentAttrs { + self.attrs + } + /// The device's name in reports and logs. #[must_use] pub const fn name(&self) -> &'static str { @@ -182,6 +191,7 @@ mod tests { /// The golden image, above every slot `slot` can place. const GOLDEN: Golden = Golden::new(Region::new(0xF000_0000, SLOT_LEN)); + const ATTRS: ComponentAttrs = ComponentAttrs::passive_required(); const LAYOUT: ImageLayout = ImageLayout::new(const { &[slot(0), slot(1)] }, Some(GOLDEN)); #[test] @@ -192,12 +202,13 @@ mod tests { 0u8, &[BOOT_COMPLETE, BOOT_COMPLETE_DUPLICATE_NAME], None, + ATTRS, ); } #[test] fn accepts_a_valid_table() { - let device = DeviceConfig::new("dev", 0u8, &[BOOT_COMPLETE], Some(LAYOUT)); + let device = DeviceConfig::new("dev", 0u8, &[BOOT_COMPLETE], Some(LAYOUT), ATTRS); assert_eq!(device.name(), "dev"); assert_eq!(*device.reset_signal(), 0); assert_eq!(device.checkpoints().len(), 1); @@ -219,28 +230,33 @@ mod tests { /// included: the eRoT never addresses a byte range for it. #[test] fn accepts_a_device_without_a_layout() { - let device = DeviceConfig::new("dev", 0u8, &[BOOT_COMPLETE], None); + let device = DeviceConfig::new("dev", 0u8, &[BOOT_COMPLETE], None, ATTRS); assert!(device.layout().is_none()); } #[test] #[should_panic(expected = "device name must not be empty")] fn rejects_an_empty_device_name() { - let _ = DeviceConfig::new("", 0u8, &[BOOT_COMPLETE], None); + let _ = DeviceConfig::new("", 0u8, &[BOOT_COMPLETE], None, ATTRS); } #[test] #[should_panic(expected = "at least one boot checkpoint")] fn rejects_an_empty_checkpoint_list() { - let _ = DeviceConfig::new("dev", 0u8, &[] as &[BootCheckpoint], None); + let _ = DeviceConfig::new("dev", 0u8, &[] as &[BootCheckpoint], None, ATTRS); } /// Two slots and a golden image need four attempts: three restores /// plus the one the last restore would otherwise never get. #[test] fn accepts_a_retry_budget_that_boots_the_golden_image() { - const DEVICES: &[DeviceConfig] = - &[DeviceConfig::new("dev", 0, &[BOOT_COMPLETE], Some(LAYOUT))]; + const DEVICES: &[DeviceConfig] = &[DeviceConfig::new( + "dev", + 0, + &[BOOT_COMPLETE], + Some(LAYOUT), + ATTRS, + )]; assert_retry_reaches_every_image(4, DEVICES); } @@ -249,7 +265,7 @@ mod tests { #[test] fn accepts_any_retry_budget_for_a_device_without_a_layout() { const DEVICES: &[DeviceConfig] = - &[DeviceConfig::new("dev", 0, &[BOOT_COMPLETE], None)]; + &[DeviceConfig::new("dev", 0, &[BOOT_COMPLETE], None, ATTRS)]; assert_retry_reaches_every_image(0, DEVICES); } @@ -257,8 +273,13 @@ mod tests { /// Three attempts restore the golden image and stop before booting it. #[should_panic(expected = "max_retry is too small")] fn rejects_a_retry_budget_that_never_boots_the_golden_image() { - const DEVICES: &[DeviceConfig] = - &[DeviceConfig::new("dev", 0, &[BOOT_COMPLETE], Some(LAYOUT))]; + const DEVICES: &[DeviceConfig] = &[DeviceConfig::new( + "dev", + 0, + &[BOOT_COMPLETE], + Some(LAYOUT), + ATTRS, + )]; assert_retry_reaches_every_image(3, DEVICES); } } diff --git a/services/orchestrator/config/src/lib.rs b/services/orchestrator/config/src/lib.rs index 7187479fc..509426f5f 100644 --- a/services/orchestrator/config/src/lib.rs +++ b/services/orchestrator/config/src/lib.rs @@ -12,11 +12,14 @@ #![cfg_attr(not(test), no_std)] +pub mod chain; pub mod checkpoint; pub mod device; pub mod layout; pub mod record; +#[doc(inline)] +pub use chain::{chain_of, ChainEntries}; #[doc(inline)] pub use checkpoint::BootCheckpoint; #[doc(inline)] diff --git a/services/orchestrator/driver/BUILD.bazel b/services/orchestrator/driver/BUILD.bazel index bd2399da1..b02c33b5f 100644 --- a/services/orchestrator/driver/BUILD.bazel +++ b/services/orchestrator/driver/BUILD.bazel @@ -16,6 +16,7 @@ rust_library( visibility = ["//visibility:public"], deps = [ "//services/orchestrator/capabilities:orchestrator_capabilities", + "//services/orchestrator/config:orchestrator_config", "//services/orchestrator/sm:orchestrator_sm", "//util/io", "@rust_crates//:heapless", diff --git a/services/orchestrator/driver/src/board.rs b/services/orchestrator/driver/src/board.rs index a16c05595..8f9650788 100644 --- a/services/orchestrator/driver/src/board.rs +++ b/services/orchestrator/driver/src/board.rs @@ -4,7 +4,7 @@ //! What the board supplies to the driver: traits and wiring data only. //! Boards (or test mocks) implement these. -use openprot_orchestrator_sm::{BootFailureKind, ComponentId, ComponentKind}; +use openprot_orchestrator_sm::{BootFailureKind, ComponentId}; use orchestrator_capabilities::Updatable; use util_io::ByteSource; @@ -202,8 +202,10 @@ pub enum SvnFloorBinding { } /// Everything the board supplies, built once at bring-up and handed to -/// `PlatformDriver::new`. Fields are public: executors may need two parts at once -/// (disjoint borrows). +/// [`bring_up`](crate::bring_up). Holds only what a board composes by hand: +/// anything the chain already states, the driver derives instead of taking it +/// here. Fields are public because a board writes this as a literal, and +/// because executors may need two parts at once (disjoint borrows). /// /// ```ignore /// struct Ast1060Board; @@ -217,20 +219,30 @@ pub enum SvnFloorBinding { /// type Updatable = PldmDevice; // device pulls its own chunks /// type Recovery = SlotRecovery; // A/B + golden, attempt-indexed /// type Staging = StagingFlash; // where the update source writes +/// type SelfUpdate = SelfUpdateSession; // session record in the eRoT's own flash /// } -/// let board = Board:: { -/// images: [bmc_image, cpld_image], -/// verifier, -/// boot_controls: [bmc_reset, cpld_reset], -/// boot_watches: [bmc_walk, cpld_walk], -/// component_kinds: [ComponentKind::Active, ComponentKind::Passive], -/// svn_floors: [SvnFloorBinding::Erot(bmc_floor), SvnFloorBinding::SelfManaged], -/// report_sink, -/// updatables: [bmc_update, cpld_update], -/// recovery: [bmc_recovery, cpld_recovery], -/// update_staging, -/// update_stall_budget_millis: 30_000, -/// }; +/// +/// // One chain, from the board's device table, for both halves. The const +/// // item is load-bearing: it forces chain_of's validation at build time. +/// const CHAIN: orchestrator_config::ChainEntries<2> = orchestrator_config::chain_of(&DEVICES); +/// let (orchestrator, driver) = bring_up::( +/// &CHAIN, +/// Board { +/// images: [bmc_image, cpld_image], +/// verifier, +/// boot_controls: [bmc_reset, cpld_reset], +/// boot_watches: [bmc_walk, cpld_walk], +/// svn_floors: [SvnFloorBinding::Erot(bmc_floor), SvnFloorBinding::SelfManaged], +/// report_sink, +/// updatables: [bmc_update, cpld_update], +/// recovery: [bmc_recovery, cpld_recovery], +/// update_staging, +/// update_stall_budget_millis: 30_000, +/// self_update, +/// self_svn_floor, +/// }, +/// MAX_RETRY, +/// ); /// ``` pub struct Board { /// `images[i]` belongs to `ComponentId(i)` — device index = chain @@ -244,10 +256,6 @@ pub struct Board { /// `boot_watches[i]` supervises `ComponentId(i)`'s boot walk, same /// indexing as `images`. pub boot_watches: [B::BootWatch; N], - /// `component_kinds[i]` classifies `ComponentId(i)`: a completed walk becomes - /// `ComponentReady` for `Active`, `Booted` for `Passive`. Comes from - /// the same board table as the SM's chain, so both sides agree. - pub component_kinds: [ComponentKind; N], /// `svn_floors[i]` says who keeps `ComponentId(i)`'s anti-rollback /// floor, same indexing as `images`. pub svn_floors: [SvnFloorBinding; N], diff --git a/services/orchestrator/driver/src/driver.rs b/services/orchestrator/driver/src/driver.rs index 1f341b953..1be728381 100644 --- a/services/orchestrator/driver/src/driver.rs +++ b/services/orchestrator/driver/src/driver.rs @@ -5,9 +5,12 @@ //! the SM through the [`Platform`] impl. use openprot_orchestrator_sm::{ - BootFailureKind, ComponentId, ComponentKind, Effect, EffectError, Event, Orchestrator, Platform, + BootFailureKind, Chain, ComponentAttrs, ComponentId, ComponentKind, Effect, EffectError, Event, + Orchestrator, Platform, }; +use orchestrator_config::ChainEntries; + use crate::board::{ Board, BoardCapabilities, ImageSource, Report, ReportSink, SvnFloorBinding, Verdict, Verifier, }; @@ -92,6 +95,10 @@ impl core::error::Error for DriverError {} /// the driver's own fields are bookkeeping. pub struct PlatformDriver { board: Board, + /// `kinds[i]` classifies `ComponentId(i)`, derived from the chain the + /// state machine runs on. A completed walk becomes `ComponentReady` for + /// an `Active` component and `Booted` for a `Passive` one. + kinds: [ComponentKind; N], /// Component whose image is staged (source opened) for verification. staged: Option, /// `watching[i]`: `ComponentId(i)` is out of reset with a walk in @@ -162,11 +169,31 @@ enum UpdatePhase { } impl PlatformDriver { - pub fn new(board: Board) -> Self { - // ComponentId is a u8, so ids for N > 256 components would wrap. - const { assert!(N <= 256) }; + /// Derives what the chain already states instead of taking it twice: the + /// component kinds come from `entries`, the same entries the state machine + /// is built from. + /// + /// # Panics + /// + /// Panics if an entry's id is not its position. The driver indexes every + /// per-component array by `id.get()`, so an entry out of position would + /// address the wrong component's reset line, flash and floor. + pub(crate) fn new(entries: &[(ComponentId, ComponentAttrs); N], board: Board) -> Self { + // ComponentId is a u8; Chain rejects more than u8::MAX entries. + const { assert!(N <= u8::MAX as usize) }; + let mut kinds = [ComponentKind::Passive; N]; + let mut i = 0; + while i < N { + assert!( + entries[i].0.get() as usize == i, + "a chain entry's id must be its position in the chain" + ); + kinds[i] = entries[i].1.kind; + i += 1; + } Self { board, + kinds, staged: None, watching: [false; N], verified_svn: [None; N], @@ -532,7 +559,7 @@ impl PlatformDriver { } WalkVerdict::Complete => { self.watching[idx] = false; - let event = match self.board.component_kinds[idx] { + let event = match self.kinds[idx] { ComponentKind::Active => Event::ComponentReady(id), ComponentKind::Passive => Event::Booted(id), }; @@ -690,6 +717,32 @@ impl Platform for PlatformDriver { } } +/// Brings a platform up from one chain: the state machine that decides and the +/// driver that acts, built from the same entries so neither can be holding a +/// different list of components than the other. +/// +/// Takes a [`ChainEntries`] returned by `orchestrator_config::chain_of`, +/// which validates the table at const time. Boards declare the result as a +/// `const` item, so an invalid table is a build error, not a runtime panic. +pub fn bring_up( + chain_entries: &'static ChainEntries, + board: Board, + max_retry: u8, +) -> (Orchestrator, PlatformDriver) { + let entries = chain_entries.entries(); + // chain_of validated: nonempty, at most u8::MAX, ids are positions, + // deps strictly earlier. Chain::try_from rechecks the same invariants + // at runtime, so the expect cannot fire. + let chain: Chain = heapless::Vec::from_slice(entries) + .expect("same length as the capacity") + .try_into() + .expect("chain_of validated the entries"); + ( + Orchestrator::new(chain, max_retry), + PlatformDriver::new(entries, board), + ) +} + /// The connection between an update frontend and the SM: called (by the /// event loop, on the frontend's behalf) once a complete candidate for /// `target` sits in the staging region. Records the job first, then injects diff --git a/services/orchestrator/driver/src/lib.rs b/services/orchestrator/driver/src/lib.rs index c26e8b903..b19145e87 100644 --- a/services/orchestrator/driver/src/lib.rs +++ b/services/orchestrator/driver/src/lib.rs @@ -38,4 +38,4 @@ mod tests; pub use board::{ Board, BoardCapabilities, ImageSource, Report, ReportSink, SvnFloorBinding, Verdict, Verifier, }; -pub use driver::{request_update, BootWalkPoll, DriverError, PlatformDriver, UpdatePoll}; +pub use driver::{bring_up, request_update, BootWalkPoll, DriverError, PlatformDriver, UpdatePoll}; diff --git a/services/orchestrator/driver/src/tests.rs b/services/orchestrator/driver/src/tests.rs index ea2a26b70..ebeec55a7 100644 --- a/services/orchestrator/driver/src/tests.rs +++ b/services/orchestrator/driver/src/tests.rs @@ -276,6 +276,28 @@ impl MockFloor { } } +/// Chain entries for an all-passive chain of `N` components, which is what +/// most of these tests want. Ids are positions, as the driver requires. +fn passive_entries() -> [(ComponentId, ComponentAttrs); N] { + core::array::from_fn(|i| { + ( + ComponentId::new(i as u8), + ComponentAttrs::passive_required(), + ) + }) +} + +/// The same, with the kinds a test cares about. +fn entries_with_kinds( + kinds: [ComponentKind; N], +) -> [(ComponentId, ComponentAttrs); N] { + core::array::from_fn(|i| { + let mut attrs = ComponentAttrs::passive_required(); + attrs.kind = kinds[i]; + (ComponentId::new(i as u8), attrs) + }) +} + /// Update adapter without a HAL. Wiring-only for now: it stages the whole /// payload in one step. The update pump replaces it with a stepping mock /// when the executors land. @@ -471,7 +493,6 @@ fn mock_board() -> Board { }, boot_controls: core::array::from_fn(|_| MockReset::new()), boot_watches: core::array::from_fn(|_| MockWalk::idle()), - component_kinds: core::array::from_fn(|_| ComponentKind::Passive), svn_floors: core::array::from_fn(|_| SvnFloorBinding::Erot(MockFloor::new())), report_sink: RecordingSink::new(), updatables: core::array::from_fn(|_| MockUpdatable::new()), @@ -482,10 +503,13 @@ fn mock_board() -> Board { } fn driver(images: [MemImage; 1]) -> PlatformDriver { - PlatformDriver::new(Board { - images, - ..mock_board() - }) + PlatformDriver::new( + &passive_entries(), + Board { + images, + ..mock_board() + }, + ) } fn orchestrator() -> Orchestrator<1, 4> { @@ -562,13 +586,16 @@ fn unreadable_source_fails_closed() { #[test] fn verifier_fault_fails_closed() { let mut orch = orchestrator(); - let mut driver = PlatformDriver::::new(Board { - verifier: XorVerifier { - fault: true, - svn: MOCK_SVN, + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + verifier: XorVerifier { + fault: true, + svn: MOCK_SVN, + }, + ..mock_board() }, - ..mock_board() - }); + ); orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); @@ -579,7 +606,7 @@ const C1: ComponentId = ComponentId::new(1); #[test] fn verify_for_a_different_component_is_refused() { - let mut driver = PlatformDriver::::new(mock_board()); + let mut driver = PlatformDriver::::new(&passive_entries(), mock_board()); driver.stage_firmware(C0).unwrap(); @@ -609,7 +636,7 @@ fn verify_of_unknown_component_is_refused() { #[test] fn reset_release_and_assert_reach_the_boot_control() { - let mut driver = PlatformDriver::::new(mock_board()); + let mut driver = PlatformDriver::::new(&passive_entries(), mock_board()); driver.release_reset(C0).unwrap(); assert!(!driver.board().boot_controls[0].held.get()); @@ -636,10 +663,13 @@ fn reset_of_unknown_component_is_refused() { fn reset_line_fault_is_reported() { let mut control = MockReset::new(); control.fail = true; - let mut driver = PlatformDriver::::new(Board { - boot_controls: [control], - ..mock_board() - }); + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + boot_controls: [control], + ..mock_board() + }, + ); assert_eq!(driver.release_reset(C0), Err(DriverError::BootControlFault)); assert_eq!(driver.assert_reset(C0), Err(DriverError::BootControlFault)); @@ -716,26 +746,28 @@ fn release_follows_verification() { let control = MockReset::new(); let held = control.held.clone(); let held_during_verify = std::rc::Rc::new(core::cell::Cell::new(false)); - let mut driver = PlatformDriver::::new(Board { - images: [MemImage::holding(valid_image())], - verifier: LineWatchingVerifier { - inner: XorVerifier { - fault: false, - svn: MOCK_SVN, + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + images: [MemImage::holding(valid_image())], + verifier: LineWatchingVerifier { + inner: XorVerifier { + fault: false, + svn: MOCK_SVN, + }, + line: held.clone(), + held_during_verify: held_during_verify.clone(), }, - line: held.clone(), - held_during_verify: held_during_verify.clone(), + boot_controls: [control], + boot_watches: [MockWalk::idle()], + svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], + report_sink: (), + updatables: [MockUpdatable::new()], + recovery: [()], + update_staging: MemStaging::new(), + update_stall_budget_millis: STALL_BUDGET_MILLIS, }, - boot_controls: [control], - boot_watches: [MockWalk::idle()], - component_kinds: [ComponentKind::Passive], - svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], - report_sink: (), - updatables: [MockUpdatable::new()], - recovery: [()], - update_staging: MemStaging::new(), - update_stall_budget_millis: STALL_BUDGET_MILLIS, - }); + ); let mut orch = orchestrator(); orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); @@ -755,10 +787,13 @@ fn failed_release_fails_closed() { let mut control = MockReset::new(); control.fail = true; let held = control.held.clone(); - let mut driver = PlatformDriver::::new(Board { - boot_controls: [control], - ..mock_board() - }); + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + boot_controls: [control], + ..mock_board() + }, + ); let mut orch = orchestrator(); orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); @@ -777,11 +812,13 @@ fn walk_driver( walks: [MockWalk; 2], component_kinds: [ComponentKind; 2], ) -> PlatformDriver { - PlatformDriver::new(Board { - boot_watches: walks, - component_kinds, - ..mock_board() - }) + PlatformDriver::new( + &entries_with_kinds(component_kinds), + Board { + boot_watches: walks, + ..mock_board() + }, + ) } // A completed walk becomes ComponentReady for Active, Booted for Passive. @@ -981,10 +1018,13 @@ fn rerelease_arms_a_fresh_walk() { #[test] fn booted_walk_settles_in_ready() { let mut orch = orchestrator(); - let mut driver = PlatformDriver::::new(Board { - boot_watches: [MockWalk::scripted(std::vec![WalkVerdict::Complete])], - ..mock_board() - }); + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + boot_watches: [MockWalk::scripted(std::vec![WalkVerdict::Complete])], + ..mock_board() + }, + ); orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); assert_eq!(orch.state(), State::Ready); @@ -1002,13 +1042,16 @@ fn booted_walk_settles_in_ready() { #[test] fn boot_failure_locks_when_recovery_is_exhausted() { let mut orch = orchestrator(); - let mut driver = PlatformDriver::::new(Board { - boot_watches: [MockWalk::scripted(std::vec![WalkVerdict::Failed { - checkpoint: "heartbeat", - cause: FailureCause::TimedOut, - }])], - ..mock_board() - }); + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + boot_watches: [MockWalk::scripted(std::vec![WalkVerdict::Failed { + checkpoint: "heartbeat", + cause: FailureCause::TimedOut, + }])], + ..mock_board() + }, + ); orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); assert_eq!(orch.state(), State::Ready); @@ -1092,7 +1135,6 @@ fn recoverable_board(sources: u8) -> Board }, boot_controls: core::array::from_fn(|_| MockReset::new()), boot_watches: core::array::from_fn(|_| MockWalk::idle()), - component_kinds: core::array::from_fn(|_| ComponentKind::Passive), svn_floors: core::array::from_fn(|_| SvnFloorBinding::Erot(MockFloor::new())), report_sink: RecordingSink::new(), updatables: core::array::from_fn(|_| MockUpdatable::new()), @@ -1108,7 +1150,8 @@ fn recoverable_board(sources: u8) -> Board // RecoverComponent with a successful restore returns Restored(id). #[test] fn recover_component_returns_restored() { - let mut driver = PlatformDriver::::new(recoverable_board(2)); + let mut driver = + PlatformDriver::::new(&passive_entries(), recoverable_board(2)); assert_eq!(driver.recover_component(C0, 0), Ok(Event::Restored(C0))); assert_eq!(driver.recover_component(C0, 1), Ok(Event::Restored(C0))); @@ -1117,7 +1160,8 @@ fn recover_component_returns_restored() { // RecoverComponent past the last source returns RecoveryUnavailable(id). #[test] fn recover_component_returns_unavailable_when_exhausted() { - let mut driver = PlatformDriver::::new(recoverable_board(1)); + let mut driver = + PlatformDriver::::new(&passive_entries(), recoverable_board(1)); assert_eq!(driver.recover_component(C0, 0), Ok(Event::Restored(C0))); assert_eq!( @@ -1130,13 +1174,16 @@ fn recover_component_returns_unavailable_when_exhausted() { // succeeds, so a fault does not poison the path. #[test] fn recovery_fault_is_reported() { - let mut driver = PlatformDriver::::new(Board { - recovery: [MockRecovery { - sources: 2, - fail_on: Some(0), - }], - ..recoverable_board(2) - }); + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + recovery: [MockRecovery { + sources: 2, + fail_on: Some(0), + }], + ..recoverable_board(2) + }, + ); assert_eq!( driver.recover_component(C0, 0), @@ -1149,7 +1196,8 @@ fn recovery_fault_is_reported() { // An unknown component is refused before the mechanism is consulted. #[test] fn recover_unknown_component_is_refused() { - let mut driver = PlatformDriver::::new(recoverable_board(2)); + let mut driver = + PlatformDriver::::new(&passive_entries(), recoverable_board(2)); assert_eq!( driver.recover_component(ComponentId::new(9), 0), @@ -1160,7 +1208,7 @@ fn recover_unknown_component_is_refused() { // The `()` impl reports exhaustion on every attempt: no sources exist. #[test] fn unit_recovery_always_exhausted() { - let mut driver = PlatformDriver::::new(mock_board()); + let mut driver = PlatformDriver::::new(&passive_entries(), mock_board()); assert_eq!( driver.recover_component(C0, 0), @@ -1174,20 +1222,24 @@ fn unit_recovery_always_exhausted() { fn execute_routes_recover_component() { use openprot_orchestrator_sm::{Effect, EffectError, Platform}; - let mut driver = PlatformDriver::::new(recoverable_board(2)); + let mut driver = + PlatformDriver::::new(&passive_entries(), recoverable_board(2)); assert_eq!( driver.execute(Effect::RecoverComponent { id: C0, attempt: 0 }), Ok(Some(Event::Restored(C0))) ); - let mut faulting_driver = PlatformDriver::::new(Board { - recovery: [MockRecovery { - sources: 2, - fail_on: Some(0), - }], - ..recoverable_board(2) - }); + let mut faulting_driver = PlatformDriver::::new( + &passive_entries(), + Board { + recovery: [MockRecovery { + sources: 2, + fail_on: Some(0), + }], + ..recoverable_board(2) + }, + ); assert_eq!( faulting_driver.execute(Effect::RecoverComponent { id: C0, attempt: 0 }), @@ -1258,7 +1310,7 @@ fn every_report_reaches_a_sink() { // after a verification has passed — the two halves of the commit contract. #[test] fn commit_advances_the_floor_to_the_verified_svn() { - let mut driver = PlatformDriver::::new(mock_board()); + let mut driver = PlatformDriver::::new(&passive_entries(), mock_board()); driver .execute(Effect::ReadFirmware(C0)) @@ -1284,10 +1336,13 @@ fn commit_advances_the_floor_to_the_verified_svn() { // mis-advance. #[test] fn commit_without_an_erot_floor_is_a_no_op() { - let mut driver = PlatformDriver::::new(Board { - svn_floors: [SvnFloorBinding::SelfManaged], - ..mock_board() - }); + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + svn_floors: [SvnFloorBinding::SelfManaged], + ..mock_board() + }, + ); assert_eq!(driver.execute(Effect::CommitSvnFloor(C0)), Ok(None)); } @@ -1308,10 +1363,13 @@ fn commit_without_a_verified_image_fails_closed() { fn rejected_image_clears_the_verified_svn() { let mut corrupt = valid_image(); corrupt[7] ^= 0x01; - let mut driver = PlatformDriver::::new(Board { - images: [MemImage::holding(valid_image()).reflash_on_reopen(corrupt)], - ..mock_board() - }); + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + images: [MemImage::holding(valid_image()).reflash_on_reopen(corrupt)], + ..mock_board() + }, + ); driver.stage_firmware(C0).expect("stage failed"); assert_eq!( @@ -1337,10 +1395,13 @@ fn rejected_image_clears_the_verified_svn() { fn floor_fault_is_reported() { let mut mock = MockFloor::new(); mock.fail = true; - let mut driver = PlatformDriver::::new(Board { - svn_floors: [SvnFloorBinding::Erot(mock)], - ..mock_board() - }); + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + svn_floors: [SvnFloorBinding::Erot(mock)], + ..mock_board() + }, + ); driver.stage_firmware(C0).expect("stage failed"); driver.verify_firmware(C0).expect("verify failed"); @@ -1352,13 +1413,16 @@ fn floor_fault_is_reported() { // hands back an error for the SM to fail closed on. #[test] fn reports_reach_the_board_sink() { - let mut driver = PlatformDriver::::new(Board { - verifier: XorVerifier { - fault: false, - svn: 0, + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + verifier: XorVerifier { + fault: false, + svn: 0, + }, + ..mock_board() }, - ..mock_board() - }); + ); for effect in [ Effect::ReportIsolated(C0), @@ -1382,13 +1446,16 @@ fn reports_reach_the_board_sink() { // fail-closed path. #[test] fn reporting_an_isolated_component_does_not_lock_the_platform() { - let mut driver = PlatformDriver::::new(Board { - verifier: XorVerifier { - fault: false, - svn: 0, + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + verifier: XorVerifier { + fault: false, + svn: 0, + }, + ..mock_board() }, - ..mock_board() - }); + ); let mut chain = heapless::Vec::<_, 2>::new(); chain .push((C0, ComponentAttrs::passive_required())) @@ -1551,10 +1618,13 @@ fn aborted_update_clears_pending_update() { } fn update_driver(updatable: MockUpdatable) -> PlatformDriver { - PlatformDriver::new(Board { - updatables: [updatable], - ..mock_board() - }) + PlatformDriver::new( + &passive_entries(), + Board { + updatables: [updatable], + ..mock_board() + }, + ) } /// Submits a job and runs the entry executor, as entry to `Updating` @@ -1793,6 +1863,36 @@ fn a_rejected_update_returns_the_platform_to_ready() { assert!(!driver.board().updatables[0].active); } +// bring_up takes a ChainEntries, the validated output of chain_of. The +// machine and the driver cannot be holding different component lists +// because the entries come from one const-validated device table. +#[test] +fn bring_up_builds_both_halves_from_one_chain() { + use core::time::Duration; + use orchestrator_config::{chain_of, BootCheckpoint, ChainEntries, DeviceConfig}; + + const CP: BootCheckpoint = BootCheckpoint::new("up", 1, Duration::from_millis(10)); + const fn dev(attrs: ComponentAttrs) -> DeviceConfig { + DeviceConfig::new("dev", 0, &[CP], None, attrs) + } + const TABLE: [DeviceConfig; 1] = [dev(ComponentAttrs::active_required())]; + const CHAIN: ChainEntries<1> = chain_of(&TABLE); + + let (orch, driver) = bring_up::(&CHAIN, mock_board(), 3); + + assert_eq!(orch.state(), State::PowerOnReset); + assert!(driver.pending_update().is_none()); +} + +// The driver indexes every per-component array by id, so an entry out of +// position would address the wrong component's reset line and flash. +#[test] +#[should_panic(expected = "id must be its position")] +fn an_entry_out_of_position_is_refused() { + let entries = [(ComponentId::new(3), ComponentAttrs::passive_required())]; + let _ = PlatformDriver::::new(&entries, mock_board()); +} + // Before the platform is in service nothing would report the request // deferred, so it is refused and no job is recorded. #[test] @@ -1813,13 +1913,16 @@ fn a_request_before_the_platform_is_in_service_is_refused() { #[test] fn a_request_to_a_locked_platform_is_refused() { let mut orch = orchestrator(); - let mut driver = PlatformDriver::::new(Board { - verifier: XorVerifier { - fault: true, - svn: MOCK_SVN, + let mut driver = PlatformDriver::::new( + &passive_entries(), + Board { + verifier: XorVerifier { + fault: true, + svn: MOCK_SVN, + }, + ..mock_board() }, - ..mock_board() - }); + ); orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); assert_eq!(orch.state(), State::Locked); diff --git a/services/orchestrator/sm/src/model.rs b/services/orchestrator/sm/src/model.rs index e561b8043..c5abb60c5 100644 --- a/services/orchestrator/sm/src/model.rs +++ b/services/orchestrator/sm/src/model.rs @@ -485,10 +485,13 @@ impl State { /// dependency is always walked before its dependents), /// - the length fits `u8`, the `cursor` index type. /// +/// Boards build it from their device table rather than entry by entry, so the +/// table stays the only place a component is declared: +/// /// ```ignore -/// let mut v = heapless::Vec::<_, 4>::new(); -/// v.push((ComponentId::new(0), ComponentAttrs::passive_required())).unwrap(); -/// let chain: Chain<4> = v.try_into()?; +/// let validated = orchestrator_config::chain_of(&DEVICES); +/// let entries = heapless::Vec::from_slice(validated.entries())?; +/// let chain: Chain<2> = entries.try_into()?; /// ``` #[derive(Clone, Debug)] pub struct Chain { diff --git a/services/orchestrator/test/BUILD.bazel b/services/orchestrator/test/BUILD.bazel index 10919bb77..fb9ea94dc 100644 --- a/services/orchestrator/test/BUILD.bazel +++ b/services/orchestrator/test/BUILD.bazel @@ -10,7 +10,10 @@ rust_library( srcs = ["devices.rs"], crate_name = "board_devices", edition = "2024", - deps = ["//services/orchestrator/config:orchestrator_config"], + deps = [ + "//services/orchestrator/config:orchestrator_config", + "//services/orchestrator/sm:orchestrator_sm", + ], ) rust_library( diff --git a/services/orchestrator/test/devices.rs b/services/orchestrator/test/devices.rs index b32b05b7a..4979ed92a 100644 --- a/services/orchestrator/test/devices.rs +++ b/services/orchestrator/test/devices.rs @@ -9,9 +9,10 @@ use core::time::Duration; +use openprot_orchestrator_sm::ComponentAttrs; use orchestrator_config::{ - assert_retry_reaches_every_image, BootCheckpoint, DeviceConfig, Golden, ImageLayout, Region, - Slot, SlotId, + assert_retry_reaches_every_image, chain_of, BootCheckpoint, ChainEntries, DeviceConfig, Golden, + ImageLayout, Region, Slot, SlotId, }; /// The mock board's boot-probe vocabulary. The schema carries these @@ -45,7 +46,7 @@ const BMC_LAYOUT: ImageLayout = ImageLayout::new( /// /// The mock board's reset controller addresses reset lines by plain index, /// so the reset id type is `u8`. -pub const MANAGED_DEVICES: &[DeviceConfig] = &[ +pub const MANAGED_DEVICES: [DeviceConfig; 2] = [ // Direct-flash SPI device (BMC archetype): the eRoT fronts its flash. // Single checkpoint: it raises a boot-complete GPIO. DeviceConfig::new( @@ -60,6 +61,9 @@ pub const MANAGED_DEVICES: &[DeviceConfig] = &[ // are counted from the start of the BMC's flash area; real // boards declare their own. Some(BMC_LAYOUT), + // No iRoT of its own, so the eRoT's check is the only one and the + // boot-complete GPIO is the only signal it sends back. + ComponentAttrs::passive_required(), ), // PLDM device (NIC archetype): self-updating, SPDM-capable. Two // checkpoints, exercising the multi-checkpoint path: transport up @@ -75,9 +79,16 @@ pub const MANAGED_DEVICES: &[DeviceConfig] = &[ // so the eRoT addresses no byte range for it and declares no // layout. None, + // SPDM-capable, so it has an iRoT that verifies itself and reports + // ready once it has. + ComponentAttrs::active_required(), ), ]; +/// The orchestrator's chain for this board, derived from the table above so +/// the two cannot name different components. +pub const CHAIN: ChainEntries<{ MANAGED_DEVICES.len() }> = chain_of(&MANAGED_DEVICES); + /// Board-local checks the schema constructors cannot do — they know the /// schema's shape, not this board's meanings. Const-fence pattern: a bad /// probe fails the build. @@ -97,7 +108,7 @@ const fn validate_probes(devices: &[DeviceConfig]) { } } -const _: () = validate_probes(MANAGED_DEVICES); +const _: () = validate_probes(&MANAGED_DEVICES); /// How many times the orchestrator restores a component before it gives up. /// Four, because the BMC has three images and the attempt that restores the @@ -105,4 +116,4 @@ const _: () = validate_probes(MANAGED_DEVICES); /// golden image. pub const MAX_RETRY: u8 = 4; -const _: () = assert_retry_reaches_every_image(MAX_RETRY, MANAGED_DEVICES); +const _: () = assert_retry_reaches_every_image(MAX_RETRY, &MANAGED_DEVICES); diff --git a/target/ast10x0/board/BUILD.bazel b/target/ast10x0/board/BUILD.bazel index 856e7b9c0..4c2c64523 100644 --- a/target/ast10x0/board/BUILD.bazel +++ b/target/ast10x0/board/BUILD.bazel @@ -23,6 +23,7 @@ rust_library( "//services/orchestrator/adapters/hal:orchestrator_hal_adapters", "//services/orchestrator/capabilities:orchestrator_capabilities", "//services/orchestrator/config:orchestrator_config", + "//services/orchestrator/sm:orchestrator_sm", "//target/ast10x0/backend/i2c:i2c_backend_ast10x0", "//target/ast10x0/peripherals", "@ast1060_pac", @@ -46,6 +47,7 @@ rust_test( "//services/orchestrator/adapters/walk:orchestrator_checkpoint_walk", "//services/orchestrator/capabilities:orchestrator_capabilities", "//services/orchestrator/config:orchestrator_config", + "//services/orchestrator/sm:orchestrator_sm", "@rust_crates//:embedded-hal", ], ) diff --git a/target/ast10x0/board/src/bmc.rs b/target/ast10x0/board/src/bmc.rs index 1951717d3..48b5d1729 100644 --- a/target/ast10x0/board/src/bmc.rs +++ b/target/ast10x0/board/src/bmc.rs @@ -15,8 +15,9 @@ use core::time::Duration; use openprot_hal_blocking::gpio_port::ActivePolarity; use openprot_hal_blocking::{DelayNs, InputPin, OutputPin}; +use openprot_orchestrator_sm::ComponentAttrs; use orchestrator_capabilities::{BootStatus, EvidenceReader}; -use orchestrator_config::{BootCheckpoint, DeviceConfig}; +use orchestrator_config::{chain_of, BootCheckpoint, ChainEntries, DeviceConfig}; use orchestrator_hal_adapters::{ GpioReadyMonitor, GpioResetControl, ReadyLineError, ResetLineError, }; @@ -49,9 +50,22 @@ const CHECKPOINTS: &[BootCheckpoint] = &[BootCheckpoint::new( READY_WINDOW, )]; -/// The mock BMC's entry in this board's device table. -pub const BMC: DeviceConfig = - DeviceConfig::new("bmc", BmcReset::Bmc, CHECKPOINTS, None); +/// The mock BMC's entry in this board's device table. Passive: it has no +/// iRoT to self-verify, so its ready line is the only post-release signal +/// it produces. Required: the platform has nothing to run without it. +pub const BMC: DeviceConfig = DeviceConfig::new( + "bmc", + BmcReset::Bmc, + CHECKPOINTS, + None, + ComponentAttrs::passive_required(), +); + +/// Every device this board manages, in the order that assigns their ids. +pub const DEVICES: [DeviceConfig; 1] = [BMC]; + +/// The orchestrator's chain, derived from [`DEVICES`]. +pub const CHAIN: ChainEntries<{ DEVICES.len() }> = chain_of(&DEVICES); /// The BMC's ready line is driven high when it has booted. /// diff --git a/target/ast10x0/boot_evidence/BUILD.bazel b/target/ast10x0/boot_evidence/BUILD.bazel index 21271a267..80385d77b 100644 --- a/target/ast10x0/boot_evidence/BUILD.bazel +++ b/target/ast10x0/boot_evidence/BUILD.bazel @@ -13,6 +13,7 @@ rust_library( "//services/orchestrator/adapters/hal:orchestrator_hal_adapters", "//services/orchestrator/capabilities:orchestrator_capabilities", "//services/orchestrator/config:orchestrator_config", + "//services/orchestrator/sm:orchestrator_sm", ], ) diff --git a/target/ast10x0/boot_evidence/src/lib.rs b/target/ast10x0/boot_evidence/src/lib.rs index ce0ebbbd3..972cc45a8 100644 --- a/target/ast10x0/boot_evidence/src/lib.rs +++ b/target/ast10x0/boot_evidence/src/lib.rs @@ -71,6 +71,7 @@ pub const BMC_DEVICE: orchestrator_config::DeviceConfig = core::time::Duration::from_millis(500), )], None, + openprot_orchestrator_sm::ComponentAttrs::passive_required(), ); #[cfg(test)]