diff --git a/services/orchestrator/config/src/lib.rs b/services/orchestrator/config/src/lib.rs
index 7b0a9a07d..e120b264e 100644
--- a/services/orchestrator/config/src/lib.rs
+++ b/services/orchestrator/config/src/lib.rs
@@ -111,6 +111,251 @@ impl
BootCheckpoint
{
}
}
+/// Names one slot in one device's layout. Ids must be unique within one
+/// layout ([`ImageLayout::new`] checks that) and mean nothing beyond the
+/// name: slot 0 on the BMC has nothing to do with slot 0 on the NIC.
+/// Recovery tries slots in the order they are declared, not in id order.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SlotId(pub u8);
+
+/// A byte range in the store that holds one device's images. Offsets count
+/// from the start of that device's area; the platform driver knows which
+/// part that is and where the area starts.
+///
+/// An image with no address, streamed or held inside a self-updating
+/// device, is not described this way.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Region {
+ base: u32,
+ size: u32,
+}
+
+impl Region {
+ /// Declares a region. Const, so a bad board table fails the build
+ /// instead of the boot.
+ ///
+ /// # Panics
+ ///
+ /// Panics if `size` is zero, or if `base + size` would run past the end
+ /// of the 32-bit offset range.
+ #[must_use]
+ pub const fn new(base: u32, size: u32) -> Self {
+ assert!(size > 0, "region size must not be zero");
+ assert!(
+ base.checked_add(size).is_some(),
+ "region must not run past the end of the offset space"
+ );
+ Self { base, size }
+ }
+
+ /// Offset of the first byte, from the start of the device's firmware
+ /// partition.
+ #[must_use]
+ pub const fn base(&self) -> u32 {
+ self.base
+ }
+
+ /// The region's size in bytes, not the size of the image currently in
+ /// it. Named `size` rather than `len`: a region is never empty, and
+ /// `len` would suggest an `is_empty` next to it.
+ #[must_use]
+ pub const fn size(&self) -> u32 {
+ self.size
+ }
+
+ /// Offset one past the last byte.
+ #[must_use]
+ pub const fn end(&self) -> u32 {
+ self.base + self.size
+ }
+
+ /// Whether the two regions share a byte.
+ const fn overlaps(&self, other: &Region) -> bool {
+ self.base < other.end() && other.base < self.end()
+ }
+}
+
+/// One slot in a device's layout. How many slots a device has is board
+/// data: A/B, one slot, or any other count, and no layout shape is named
+/// in code.
+///
+/// Every slot can be written and booted. The golden image can be neither,
+/// and it is not a slot, so there is no flag to set and nothing to check.
+#[derive(Debug, Clone, Copy)]
+pub struct Slot {
+ id: SlotId,
+ region: Region,
+}
+
+impl Slot {
+ /// Declares one slot. Const, so board tables build at compile time.
+ /// Whole-layout rules (unique ids, no overlap) are checked by
+ /// [`ImageLayout::new`], which sees the whole list.
+ #[must_use]
+ pub const fn new(id: SlotId, region: Region) -> Self {
+ Self { id, region }
+ }
+
+ /// This slot's id, unique within the layout (checked by
+ /// [`ImageLayout::new`]).
+ #[must_use]
+ pub const fn id(&self) -> SlotId {
+ self.id
+ }
+
+ /// Where this slot lives in the device's image store.
+ #[must_use]
+ pub const fn region(&self) -> Region {
+ self.region
+ }
+}
+
+/// The golden image: the last image recovery falls back to, and the one
+/// image the eRoT never writes. A board without one declares none.
+///
+/// It is not a slot, so code that walks the slot list to pick a write
+/// target or to check the SVN floor never sees it. The floor check has to
+/// skip it: a golden image's security version is fixed when the board is
+/// made, so once the floor moves past it, the check would reject the last
+/// image that still boots.
+///
+/// That only works while the image truly cannot be written. The board has
+/// to guarantee it in hardware (a write-protect pin, a locked flash block,
+/// or a separate part); this crate cannot check it.
+#[derive(Debug, Clone, Copy)]
+pub struct Golden {
+ region: Region,
+}
+
+impl Golden {
+ /// Declares a layout's golden image.
+ #[must_use]
+ pub const fn new(region: Region) -> Self {
+ Self { region }
+ }
+
+ /// Where the golden image lives in the device's image store.
+ #[must_use]
+ pub const fn region(&self) -> Region {
+ self.region
+ }
+}
+
+/// Every image of one device that the eRoT can address: the slots it
+/// writes, plus the golden image when the board has one. Slots are declared
+/// in recovery order: recovery tries them from top to bottom and falls back
+/// to the golden image last.
+///
+/// The golden image is optional. A board that only has A/B slots declares
+/// none, and recovery then stops after the last slot.
+///
+/// A device has a layout when the eRoT owns its flash and writes its
+/// images. A device that takes its own updates over PLDM and picks what it
+/// boots has no layout, and the eRoT never names a byte range for it.
+#[derive(Debug, Clone, Copy)]
+pub struct ImageLayout {
+ slots: &'static [Slot],
+ golden: Option,
+}
+
+impl ImageLayout {
+ /// Declares a device's images. Const, so a bad layout fails the
+ /// build.
+ ///
+ /// # Panics
+ ///
+ /// Panics if the layout holds no image, if two slots share an id, or if
+ /// two regions overlap, the golden image included: an update to one
+ /// image would corrupt another.
+ ///
+ /// The check covers addresses only. Two regions in the same flash erase
+ /// block still wipe each other without overlapping, and this crate does
+ /// not know the erase block size, so a board checks that itself.
+ #[must_use]
+ pub const fn new(slots: &'static [Slot], golden: Option) -> Self {
+ assert!(
+ !slots.is_empty() || golden.is_some(),
+ "a layout must hold at least one image"
+ );
+ let mut s = 0;
+ while s < slots.len() {
+ if let Some(golden) = golden {
+ assert!(
+ !slots[s].region.overlaps(&golden.region),
+ "a slot must not overlap the golden image"
+ );
+ }
+ let mut t = s + 1;
+ while t < slots.len() {
+ assert!(
+ slots[s].id.0 != slots[t].id.0,
+ "slot ids must be unique within a layout"
+ );
+ assert!(
+ !slots[s].region.overlaps(&slots[t].region),
+ "slots must not overlap"
+ );
+ t += 1;
+ }
+ s += 1;
+ }
+ Self { slots, golden }
+ }
+
+ /// The slots the eRoT writes, in declaration order. Empty when the
+ /// golden image is the device's only image.
+ #[must_use]
+ pub const fn slots(&self) -> &'static [Slot] {
+ self.slots
+ }
+
+ /// The golden image, when the board has one.
+ #[must_use]
+ pub const fn golden(&self) -> Option {
+ self.golden
+ }
+
+ /// How many images recovery can try: every slot, then the golden image
+ /// if there is one.
+ #[must_use]
+ pub const fn image_count(&self) -> usize {
+ self.slots.len() + self.golden.is_some() as usize
+ }
+}
+
+/// 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
+/// counts the restore before deciding whether to boot, so the last restore
+/// the budget allows is never booted: two slots plus a golden image need
+/// four attempts, not three.
+///
+/// This is a minimum, not the exact number. A driver that retries the same
+/// image before stepping to the next one needs more. Devices with no
+/// layout are skipped: they have no images to step through.
+///
+/// Boards call it from a const fence, so a budget that is too small fails
+/// the build:
+///
+/// ```ignore
+/// const _: () = assert_retry_reaches_every_image(MAX_RETRY, MANAGED_DEVICES);
+/// ```
+///
+/// # Panics
+///
+/// Panics if `max_retry` is not greater than a device's image count.
+pub const fn assert_retry_reaches_every_image(max_retry: u8, devices: &[DeviceConfig]) {
+ let mut i = 0;
+ while i < devices.len() {
+ if let Some(layout) = devices[i].layout() {
+ assert!(
+ max_retry as usize > layout.image_count(),
+ "max_retry is too small to boot every image of a device"
+ );
+ }
+ i += 1;
+ }
+}
+
/// One managed downstream device, as declared by the board config.
///
/// Generic over the board's reset signal type `R` (which must match the
@@ -130,6 +375,7 @@ pub struct DeviceConfig {
name: &'static str,
reset_signal: R,
checkpoints: &'static [BootCheckpoint],
+ layout: Option,
}
impl DeviceConfig {
@@ -139,14 +385,15 @@ impl DeviceConfig {
/// # Panics
///
/// Panics — a build error in const context — if `name` is empty, if
- /// `checkpoints` is empty, or if two checkpoints share a name
- /// (failure reports identify a checkpoint by name; a duplicate would
- /// make them ambiguous).
+ /// `checkpoints` is empty, if two checkpoints share a name (failure
+ /// reports identify a checkpoint by name; a duplicate would make them
+ /// ambiguous). Layout rules are checked by [`ImageLayout::new`].
#[must_use]
pub const fn new(
name: &'static str,
reset_signal: R,
checkpoints: &'static [BootCheckpoint],
+ layout: Option,
) -> Self {
assert!(!name.is_empty(), "device name must not be empty");
assert!(
@@ -169,6 +416,7 @@ impl DeviceConfig {
name,
reset_signal,
checkpoints,
+ layout,
}
}
@@ -192,6 +440,14 @@ impl DeviceConfig {
pub const fn checkpoints(&self) -> &'static [BootCheckpoint] {
self.checkpoints
}
+
+ /// This device's images, when the eRoT owns its flash. `None` when the
+ /// device takes its own updates, where the eRoT never addresses a byte
+ /// range.
+ #[must_use]
+ pub const fn layout(&self) -> Option {
+ self.layout
+ }
}
// `==` on `&str` is not const; compare bytes by hand.
@@ -219,41 +475,219 @@ mod tests {
// rejection is a build error nobody can assert on. These tests call
// them at runtime to prove the reject paths actually fire.
- const CHECKPOINT: BootCheckpoint =
+ const BOOT_COMPLETE: BootCheckpoint =
BootCheckpoint::new("boot-complete", 0, Duration::from_secs(1));
// Same name, different signal: each checkpoint is individually valid,
// so the pair only trips the device-level duplicate check.
- const CHECKPOINT_DUP: BootCheckpoint =
+ const BOOT_COMPLETE_DUPLICATE_NAME: BootCheckpoint =
BootCheckpoint::new("boot-complete", 1, Duration::from_secs(1));
+ /// Slots are one megabyte each; the values only have to be distinct
+ /// and non-overlapping.
+ const SLOT_SIZE: u32 = 0x10_0000;
+
+ /// An ordinary slot, placed by id so two of them never overlap by
+ /// accident.
+ const fn slot(id: u8) -> Slot {
+ Slot::new(SlotId(id), Region::new(id as u32 * SLOT_SIZE, SLOT_SIZE))
+ }
+
+ /// The golden image, above every slot `slot` can place.
+ const GOLDEN: Golden = Golden::new(Region::new(0xF000_0000, SLOT_SIZE));
+
+ const LAYOUT: ImageLayout = ImageLayout::new(const { &[slot(0), slot(1)] }, Some(GOLDEN));
+
+ /// The same two slots on a board that carries no golden image.
+ const SLOTS_ONLY: ImageLayout = ImageLayout::new(const { &[slot(0), slot(1)] }, None);
+
#[test]
fn accepts_a_valid_table() {
- let device = DeviceConfig::new("dev", 0u8, &[CHECKPOINT]);
+ let device = DeviceConfig::new("dev", 0u8, &[BOOT_COMPLETE], Some(LAYOUT));
assert_eq!(device.name(), "dev");
assert_eq!(*device.reset_signal(), 0);
assert_eq!(device.checkpoints().len(), 1);
assert_eq!(device.checkpoints()[0].name(), "boot-complete");
assert_eq!(*device.checkpoints()[0].probe(), 0);
assert_eq!(device.checkpoints()[0].timeout(), Duration::from_secs(1));
+
+ let layout = device.layout().expect("declared above");
+ assert_eq!(layout.slots().len(), 2);
+ assert_eq!(layout.slots()[1].region().base(), SLOT_SIZE);
+ assert_eq!(layout.slots()[1].region().end(), 2 * SLOT_SIZE);
+ assert_eq!(
+ layout.golden().expect("declared above").region().base(),
+ 0xF000_0000
+ );
+ }
+
+ /// A device that owns its own images declares no layout at all, golden
+ /// 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);
+ assert!(device.layout().is_none());
+ }
+
+ /// The golden image may be a device's only image.
+ #[test]
+ fn accepts_a_layout_with_no_slots() {
+ let layout = ImageLayout::new(&[], Some(GOLDEN));
+ assert!(layout.slots().is_empty());
+ }
+
+ /// A board with plain A/B slots carries no golden image, and recovery
+ /// stops after the last slot.
+ #[test]
+ fn accepts_a_layout_without_a_golden_image() {
+ assert!(SLOTS_ONLY.golden().is_none());
+ assert_eq!(SLOTS_ONLY.slots().len(), 2);
+ }
+
+ /// A layout with no slots and no golden image describes no image at
+ /// all, so a device declaring it would have nothing to boot.
+ #[test]
+ #[should_panic(expected = "at least one image")]
+ fn rejects_a_layout_with_no_images() {
+ let _ = ImageLayout::new(&[], None);
}
#[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,
+ &[BOOT_COMPLETE, BOOT_COMPLETE_DUPLICATE_NAME],
+ None,
+ );
}
#[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, &[BOOT_COMPLETE], None);
}
#[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, &[] as &[BootCheckpoint], None);
+ }
+
+ #[test]
+ #[should_panic(expected = "slot ids must be unique")]
+ fn rejects_duplicate_slot_ids() {
+ let _ = ImageLayout::new(
+ const {
+ &[
+ Slot::new(SlotId(0), Region::new(0, SLOT_SIZE)),
+ Slot::new(SlotId(0), Region::new(SLOT_SIZE, SLOT_SIZE)),
+ ]
+ },
+ Some(GOLDEN),
+ );
+ }
+
+ #[test]
+ #[should_panic(expected = "slots must not overlap")]
+ fn rejects_overlapping_slots() {
+ let _ = ImageLayout::new(
+ const {
+ &[
+ Slot::new(SlotId(0), Region::new(0, SLOT_SIZE)),
+ Slot::new(SlotId(1), Region::new(SLOT_SIZE - 1, SLOT_SIZE)),
+ ]
+ },
+ Some(GOLDEN),
+ );
+ }
+
+ #[test]
+ #[should_panic(expected = "must not overlap the golden image")]
+ fn rejects_a_slot_overlapping_the_golden_image() {
+ let _ = ImageLayout::new(
+ const { &[Slot::new(SlotId(0), Region::new(0xF000_0000, SLOT_SIZE))] },
+ Some(GOLDEN),
+ );
+ }
+
+ /// Regions that touch do not overlap: `end` is one past the last
+ /// byte.
+ #[test]
+ fn accepts_adjacent_regions() {
+ let layout = ImageLayout::new(
+ const {
+ &[
+ Slot::new(SlotId(0), Region::new(0, SLOT_SIZE)),
+ Slot::new(SlotId(1), Region::new(SLOT_SIZE, SLOT_SIZE)),
+ ]
+ },
+ Some(GOLDEN),
+ );
+ assert_eq!(
+ layout.slots()[0].region().end(),
+ layout.slots()[1].region().base()
+ );
+ }
+
+ #[test]
+ fn counts_every_slot_and_the_golden_image_when_there_is_one() {
+ assert_eq!(LAYOUT.image_count(), 3);
+ assert_eq!(ImageLayout::new(&[], Some(GOLDEN)).image_count(), 1);
+ assert_eq!(SLOTS_ONLY.image_count(), 2);
+ }
+
+ /// 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))];
+ assert_retry_reaches_every_image(4, DEVICES);
+ }
+
+ /// Two slots and no golden image need three attempts: two restores
+ /// plus the one the last restore would otherwise never get.
+ #[test]
+ fn accepts_a_retry_budget_that_boots_every_slot() {
+ const DEVICES: &[DeviceConfig] = &[DeviceConfig::new(
+ "dev",
+ 0,
+ &[BOOT_COMPLETE],
+ Some(SLOTS_ONLY),
+ )];
+ assert_retry_reaches_every_image(3, DEVICES);
+ }
+
+ /// A device with no layout has no images to step through, so any
+ /// budget covers it.
+ #[test]
+ fn accepts_any_retry_budget_for_a_device_without_a_layout() {
+ const DEVICES: &[DeviceConfig] =
+ &[DeviceConfig::new("dev", 0, &[BOOT_COMPLETE], None)];
+ assert_retry_reaches_every_image(0, DEVICES);
+ }
+
+ #[test]
+ /// 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))];
+ assert_retry_reaches_every_image(3, DEVICES);
+ }
+
+ #[test]
+ #[should_panic(expected = "region size must not be zero")]
+ fn rejects_a_zero_size_region() {
+ let _ = Region::new(0, 0);
+ }
+
+ #[test]
+ #[should_panic(expected = "must not run past the end of the offset space")]
+ fn rejects_a_region_past_the_end_of_the_offset_space() {
+ let _ = Region::new(u32::MAX, 1);
}
#[test]
diff --git a/services/orchestrator/test/devices.rs b/services/orchestrator/test/devices.rs
index 9415a1e1e..7bb1eb0f9 100644
--- a/services/orchestrator/test/devices.rs
+++ b/services/orchestrator/test/devices.rs
@@ -9,7 +9,10 @@
use core::time::Duration;
-use orchestrator_config::{BootCheckpoint, DeviceConfig};
+use orchestrator_config::{
+ assert_retry_reaches_every_image, BootCheckpoint, DeviceConfig, Golden, ImageLayout, Region,
+ Slot, SlotId,
+};
/// The mock board's boot-probe vocabulary. The schema carries these
/// opaquely; only this board's `EvidenceReader` gives them meaning.
@@ -24,6 +27,18 @@ pub enum MockProbe {
Heartbeat,
}
+/// The BMC's images: two 2 MiB slots at the bottom of its firmware
+/// partition, the golden image above them.
+const BMC_LAYOUT: ImageLayout = ImageLayout::new(
+ const {
+ &[
+ Slot::new(SlotId(0), Region::new(0x00_0000, 0x20_0000)),
+ Slot::new(SlotId(1), Region::new(0x20_0000, 0x20_0000)),
+ ]
+ },
+ Some(Golden::new(Region::new(0x40_0000, 0x20_0000))),
+);
+
/// 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.
@@ -41,6 +56,9 @@ pub const MANAGED_DEVICES: &[DeviceConfig] = &[
MockProbe::Gpio(12),
Duration::from_secs(90),
)],
+ // A/B plus the golden image, in the flash the eRoT owns. Offsets
+ // count from the start of the BMC's flash area.
+ Some(BMC_LAYOUT),
),
// PLDM device (NIC archetype): self-updating, SPDM-capable. Two
// checkpoints, exercising the multi-checkpoint path: transport up
@@ -52,6 +70,9 @@ pub const MANAGED_DEVICES: &[DeviceConfig] = &[
BootCheckpoint::new("mctp-ready", MockProbe::MctpReady, Duration::from_secs(20)),
BootCheckpoint::new("heartbeat", MockProbe::Heartbeat, Duration::from_secs(10)),
],
+ // Self-updating: it owns its images and its boot selection, so
+ // the eRoT addresses no byte range for it and declares no layout.
+ None,
),
];
@@ -75,3 +96,10 @@ const fn validate_probes(devices: &[DeviceConfig]) {
}
const _: () = validate_probes(MANAGED_DEVICES);
+
+/// How many times the orchestrator restores a component before it gives up.
+/// Four: the BMC has three images and the attempt that restores the last one
+/// is never booted, so three would stop one boot short of the golden image.
+pub const MAX_RETRY: u8 = 4;
+
+const _: () = assert_retry_reaches_every_image(MAX_RETRY, MANAGED_DEVICES);
diff --git a/target/ast10x0/tests/orchestrator/runtime/main.rs b/target/ast10x0/tests/orchestrator/runtime/main.rs
index ac612bf46..0d89a40ca 100644
--- a/target/ast10x0/tests/orchestrator/runtime/main.rs
+++ b/target/ast10x0/tests/orchestrator/runtime/main.rs
@@ -27,7 +27,7 @@ use openprot_orchestrator_sm::{
};
use orchestrator_capabilities::{BootStatus, BootWatch, EvidenceReader, FailureCause, WalkVerdict};
use orchestrator_checkpoint_walk::CheckpointWalk;
-use orchestrator_config::{BootCheckpoint, DeviceConfig};
+use orchestrator_config::{assert_retry_reaches_every_image, BootCheckpoint, DeviceConfig};
use pw_status::{Error, Result};
use userspace::time::{Clock, Duration, Instant, SystemClock};
use userspace::{entry, syscall};
@@ -41,6 +41,8 @@ const N: usize = 4;
const E: usize = 2 * N + 2;
const MAX_RETRY: u8 = 3;
+const _: () = assert_retry_reaches_every_image(MAX_RETRY, &[SOC]);
+
/// Commit watchdog window. Not a boot window, so it stays a local constant
/// rather than coming from the device table.
const COMMIT_WINDOW: Duration = Duration::from_millis(50);
@@ -59,6 +61,8 @@ const SOC: DeviceConfig = DeviceConfig::new(
BootCheckpoint::new("bl1", 1, core::time::Duration::from_millis(500)),
BootCheckpoint::new("kernel", 2, core::time::Duration::from_millis(500)),
],
+ // The boot walk never looks at images; no layout is legal.
+ None,
);
/// The device table speaks `core::time::Duration`; the runtime speaks the