diff --git a/services/orchestrator/capabilities/BUILD.bazel b/services/orchestrator/capabilities/BUILD.bazel index 859f7a9c5..fbcbd1571 100644 --- a/services/orchestrator/capabilities/BUILD.bazel +++ b/services/orchestrator/capabilities/BUILD.bazel @@ -11,6 +11,7 @@ rust_library( "src/evidence.rs", "src/lib.rs", "src/svn_floor.rs", + "src/updatable.rs", ], edition = "2024", visibility = ["//visibility:public"], diff --git a/services/orchestrator/capabilities/src/lib.rs b/services/orchestrator/capabilities/src/lib.rs index 0a721eac5..1d1d40461 100644 --- a/services/orchestrator/capabilities/src/lib.rs +++ b/services/orchestrator/capabilities/src/lib.rs @@ -16,6 +16,12 @@ //! checkpoints as data (`BootCheckpoint` in `orchestrator-config`), and the //! board's reader gives the ids meaning. //! +//! `Updatable` is the update capability: stage a payload on one device +//! (polled, one bounded step at a time) and mark it the boot candidate — +//! always tentatively; the commit gate lives elsewhere. `PayloadSource` is +//! the chunked read seam staging pulls from — transports and slot +//! bookkeeping stay behind the adapter. +//! //! `BootWatch` is the seam the orchestrator polls: one device's boot walk, //! erased of every device-specific type, answering with a `WalkVerdict`. //! @@ -33,8 +39,10 @@ mod boot_control; mod boot_watch; mod evidence; mod svn_floor; +mod updatable; pub use boot_control::BootControl; pub use boot_watch::{BootWatch, FailureCause, WalkVerdict}; pub use evidence::{BootStatus, EvidenceReader}; pub use svn_floor::{Svn, SvnFloor}; +pub use updatable::{PayloadFault, PayloadSource, StageProgress, Updatable}; diff --git a/services/orchestrator/capabilities/src/updatable.rs b/services/orchestrator/capabilities/src/updatable.rs new file mode 100644 index 000000000..7daf81954 --- /dev/null +++ b/services/orchestrator/capabilities/src/updatable.rs @@ -0,0 +1,653 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The [`Updatable`] update capability contract. + +/// Update capability: stage a payload on one managed device and mark the +/// staged image as its boot candidate. +/// +/// The two operations: write to the inactive slot only, then update +/// slot metadata to prefer the new image. Universal across device +/// archetypes: a direct-flash adapter writes the payload into the +/// device's inactive slot itself, a PLDM adapter drives the device's +/// own transfer. Slot identity never crosses the +/// seam — which slot is inactive is device state, so +/// [`activate`](Self::activate) can only ever mean "the image the last +/// completed staging delivered". +/// +/// What this trait deliberately does not claim: +/// +/// - **Verification** runs on the candidate before staging as +/// orchestrator policy; post-write read-back is the optional +/// `ReadBack` capability. +/// - **Commit.** Activation is always tentative: it proposes the staged +/// image as the preferred boot target, never commits it. The commit +/// gate is the optional `TrialBoot` capability, which resolves what +/// `activate` proposed; there is no second slot-selection owner. +/// - **Booting.** Resetting the device into the candidate is +/// [`BootControl`](crate::BootControl). When activation takes effect +/// (next reset, or a device-internal restart on self-activating +/// devices) is device-defined; sequencing belongs to the flows. +/// +/// # Contract +/// +/// - **Staging is inert.** However staging ends — fault, abandon, power +/// loss — the active image is untouched: the staging area is inactive +/// by construction. Staging anew is always allowed and discards any +/// previously staged, unactivated payload. +/// - **Staging is polled, never blocking.** A payload is tens of +/// megabytes and a transfer takes minutes; each +/// [`poll_stage`](Self::poll_stage) call does one step, at most one +/// payload pull plus one device transaction, and returns without +/// waiting on the device. A busy device is not an error: the step +/// returns [`Transferring`](StageProgress::Transferring) with `done` +/// unchanged. So a single-threaded runtime stays live, the update +/// source gets progress, and abandoning mid-transfer is +/// [`abandon`](Self::abandon) instead of waiting out a blocked call. +/// Liveness policy stays with the caller: it watches `done` and +/// abandons a transfer that stalls too long, on its own clock. +/// - **`Ready` means ready.** The device holds the complete payload and +/// `activate` may be called. `activate` in any other staging state is +/// an error. +pub trait Updatable { + /// The error type of this device's update path. + /// + /// Bounded by [`core::error::Error`] so the orchestrator gets + /// `Display` and a `source()` cause chain, not just a `Debug` dump. + /// Error categories are implementation-defined. + type Error: core::error::Error; + + /// Advances staging by one step, pulling from `payload`. + /// + /// A step is bounded: at most one pull from `payload` and at most one + /// device transaction, never a wait for device progress. Returning + /// with `done` unchanged is legal (busy device, PLDM retransmit) and + /// is not an error. + /// + /// The first call from idle (fresh device, after [`Ready`], an error, + /// or [`abandon`](Self::abandon)) starts a new transfer; the caller + /// keeps polling with the same `payload` until [`Ready`] or an error. + /// The implementor pulls at whatever offsets its transfer needs (a + /// PLDM device requests its own chunks, including retransmits); + /// `payload` must serve any in-range read. + /// + /// Generic for static dispatch, which makes `Updatable` itself + /// non-dyn-compatible; the associated `Error` type effectively + /// already did. + /// + /// [`Ready`]: StageProgress::Ready + fn poll_stage(&mut self, payload: &impl PayloadSource) -> Result; + + /// Discards the in-progress transfer or staged, unactivated payload. + /// + /// Infallible: back to idle unconditionally. Cleanup a device needs + /// (marking a half-written slot dirty) is the implementor's, deferred + /// to the next staging if it must touch hardware. + fn abandon(&mut self); + + /// Marks the staged image as the device's boot candidate + /// (tentative; see the trait docs on commit). + /// + /// [`Ready`] persists until a new staging starts or + /// [`abandon`](Self::abandon), and `activate` while [`Ready`] is + /// idempotent: a repeated call succeeds. + /// + /// [`Ready`]: StageProgress::Ready + fn activate(&mut self) -> Result<(), Self::Error>; +} + +/// What one [`Updatable::poll_stage`] step established. +/// +/// Intentionally exhaustive (not `#[non_exhaustive]`): adding a state is a +/// breaking change, so every consumer handles it explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StageProgress { + /// Transfer ongoing; poll again. `done`/`total` bytes feed the Update + /// Source's progress report. + Transferring { + /// Bytes the device holds so far. Monotonic and below `total`, + /// but free to hold still across calls (busy device, + /// retransmit); a caller deciding when a transfer has stalled + /// keys on this value. + done: u64, + /// Total payload bytes. + total: u64, + }, + /// The device holds the complete payload; `activate` may be called. + Ready, +} + +/// Chunked, random-access read seam [`Updatable::poll_stage`] pulls from. +/// +/// The candidate payload is streamed and never RAM-resident; +/// this is the window a device adapter reads it through. Where the bytes +/// live — frontend staging flash, a mapped blob, a test slice — stays +/// behind the source. +pub trait PayloadSource { + /// Total payload length in bytes. + fn len(&self) -> u64; + + /// True if the payload is empty. + fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Fills `buf` from `offset`. The read is exact: short fills are a + /// fault, and `offset + buf.len()` beyond [`len`](Self::len) is out + /// of range. + fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), PayloadFault>; +} + +/// Why a payload read failed — the one distinction retry policy needs. +/// +/// No further detail crosses the seam (mirroring `BootWatch`): the source +/// logs the concrete cause while it is still in scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PayloadFault { + /// The requested range is outside the payload — a caller bug, never + /// retriable. + OutOfRange, + /// The backing storage failed the read — possibly transient; staging + /// anew may succeed. + Storage, +} + +impl core::fmt::Display for PayloadFault { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + PayloadFault::OutOfRange => "payload read out of range", + PayloadFault::Storage => "payload storage fault", + }) + } +} + +impl core::error::Error for PayloadFault {} + +#[cfg(test)] +mod tests { + use super::*; + use core::error::Error as _; + + // A PayloadSource over a plain slice — the seam must be satisfiable + // with no storage stack at all. + struct SliceSource(&'static [u8]); + + impl PayloadSource for SliceSource { + fn len(&self) -> u64 { + self.0.len() as u64 + } + + fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), PayloadFault> { + let start = usize::try_from(offset).map_err(|_| PayloadFault::OutOfRange)?; + let end = start + .checked_add(buf.len()) + .ok_or(PayloadFault::OutOfRange)?; + buf.copy_from_slice(self.0.get(start..end).ok_or(PayloadFault::OutOfRange)?); + Ok(()) + } + } + + // An Updatable implemented against no HAL or transport — the contract + // must be satisfiable from any stack (mock, IPC proxy, simulator). + // Pulls two bytes per poll to exercise the resumable-transfer shape. + struct MockDevice { + staged: Vec, + done: usize, + ready: bool, + active: bool, + } + + impl MockDevice { + fn idle() -> Self { + MockDevice { + staged: Vec::new(), + done: 0, + ready: false, + active: false, + } + } + } + + #[derive(Debug, PartialEq, Eq)] + enum MockFault { + Pull(PayloadFault), + NothingStaged, + } + + impl core::fmt::Display for MockFault { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + MockFault::Pull(_) => f.write_str("staging pull failed"), + MockFault::NothingStaged => f.write_str("nothing staged"), + } + } + } + + impl core::error::Error for MockFault { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + MockFault::Pull(fault) => Some(fault), + MockFault::NothingStaged => None, + } + } + } + + impl Updatable for MockDevice { + type Error = MockFault; + + fn poll_stage(&mut self, payload: &impl PayloadSource) -> Result { + if self.ready { + self.abandon(); // a poll after Ready starts a new transfer + } + let total = usize::try_from(payload.len()).unwrap(); + if self.done == 0 { + self.staged = vec![0; total]; + } + let end = (self.done + 2).min(total); + if let Err(fault) = payload.read_at(self.done as u64, &mut self.staged[self.done..end]) + { + self.abandon(); + return Err(MockFault::Pull(fault)); + } + self.done = end; + if self.done == total { + self.ready = true; + Ok(StageProgress::Ready) + } else { + Ok(StageProgress::Transferring { + done: self.done as u64, + total: total as u64, + }) + } + } + + fn abandon(&mut self) { + self.staged = Vec::new(); + self.done = 0; + self.ready = false; + } + + fn activate(&mut self) -> Result<(), MockFault> { + if !self.ready { + return Err(MockFault::NothingStaged); + } + self.active = true; + Ok(()) + } + } + + /// Polls to completion — the orchestrator's staging loop shape. + fn stage_all(dev: &mut D, payload: &impl PayloadSource) -> Result<(), D::Error> { + loop { + if let StageProgress::Ready = dev.poll_stage(payload)? { + return Ok(()); + } + } + } + + #[test] + fn contract_is_implementable_without_the_hal() { + let mut dev = MockDevice::idle(); + + stage_all(&mut dev, &SliceSource(b"image")).expect("staging failed"); + dev.activate().expect("activate failed"); + + assert_eq!(dev.staged, b"image"); + assert!(dev.active); + } + + #[test] + fn progress_is_reportable_mid_transfer() { + let mut dev = MockDevice::idle(); + let payload = SliceSource(b"image"); + + assert_eq!( + dev.poll_stage(&payload), + Ok(StageProgress::Transferring { done: 2, total: 5 }) + ); + assert_eq!( + dev.poll_stage(&payload), + Ok(StageProgress::Transferring { done: 4, total: 5 }) + ); + assert_eq!(dev.poll_stage(&payload), Ok(StageProgress::Ready)); + } + + #[test] + fn out_of_range_pull_aborts_staging() { + struct Lying; + + // Claims more bytes than it can serve — the adapter's pull runs + // past the real end and must surface the fault. + impl PayloadSource for Lying { + fn len(&self) -> u64 { + 8 + } + + fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), PayloadFault> { + SliceSource(b"shrt").read_at(offset, buf) + } + } + + let mut dev = MockDevice::idle(); + + let err = stage_all(&mut dev, &Lying).expect_err("expected the pull fault"); + + assert_eq!(err, MockFault::Pull(PayloadFault::OutOfRange)); + // The cause chain carries the fault, per the Error bound. + assert!(err.source().is_some()); + + // Staging anew after a fault is always allowed. + stage_all(&mut dev, &SliceSource(b"image")).expect("re-staging failed"); + dev.activate().expect("activate after re-staging failed"); + } + + #[test] + fn abandon_returns_to_idle_and_discards() { + let mut dev = MockDevice::idle(); + let payload = SliceSource(b"image"); + + dev.poll_stage(&payload).expect("first step failed"); + dev.abandon(); + + assert_eq!(dev.activate(), Err(MockFault::NothingStaged)); + // A fresh transfer starts from byte zero. + assert_eq!( + dev.poll_stage(&payload), + Ok(StageProgress::Transferring { done: 2, total: 5 }) + ); + } + + #[test] + fn a_busy_step_is_not_an_error_and_holds_done_still() { + // A device that needs a bus turn between steps: every other poll + // is busy and returns with `done` unchanged. The contract says + // that is a legal step, so the plain polling loop still finishes. + struct BusyDevice { + inner: MockDevice, + busy: bool, + } + + impl Updatable for BusyDevice { + type Error = MockFault; + + fn poll_stage( + &mut self, + payload: &impl PayloadSource, + ) -> Result { + self.busy = !self.busy; + if self.busy { + return Ok(StageProgress::Transferring { + done: self.inner.done as u64, + total: payload.len(), + }); + } + self.inner.poll_stage(payload) + } + + fn abandon(&mut self) { + self.inner.abandon(); + } + + fn activate(&mut self) -> Result<(), MockFault> { + self.inner.activate() + } + } + + let mut dev = BusyDevice { + inner: MockDevice::idle(), + busy: false, + }; + let payload = SliceSource(b"image"); + + // The caller-side stall accounting the contract prescribes: + // count polls since `done` last moved, abandon past a budget. + let mut last_done = 0; + let mut stalled_polls = 0; + loop { + match dev.poll_stage(&payload).expect("staging failed") { + StageProgress::Ready => break, + StageProgress::Transferring { done, .. } => { + if done > last_done { + last_done = done; + stalled_polls = 0; + } else { + stalled_polls += 1; + } + assert!(stalled_polls < 3, "transfer stalled"); + } + } + } + + dev.activate().expect("activate failed"); + assert_eq!(dev.inner.staged, b"image"); + } + + // An adapter for a device that drives its own transfer, the PLDM + // shape: one fixed-size chunk request per step, at offsets the device + // picks, including a retransmit. Out-of-range reads are a fault, so + // the adapter clamps the device's last request to the payload end. + const PLDM_CHUNK: usize = 3; + + struct MockPldmDevice { + staged: Vec, + offset: usize, + retransmitted: bool, + ready: bool, + active: bool, + } + + impl MockPldmDevice { + fn idle() -> Self { + MockPldmDevice { + staged: Vec::new(), + offset: 0, + retransmitted: false, + ready: false, + active: false, + } + } + } + + impl Updatable for MockPldmDevice { + type Error = MockFault; + + fn poll_stage(&mut self, payload: &impl PayloadSource) -> Result { + if self.ready { + self.abandon(); + } + let total = usize::try_from(payload.len()).unwrap(); + if self.staged.len() != total { + self.staged = vec![0; total]; + } + // The device re-requests the previous chunk once mid-transfer. + let request = if self.offset == 2 * PLDM_CHUNK && !self.retransmitted { + self.retransmitted = true; + self.offset - PLDM_CHUNK + } else { + self.offset + }; + let len = PLDM_CHUNK.min(total - request); + payload + .read_at(request as u64, &mut self.staged[request..request + len]) + .map_err(MockFault::Pull)?; + if request == self.offset { + self.offset += len; + } + if self.offset == total { + self.ready = true; + Ok(StageProgress::Ready) + } else { + Ok(StageProgress::Transferring { + done: self.offset as u64, + total: total as u64, + }) + } + } + + fn abandon(&mut self) { + self.staged = Vec::new(); + self.offset = 0; + self.retransmitted = false; + self.ready = false; + } + + fn activate(&mut self) -> Result<(), MockFault> { + if !self.ready { + return Err(MockFault::NothingStaged); + } + self.active = true; + Ok(()) + } + } + + #[test] + fn a_pldm_shaped_device_fits_the_seam() { + let mut dev = MockPldmDevice::idle(); + // 7 bytes with chunk size 3: two full chunks, then a short one. + let payload = SliceSource(b"chunked"); + + assert_eq!( + dev.poll_stage(&payload), + Ok(StageProgress::Transferring { done: 3, total: 7 }) + ); + assert_eq!( + dev.poll_stage(&payload), + Ok(StageProgress::Transferring { done: 6, total: 7 }) + ); + // The retransmit step pulls again but holds `done` still. + assert_eq!( + dev.poll_stage(&payload), + Ok(StageProgress::Transferring { done: 6, total: 7 }) + ); + assert_eq!(dev.poll_stage(&payload), Ok(StageProgress::Ready)); + + dev.activate().expect("activate failed"); + assert_eq!(dev.staged, b"chunked"); + } + + // A direct-flash adapter, the erase-before-write shape: one step is + // one flash operation, either erasing the next sector or programming + // the next page. Erase steps pull nothing and hold `done` still. + const FLASH_PAGE: usize = 2; + const FLASH_SECTOR: usize = 4; + + struct MockFlashDevice { + slot: [u8; 2 * FLASH_SECTOR], + erased: [bool; 2], + done: usize, + ready: bool, + active: bool, + } + + impl MockFlashDevice { + fn idle() -> Self { + MockFlashDevice { + slot: [0; 2 * FLASH_SECTOR], + erased: [false; 2], + done: 0, + ready: false, + active: false, + } + } + } + + impl Updatable for MockFlashDevice { + type Error = MockFault; + + fn poll_stage(&mut self, payload: &impl PayloadSource) -> Result { + if self.ready { + self.abandon(); + } + let total = usize::try_from(payload.len()).unwrap(); + assert!(total <= self.slot.len(), "payload exceeds the slot"); + let sector = self.done / FLASH_SECTOR; + if !self.erased[sector] { + self.slot[sector * FLASH_SECTOR..(sector + 1) * FLASH_SECTOR].fill(0xff); + self.erased[sector] = true; + return Ok(StageProgress::Transferring { + done: self.done as u64, + total: total as u64, + }); + } + let end = (self.done + FLASH_PAGE).min(total); + payload + .read_at(self.done as u64, &mut self.slot[self.done..end]) + .map_err(MockFault::Pull)?; + self.done = end; + if self.done == total { + self.ready = true; + Ok(StageProgress::Ready) + } else { + Ok(StageProgress::Transferring { + done: self.done as u64, + total: total as u64, + }) + } + } + + fn abandon(&mut self) { + self.erased = [false; 2]; + self.done = 0; + self.ready = false; + } + + fn activate(&mut self) -> Result<(), MockFault> { + if !self.ready { + return Err(MockFault::NothingStaged); + } + self.active = true; + Ok(()) + } + } + + #[test] + fn a_flash_shaped_device_fits_the_seam() { + let mut dev = MockFlashDevice::idle(); + let payload = SliceSource(b"8 bytes!"); + + // Erase sector 0: a step with no pull, `done` holds still. + assert_eq!( + dev.poll_stage(&payload), + Ok(StageProgress::Transferring { done: 0, total: 8 }) + ); + assert_eq!( + dev.poll_stage(&payload), + Ok(StageProgress::Transferring { done: 2, total: 8 }) + ); + assert_eq!( + dev.poll_stage(&payload), + Ok(StageProgress::Transferring { done: 4, total: 8 }) + ); + // Erase sector 1. + assert_eq!( + dev.poll_stage(&payload), + Ok(StageProgress::Transferring { done: 4, total: 8 }) + ); + assert_eq!( + dev.poll_stage(&payload), + Ok(StageProgress::Transferring { done: 6, total: 8 }) + ); + assert_eq!(dev.poll_stage(&payload), Ok(StageProgress::Ready)); + + dev.activate().expect("activate failed"); + assert_eq!(&dev.slot, b"8 bytes!"); + } + + #[test] + fn activate_is_idempotent_while_ready() { + let mut dev = MockDevice::idle(); + stage_all(&mut dev, &SliceSource(b"image")).expect("staging failed"); + + dev.activate().expect("first activate failed"); + dev.activate().expect("repeated activate failed"); + } + + #[test] + fn activate_without_ready_is_an_error() { + let mut dev = MockDevice::idle(); + + let err = dev.activate().expect_err("expected nothing staged"); + + assert_eq!(err.to_string(), "nothing staged"); + } +}