orchestrator: Compose report delivery from the board's ReportSink capability BoardCapabilities gains ReportSink and Board a report_sink field, and the four Report effects leave the not-yet-composed group to hand their Report to the sink. One sink per platform, not one per component: two of the four reports name no component. Driver tests build their Board through mock_board() and override only the field under test; a seventh field would otherwise be repeated in fourteen literals. Assisted-by: Claude:claude-opus-5 Signed-off-by: Christina Quast <christina.quast@9elements.com>
diff --git a/services/orchestrator/driver/src/board.rs b/services/orchestrator/driver/src/board.rs index f471a28..dfed222 100644 --- a/services/orchestrator/driver/src/board.rs +++ b/services/orchestrator/driver/src/board.rs
@@ -166,6 +166,8 @@ /// survives reset and power loss, otherwise a power cycle would /// re-admit images below the floor. type SvnFloor: SvnFloor; + /// Where reports go. `()` for a board with no management side to tell. + type ReportSink: ReportSink; // Later seams: Recovery, Staging. } @@ -193,6 +195,7 @@ /// type BootControl = ExtrstGpio; // per-component reset line /// type BootWatch = CheckpointWalk; // GPIO checkpoint walk over the boot window /// type SvnFloor = OtpSvnFloor; // fuse-backed anti-rollback floor +/// type ReportSink = MctpReports; // reports out over the management transport /// } /// let board = Board::<Ast1060Board, 2> { /// images: [bmc_image, cpld_image], @@ -201,6 +204,7 @@ /// boot_watches: [bmc_walk, cpld_walk], /// component_kinds: [ComponentKind::Active, ComponentKind::Passive], /// svn_floors: [SvnFloorBinding::Erot(bmc_floor), SvnFloorBinding::SelfManaged], +/// report_sink, /// }; /// ``` pub struct Board<B: BoardCapabilities, const N: usize> { @@ -222,5 +226,8 @@ /// `svn_floors[i]` says who keeps `ComponentId(i)`'s anti-rollback /// floor, same indexing as `images`. pub svn_floors: [SvnFloorBinding<B::SvnFloor>; N], + /// Where the driver hands the SM's reports. One per platform, not one + /// per component: two of the four reports name no component. + pub report_sink: B::ReportSink, // Later seams add fields, e.g. recovery: [B::Recovery; N]. }
diff --git a/services/orchestrator/driver/src/driver.rs b/services/orchestrator/driver/src/driver.rs index 1a14d41..0176f0b 100644 --- a/services/orchestrator/driver/src/driver.rs +++ b/services/orchestrator/driver/src/driver.rs
@@ -6,7 +6,9 @@ use openprot_orchestrator_sm::{ComponentId, ComponentKind, Effect, EffectError, Event, Platform}; -use crate::board::{Board, BoardCapabilities, ImageSource, SvnFloorBinding, Verdict, Verifier}; +use crate::board::{ + Board, BoardCapabilities, ImageSource, Report, ReportSink, SvnFloorBinding, Verdict, Verifier, +}; use orchestrator_capabilities::{BootControl, BootWatch, Svn, SvnFloor, WalkVerdict}; /// Why the driver could not carry out an effect. @@ -232,6 +234,13 @@ next_deadline_millis, } } + + /// Hands one report to the board's sink. Cannot fail, so reporting stays + /// off the fail-closed path; reports arrive in the order the SM emitted + /// them. + pub fn report(&mut self, report: Report) { + self.board.report_sink.report(report); + } } /// One [`PlatformDriver::poll_boot_walks`] round. @@ -259,24 +268,37 @@ Effect::ReleaseReset(id) => self.release_reset(id).map(|_| None), Effect::AssertReset(id) => self.assert_reset(id).map(|_| None), Effect::CommitSvnFloor(id) => self.commit_svn_floor(id).map(|_| None), + // Reports carry no error, so they never reach the fail-closed + // group below. + Effect::ReportIsolated(id) => { + self.report(Report::Isolated(id)); + Ok(None) + } + Effect::ReportRecoveryFailed(id) => { + self.report(Report::RecoveryFailed(id)); + Ok(None) + } + Effect::ReportUpdateDeferred => { + self.report(Report::UpdateDeferred); + Ok(None) + } + Effect::ReportUpdateAborted => { + self.report(Report::UpdateAborted); + Ok(None) + } // No board capability is composed for these seams yet, so they // fail closed here instead of behind stub methods. Each group // gains an executor when its capability joins // [`BoardCapabilities`], as BootControl did above: recovery // sourcing for RecoverComponent; update staging, authentication // and trial activation for the update quartet; evidence signing - // for SignAttestation; the management reporting path for the - // Report effects; the terminal latch for LatchLockdown. + // for SignAttestation; the terminal latch for LatchLockdown. Effect::RecoverComponent { .. } | Effect::AuthenticateUpdate | Effect::StageUpdate | Effect::ActivateUpdate | Effect::DiscardStaged | Effect::SignAttestation - | Effect::ReportIsolated(_) - | Effect::ReportRecoveryFailed(_) - | Effect::ReportUpdateDeferred - | Effect::ReportUpdateAborted | Effect::LatchLockdown => return Err(EffectError), // Emit is consumed by the orchestrator; receiving one is a // driver bug.
diff --git a/services/orchestrator/driver/src/tests.rs b/services/orchestrator/driver/src/tests.rs index c18b82f..1dc9f00 100644 --- a/services/orchestrator/driver/src/tests.rs +++ b/services/orchestrator/driver/src/tests.rs
@@ -301,19 +301,34 @@ type BootControl = MockReset; type BootWatch = MockWalk; type SvnFloor = MockFloor; + type ReportSink = RecordingSink; +} + +/// The SVN `mock_board`'s verifier vouches for. Tests that read the floor +/// back assert against it. +const MOCK_SVN: u32 = 5; + +/// Happy-path wiring for `N` components. Tests override the one field +/// they exercise with `..mock_board()`. +fn mock_board<const N: usize>() -> Board<MockBoard, N> { + Board { + images: core::array::from_fn(|_| MemImage::holding(valid_image())), + verifier: XorVerifier { + fault: false, + svn: MOCK_SVN, + }, + 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(), + } } fn driver(images: [MemImage; 1]) -> PlatformDriver<MockBoard, 1> { PlatformDriver::new(Board { images, - verifier: XorVerifier { - fault: false, - svn: 5, - }, - boot_controls: [MockReset::new()], - boot_watches: [MockWalk::idle()], - component_kinds: [ComponentKind::Passive], - svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], + ..mock_board() }) } @@ -392,15 +407,11 @@ fn verifier_fault_fails_closed() { let mut orch = orchestrator(); let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { - images: [MemImage::holding(valid_image())], verifier: XorVerifier { fault: true, - svn: 5, + svn: MOCK_SVN, }, - boot_controls: [MockReset::new()], - boot_watches: [MockWalk::idle()], - component_kinds: [ComponentKind::Passive], - svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], + ..mock_board() }); orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); @@ -412,23 +423,7 @@ #[test] fn verify_for_a_different_component_is_refused() { - let mut driver = PlatformDriver::<MockBoard, 2>::new(Board { - images: [ - MemImage::holding(valid_image()), - MemImage::holding(valid_image()), - ], - verifier: XorVerifier { - fault: false, - svn: 5, - }, - boot_controls: [MockReset::new(), MockReset::new()], - boot_watches: [MockWalk::idle(), MockWalk::idle()], - component_kinds: [ComponentKind::Passive, ComponentKind::Passive], - svn_floors: [ - SvnFloorBinding::Erot(MockFloor::new()), - SvnFloorBinding::Erot(MockFloor::new()), - ], - }); + let mut driver = PlatformDriver::<MockBoard, 2>::new(mock_board()); driver.stage_firmware(C0).unwrap(); @@ -458,17 +453,7 @@ #[test] fn reset_release_and_assert_reach_the_boot_control() { - let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { - images: [MemImage::holding(valid_image())], - verifier: XorVerifier { - fault: false, - svn: 5, - }, - boot_controls: [MockReset::new()], - boot_watches: [MockWalk::idle()], - component_kinds: [ComponentKind::Passive], - svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], - }); + let mut driver = PlatformDriver::<MockBoard, 1>::new(mock_board()); driver.release_reset(C0).unwrap(); assert!(!driver.board().boot_controls[0].held.get()); @@ -496,15 +481,8 @@ let mut control = MockReset::new(); control.fail = true; let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { - images: [MemImage::holding(valid_image())], - verifier: XorVerifier { - fault: false, - svn: 5, - }, boot_controls: [control], - boot_watches: [MockWalk::idle()], - component_kinds: [ComponentKind::Passive], - svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], + ..mock_board() }); assert_eq!(driver.release_reset(C0), Err(DriverError::BootControlFault)); @@ -568,6 +546,8 @@ type BootControl = MockReset; type BootWatch = MockWalk; type SvnFloor = MockFloor; + // A board with nothing to tell: exercises the no-op sink. + type ReportSink = (); } // The at-rest guarantee end to end: the component is still held while its @@ -582,7 +562,7 @@ verifier: LineWatchingVerifier { inner: XorVerifier { fault: false, - svn: 5, + svn: MOCK_SVN, }, line: held.clone(), held_during_verify: held_during_verify.clone(), @@ -591,6 +571,7 @@ boot_watches: [MockWalk::idle()], component_kinds: [ComponentKind::Passive], svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], + report_sink: (), }); let mut orch = orchestrator(); @@ -612,15 +593,8 @@ control.fail = true; let held = control.held.clone(); let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { - images: [MemImage::holding(valid_image())], - verifier: XorVerifier { - fault: false, - svn: 5, - }, boot_controls: [control], - boot_watches: [MockWalk::idle()], - component_kinds: [ComponentKind::Passive], - svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], + ..mock_board() }); let mut orch = orchestrator(); @@ -641,21 +615,9 @@ component_kinds: [ComponentKind; 2], ) -> PlatformDriver<MockBoard, 2> { PlatformDriver::new(Board { - images: [ - MemImage::holding(valid_image()), - MemImage::holding(valid_image()), - ], - verifier: XorVerifier { - fault: false, - svn: 5, - }, - boot_controls: [MockReset::new(), MockReset::new()], boot_watches: walks, component_kinds, - svn_floors: [ - SvnFloorBinding::Erot(MockFloor::new()), - SvnFloorBinding::Erot(MockFloor::new()), - ], + ..mock_board() }) } @@ -834,15 +796,8 @@ fn booted_walk_settles_in_ready() { let mut orch = orchestrator(); let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { - images: [MemImage::holding(valid_image())], - verifier: XorVerifier { - fault: false, - svn: 5, - }, - boot_controls: [MockReset::new()], boot_watches: [MockWalk::scripted(std::vec![WalkVerdict::Complete])], - component_kinds: [ComponentKind::Passive], - svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], + ..mock_board() }); orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); @@ -863,18 +818,11 @@ fn boot_timeout_fails_closed_without_recovery() { let mut orch = orchestrator(); let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { - images: [MemImage::holding(valid_image())], - verifier: XorVerifier { - fault: false, - svn: 5, - }, - boot_controls: [MockReset::new()], boot_watches: [MockWalk::scripted(std::vec![WalkVerdict::Failed { checkpoint: "heartbeat", cause: FailureCause::TimedOut, }])], - component_kinds: [ComponentKind::Passive], - svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], + ..mock_board() }); orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); @@ -892,16 +840,15 @@ // --------------------------------------------------------------------------- /// Records what it is handed: the seam satisfied without a management -/// transport. +/// transport. Tests read `seen` back through `PlatformDriver::board`. +#[derive(Default)] struct RecordingSink { seen: std::vec::Vec<Report>, } impl RecordingSink { fn new() -> Self { - Self { - seen: std::vec::Vec::new(), - } + Self::default() } } @@ -946,17 +893,7 @@ // 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::<MockBoard, 1>::new(Board { - images: [MemImage::holding(valid_image())], - verifier: XorVerifier { - fault: false, - svn: 5, - }, - boot_controls: [MockReset::new()], - boot_watches: [MockWalk::idle()], - component_kinds: [ComponentKind::Passive], - svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], - }); + let mut driver = PlatformDriver::<MockBoard, 1>::new(mock_board()); driver .execute(Effect::ReadFirmware(C0)) @@ -972,7 +909,7 @@ }; assert_eq!( floor.floor(), - Ok(Svn(5)), + Ok(Svn(MOCK_SVN)), "floor advanced to the verifier's SVN" ); } @@ -983,15 +920,8 @@ #[test] fn commit_without_an_erot_floor_is_a_no_op() { let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { - images: [MemImage::holding(valid_image())], - verifier: XorVerifier { - fault: false, - svn: 5, - }, - boot_controls: [MockReset::new()], - boot_watches: [MockWalk::idle()], - component_kinds: [ComponentKind::Passive], svn_floors: [SvnFloorBinding::SelfManaged], + ..mock_board() }); assert_eq!(driver.execute(Effect::CommitSvnFloor(C0)), Ok(None)); @@ -1015,14 +945,7 @@ corrupt[7] ^= 0x01; let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { images: [MemImage::holding(valid_image()).reflash_on_reopen(corrupt)], - verifier: XorVerifier { - fault: false, - svn: 5, - }, - boot_controls: [MockReset::new()], - boot_watches: [MockWalk::idle()], - component_kinds: [ComponentKind::Passive], - svn_floors: [SvnFloorBinding::Erot(MockFloor::new())], + ..mock_board() }); driver.stage_firmware(C0).expect("stage failed"); @@ -1050,15 +973,8 @@ let mut mock = MockFloor::new(); mock.fail = true; let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { - images: [MemImage::holding(valid_image())], - verifier: XorVerifier { - fault: false, - svn: 5, - }, - boot_controls: [MockReset::new()], - boot_watches: [MockWalk::idle()], - component_kinds: [ComponentKind::Passive], svn_floors: [SvnFloorBinding::Erot(mock)], + ..mock_board() }); driver.stage_firmware(C0).expect("stage failed"); @@ -1066,3 +982,57 @@ assert_eq!(driver.commit_svn_floor(C0), Err(DriverError::SvnFloorFault)); } + +// Every report effect reaches the board's sink, in emission order, and none +// hands back an error for the SM to fail closed on. +#[test] +fn reports_reach_the_board_sink() { + let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { + verifier: XorVerifier { + fault: false, + svn: 0, + }, + ..mock_board() + }); + + for effect in [ + Effect::ReportIsolated(C0), + Effect::ReportRecoveryFailed(C0), + Effect::ReportUpdateDeferred, + Effect::ReportUpdateAborted, + ] { + assert_eq!(driver.execute(effect), Ok(None)); + } + + assert_eq!(driver.board().report_sink.seen, every_report()); +} + +// An Isolable component is contained and reported, and the platform keeps +// running: executing a report returns no error, so it never reaches the +// fail-closed path. +#[test] +fn reporting_an_isolated_component_does_not_lock_the_platform() { + let mut driver = PlatformDriver::<MockBoard, 2>::new(Board { + verifier: XorVerifier { + fault: false, + svn: 0, + }, + ..mock_board() + }); + let mut chain = heapless::Vec::<_, 2>::new(); + chain + .push((C0, ComponentAttrs::passive_required())) + .unwrap(); + chain + .push((C1, ComponentAttrs::passive_isolable())) + .unwrap(); + let mut orch = Orchestrator::<2, 6>::new(chain.try_into().unwrap(), 3); + + orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); + assert_eq!(orch.state(), State::Ready, "both components verified"); + + orch.dispatch(&mut driver, Event::CorruptionDetected(C1)); + + assert_eq!(orch.state(), State::Ready, "contained, not locked"); + assert_eq!(driver.board().report_sink.seen, [Report::Isolated(C1)]); +}