diff --git a/services/orchestrator/config/src/lib.rs b/services/orchestrator/config/src/lib.rs index f4e74241..b15f569a 100644 --- a/services/orchestrator/config/src/lib.rs +++ b/services/orchestrator/config/src/lib.rs @@ -6,12 +6,62 @@ //! device is named here. //! //! Invariants are enforced in the `const fn` constructors, so an invalid -//! table is a build error and there is no validate step to forget. Checks -//! on board-defined types belong next to the table that gives them -//! meaning (`target/mock/devices.rs` shows the pattern). +//! table is a build error and there is no validate step to forget. +//! Per-entry checks live in [`DeviceConfig::new`] and +//! [`BootCheckpoint::new`]; cross-entry checks (unique device names, +//! dependency ordering) live in [`DeviceTable::new`] — each invariant is +//! checked in exactly one place. Checks on board-defined types belong +//! next to the table that gives them meaning (`target/mock/devices.rs` +//! shows the pattern). #![cfg_attr(not(test), no_std)] +/// How a device in the chain of trust is classified. Declared per device +/// in the board table; the orchestrator supervises accordingly. +/// +/// Corresponds directly to the two-tier model in the CSA architecture +/// document: `Active` = eRoT gate + iRoT gate; `Passive` = eRoT gate +/// only. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComponentKind { + /// Has an integrated iRoT (e.g. Caliptra). Both the eRoT-side checks + /// (signature + SVN) and the iRoT-side check (local self-verification) + /// apply. The orchestrator waits for the device's readiness report + /// (`ComponentReady`) before advancing the chain walk. + Active, + /// No integrated iRoT. The eRoT's signature + SVN check is the only + /// *trust* gate, so the chain walk advances speculatively after the + /// device's reset release without blocking on readiness. The released + /// device is still watched for boot-progress liveness (`Booted`) + /// under the same per-device watchdog as an `Active` device's + /// readiness: a passive device that never reports in before its + /// timeout is recovered like any other boot failure. CSA + /// boot-progress checkpointing is device-agnostic — every released + /// device owes a boot-progress signal, iRoT or not. + Passive, +} + +/// Recovery-failure classification: what the orchestrator does once a +/// device's restore attempts are **exhausted** (its per-device retry +/// count reaches the board's retry budget). Every verification or +/// corruption failure is retried first, regardless of this +/// classification — CSA's "recover first" principle. This value is +/// consulted only after retries are exhausted. +/// +/// (The narrative design docs sometimes call the `Required` outcome +/// "platform halt" — same behavior, this is the type-level name.) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailurePolicy { + /// Stop the boot sequence entirely: the orchestrator locks down. + Required, + /// Hold this device in reset and continue booting the rest of the + /// platform. + Isolable, + /// Hold this device **and** any device whose `depends_on` names it + /// (transitively), then continue booting the rest of the platform. + Cascading, +} + /// One boot checkpoint: a signal the orchestrator waits for, 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 @@ -88,17 +138,21 @@ impl BootCheckpoint { /// implementation) and its boot-signal vocabulary `G`, for the same /// reason: signal ids 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. +/// Attestation and commit requirements are deliberately not separate +/// settings: they follow from [`kind`](Self::kind) (iRoT-backed or +/// symbiont) — a second knob would only let the two disagree. /// /// Fields are private so a device entry that violates the schema is /// unrepresentable: [`new`](Self::new) is the only way in, and it checks. +/// Cross-entry invariants (name uniqueness, dependency ordering) are +/// [`DeviceTable::new`]'s job — one check, one place. #[derive(Debug, Clone, Copy)] pub struct DeviceConfig { name: &'static str, reset_signal: R, + kind: ComponentKind, + failure_policy: FailurePolicy, + depends_on: Option<&'static str>, checkpoints: &'static [BootCheckpoint], } @@ -116,6 +170,8 @@ impl DeviceConfig { pub const fn new( name: &'static str, reset_signal: R, + kind: ComponentKind, + failure_policy: FailurePolicy, checkpoints: &'static [BootCheckpoint], ) -> Self { assert!(!name.is_empty(), "device name must not be empty"); @@ -138,10 +194,25 @@ impl DeviceConfig { Self { name, reset_signal, + kind, + failure_policy, + depends_on: None, checkpoints, } } + /// Builder: declare that this device is held whenever the named + /// device is held (cascade). Only meaningful when the *named* device + /// is [`FailurePolicy::Cascading`]. The name must belong to a device + /// declared **earlier** in the table — checked by + /// [`DeviceTable::new`], not here (a single entry cannot see the + /// table). + #[must_use] + pub const fn with_depends_on(mut self, dependency: &'static str) -> Self { + self.depends_on = Some(dependency); + self + } + /// The device's name in reports and logs. #[must_use] pub const fn name(&self) -> &'static str { @@ -154,6 +225,24 @@ impl DeviceConfig { &self.reset_signal } + /// Trust-chain classification (iRoT gate or not). + #[must_use] + pub const fn kind(&self) -> ComponentKind { + self.kind + } + + /// What happens once this device's recovery is exhausted. + #[must_use] + pub const fn failure_policy(&self) -> FailurePolicy { + self.failure_policy + } + + /// Name of the earlier table entry this device cascades with, if any. + #[must_use] + pub const fn depends_on(&self) -> Option<&'static str> { + self.depends_on + } + /// Boot 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 attempt — whether to retry or recover is @@ -164,6 +253,71 @@ impl DeviceConfig { } } +/// The board's device table: every managed device, in boot order. The +/// only way to get one is [`new`](Self::new), which proves the +/// cross-entry invariants at build time — so holding a `DeviceTable` *is* +/// the proof, and downstream conversions (the orchestrator's chain of +/// trust) need no failure path of their own. +#[derive(Debug, Clone, Copy)] +pub struct DeviceTable { + devices: &'static [DeviceConfig], +} + +impl DeviceTable { + /// Declares the board's device table. `const`, so a bad table is a + /// build error. + /// + /// # Panics + /// + /// Panics — a build error in const context — if the table is empty, + /// longer than `u8::MAX` (the orchestrator's cursor bound), declares + /// two devices with the same name, or contains a `depends_on` that + /// does not name a **strictly earlier** entry (which also rules out + /// dangling and self dependencies: a dependency is always walked + /// before its dependents). + #[must_use] + pub const fn new(devices: &'static [DeviceConfig]) -> Self { + assert!(!devices.is_empty(), "device table must not be empty"); + assert!( + devices.len() <= u8::MAX as usize, + "device table exceeds the orchestrator's cursor bound" + ); + let mut i = 0; + while i < devices.len() { + let mut j = i + 1; + while j < devices.len() { + assert!( + !str_eq(devices[i].name, devices[j].name), + "device names must be unique" + ); + j += 1; + } + if let Some(dep) = devices[i].depends_on { + let mut found_earlier = false; + let mut k = 0; + while k < i { + if str_eq(devices[k].name, dep) { + found_earlier = true; + } + k += 1; + } + assert!( + found_earlier, + "depends_on must name a device declared earlier in the table" + ); + } + i += 1; + } + Self { devices } + } + + /// The devices, in declaration order — which is the boot order. + #[must_use] + pub const fn devices(&self) -> &'static [DeviceConfig] { + self.devices + } +} + // `==` on `&str` is not const; compare bytes by hand. const fn str_eq(a: &str, b: &str) -> bool { let (a, b) = (a.as_bytes(), b.as_bytes()); @@ -197,33 +351,116 @@ mod tests { const CHECKPOINT_DUP: BootCheckpoint = BootCheckpoint::new("boot-complete", 1, Duration::from_secs(1)); + const fn device(name: &'static str) -> DeviceConfig { + DeviceConfig::new( + name, + 0u8, + ComponentKind::Passive, + FailurePolicy::Required, + &[CHECKPOINT], + ) + } + #[test] fn accepts_a_valid_table() { - let device = DeviceConfig::new("dev", 0u8, &[CHECKPOINT]); + const DEVICE: DeviceConfig = DeviceConfig::new( + "dev", + 0u8, + ComponentKind::Active, + FailurePolicy::Isolable, + &[CHECKPOINT], + ); + let table = DeviceTable::new(&[DEVICE]); + let device = &table.devices()[0]; assert_eq!(device.name(), "dev"); assert_eq!(*device.reset_signal(), 0); + assert_eq!(device.kind(), ComponentKind::Active); + assert_eq!(device.failure_policy(), FailurePolicy::Isolable); + assert_eq!(device.depends_on(), None); 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].timeout(), Duration::from_secs(1)); } + #[test] + fn accepts_a_backward_dependency() { + const ROOT: DeviceConfig = device("root"); + const LEAF: DeviceConfig = device("leaf").with_depends_on("root"); + let table = DeviceTable::new(&[ROOT, LEAF]); + assert_eq!(table.devices()[1].depends_on(), Some("root")); + } + + #[test] + #[should_panic(expected = "device table must not be empty")] + fn rejects_an_empty_table() { + let _ = DeviceTable::new(&[] as &[DeviceConfig]); + } + + #[test] + #[should_panic(expected = "device names must be unique")] + fn rejects_duplicate_device_names() { + const A: DeviceConfig = device("dev"); + const B: DeviceConfig = device("dev"); + let _ = DeviceTable::new(&[A, B]); + } + + #[test] + #[should_panic(expected = "depends_on must name a device declared earlier")] + fn rejects_an_unknown_dependency() { + const LEAF: DeviceConfig = device("leaf").with_depends_on("ghost"); + let _ = DeviceTable::new(&[LEAF]); + } + + #[test] + #[should_panic(expected = "depends_on must name a device declared earlier")] + fn rejects_a_forward_dependency() { + const LEAF: DeviceConfig = device("leaf").with_depends_on("root"); + const ROOT: DeviceConfig = device("root"); + let _ = DeviceTable::new(&[LEAF, ROOT]); + } + + #[test] + #[should_panic(expected = "depends_on must name a device declared earlier")] + fn rejects_a_self_dependency() { + const DEV: DeviceConfig = device("dev").with_depends_on("dev"); + let _ = DeviceTable::new(&[DEV]); + } + #[test] #[should_panic(expected = "checkpoint names must be unique")] fn rejects_duplicate_checkpoint_names() { - let _ = DeviceConfig::new("dev", 0u8, &[CHECKPOINT, CHECKPOINT_DUP]); + let _ = DeviceConfig::new( + "dev", + 0u8, + ComponentKind::Passive, + FailurePolicy::Required, + &[CHECKPOINT, CHECKPOINT_DUP], + ); } #[test] #[should_panic(expected = "device name must not be empty")] fn rejects_an_empty_device_name() { - let _ = DeviceConfig::new("", 0u8, &[CHECKPOINT]); + let _ = DeviceConfig::new( + "", + 0u8, + ComponentKind::Passive, + FailurePolicy::Required, + &[CHECKPOINT], + ); } #[test] #[should_panic(expected = "at least one boot checkpoint")] fn rejects_an_empty_checkpoint_list() { - let _ = DeviceConfig::new("dev", 0u8, &[] as &[BootCheckpoint]); + let _ = DeviceConfig::new( + "dev", + 0u8, + ComponentKind::Passive, + FailurePolicy::Required, + &[] as &[BootCheckpoint], + ); } #[test] diff --git a/services/orchestrator/sm/BUILD.bazel b/services/orchestrator/sm/BUILD.bazel index 6707f9fa..76bfd872 100644 --- a/services/orchestrator/sm/BUILD.bazel +++ b/services/orchestrator/sm/BUILD.bazel @@ -14,6 +14,7 @@ rust_library( edition = "2024", visibility = ["//visibility:public"], deps = [ + "//services/orchestrator/config:orchestrator_config", "@rust_crates//:heapless", ], ) diff --git a/services/orchestrator/sm/src/model.rs b/services/orchestrator/sm/src/model.rs index fff7cb6a..361c6c4b 100644 --- a/services/orchestrator/sm/src/model.rs +++ b/services/orchestrator/sm/src/model.rs @@ -20,50 +20,10 @@ impl ComponentId { } } -/// How a component in the trust chain is classified. The board supplies one -/// [`ComponentKind`] per [`ComponentId`] when building the chain. -/// -/// Corresponds directly to the two-tier model in the CSA architecture document: -/// `Active` = eRoT gate + iRoT gate; `Passive` = eRoT gate only. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum ComponentKind { - /// Has an integrated iRoT (e.g. Caliptra). Both eRoT-side (signature + SVN) - /// and iRoT-side (local self-verification) checks apply. The machine waits in - /// [`State::AwaitingReady`] for [`Event::ComponentReady`] before advancing. - Active, - /// No integrated iRoT. The eRoT's signature + SVN check is the only *trust* - /// gate, so the chain walk advances speculatively after `ReleaseReset` - /// without blocking in [`State::AwaitingReady`]. The released component is - /// still watched for boot-progress liveness ([`Event::Booted`]) under the - /// same per-component watchdog as an `Active` component's - /// [`Event::ComponentReady`]: a passive device that never reports in before - /// its [`Event::Timeout`] is recovered like any other boot failure. CSA - /// boot-progress checkpointing is device-agnostic — every released device - /// owes a boot-progress signal, iRoT or not. - Passive, -} - -/// Recovery-failure classification: what the machine does once a required -/// component's restore attempts are **exhausted** (its per-component retry -/// count reaches `max_retry`). Every verification or corruption failure enters -/// [`State::Recovering`] and is retried first, regardless of this -/// classification — CSA's "recover first" principle. This value is consulted -/// only after retries are exhausted. -/// -/// (The narrative design docs sometimes call the `Required` outcome "platform -/// halt" — same behavior, this is the type-level name.) -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum FailurePolicy { - /// Stop the boot sequence entirely: self-emits [`Event::RecoveryFailed`], - /// which drives the machine to [`State::Locked`]. - Required, - /// Hold this component in reset (added to `Rot.gated`) and continue - /// booting the rest of the platform. - Isolable, - /// Hold this component **and** any component whose `depends_on` names it - /// (transitively), then continue booting the rest of the platform. - Cascading, -} +// The component-classification vocabulary lives in the device-table schema +// crate (single source of truth: boards declare kind and policy per device in +// their table). Re-exported here so the reducer's API is unchanged. +pub use orchestrator_config::{ComponentKind, FailurePolicy}; /// Opaque recovery-region key supplied by the board at chain-build time. /// Components sharing a `RegionId` are restored together: when any region @@ -379,94 +339,66 @@ pub enum State { /// A validated **chain of trust**: the ordered list of components the eRoT /// walks, verifies, and supervises, in walk order. /// -/// Build one with [`TryFrom`]/[`TryInto`] from a `heapless::Vec` of -/// `(ComponentId, ComponentAttrs)` pairs. The conversion is the single place -/// the reducer's structural invariants are enforced, so a malformed chain -/// fails closed at the boundary instead of misbehaving later: -/// -/// - the chain is non-empty, -/// - every [`ComponentId`] is unique, -/// - every `depends_on` names a component that exists and appears *strictly -/// earlier* in the chain (no dangling, forward, or self dependencies — a -/// dependency is always walked before its dependents), -/// - the length fits `u8`, the `cursor` index type. -/// -/// ```ignore -/// let mut v = heapless::Vec::<_, 4>::new(); -/// v.push((ComponentId::new(0), ComponentAttrs::passive_required())).unwrap(); -/// let chain: Chain<4> = v.try_into()?; -/// ``` +/// Built from the board's validated [`DeviceTable`](orchestrator_config::DeviceTable) +/// via [`from_table`](Self::from_table). The structural invariants the reducer +/// relies on — non-empty, unique ids, every `depends_on` strictly earlier in +/// the walk, length within the `cursor`'s `u8` bound — are proved once, at +/// build time, by `DeviceTable::new`; holding a table is holding the proof, so +/// the conversion here cannot fail and no second copy of the rules exists. #[derive(Clone, Debug)] pub struct Chain { entries: heapless::Vec<(ComponentId, ComponentAttrs), N>, } -/// Why a `heapless::Vec` of components is not a valid [`Chain`]. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum ChainError { - /// The chain has no components. - Empty, - /// The chain is longer than `u8::MAX`, so `cursor` could not index it. - TooLong, - /// The same [`ComponentId`] appears more than once. - DuplicateId(ComponentId), - /// A `depends_on` names an id that is not in the chain. - UnknownDependency { - component: ComponentId, - depends_on: ComponentId, - }, - /// A `depends_on` names a component that does not appear strictly earlier - /// in the chain (a forward reference or a self-reference). A dependency - /// must be walked before its dependents. - ForwardDependency { - component: ComponentId, - depends_on: ComponentId, - }, -} - impl Chain { - /// Consume the validated chain, yielding its components in walk order. - pub(crate) fn into_entries(self) -> heapless::Vec<(ComponentId, ComponentAttrs), N> { - self.entries + /// Derive the chain of trust from the board's device table: index `i` + /// becomes `ComponentId(i)` (declaration order is walk order), kind and + /// failure policy are copied, and `depends_on` names resolve to the ids + /// of their (strictly earlier) entries. `recovery_region` has no table + /// home yet — nothing consumes it — so every component gets the default + /// region. + /// + /// # Panics + /// + /// Panics if `N` is smaller than the table. Unreachable when `N` is + /// derived from the same table (`table.devices().len()`), which is the + /// only intended call shape. + pub fn from_table(table: &orchestrator_config::DeviceTable) -> Self { + let devices = table.devices(); + assert!( + devices.len() <= N, + "chain capacity N is smaller than the device table" + ); + let mut entries = heapless::Vec::new(); + for (i, device) in devices.iter().enumerate() { + let depends_on = device.depends_on().map(|dep| { + let position = devices + .iter() + .position(|d| d.name() == dep) + .expect("DeviceTable::new proved every dependency exists"); + ComponentId::new(position as u8) + }); + let attrs = ComponentAttrs { + kind: device.kind(), + failure_policy: device.failure_policy(), + recovery_region: RegionId::new(0), + depends_on, + }; + let _ = entries.push((ComponentId::new(i as u8), attrs)); + } + Self { entries } } -} -impl TryFrom> for Chain { - type Error = ChainError; + /// Test-only back door for reducer tests that build ad-hoc chains + /// without a board table. Not compiled into production builds, so + /// `from_table` stays the only way to obtain a `Chain` there. + #[cfg(test)] + pub(crate) fn new_unchecked(entries: heapless::Vec<(ComponentId, ComponentAttrs), N>) -> Self { + Self { entries } + } - fn try_from( - entries: heapless::Vec<(ComponentId, ComponentAttrs), N>, - ) -> Result { - if entries.is_empty() { - return Err(ChainError::Empty); - } - if entries.len() > u8::MAX as usize { - return Err(ChainError::TooLong); - } - for (i, (id, _)) in entries.iter().enumerate() { - if entries[..i].iter().any(|(prev, _)| prev == id) { - return Err(ChainError::DuplicateId(*id)); - } - } - for (i, (id, attrs)) in entries.iter().enumerate() { - if let Some(dep) = attrs.depends_on { - match entries.iter().position(|(cid, _)| *cid == dep) { - None => { - return Err(ChainError::UnknownDependency { - component: *id, - depends_on: dep, - }); - } - Some(j) if j >= i => { - return Err(ChainError::ForwardDependency { - component: *id, - depends_on: dep, - }); - } - Some(_) => {} - } - } - } - Ok(Self { entries }) + /// Consume the validated chain, yielding its components in walk order. + pub(crate) fn into_entries(self) -> heapless::Vec<(ComponentId, ComponentAttrs), N> { + self.entries } } diff --git a/services/orchestrator/sm/src/tests.rs b/services/orchestrator/sm/src/tests.rs index df188ba0..1be69b78 100644 --- a/services/orchestrator/sm/src/tests.rs +++ b/services/orchestrator/sm/src/tests.rs @@ -58,8 +58,7 @@ fn drive( chain: heapless::Vec<(ComponentId, ComponentAttrs), CAPACITY>, script: &[Event], ) -> (Vec, State) { - let mut orch = - Orchestrator::::new(chain.try_into().expect("valid chain"), MAX_RETRY); + let mut orch = Orchestrator::::new(Chain::new_unchecked(chain), MAX_RETRY); let mut platform = Recorder::new(); for &event in script { orch.dispatch(&mut platform, event); @@ -347,7 +346,7 @@ fn retry_count_resets_after_successful_recovery() { let mut c = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new(); c.push((C0, ComponentAttrs::passive_required())) .expect("fits"); - let mut orch = Orchestrator::::new(c.try_into().expect("valid chain"), 2); + let mut orch = Orchestrator::::new(Chain::new_unchecked(c), 2); let mut effects = Vec::new(); for ev in [ @@ -392,7 +391,7 @@ fn retry_budget_is_per_component() { .expect("fits"); c.push((C1, ComponentAttrs::passive_required())) .expect("fits"); - let mut orch = Orchestrator::::new(c.try_into().expect("valid chain"), 2); + let mut orch = Orchestrator::::new(Chain::new_unchecked(c), 2); let mut effects = Vec::new(); for ev in [ @@ -422,7 +421,7 @@ fn custom_retry_cap_latches_sooner() { let mut c = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new(); c.push((C0, ComponentAttrs::passive_required())) .expect("fits"); - let mut orch = Orchestrator::::new(c.try_into().expect("valid chain"), 1); + let mut orch = Orchestrator::::new(Chain::new_unchecked(c), 1); let mut effects = Vec::new(); for ev in [ BOOT, @@ -447,7 +446,7 @@ fn custom_capacity_walks_full_chain() { c.push((id, ComponentAttrs::passive_required())) .expect("3 fits"); } - let mut orch = Orchestrator::<3, 8>::new(c.try_into().expect("valid chain"), MAX_RETRY); + let mut orch = Orchestrator::<3, 8>::new(Chain::new_unchecked(c), MAX_RETRY); let mut effects = Vec::new(); for ev in [ BOOT, @@ -1422,7 +1421,7 @@ fn locked_is_terminal() { let mut c: heapless::Vec<(ComponentId, ComponentAttrs), CAPACITY> = heapless::Vec::new(); c.push((C0, ComponentAttrs::passive_required())).unwrap(); // max_retry = 1 so the first failed restore latches immediately. - let mut orch = Orchestrator::::new(c.try_into().expect("valid chain"), 1); + let mut orch = Orchestrator::::new(Chain::new_unchecked(c), 1); let mut effects: Vec = Vec::new(); for ev in [BOOT, Event::VerificationFailed(C0), Event::Restored(C0)] { @@ -1485,12 +1484,10 @@ fn isolable_first_component_exhausts_then_walk_continues() { #[test] fn speculative_read_effects_are_emitted_together() { let mut orch = Orchestrator::::new( - chain(&[ + Chain::new_unchecked(chain(&[ (C0, ComponentAttrs::active_required()), (C1, ComponentAttrs::passive_required()), - ]) - .try_into() - .expect("valid chain"), + ])), MAX_RETRY, ); let mut effects: Vec = Vec::new(); @@ -1542,77 +1539,45 @@ fn single_active_chain_goes_directly_to_ready() { ); } -/// An empty component list is not a valid chain of trust. -#[test] -fn chain_rejects_empty() { - let empty = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new(); - assert_eq!(Chain::try_from(empty).unwrap_err(), ChainError::Empty); -} - -/// A repeated `ComponentId` is rejected: the reducer's linear id lookups would -/// otherwise be ambiguous. -#[test] -fn chain_rejects_duplicate_id() { - let v = chain(&[ - (C0, ComponentAttrs::passive_required()), - (C0, ComponentAttrs::passive_required()), - ]); - assert_eq!(Chain::try_from(v).unwrap_err(), ChainError::DuplicateId(C0),); -} - -/// A `depends_on` that names a component not in the chain is rejected. -#[test] -fn chain_rejects_unknown_dependency() { - let v = chain(&[(C1, ComponentAttrs::passive_required().with_depends_on(C0))]); - assert_eq!( - Chain::try_from(v).unwrap_err(), - ChainError::UnknownDependency { - component: C1, - depends_on: C0, - }, - ); -} - -/// A dependency must appear strictly earlier in the walk than its dependent; -/// a forward reference is rejected. -#[test] -fn chain_rejects_forward_dependency() { - let v = chain(&[ - (C0, ComponentAttrs::passive_required().with_depends_on(C1)), - (C1, ComponentAttrs::passive_cascading()), - ]); - assert_eq!( - Chain::try_from(v).unwrap_err(), - ChainError::ForwardDependency { - component: C0, - depends_on: C1, - }, - ); -} +/// The chain is derived from the board's `DeviceTable`: index becomes id, +/// kind/policy are copied, and `depends_on` names resolve to the ids of +/// their earlier entries. The table's invariants are proved at its own +/// construction (`DeviceTable::new`, tested in `orchestrator-config`), so +/// this conversion is infallible — only the mapping itself needs checking. +#[test] +fn chain_derives_from_device_table() { + use core::time::Duration; + use orchestrator_config::{BootCheckpoint, DeviceConfig, DeviceTable}; + + const CHECKPOINT: BootCheckpoint = + BootCheckpoint::new("boot-complete", 0, Duration::from_secs(1)); + const ROOT: DeviceConfig = DeviceConfig::new( + "root", + 0u8, + ComponentKind::Passive, + FailurePolicy::Cascading, + &[CHECKPOINT], + ); + const LEAF: DeviceConfig = DeviceConfig::new( + "leaf", + 1u8, + ComponentKind::Active, + FailurePolicy::Required, + &[CHECKPOINT], + ) + .with_depends_on("root"); + const TABLE: DeviceTable = DeviceTable::new(&[ROOT, LEAF]); -/// A component may not depend on itself. -#[test] -fn chain_rejects_self_dependency() { - let v = chain(&[(C0, ComponentAttrs::passive_required().with_depends_on(C0))]); + let entries = Chain::::from_table(&TABLE).into_entries(); assert_eq!( - Chain::try_from(v).unwrap_err(), - ChainError::ForwardDependency { - component: C0, - depends_on: C0, - }, + entries.as_slice(), + &[ + (C0, ComponentAttrs::passive_cascading()), + (C1, ComponentAttrs::active_required().with_depends_on(C0)), + ], ); } -/// A well-formed chain with a backward dependency validates successfully. -#[test] -fn chain_accepts_valid_dependency() { - let v = chain(&[ - (C0, ComponentAttrs::passive_cascading()), - (C1, ComponentAttrs::passive_required().with_depends_on(C0)), - ]); - assert!(Chain::try_from(v).is_ok()); -} - /// A [`Platform`] that records every effect and fails a chosen one, to exercise /// the effect failure channel. struct FailOn { @@ -1649,8 +1614,7 @@ impl Platform for FailOn { fn effect_failure_latches_lockdown() { let mut c = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new(); c.push((C0, ComponentAttrs::passive_required())).unwrap(); - let mut orch = - Orchestrator::::new(c.try_into().expect("valid chain"), MAX_RETRY); + let mut orch = Orchestrator::::new(Chain::new_unchecked(c), MAX_RETRY); let mut plat = FailOn::new(Effect::ReleaseReset(C0)); orch.dispatch(&mut plat, BOOT); // ReadFirmware/VerifyFirmware C0 — both succeed @@ -1668,8 +1632,7 @@ fn failed_isolation_actuation_latches_lockdown() { let mut c = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new(); c.push((C0, ComponentAttrs::passive_required())).unwrap(); c.push((C1, ComponentAttrs::passive_isolable())).unwrap(); - let mut orch = - Orchestrator::::new(c.try_into().expect("valid chain"), MAX_RETRY); + let mut orch = Orchestrator::::new(Chain::new_unchecked(c), MAX_RETRY); let mut plat = FailOn::new(Effect::AssertReset(C1)); orch.dispatch(&mut plat, BOOT); @@ -1689,8 +1652,7 @@ fn failed_isolation_actuation_latches_lockdown() { fn failed_restore_actuation_latches_lockdown() { let mut c = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new(); c.push((C0, ComponentAttrs::passive_required())).unwrap(); - let mut orch = - Orchestrator::::new(c.try_into().expect("valid chain"), MAX_RETRY); + let mut orch = Orchestrator::::new(Chain::new_unchecked(c), MAX_RETRY); let mut plat = FailOn::new(Effect::RecoverComponent(C0)); orch.dispatch(&mut plat, BOOT); @@ -1709,8 +1671,7 @@ fn failed_restore_actuation_latches_lockdown() { fn failed_lockdown_actuation_does_not_loop() { let mut c = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new(); c.push((C0, ComponentAttrs::passive_required())).unwrap(); - let mut orch = - Orchestrator::::new(c.try_into().expect("valid chain"), MAX_RETRY); + let mut orch = Orchestrator::::new(Chain::new_unchecked(c), MAX_RETRY); let mut plat = FailOn::new(Effect::LatchLockdown); // An unprovisioned power-on latches immediately; the latch actuation fails. @@ -1736,7 +1697,7 @@ fn failed_lockdown_actuation_does_not_loop() { #[test] fn batch_actuation_is_fail_fast() { let mut orch = Orchestrator::::new( - passive_required(&[C0, C1]).try_into().expect("valid chain"), + Chain::new_unchecked(passive_required(&[C0, C1])), MAX_RETRY, ); let mut plat = FailOn::new(Effect::ReleaseReset(C0)); @@ -1921,8 +1882,7 @@ fn property_verify_before_release_holds_under_random_sequences() { (C1, ComponentAttrs::active_isolable()), (C2, ComponentAttrs::passive_required()), ]); - let mut orch = - Orchestrator::::new(ch.try_into().expect("valid chain"), MAX_RETRY); + let mut orch = Orchestrator::::new(Chain::new_unchecked(ch), MAX_RETRY); let mut platform = Recorder::new(); // Power on first — usually a clean provisioned boot, occasionally a diff --git a/target/mock/BUILD.bazel b/target/mock/BUILD.bazel index a4dbf970..e247ddb6 100644 --- a/target/mock/BUILD.bazel +++ b/target/mock/BUILD.bazel @@ -1,7 +1,7 @@ # Licensed under the Apache-2.0 license # SPDX-License-Identifier: Apache-2.0 -load("@rules_rust//rust:defs.bzl", "rust_library") +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") package(default_visibility = ["//visibility:public"]) @@ -12,3 +12,11 @@ rust_library( edition = "2024", deps = ["//services/orchestrator/config:orchestrator_config"], ) + +# Host test: the table this board declares is everything the orchestrator +# needs — table → chain → orchestrator, end to end. +rust_test( + name = "devices_test", + crate = ":devices", + deps = ["//services/orchestrator/sm:orchestrator_sm"], +) diff --git a/target/mock/devices.rs b/target/mock/devices.rs index 98802697..b1227044 100644 --- a/target/mock/devices.rs +++ b/target/mock/devices.rs @@ -9,7 +9,9 @@ use core::time::Duration; -use orchestrator_config::{BootCheckpoint, DeviceConfig}; +use orchestrator_config::{ + BootCheckpoint, ComponentKind, DeviceConfig, DeviceTable, FailurePolicy, +}; /// The mock board's boot-signal vocabulary. The schema carries these /// opaquely; only this board's `EvidenceReader` gives them meaning. @@ -26,34 +28,56 @@ pub enum MockSignal { /// Declaration order is the boot order: the orchestrator releases devices /// top to bottom, one at a time. This table is the authority — the -/// orchestrator's chain of trust is built from it, never beside it. +/// orchestrator's chain of trust is built from it +/// (`Chain::from_table`), never beside it. /// /// 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: DeviceTable = DeviceTable::new(&[ // Direct-flash SPI device (BMC archetype): the eRoT fronts its flash. - // Single checkpoint: it raises a boot-complete GPIO. + // No iRoT, so the eRoT's check is the only trust gate (Passive), and + // the platform is pointless without its BMC (Required). Single + // checkpoint: it raises a boot-complete GPIO. DeviceConfig::new( "bmc", 7, + ComponentKind::Passive, + FailurePolicy::Required, &[BootCheckpoint::new( "boot-complete", MockSignal::Gpio(12), Duration::from_secs(90), )], ), - // PLDM device (NIC archetype): self-updating, SPDM-capable. Two - // checkpoints, exercising the multi-checkpoint path: transport up - // first, then proof the workload is alive. + // PLDM device (NIC archetype): self-updating, SPDM-capable — an iRoT + // of its own (Active), and the platform can serve degraded without it + // (Isolable). Two checkpoints, exercising the multi-checkpoint path: + // transport up first, then proof the workload is alive. DeviceConfig::new( "nic", 3, + ComponentKind::Active, + FailurePolicy::Isolable, &[ BootCheckpoint::new("mctp-ready", MockSignal::MctpReady, Duration::from_secs(20)), BootCheckpoint::new("heartbeat", MockSignal::Heartbeat, Duration::from_secs(10)), ], ), -]; +]); + +/// Derived, not declared: the orchestrator's chain capacity is exactly the +/// table's length. +pub const DEVICE_COUNT: usize = MANAGED_DEVICES.devices().len(); + +/// Derived, not declared: the orchestrator's proven effect-buffer floor +/// (`E >= 2 * N + 2`), with no headroom — headroom would be a second, +/// hand-picked number. +pub const EFFECT_CAP: usize = 2 * DEVICE_COUNT + 2; + +/// Consecutive failed-restore attempts per device before its failure +/// policy is consulted. A genuine board fact — not derivable — so it is +/// declared here, next to the rest of the board's boot policy. +pub const MAX_RETRY: u8 = 3; /// Board-local checks the schema constructors cannot do — they know the /// schema's shape, not this board's meanings. Const-fence pattern: a bad @@ -74,4 +98,55 @@ const fn validate_signals(devices: &[DeviceConfig]) { } } -const _: () = validate_signals(MANAGED_DEVICES); +const _: () = validate_signals(MANAGED_DEVICES.devices()); + +#[cfg(test)] +mod tests { + extern crate std; + + use std::vec::Vec; + + use openprot_orchestrator_sm::{ + Chain, ComponentId, Effect, Event, Orchestrator, PowerOnResult, State, + }; + + use super::*; + + /// End-to-end handoff: the table this board declares is everything the + /// orchestrator needs. Kind and policy come from the table too — the + /// bmc is `Passive`, so releasing it must advance the walk speculatively + /// instead of blocking in `AwaitingReady`. + #[test] + fn table_feeds_the_orchestrator() { + let chain = Chain::::from_table(&MANAGED_DEVICES); + let mut orch = Orchestrator::::new(chain, MAX_RETRY); + let bmc = ComponentId::new(0); + let nic = ComponentId::new(1); + + let mut effects: Vec = Vec::new(); + orch.dispatch_with(Event::PowerGood(PowerOnResult::Provisioned), |e| { + effects.push(e); + Ok(()) + }); + assert_eq!( + effects, + [Effect::ReadFirmware(bmc), Effect::VerifyFirmware(bmc)] + ); + assert_eq!(orch.state(), State::PreSupervision); + + effects.clear(); + orch.dispatch_with(Event::VerificationPassed(bmc), |e| { + effects.push(e); + Ok(()) + }); + assert_eq!( + effects, + [ + Effect::ReleaseReset(bmc), + Effect::ReadFirmware(nic), + Effect::VerifyFirmware(nic), + ] + ); + assert_eq!(orch.state(), State::PreSupervision); + } +}