diff --git a/services/orchestrator/config/src/lib.rs b/services/orchestrator/config/src/lib.rs index 34aae93b..eec57094 100644 --- a/services/orchestrator/config/src/lib.rs +++ b/services/orchestrator/config/src/lib.rs @@ -55,6 +55,94 @@ pub struct BootCheckpoint { pub window: core::time::Duration, } +/// Identifies one slot within one device's layout. An opaque per-device +/// token, not an index: ids need only be unique within one device's table +/// ([`validate`] enforces exactly that) — they are not required to be +/// contiguous, ordered, or to start at zero, and slot 0 on the BMC and +/// slot 0 on the NIC are unrelated. Ladder order comes from table +/// declaration order, never from id values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SlotId(pub u8); + +/// A distinguished duty a slot carries beyond being writable/bootable. +/// +/// Intentionally exhaustive (not `#[non_exhaustive]`): adding a role is a +/// breaking change, so every consumer that dispatches on roles — in +/// particular recovery-candidate selection — is forced to handle the new +/// role explicitly instead of falling into a wildcard arm. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlotRole { + /// The slot recovery falls back to after every ordinary rung failed. + /// A "golden" image is this role plus `writable: false` — a property + /// combination, not a separate name. + Recovery, +} + +/// One slot in a device's layout: topology as data, not a type. A layout +/// is plain A/B, A/B + golden, or single + golden purely by what the table +/// declares — no layout shape is named anywhere. +/// +/// Immutable, and only constructible through [`Slot::new`], which +/// enforces the per-slot invariant — an invalid slot is unrepresentable, +/// not merely rejected later. Rules that span a whole layout (unique ids, +/// one recovery slot, ladder rungs) stay in [`validate`], which sees the +/// list. +#[derive(Debug, Clone, Copy)] +pub struct Slot { + id: SlotId, + writable: bool, + bootable: bool, + role: Option, +} + +impl Slot { + /// Declares one slot. Const, so board tables still build at compile + /// time — where a rejected slot is a build error, the same teeth as + /// [`validate`]. (A `Result`-returning constructor cannot build a + /// `&'static` table; panicking in const context is how schema rules + /// fail the build.) + /// + /// # Panics + /// + /// Panics if `role` is [`SlotRole::Recovery`] and `bootable` is + /// `false` — recovery boots the device from that slot, so an + /// unbootable recovery slot is a contradiction. + pub const fn new(id: SlotId, writable: bool, bootable: bool, role: Option) -> Self { + assert!( + !(matches!(role, Some(SlotRole::Recovery)) && !bootable), + "a recovery-role slot must be bootable" + ); + Self { + id, + writable, + bootable, + role, + } + } + + /// This slot's id, unique within the device (checked by [`validate`]). + pub const fn id(&self) -> SlotId { + self.id + } + + /// May the update path write this slot? `false` on a recovery-role + /// slot is what makes it "golden". + pub const fn writable(&self) -> bool { + self.writable + } + + /// May the device boot from this slot? Every bootable slot is a rung + /// of the recovery ladder. + pub const fn bootable(&self) -> bool { + self.bootable + } + + /// This slot's special role, if any. + pub const fn role(&self) -> Option { + self.role + } +} + /// One managed downstream device, as declared by the board config. /// /// Generic over the board's reset signal type `R`, which must match the @@ -75,6 +163,13 @@ pub struct DeviceConfig { /// checkpoint whose window expires fails the boot. pub checkpoints: &'static [BootCheckpoint], pub commit_policy: CommitPolicy, + /// This device's slot layout. The recovery ladder is derived from it, + /// never declared: bootable slots in declaration order, recovery-role + /// slot last, escalation to out-of-band recovery once no rung is left. + /// A layout without rungs — e.g. empty, for a device that owns its + /// boot selection internally (the PLDM archetype) — leaves escalation + /// as the only step. + pub slots: &'static [Slot], } /// Checks a device table. Board configs call this in a const context so a @@ -99,6 +194,38 @@ pub const fn validate(devices: &[DeviceConfig]) { ); c += 1; } + + let slots = devices[i].slots; + let mut bootable = 0; + let mut recovery_slots = 0; + let mut s = 0; + while s < slots.len() { + if slots[s].bootable() { + bootable += 1; + } + // Recovery ⇒ bootable is enforced by Slot::new — an + // unbootable recovery slot is unrepresentable here. + if matches!(slots[s].role(), Some(SlotRole::Recovery)) { + recovery_slots += 1; + } + 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 device" + ); + t += 1; + } + s += 1; + } + assert!( + recovery_slots <= 1, + "at most one recovery-role slot per device" + ); + assert!( + slots.is_empty() || bootable > 0, + "a non-empty slot layout needs a bootable slot" + ); i += 1; } } @@ -119,11 +246,22 @@ mod tests { window: Duration::from_secs(1), }; + /// An ordinary slot: writable, bootable, no role. + const fn slot(id: u8) -> Slot { + Slot::new(SlotId(id), true, true, None) + } + + /// A recovery-role slot; non-writable, but no test depends on that. + const fn recovery_slot(id: u8) -> Slot { + Slot::new(SlotId(id), false, true, Some(SlotRole::Recovery)) + } + const DEVICE: DeviceConfig = DeviceConfig { name: "dev", reset_signal: 0, checkpoints: &[CHECKPOINT], commit_policy: CommitPolicy::Liveness, + slots: &[slot(0), slot(1)], }; #[test] @@ -131,6 +269,57 @@ mod tests { validate(&[DEVICE]); } + #[test] + fn accepts_a_layout_with_a_recovery_slot() { + validate(&[DeviceConfig { + slots: const { &[slot(0), slot(1), recovery_slot(2)] }, + ..DEVICE + }]); + } + + #[test] + fn accepts_an_empty_layout() { + validate(&[DeviceConfig { + slots: &[], + ..DEVICE + }]); + } + + #[test] + #[should_panic(expected = "slot ids must be unique")] + fn rejects_duplicate_slot_ids() { + validate(&[DeviceConfig { + slots: const { &[slot(0), slot(0)] }, + ..DEVICE + }]); + } + + #[test] + #[should_panic(expected = "at most one recovery-role slot")] + fn rejects_two_recovery_role_slots() { + validate(&[DeviceConfig { + slots: const { &[slot(0), recovery_slot(1), recovery_slot(2)] }, + ..DEVICE + }]); + } + + // The per-slot invariant fails at construction, before any list-level + // validate could run — an invalid slot is unrepresentable. + #[test] + #[should_panic(expected = "recovery-role slot must be bootable")] + fn rejects_an_unbootable_recovery_slot_at_construction() { + Slot::new(SlotId(2), false, false, Some(SlotRole::Recovery)); + } + + #[test] + #[should_panic(expected = "needs a bootable slot")] + fn rejects_a_layout_with_no_bootable_slot() { + validate(&[DeviceConfig { + slots: const { &[Slot::new(SlotId(0), true, false, None)] }, + ..DEVICE + }]); + } + #[test] #[should_panic(expected = "device name must not be empty")] fn rejects_an_empty_device_name() { diff --git a/target/mock/devices.rs b/target/mock/devices.rs index e5c3af88..1132e92e 100644 --- a/target/mock/devices.rs +++ b/target/mock/devices.rs @@ -9,7 +9,7 @@ use core::time::Duration; -use orchestrator_config::{BootCheckpoint, BootSignal, CommitPolicy, DeviceConfig}; +use orchestrator_config::{BootCheckpoint, BootSignal, CommitPolicy, DeviceConfig, Slot, SlotId}; /// Declaration order is the boot order: the orchestrator releases devices /// top to bottom, one at a time. @@ -28,6 +28,13 @@ pub const MANAGED_DEVICES: &[DeviceConfig] = &[ window: Duration::from_secs(90), }], commit_policy: CommitPolicy::Liveness, + // Plain A/B: two equal writable slots, recovery falls back to the + // other one. Real boards declare their own topology; a golden + // slot is optional. + slots: &[ + Slot::new(SlotId(0), true, true, None), + Slot::new(SlotId(1), true, true, None), + ], }, // PLDM device (NIC archetype): self-updating, SPDM-capable. Two // checkpoints, exercising the multi-checkpoint path. @@ -47,6 +54,10 @@ pub const MANAGED_DEVICES: &[DeviceConfig] = &[ }, ], commit_policy: CommitPolicy::LivenessAndAttestation, + // Self-updating device: it owns its boot selection, the eRoT never + // sees its slot topology. No local ladder rungs, so recovery can + // only escalate. + slots: &[], }, ];