From 51b4dfaa7fe070bc620f6a7b112f653670a1fd93 Mon Sep 17 00:00:00 2001 From: Anthony Rocha Date: Thu, 27 Aug 2026 16:05:14 -0700 Subject: [PATCH 1/3] docs: add orchestrator runtime concept page and board composition --- .../orchestrator/orchestrator-overview.md | 22 +++- .../orchestrator/orchestrator-runtime.md | 111 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 docs/src/design/orchestrator/orchestrator-runtime.md diff --git a/docs/src/design/orchestrator/orchestrator-overview.md b/docs/src/design/orchestrator/orchestrator-overview.md index 54f14166f..a005e2b05 100644 --- a/docs/src/design/orchestrator/orchestrator-overview.md +++ b/docs/src/design/orchestrator/orchestrator-overview.md @@ -1,4 +1,4 @@ -# Orchestrator State Machine +# Orchestrator The orchestrator is the eRoT's boot-sequence controller. It walks the platform trust chain — verifying each component's firmware and releasing it from reset in @@ -28,6 +28,10 @@ avoid the two drifting apart. - [**Platform Architecture**](./orchestrator-platform.md): The platform half around the core — surrounding services, capability contracts, the board device table, and the fail-safe rules at the responsibility boundary. +- [**Runtime**](./orchestrator-runtime.md): How the runtime loop + gathers hardware interrupts, IPC channel messages, and watchdog deadlines into + the core's event stream, and carries the resulting effects and decisions back + out. ## Design Principles @@ -48,6 +52,22 @@ hiding them as implicit state changes. See the The platform supplies the trust chain (component ids, kinds, and required/optional policy) and the recovery-retry cap at startup. +## Board composition + +*Board composition* (or *system composition*) is the per-target choice — described +declaratively in a [`system.json5`](../../architecture.md) file, assembled by +Pigweed at build time — of how the platform's functions are split across +processes and which resources each process owns: hardware register blocks and the +kernel [interrupt objects and IPC channels](../pw-kernel-ipc.md) built on top of +them. It is separate from the +board-supplied *policy* above: policy is *what* to verify (the trust chain); +composition is *how* the surrounding services are wired. The orchestrator core +and its platform-agnostic crates are the same across every composition — a driver +may own a GPIO bank and forward boot-progress over a channel in one image, while +the orchestrator holds the pins directly in another. That choice changes which +inbound sources the [runtime](./orchestrator-runtime.md) sees and who owns each +device, but never the core's states or the loop that serves them. + ## Relationship to CSA Architecture The state machine is a direct implementation of the boot sequence described in diff --git a/docs/src/design/orchestrator/orchestrator-runtime.md b/docs/src/design/orchestrator/orchestrator-runtime.md new file mode 100644 index 000000000..fbff52c24 --- /dev/null +++ b/docs/src/design/orchestrator/orchestrator-runtime.md @@ -0,0 +1,111 @@ +# Runtime + +The **runtime** is the orchestrator's event loop: the single task that sits +between two collaborators — the pure [state machine](./orchestrator-machine.md) +(the *core*) that decides, and the `PlatformDriver` that executes — turning +outside happenings (interrupts, timeouts, IPC messages) into the `Event`s the +core consumes, and handing the `Effect`s it emits to the driver. It holds no +policy of its own — every decision stays in the core; the runtime only moves +information across the process boundary. + +It gathers three possible inbound sources — **hardware interrupts** +(boot-progress lines the orchestrator owns directly), **IPC channels**, and +**watchdog deadlines** (timeouts) — into the one event stream the core +supervises, and carries the core's decision back out. Which sources are present +is a [board-composition](./orchestrator-overview.md#board-composition) choice: +boot-progress is a hardware interrupt only when the orchestrator owns the pins; +otherwise a monitor forwards it as an IPC message. + +The unifying idea is the kernel **wait group**: interrupts and IPC are not +separate mechanisms but interchangeable *members* of one group, and a watchdog +deadline is that same wait's *timeout* — so all three collapse into the return +of one `object_wait(handle, signal_mask, deadline)`. A *signal* here is just a +named bit on a waitable object, not a source in its own right: a latched IRQ bit +on an interrupt object, or `READABLE` / `USER` on a channel (a service raises +`Signals::USER` on a client channel to notify without a reply). The loop is +written once against "a member that signaled, or the deadline that lapsed," +never against a specific source; what differs between sources is only the +*decoder* that turns each into an `Event`. + +Two neighboring pages carry the supporting detail: +[Platform Architecture](./orchestrator-platform.md) names the services and the +responsibility boundary, and [pw_kernel IPC](../pw-kernel-ipc.md) gives the +concrete channel syscalls. The worked, compiling reference is the QEMU +integration test at `target/ast10x0/tests/orchestrator/runtime/main.rs`. + +## The single wait point + +The runtime is a single-threaded loop parked in one place: a kernel +`object_wait` over a **wait group**. Every inbound source is registered once as a +*member* of that group (`wait_group_add`), and the wait returns whichever member +signaled. Each member resolves to at most one `Event`: + +- **Boot-progress signals** — a component reaching a checkpoint raises a + boot-progress signal. Depending on the board's composition it arrives as an + interrupt object the orchestrator holds directly or as a message a monitor + forwards over a channel; either way the loop maps it to + [`Event::ComponentReady`] (an `Active` component's iRoT-verified readiness) + or [`Event::Booted`] (a `Passive` component's liveness). +- **Watchdog deadlines** — the timer is not a separate task. `BootWatchdogs` + (`services/orchestrator/server`) folds all armed boot windows and the commit + window into a *single deadline* passed straight to `object_wait`. When the + wait returns `DeadlineExceeded`, `poll_expired()` yields the mapped + [`Event::Timeout`] / [`Event::CommitTimeout`]. +- **IPC channel messages** — an update agent, management path, peer service, or + a boot-progress-forwarding monitor holds a channel *initiator*; the runtime + holds the *handler*. A readable channel is another object the loop waits on; + its message decodes to an `Event` — an [`Event::UpdateRequest`] to answer, or + a forwarded [`Event::Booted`] / [`Event::ComponentReady`] notification. + +The three share one `object_wait`, so a slow image hash on one path cannot +delay a boot window on another — this is the "never block" rule of the +[Platform Architecture](./orchestrator-platform.md#responsibility-scope) made +concrete: the loop only ever blocks at the wait, and only until the *nearest* +of any signal, any channel, or the nearest deadline. + +**Members are uniform; only the decode differs.** A wait-group member is an +object watched for a signal bit — a latched IRQ bit on an *interrupt object*, or +`READABLE` / `USER` on a *channel* — and the loop treats them identically: wait, +see which member signaled, run that member's decode, dispatch the resulting +`Event`. Nothing above the decode step knows which kind a member is. That +uniformity pushes two questions *below* the runtime layer, where they belong: + +- **Who owns the underlying hardware** — does the orchestrator own the GPIO bank + and hold the boot-progress interrupt object itself, or does a monitor/GPIO + server own the pins and forward boot-progress over a channel? — is a + [board-composition](./orchestrator-overview.md#board-composition) choice made + in `system.json5`. Either shape is just one member of the group; the loop is + byte-for-byte the same. +- **What a member means** is the per-member decode. + +```mermaid +flowchart LR + subgraph SRC["Inbound sources (SIG/CH are wait-group members; TMR is the wait's timeout; boot-progress rides CH if a monitor owns the pins)"] + SIG["Boot-progress signal
(directly-owned interrupt object, latched)"] + TMR["Watchdog deadline
(BootWatchdogs → one Instant)"] + CH["IPC channel
(handler endpoint, READABLE)"] + end + + WAIT["object_wait(signals, deadline)
the single park point"] + + subgraph MAP["Inbound adapters (source → Event)"] + SMAP["signal → ComponentReady / Booted"] + TMAP["poll_expired → Timeout / CommitTimeout"] + CMAP["channel_read → decode → UpdateRequest / ..."] + end + + CORE["Orchestrator::dispatch
(pure reducer)"] + + OUT["Effects → PlatformDriver
+ response → channel_respond"] + + SIG --> WAIT --> SMAP --> CORE + TMR --> WAIT --> TMAP --> CORE + CH --> WAIT --> CMAP --> CORE + CORE --> OUT +``` + +[`Event::ComponentReady`]: ./orchestrator-machine.md +[`Event::Booted`]: ./orchestrator-machine.md +[`Event::Timeout`]: ./orchestrator-machine.md +[`Event::CommitTimeout`]: ./orchestrator-machine.md +[`Event::UpdateRequest`]: ./orchestrator-machine.md From c60410e6f2804a0d01a5137d6bc6c4bf91b46359 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Mon, 31 Aug 2026 13:12:22 +0200 Subject: [PATCH 2/3] orchestrator: Add the update intake seam for an update source The contract between the orchestrator and an update source (the PLDM firmware-device service today, self-update later): the UpdateIntake trait a source calls, the IntakeStatus phases the orchestrator answers with, and the wire encoding that carries both over a kernel channel. Api layer only, no transport and no dispatch, so it builds and tests on the host. The source is the channel's initiator and the orchestrator its handler, with no channel the other way, so the orchestrator never waits on the source and a wedged source cannot delay a boot window. That is why the seam is polled: everything the orchestrator decides comes back as the phase on a response, including the two update reports, which latch a phase instead of sending a message. --- services/orchestrator/update-api/BUILD.bazel | 24 + services/orchestrator/update-api/README.md | 30 + services/orchestrator/update-api/src/lib.rs | 57 ++ .../orchestrator/update-api/src/status.rs | 104 +++ .../orchestrator/update-api/src/traits.rs | 100 +++ services/orchestrator/update-api/src/wire.rs | 618 ++++++++++++++++++ 6 files changed, 933 insertions(+) create mode 100644 services/orchestrator/update-api/BUILD.bazel create mode 100644 services/orchestrator/update-api/README.md create mode 100644 services/orchestrator/update-api/src/lib.rs create mode 100644 services/orchestrator/update-api/src/status.rs create mode 100644 services/orchestrator/update-api/src/traits.rs create mode 100644 services/orchestrator/update-api/src/wire.rs diff --git a/services/orchestrator/update-api/BUILD.bazel b/services/orchestrator/update-api/BUILD.bazel new file mode 100644 index 000000000..7b09887fe --- /dev/null +++ b/services/orchestrator/update-api/BUILD.bazel @@ -0,0 +1,24 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") + +rust_library( + name = "orchestrator_update_api", + srcs = [ + "src/lib.rs", + "src/status.rs", + "src/traits.rs", + "src/wire.rs", + ], + crate_name = "openprot_orchestrator_update_api", + edition = "2024", + visibility = ["//visibility:public"], + deps = ["@rust_crates//:zerocopy"], +) + +# Host tests: build on the host platform, no kernel/QEMU. +rust_test( + name = "orchestrator_update_api_test", + crate = ":orchestrator_update_api", +) diff --git a/services/orchestrator/update-api/README.md b/services/orchestrator/update-api/README.md new file mode 100644 index 000000000..ac2947a5d --- /dev/null +++ b/services/orchestrator/update-api/README.md @@ -0,0 +1,30 @@ +# orchestrator-update-api + +The update intake seam between the orchestrator and an update source (the +PLDM firmware-device service in `services/pldm` today, a self-update path +later). Contract only: the `UpdateIntake` trait a source calls, the +`IntakeStatus` phases the orchestrator answers with, and the `wire` encoding +that carries both over a kernel channel. No transport, no policy, no state, +so both processes depend on it and it builds and tests on the host. + +The source is the channel's initiator and the orchestrator its handler, and +there is no channel the other way. The orchestrator never waits on the +source, and every request it answers is one bounded step, so a wedged update +source cannot delay a boot window or a recovery. Everything the orchestrator +has to say comes back as the answer to a request the source made: each +response carries the current phase, and the source reads it with `poll` as +often as its own protocol needs. + +One update, from the source's side: + +1. `offer(target, total)` reserves the staging region. +2. `write(offset, bytes)` fills it, at most 512 bytes per call, in any order. +3. `complete()` hands the state machine the update request. +4. `poll()` until `Activated` or `Failed`. + +`abort()` drops the job from any phase. Activation needs no call: the state +machine activates on its own verdict once the candidate authenticates. + +Run the tests with: + + bazel test //services/orchestrator/update-api:orchestrator_update_api_test diff --git a/services/orchestrator/update-api/src/lib.rs b/services/orchestrator/update-api/src/lib.rs new file mode 100644 index 000000000..fa1d2e76e --- /dev/null +++ b/services/orchestrator/update-api/src/lib.rs @@ -0,0 +1,57 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The update intake seam between the orchestrator and an update source. +//! +//! An update source is the protocol frontend that takes a candidate image +//! from outside the platform: the PLDM firmware-device service +//! (`services/pldm`) today, self-update later. This crate is the contract +//! and nothing else, so both processes depend on it and it builds on the +//! host: the [`UpdateIntake`] trait a source calls, the [`IntakeStatus`] +//! phases the orchestrator answers with, and the [`wire`] encoding between +//! them. +//! +//! The source is the kernel channel's initiator and the orchestrator its +//! handler, with no channel the other way. So the orchestrator never waits +//! on the source, and handling a request is one bounded step: no request +//! means "wait until the state machine decides". Everything the +//! orchestrator decides therefore comes back as the phase on a response, +//! read as often as the source needs ([`UpdateIntake::poll`]). +//! +//! Two consequences, both easy to undo by accident: +//! +//! - `Effect::ReportUpdateDeferred` and `Effect::ReportUpdateAborted` latch +//! a phase, they do not send. The source collects them as +//! [`FailureCause::Deferred`] / [`FailureCause::Superseded`]. +//! - Polling need not be timer-driven: the handler can raise +//! `Signals::USER` on the source's channel end +//! (`syscall::object_set_peer_user_signal`, as `services/i2c` does) when +//! the phase changes. That is a nudge carrying no data, and the syscall +//! does not block on the peer. +//! +//! This is the `api` layer of the pattern `services/i2c` and +//! `services/mctp` established. The orchestrator-side dispatch and the +//! source-side [`UpdateIntake`] impl over `channel_transact` are separate +//! crates. + +#![cfg_attr(not(test), no_std)] +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +pub mod status; +pub mod traits; +pub mod wire; + +pub use status::{FailureCause, IntakeStatus, Progress, Reject}; +pub use traits::{IntakeError, UpdateIntake}; +pub use wire::{Request, Response, UpdateOp}; + +/// Which managed device a candidate is for: the device's index in the +/// board's device table, which is what the driver validates an offer +/// against. +/// +/// The orchestrator's `ComponentId` stays inside the orchestrator. A source +/// names a target, and the state machine never sees it at all: it only +/// decides that an update runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TargetId(pub u8); diff --git a/services/orchestrator/update-api/src/status.rs b/services/orchestrator/update-api/src/status.rs new file mode 100644 index 000000000..d5a134a33 --- /dev/null +++ b/services/orchestrator/update-api/src/status.rs @@ -0,0 +1,104 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! What the orchestrator answers: the phase the source polls +//! ([`IntakeStatus`]), plus one failure vocabulary for a request refused on +//! the spot ([`Reject`]) and one for a job that ran and failed +//! ([`FailureCause`]). + +/// Bytes moved so far out of the total the offer declared. +/// +/// The wire form of `StageProgress::Transferring`: `written` is monotonic +/// and may hold still across polls, so a source watching for a stall keys on +/// the value, not on the poll count. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Progress { + /// Bytes moved so far, at or below `total`. + pub written: u64, + /// Total payload bytes, from the accepted offer. + pub total: u64, +} + +/// The phase of the update in flight. +/// +/// The source maps it onto what its own protocol owes its peer: a PLDM +/// firmware device turns [`Receiving`](Self::Receiving) into a transfer in +/// progress, [`Authenticating`](Self::Authenticating) into `VerifyPending`, +/// [`Staging`](Self::Staging) into apply progress, and +/// [`Failed`](Self::Failed) into the matching result code. +/// +/// Intentionally exhaustive: adding a phase is a breaking change, so every +/// source handles each one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IntakeStatus { + /// No job: none offered, or the last one collected by a fresh offer. + Idle, + /// The source is writing the candidate into the staging region. + /// `written` counts what the orchestrator took, so a source that lost + /// track can resume. + Receiving(Progress), + /// The orchestrator is authenticating the complete candidate. No + /// counters: authentication is one verdict, not a transfer. + Authenticating, + /// The candidate authenticated; the orchestrator is pushing it to the + /// target device, one polled step at a time. + Staging(Progress), + /// The staged image is the device's boot candidate, tentatively. The + /// commit gate is orchestrator policy, so this is the last phase a + /// successful update shows the source. + Activated, + /// The job ended without activating. Terminal: it holds until the next + /// accepted offer, so a source that polls late still learns the outcome. + Failed(FailureCause), +} + +/// Why a job that started did not activate. +/// +/// Coarse on purpose, mirroring `UpdateError`: the source reports an outcome +/// and retries or does not, and the orchestrator logs the concrete fault +/// while it is still in scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailureCause { + /// The candidate failed authentication. Not retriable with these bytes. + Authentication, + /// The state machine was mid-walk, mid-recovery, or mid-update when the + /// candidate completed, so it refused the update. The candidate is + /// discarded, not judged: offering it again later may succeed. + Deferred, + /// Recovery preempted the update after it started. Retriable like + /// [`Deferred`](Self::Deferred), different cause: the platform took the + /// update away, not the timing. + Superseded, + /// The device or the staging region failed the transfer, or activation + /// failed. Offering again may succeed. + Device, +} + +/// Why the orchestrator refused a request outright. +/// +/// A [`Reject`] answers the request itself and never leaves a job +/// half-started: nothing was written, no phase changed. Contrast +/// [`FailureCause`], which is a job that ran. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reject { + /// The offer named a target the board's device table does not declare. + UnknownTarget, + /// An offer while a job is in flight. That job is untouched: a second + /// source cannot write into a region that is mid-authenticate. + Busy, + /// A write or complete with no accepted offer, or after the job left + /// [`IntakeStatus::Receiving`]. + NoJob, + /// A write range outside the offered payload, or an offer larger than + /// the staging region. + OutOfRange, + /// An offer of zero bytes, or a complete before every offered byte was + /// written. There is no candidate, so authentication must not run. + Incomplete, + /// The staging write failed. Possibly transient. + Storage, + /// The request did not decode (see [`wire::WireError`](crate::wire::WireError)). + /// Two sides built from this crate never see it; the request side is + /// another process and is treated as untrusted. + Malformed, +} diff --git a/services/orchestrator/update-api/src/traits.rs b/services/orchestrator/update-api/src/traits.rs new file mode 100644 index 000000000..267809271 --- /dev/null +++ b/services/orchestrator/update-api/src/traits.rs @@ -0,0 +1,100 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The [`UpdateIntake`] seam: what an update source calls on the +//! orchestrator. + +use crate::{IntakeStatus, Reject, TargetId}; + +/// Why an [`UpdateIntake`] call did not return an answer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IntakeError { + /// The orchestrator answered, and the answer is a refusal. The request + /// had no effect. + Rejected(Reject), + /// The call did not reach the orchestrator, or its answer did not decode. + /// The request may or may not have taken effect, so a source that cares + /// re-reads the phase with [`poll`](UpdateIntake::poll). + Transport, +} + +impl core::fmt::Display for IntakeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + IntakeError::Rejected(_) => f.write_str("the orchestrator refused the request"), + IntakeError::Transport => f.write_str("the intake call did not complete"), + } + } +} + +impl core::error::Error for IntakeError {} + +/// The orchestrator's update intake, as an update source calls it. +/// +/// A source carries bytes and holds no authority: it never names a slot, +/// never verifies, never decides that an update runs. It offers a candidate, +/// fills the staging region, says when the candidate is complete, and reports +/// the outcome to its own peer. +/// +/// One update: [`offer`](Self::offer) the target and length, +/// [`write`](Self::write) the payload at whatever offsets the source's +/// protocol delivers, [`complete`](Self::complete) once every offered byte is +/// written, then [`poll`](Self::poll) until [`IntakeStatus::Activated`] or +/// [`IntakeStatus::Failed`]. [`abort`](Self::abort) drops the job from any +/// phase. Activation needs no call: the state machine activates on its own +/// verdict once the candidate authenticates. +/// +/// The source is the channel's initiator and the orchestrator its handler, +/// with no channel the other way, so the orchestrator never waits on the +/// source and a wedged source cannot delay a boot window or a recovery. See +/// the crate docs for what follows from that. +/// +/// The methods take `&self` so a source can call through a shared reference +/// from its protocol code (`FdOps` and friends are all `&self`); an +/// implementation over a kernel channel keeps its buffers in a `RefCell`, as +/// the MCTP IPC client does. +pub trait UpdateIntake { + /// Offers a candidate of `total` bytes for `target`, reserving the + /// staging region. + /// + /// The orchestrator validates the target against its device table and + /// the length against the region. It does not consult the state machine, + /// so acceptance is not a promise that the update will run. An offer + /// while a job is in flight is [`Reject::Busy`] and leaves that job + /// untouched; a zero-length offer is [`Reject::Incomplete`]. + /// + /// A fresh accepted offer collects the previous job's terminal phase. + fn offer(&self, target: TargetId, total: u64) -> Result<(), IntakeError>; + + /// Writes `bytes` into the staging region at `offset`, relative to the + /// start of the offered payload. + /// + /// One call is one staging write of at most + /// [`MAX_CHUNK`](crate::wire::MAX_CHUNK) bytes. Ranges outside the + /// payload are [`Reject::OutOfRange`]; a repeated range overwrites, so a + /// retransmitting transfer needs no bookkeeping here. + fn write(&self, offset: u64, bytes: &[u8]) -> Result<(), IntakeError>; + + /// Declares the candidate complete, which is what starts the update. + /// + /// The orchestrator checks that every offered byte was written and hands + /// the state machine an update request. The verdict is not part of the + /// answer: it arrives through [`poll`](Self::poll). A state machine that + /// refuses the request surfaces as + /// [`FailureCause::Deferred`](crate::FailureCause::Deferred), not as a + /// [`Reject`] here. + fn complete(&self) -> Result<(), IntakeError>; + + /// Drops the job: the staging region is released and any in-flight + /// staging is abandoned. + /// + /// Legal in every phase, including with no job, so a source that lost + /// track can always get back to [`IntakeStatus::Idle`]. The active image + /// is untouched whenever this lands: the staging region is inactive by + /// construction. + fn abort(&self) -> Result<(), IntakeError>; + + /// Reads the current phase. One bounded read of a latched value, with no + /// device or crypto work behind it; cadence is the source's choice. + fn poll(&self) -> Result; +} diff --git a/services/orchestrator/update-api/src/wire.rs b/services/orchestrator/update-api/src/wire.rs new file mode 100644 index 000000000..3903e28fb --- /dev/null +++ b/services/orchestrator/update-api/src/wire.rs @@ -0,0 +1,618 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The intake wire protocol: fixed-size headers over a kernel channel. +//! +//! ```text +//! Request (16-byte header, then the chunk on Write only): +//! ┌─────┬────────┬──────────┬────────┬────────┬──────────┐ +//! │ op │ target │ reserved │ arg │ len │ reserved │ +//! │ 1B │ 1B │ 2B │ 8B LE │ 2B LE │ 2B │ +//! └─────┴────────┴──────────┴────────┴────────┴──────────┘ +//! +//! Response (20 bytes, no payload): +//! ┌──────┬───────┬────────┬──────────┬─────────┬─────────┐ +//! │ code │ phase │ detail │ reserved │ written │ total │ +//! │ 1B │ 1B │ 1B │ 1B │ 8B LE │ 8B LE │ +//! └──────┴───────┴────────┴──────────┴─────────┴─────────┘ +//! ``` +//! +//! `arg` is the payload length on [`Offer`](UpdateOp::Offer) and the write +//! offset on [`Write`](UpdateOp::Write); `len` is the chunk length on +//! [`Write`](UpdateOp::Write). Both are zero on every other op. Reserved +//! fields are zero everywhere and the handler rejects a request that sets +//! them, so they can be given a meaning later. +//! +//! Every response carries the phase, not just the answer to +//! [`Poll`](UpdateOp::Poll), so a source acting on each write's outcome sees +//! a job fail without a second round trip. `written`/`total` are zero +//! outside the phases that carry [`Progress`], `detail` zero outside +//! [`IntakeStatus::Failed`]. +//! +//! The request side is another process and is treated as untrusted: +//! decoding validates lengths, opcodes, reserved fields and the chunk bound +//! before anything looks at the payload, and never panics. + +use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout}; + +use crate::{FailureCause, IntakeStatus, Progress, Reject, TargetId}; + +/// Largest chunk one [`Write`](UpdateOp::Write) may carry. +/// +/// Bounds the handler's stack read buffer and the work of one request. 512 +/// matches `FD_MAX_XFER_SIZE` in `pldm-interface`, the largest transfer the +/// firmware device negotiates, so a `RequestFirmwareData` chunk passes +/// through without splitting. +pub const MAX_CHUNK: usize = 512; + +/// Request buffer size a handler must provide. +pub const MAX_REQUEST_SIZE: usize = RequestHeader::SIZE + MAX_CHUNK; + +/// Response buffer size an initiator must provide. +pub const MAX_RESPONSE_SIZE: usize = ResponseHeader::SIZE; + +/// Why a buffer did not decode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WireError { + /// Shorter than the header, or shorter than the header plus the chunk + /// length the header declares. + Truncated, + /// The output buffer cannot hold the encoding. + BufferTooSmall, + /// The op byte names no operation. + InvalidOpcode(u8), + /// A field carries a value this op does not define: a reserved field + /// set, a chunk over [`MAX_CHUNK`], or `arg`/`len` non-zero on an op + /// that has no use for them. + InvalidField, +} + +impl core::fmt::Display for WireError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + WireError::Truncated => "buffer shorter than the message", + WireError::BufferTooSmall => "buffer too small for the message", + WireError::InvalidOpcode(_) => "unknown operation code", + WireError::InvalidField => "field value not defined for this operation", + }) + } +} + +impl core::error::Error for WireError {} + +/// The intake operations, one per [`UpdateIntake`](crate::UpdateIntake) +/// method. +/// +/// `#[non_exhaustive]`: the two sides are separate processes and may be built +/// from different revisions, so an unknown op is a runtime case +/// ([`WireError::InvalidOpcode`]), not a compile-time one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +#[non_exhaustive] +pub enum UpdateOp { + /// Offer a candidate and reserve the staging region. + Offer = 0, + /// Write one chunk into the staging region. + Write = 1, + /// Declare the candidate complete, starting the update. + Complete = 2, + /// Drop the job. + Abort = 3, + /// Read the phase. + Poll = 4, +} + +impl TryFrom for UpdateOp { + type Error = WireError; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(UpdateOp::Offer), + 1 => Ok(UpdateOp::Write), + 2 => Ok(UpdateOp::Complete), + 3 => Ok(UpdateOp::Abort), + 4 => Ok(UpdateOp::Poll), + other => Err(WireError::InvalidOpcode(other)), + } + } +} + +/// The 16-byte request header. Fields are private because the accessors are +/// what validate them. +#[repr(C, packed)] +#[derive(Debug, Clone, Copy, FromBytes, IntoBytes, Immutable, KnownLayout)] +pub struct RequestHeader { + op_code: u8, + target: u8, + reserved: u16, + arg: u64, + len: u16, + reserved_tail: u16, +} + +impl RequestHeader { + /// Encoded size in bytes. + pub const SIZE: usize = core::mem::size_of::(); + + fn new(op: UpdateOp, target: u8, arg: u64, len: u16) -> Self { + Self { + op_code: op as u8, + target, + reserved: 0, + arg: arg.to_le(), + len: len.to_le(), + reserved_tail: 0, + } + } + + /// The operation, or why the op byte is not one. + pub fn op(&self) -> Result { + UpdateOp::try_from(self.op_code) + } + + /// The target byte, meaningful on [`UpdateOp::Offer`] only. + pub fn target(&self) -> TargetId { + TargetId(self.target) + } + + /// The payload length on [`UpdateOp::Offer`], the write offset on + /// [`UpdateOp::Write`], zero otherwise. + pub fn arg(&self) -> u64 { + u64::from_le(self.arg) + } + + /// The chunk length on [`UpdateOp::Write`], zero otherwise. + pub fn len(&self) -> u16 { + u16::from_le(self.len) + } + + fn reserved_are_clear(&self) -> bool { + self.reserved == 0 && self.reserved_tail == 0 + } +} + +/// One decoded request. Borrows the chunk out of the handler's read buffer, +/// so a staging write needs no second copy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Request<'a> { + /// [`UpdateIntake::offer`](crate::UpdateIntake::offer). + Offer { + /// The device the candidate is for. + target: TargetId, + /// Payload length in bytes. + total: u64, + }, + /// [`UpdateIntake::write`](crate::UpdateIntake::write). + Write { + /// Offset into the offered payload. + offset: u64, + /// The chunk, at most [`MAX_CHUNK`] bytes. + bytes: &'a [u8], + }, + /// [`UpdateIntake::complete`](crate::UpdateIntake::complete). + Complete, + /// [`UpdateIntake::abort`](crate::UpdateIntake::abort). + Abort, + /// [`UpdateIntake::poll`](crate::UpdateIntake::poll). + Poll, +} + +impl Request<'_> { + /// Encodes into `buf`, returning the encoded length. + pub fn encode(&self, buf: &mut [u8]) -> Result { + let (header, chunk) = match *self { + Request::Offer { target, total } => ( + RequestHeader::new(UpdateOp::Offer, target.0, total, 0), + None, + ), + Request::Write { offset, bytes } => { + let len = u16::try_from(bytes.len()).map_err(|_| WireError::InvalidField)?; + if bytes.len() > MAX_CHUNK { + return Err(WireError::InvalidField); + } + ( + RequestHeader::new(UpdateOp::Write, 0, offset, len), + Some(bytes), + ) + } + Request::Complete => (RequestHeader::new(UpdateOp::Complete, 0, 0, 0), None), + Request::Abort => (RequestHeader::new(UpdateOp::Abort, 0, 0, 0), None), + Request::Poll => (RequestHeader::new(UpdateOp::Poll, 0, 0, 0), None), + }; + let chunk = chunk.unwrap_or(&[]); + let total = RequestHeader::SIZE + chunk.len(); + let out = buf.get_mut(..total).ok_or(WireError::BufferTooSmall)?; + out[..RequestHeader::SIZE].copy_from_slice(header.as_bytes()); + out[RequestHeader::SIZE..].copy_from_slice(chunk); + Ok(total) + } + + /// Decodes one request out of a handler's read buffer, validating header + /// length, opcode, reserved fields, the chunk bound, and that fields an + /// op does not define are zero. + pub fn decode(buf: &[u8]) -> Result, WireError> { + let (head, rest) = buf + .split_at_checked(RequestHeader::SIZE) + .ok_or(WireError::Truncated)?; + // Infallible on an exact-size slice; report it as a short buffer. + let header = RequestHeader::read_from_bytes(head).map_err(|_| WireError::Truncated)?; + if !header.reserved_are_clear() { + return Err(WireError::InvalidField); + } + let op = header.op()?; + let arg = header.arg(); + let len = usize::from(header.len()); + // Reject rather than ignore a field the op does not define: a + // mismatch means the two sides disagree about the protocol. + if op != UpdateOp::Write && len != 0 { + return Err(WireError::InvalidField); + } + if matches!(op, UpdateOp::Complete | UpdateOp::Abort | UpdateOp::Poll) && arg != 0 { + return Err(WireError::InvalidField); + } + Ok(match op { + UpdateOp::Offer => Request::Offer { + target: header.target(), + total: arg, + }, + UpdateOp::Write => { + if len > MAX_CHUNK { + return Err(WireError::InvalidField); + } + Request::Write { + offset: arg, + bytes: rest.get(..len).ok_or(WireError::Truncated)?, + } + } + UpdateOp::Complete => Request::Complete, + UpdateOp::Abort => Request::Abort, + UpdateOp::Poll => Request::Poll, + }) + } +} + +/// The 20-byte response. +#[repr(C, packed)] +#[derive(Debug, Clone, Copy, FromBytes, IntoBytes, Immutable, KnownLayout)] +pub struct ResponseHeader { + code: u8, + phase: u8, + detail: u8, + reserved: u8, + written: u64, + total: u64, +} + +impl ResponseHeader { + /// Encoded size in bytes. + pub const SIZE: usize = core::mem::size_of::(); +} + +/// What the handler answers: the outcome of the request, plus the phase as +/// of that answer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Response { + /// Whether the request took effect. A refusal leaves the phase + /// unchanged, so `status` still describes the job already there. + pub outcome: Result<(), Reject>, + /// The phase as of this answer. + pub status: IntakeStatus, +} + +impl Response { + /// Encodes into `buf`, returning the encoded length. + pub fn encode(&self, buf: &mut [u8]) -> Result { + let (phase, detail, progress) = match self.status { + IntakeStatus::Idle => (PHASE_IDLE, 0, None), + IntakeStatus::Receiving(p) => (PHASE_RECEIVING, 0, Some(p)), + IntakeStatus::Authenticating => (PHASE_AUTHENTICATING, 0, None), + IntakeStatus::Staging(p) => (PHASE_STAGING, 0, Some(p)), + IntakeStatus::Activated => (PHASE_ACTIVATED, 0, None), + IntakeStatus::Failed(cause) => (PHASE_FAILED, cause_code(cause), None), + }; + let progress = progress.unwrap_or(Progress { + written: 0, + total: 0, + }); + let header = ResponseHeader { + code: match self.outcome { + Ok(()) => CODE_OK, + Err(reject) => reject_code(reject), + }, + phase, + detail, + reserved: 0, + written: progress.written.to_le(), + total: progress.total.to_le(), + }; + let out = buf + .get_mut(..ResponseHeader::SIZE) + .ok_or(WireError::BufferTooSmall)?; + out.copy_from_slice(header.as_bytes()); + Ok(ResponseHeader::SIZE) + } + + /// Decodes the handler's answer. + pub fn decode(buf: &[u8]) -> Result { + let head = buf + .get(..ResponseHeader::SIZE) + .ok_or(WireError::Truncated)?; + let header = ResponseHeader::read_from_bytes(head).map_err(|_| WireError::Truncated)?; + if header.reserved != 0 { + return Err(WireError::InvalidField); + } + let progress = Progress { + written: u64::from_le(header.written), + total: u64::from_le(header.total), + }; + let status = match header.phase { + PHASE_IDLE => IntakeStatus::Idle, + PHASE_RECEIVING => IntakeStatus::Receiving(progress), + PHASE_AUTHENTICATING => IntakeStatus::Authenticating, + PHASE_STAGING => IntakeStatus::Staging(progress), + PHASE_ACTIVATED => IntakeStatus::Activated, + PHASE_FAILED => IntakeStatus::Failed(cause_from_code(header.detail)?), + _ => return Err(WireError::InvalidField), + }; + let outcome = match header.code { + CODE_OK => Ok(()), + code => Err(reject_from_code(code)?), + }; + Ok(Response { outcome, status }) + } +} + +const CODE_OK: u8 = 0; + +const PHASE_IDLE: u8 = 0; +const PHASE_RECEIVING: u8 = 1; +const PHASE_AUTHENTICATING: u8 = 2; +const PHASE_STAGING: u8 = 3; +const PHASE_ACTIVATED: u8 = 4; +const PHASE_FAILED: u8 = 5; + +fn cause_code(cause: FailureCause) -> u8 { + match cause { + FailureCause::Authentication => 1, + FailureCause::Deferred => 2, + FailureCause::Superseded => 3, + FailureCause::Device => 4, + } +} + +fn cause_from_code(code: u8) -> Result { + match code { + 1 => Ok(FailureCause::Authentication), + 2 => Ok(FailureCause::Deferred), + 3 => Ok(FailureCause::Superseded), + 4 => Ok(FailureCause::Device), + _ => Err(WireError::InvalidField), + } +} + +fn reject_code(reject: Reject) -> u8 { + match reject { + Reject::UnknownTarget => 1, + Reject::Busy => 2, + Reject::NoJob => 3, + Reject::OutOfRange => 4, + Reject::Incomplete => 5, + Reject::Storage => 6, + Reject::Malformed => 7, + } +} + +fn reject_from_code(code: u8) -> Result { + match code { + 1 => Ok(Reject::UnknownTarget), + 2 => Ok(Reject::Busy), + 3 => Ok(Reject::NoJob), + 4 => Ok(Reject::OutOfRange), + 5 => Ok(Reject::Incomplete), + 6 => Ok(Reject::Storage), + 7 => Ok(Reject::Malformed), + _ => Err(WireError::InvalidField), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn round_trip_request(request: Request<'_>) { + let mut buf = [0u8; MAX_REQUEST_SIZE]; + let len = request.encode(&mut buf).expect("encode failed"); + assert_eq!(Request::decode(&buf[..len]), Ok(request)); + } + + #[test] + fn every_request_round_trips() { + round_trip_request(Request::Offer { + target: TargetId(3), + total: 12 * 1024 * 1024, + }); + round_trip_request(Request::Write { + offset: u64::from(u32::MAX) + 1, + bytes: &[0xa5; MAX_CHUNK], + }); + round_trip_request(Request::Write { + offset: 0, + bytes: &[], + }); + round_trip_request(Request::Complete); + round_trip_request(Request::Abort); + round_trip_request(Request::Poll); + } + + #[test] + fn a_request_header_is_sixteen_bytes() { + // Part of the protocol, not an artifact of the field list: a handler + // sizes its read buffer from it. + assert_eq!(RequestHeader::SIZE, 16); + assert_eq!(MAX_REQUEST_SIZE, 16 + MAX_CHUNK); + } + + #[test] + fn a_chunk_over_the_bound_is_refused_by_both_sides() { + let oversized = [0u8; MAX_CHUNK + 1]; + let mut buf = [0u8; MAX_REQUEST_SIZE + 1]; + + assert_eq!( + Request::Write { + offset: 0, + bytes: &oversized, + } + .encode(&mut buf), + Err(WireError::InvalidField) + ); + + // Hand-built by a source ignoring the bound: the handler must not + // read past its own buffer on the strength of a declared length. + let header = RequestHeader::new(UpdateOp::Write, 0, 0, (MAX_CHUNK + 1) as u16); + buf[..RequestHeader::SIZE].copy_from_slice(header.as_bytes()); + assert_eq!( + Request::decode(&buf[..RequestHeader::SIZE + MAX_CHUNK + 1]), + Err(WireError::InvalidField) + ); + } + + #[test] + fn a_write_shorter_than_its_declared_chunk_is_truncated() { + let mut buf = [0u8; MAX_REQUEST_SIZE]; + let len = Request::Write { + offset: 0, + bytes: &[1, 2, 3, 4], + } + .encode(&mut buf) + .expect("encode failed"); + + assert_eq!(Request::decode(&buf[..len - 1]), Err(WireError::Truncated)); + } + + #[test] + fn a_short_header_is_truncated_not_a_panic() { + for len in 0..RequestHeader::SIZE { + assert_eq!( + Request::decode(&[0u8; 32][..len]), + Err(WireError::Truncated) + ); + } + } + + #[test] + fn an_unknown_opcode_is_reported_with_its_value() { + let mut buf = [0u8; RequestHeader::SIZE]; + buf[0] = 9; + + assert_eq!(Request::decode(&buf), Err(WireError::InvalidOpcode(9))); + } + + #[test] + fn fields_an_op_does_not_define_must_be_zero() { + // Reserved, so they stay free for a later meaning. + let mut buf = [0u8; RequestHeader::SIZE]; + buf[0] = UpdateOp::Poll as u8; + buf[2] = 1; + assert_eq!(Request::decode(&buf), Err(WireError::InvalidField)); + + // A chunk length on an op that carries no chunk. + let header = RequestHeader::new(UpdateOp::Complete, 0, 0, 4); + assert_eq!( + Request::decode(header.as_bytes()), + Err(WireError::InvalidField) + ); + + // An arg on an op that has no use for one. + let header = RequestHeader::new(UpdateOp::Poll, 0, 64, 0); + assert_eq!( + Request::decode(header.as_bytes()), + Err(WireError::InvalidField) + ); + } + + fn round_trip_response(response: Response) { + let mut buf = [0u8; MAX_RESPONSE_SIZE]; + let len = response.encode(&mut buf).expect("encode failed"); + assert_eq!(len, MAX_RESPONSE_SIZE); + assert_eq!(Response::decode(&buf[..len]), Ok(response)); + } + + #[test] + fn every_phase_round_trips() { + let progress = Progress { + written: 4096, + total: 12 * 1024 * 1024, + }; + for status in [ + IntakeStatus::Idle, + IntakeStatus::Receiving(progress), + IntakeStatus::Authenticating, + IntakeStatus::Staging(progress), + IntakeStatus::Activated, + IntakeStatus::Failed(FailureCause::Authentication), + IntakeStatus::Failed(FailureCause::Deferred), + IntakeStatus::Failed(FailureCause::Superseded), + IntakeStatus::Failed(FailureCause::Device), + ] { + round_trip_response(Response { + outcome: Ok(()), + status, + }); + } + } + + #[test] + fn every_reject_round_trips_and_keeps_the_phase() { + // The phase rides along with a refusal, so a source that only + // writes still learns that its job died. + for reject in [ + Reject::UnknownTarget, + Reject::Busy, + Reject::NoJob, + Reject::OutOfRange, + Reject::Incomplete, + Reject::Storage, + Reject::Malformed, + ] { + round_trip_response(Response { + outcome: Err(reject), + status: IntakeStatus::Failed(FailureCause::Device), + }); + } + } + + #[test] + fn progress_is_zero_outside_the_phases_that_carry_it() { + let mut buf = [0u8; MAX_RESPONSE_SIZE]; + Response { + outcome: Ok(()), + status: IntakeStatus::Activated, + } + .encode(&mut buf) + .expect("encode failed"); + + assert_eq!(buf[ResponseHeader::SIZE - 16..], [0u8; 16]); + } + + #[test] + fn an_undefined_phase_or_cause_does_not_decode() { + let mut buf = [0u8; MAX_RESPONSE_SIZE]; + buf[1] = 9; + assert_eq!(Response::decode(&buf), Err(WireError::InvalidField)); + + // Failed with no cause: the two only mean something together. + buf[1] = PHASE_FAILED; + buf[2] = 0; + assert_eq!(Response::decode(&buf), Err(WireError::InvalidField)); + } + + #[test] + fn a_short_response_is_truncated_not_a_panic() { + for len in 0..ResponseHeader::SIZE { + assert_eq!( + Response::decode(&[0u8; 32][..len]), + Err(WireError::Truncated) + ); + } + } +} From e7872d1b2c3db20367fddc8e1b098807490c61ff Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Tue, 1 Sep 2026 21:44:01 +0200 Subject: [PATCH 3/3] orchestrator: Note that the intake phase order is not fixed IntakeStatus documented one order. A board that writes the candidate straight into the target's inactive slot authenticates by reading that slot back, and a target gated on a signed manifest is authenticated before any byte moves, so the order differs by board and a source must not assume one. Staging is now marked as reachable only where the orchestrator stages into a region of its own. Write-through is the expected shape here: the part has 768 KB of SRAM against a payload of tens of megabytes, so there is no room to hold a copy. The README also stops saying complete() hands the state machine the update request. Where that event fires is still open, so the step now says only what complete() itself does. --- services/orchestrator/update-api/README.md | 9 ++++++++- services/orchestrator/update-api/src/lib.rs | 7 +++++++ .../orchestrator/update-api/src/status.rs | 19 ++++++++++++++++--- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/services/orchestrator/update-api/README.md b/services/orchestrator/update-api/README.md index ac2947a5d..a405d323b 100644 --- a/services/orchestrator/update-api/README.md +++ b/services/orchestrator/update-api/README.md @@ -19,9 +19,16 @@ One update, from the source's side: 1. `offer(target, total)` reserves the staging region. 2. `write(offset, bytes)` fills it, at most 512 bytes per call, in any order. -3. `complete()` hands the state machine the update request. +3. `complete()` declares the candidate complete, which is what starts the + update. 4. `poll()` until `Activated` or `Failed`. +What backs the staging region is the board's choice. Where the eRoT sits in +the target's flash path there is no room to hold a copy, so a write goes +straight into that device's inactive slot and the eRoT authenticates by +reading the slot back. The phase order follows from that and is not fixed: +see the `IntakeStatus` docs before assuming one. + `abort()` drops the job from any phase. Activation needs no call: the state machine activates on its own verdict once the candidate authenticates. diff --git a/services/orchestrator/update-api/src/lib.rs b/services/orchestrator/update-api/src/lib.rs index fa1d2e76e..158728087 100644 --- a/services/orchestrator/update-api/src/lib.rs +++ b/services/orchestrator/update-api/src/lib.rs @@ -29,6 +29,13 @@ //! the phase changes. That is a nudge carrying no data, and the syscall //! does not block on the peer. //! +//! What backs the staging region is the board's choice and never the +//! source's. On a passive device the eRoT sits in the flash path, so the +//! region is that device's inactive slot and a write goes straight into it; +//! the eRoT then authenticates by reading the slot back. A board with its +//! own staging flash copies first instead. Either way the source offers, +//! writes at offsets, and completes. +//! //! This is the `api` layer of the pattern `services/i2c` and //! `services/mctp` established. The orchestrator-side dispatch and the //! source-side [`UpdateIntake`] impl over `channel_transact` are separate diff --git a/services/orchestrator/update-api/src/status.rs b/services/orchestrator/update-api/src/status.rs index d5a134a33..49bee0b16 100644 --- a/services/orchestrator/update-api/src/status.rs +++ b/services/orchestrator/update-api/src/status.rs @@ -27,21 +27,34 @@ pub struct Progress { /// [`Staging`](Self::Staging) into apply progress, and /// [`Failed`](Self::Failed) into the matching result code. /// +/// The order is not fixed and a source must not assume one. A board that +/// writes through to the target's inactive slot authenticates by reading +/// that slot back, so it runs [`Receiving`](Self::Receiving), +/// [`Authenticating`](Self::Authenticating), [`Activated`](Self::Activated). +/// A target whose gate is a signed manifest is authenticated before any byte +/// moves, which swaps the first two. +/// /// Intentionally exhaustive: adding a phase is a breaking change, so every /// source handles each one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IntakeStatus { /// No job: none offered, or the last one collected by a fresh offer. Idle, - /// The source is writing the candidate into the staging region. - /// `written` counts what the orchestrator took, so a source that lost - /// track can resume. + /// The source is writing the candidate into the staging region, which + /// on a write-through board is the target's inactive slot. `written` + /// counts what the orchestrator took, so a source that lost track can + /// resume. Receiving(Progress), /// The orchestrator is authenticating the complete candidate. No /// counters: authentication is one verdict, not a transfer. Authenticating, /// The candidate authenticated; the orchestrator is pushing it to the /// target device, one polled step at a time. + /// + /// Only a board that stages into its own region ever reports this. Under + /// write-through there is nothing left to push, so the phase goes + /// straight from [`Authenticating`](Self::Authenticating) to + /// [`Activated`](Self::Activated). Staging(Progress), /// The staged image is the device's boot candidate, tentatively. The /// commit gate is orchestrator policy, so this is the last phase a