polyc_runtime/stager.rs
1//! Update stager — the apply substrate the hot and warm update paths build on.
2//!
3//! The stager runs one job: land a staged update safely. It downloads the
4//! update to a *side* location that never touches the live path, verifies its
5//! signature *before* anything is applied, flips the live pointer to the new
6//! version, and — if a post-apply health check fails — flips the pointer back
7//! to the previous version. The previous version stays staged the whole time,
8//! so a rollback is a pointer flip rather than a re-download.
9//!
10//! # The seams
11//!
12//! The stager owns the *state machine*, not the I/O. Everything that touches
13//! the outside world is an injectable seam, so the machine can be exercised
14//! without a real download, a real key, or a real process boot:
15//!
16//! - [`UpdateSource`] downloads the release to a side location.
17//! - [`SignatureVerifier`] checks the ed25519 signature over the staged bytes.
18//! In production this wraps the project's ed25519 key custody (the same
19//! `verify` the event log and approval flow use); the stager only ever sees a
20//! yes/no answer, so a foundation crate needs no signing dependency to run it.
21//! - [`Activator`] materializes the pointer flip — a symlink swap, an image
22//! tag, or a process swap. It is called once to apply and, in reverse, once
23//! to roll back.
24//! - [`HealthCheck`] probes the freshly-applied version. [`Health::Healthy`]
25//! commits; anything else triggers the auto-rollback.
26//!
27//! Each seam has a blanket implementation for the matching closure, so a caller
28//! can pass a closure where a full type would be overkill.
29//!
30//! # The state machine
31//!
32//! [`Stager::stage_and_apply`] walks a fixed sequence:
33//!
34//! ```text
35//! download ──▶ verify ──▶ apply (pointer flip) ──▶ health check
36//! │ │
37//! │ (unverified) ├─ healthy ──▶ Committed
38//! ▼ │
39//! refused └─ unhealthy ──▶ auto-rollback ──▶ RolledBack
40//! (nothing applied) (pointer flips back)
41//! ```
42//!
43//! The invariant the tests pin: an unverified bundle is never applied (the live
44//! pointer is untouched), the previous version stays staged, and a failed
45//! health check leaves the system honestly back on the previous version.
46//!
47//! The staged artifact carries the [`StagedBundle`] it delivers, so a caller
48//! that already classified the release (see
49//! [`crate::compat`]) hands the same value straight through to apply.
50
51use std::path::PathBuf;
52
53use thiserror::Error;
54
55use crate::compat::StagedBundle;
56
57/// A release identity — the pointer the live slot resolves to and flips between.
58///
59/// Opaque and cheap to clone; in production it is a content address, a version
60/// tag, or an image digest, and the [`Activator`] decides how a pointer flip to
61/// it materializes.
62#[derive(Debug, Clone, PartialEq, Eq, Hash)]
63pub struct ReleaseId(String);
64
65impl ReleaseId {
66 /// Wrap a release identity string.
67 #[must_use]
68 pub fn new(id: impl Into<String>) -> Self {
69 Self(id.into())
70 }
71
72 /// The underlying identity string.
73 #[must_use]
74 pub const fn as_str(&self) -> &str {
75 self.0.as_str()
76 }
77}
78
79impl std::fmt::Display for ReleaseId {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.write_str(&self.0)
82 }
83}
84
85/// An update downloaded to a side location, before verification.
86///
87/// Holds everything the stager needs to verify and apply the release without
88/// re-reading the source: the release identity, the side path it landed at
89/// (never the live path), the [`StagedBundle`] it delivers, and the detached
90/// ed25519 signature over `signed_bytes` under `signer_public_key`.
91#[derive(Debug, Clone)]
92pub struct StagedArtifact {
93 /// Which release this artifact is.
94 pub release: ReleaseId,
95 /// The side location the update was downloaded to — never the live path.
96 pub staged_path: PathBuf,
97 /// The config-as-data bundle this artifact delivers, already classified
98 /// against the running runtime by [`crate::compat`].
99 pub bundle: StagedBundle,
100 /// The canonical bytes the signature commits to.
101 pub signed_bytes: Vec<u8>,
102 /// The detached ed25519 signature over [`Self::signed_bytes`].
103 pub signature: Vec<u8>,
104 /// The encoded public key the signature is expected to verify against.
105 pub signer_public_key: Vec<u8>,
106}
107
108/// The result of a post-apply health check.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum Health {
111 /// The applied version is serving; commit it.
112 Healthy,
113 /// The applied version failed to come up; the enclosed reason is recorded
114 /// and surfaced in the rollback outcome.
115 Unhealthy(String),
116}
117
118/// What happened to a `stage_and_apply` call that got as far as a health check.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum Outcome {
121 /// The new version passed its health check and is now the live version.
122 Committed {
123 /// The version now serving.
124 version: ReleaseId,
125 },
126 /// The new version failed its health check; the pointer flipped back and the
127 /// previous version is live again.
128 RolledBack {
129 /// The version still serving after the rollback — the one that was live
130 /// before the attempt.
131 stayed_on: ReleaseId,
132 /// The health-check failure that triggered the rollback.
133 reason: String,
134 },
135}
136
137impl Outcome {
138 /// A one-line, user-facing summary of the outcome.
139 ///
140 /// Honest about a rollback — it names the version still serving and why the
141 /// update did not stick — so the surface presenting it never has to word the
142 /// state twice.
143 #[must_use]
144 pub fn summary(&self) -> String {
145 match self {
146 Self::Committed { version } => format!("Now running {version}."),
147 Self::RolledBack { stayed_on, reason } => {
148 format!(
149 "Stayed on {stayed_on} — the new version failed its health check ({reason})."
150 )
151 }
152 }
153 }
154}
155
156/// Why a `stage_and_apply` call could not reach a health check.
157#[derive(Debug, Error)]
158pub enum StageError {
159 /// The source could not download the release to its side location.
160 #[error("staging the update failed: {0}")]
161 Download(String),
162 /// The staged bundle's signature did not verify against a trusted key, so it
163 /// was refused before anything was applied.
164 #[error("the update's signature did not verify against a trusted key")]
165 Unverified,
166 /// The pointer flip itself failed — either applying the new version or
167 /// flipping back to the previous one.
168 #[error("activating the staged update failed: {0}")]
169 Activate(String),
170}
171
172/// Downloads a release to a side location, never the live path.
173pub trait UpdateSource {
174 /// Fetch `release` into a side location and return the staged artifact.
175 ///
176 /// # Errors
177 ///
178 /// Returns [`StageError::Download`] when the release cannot be fetched or
179 /// written to its side location.
180 fn fetch(&self, release: &ReleaseId) -> Result<StagedArtifact, StageError>;
181}
182
183impl<F> UpdateSource for F
184where
185 F: Fn(&ReleaseId) -> Result<StagedArtifact, StageError>,
186{
187 fn fetch(&self, release: &ReleaseId) -> Result<StagedArtifact, StageError> {
188 self(release)
189 }
190}
191
192/// Verifies the ed25519 signature over a staged artifact.
193///
194/// The contract is a plain yes/no: `true` iff the signature over
195/// [`StagedArtifact::signed_bytes`] verifies against a trusted key. A `false`
196/// return means the bundle is refused and never applied. Keeping the answer a
197/// boolean lets a foundation crate run the interlock without depending on the
198/// signing crate — production wraps the project's ed25519 `verify` here.
199pub trait SignatureVerifier {
200 /// Whether `artifact`'s signature verifies against a trusted key.
201 fn verify(&self, artifact: &StagedArtifact) -> bool;
202}
203
204impl<F> SignatureVerifier for F
205where
206 F: Fn(&StagedArtifact) -> bool,
207{
208 fn verify(&self, artifact: &StagedArtifact) -> bool {
209 self(artifact)
210 }
211}
212
213/// Materializes a pointer flip to a release — a symlink swap, an image tag, or a
214/// process swap.
215///
216/// Called once to apply the new version and, in reverse, once to roll back to
217/// the previous one. The previous version stays staged, so a rollback flip never
218/// re-downloads.
219pub trait Activator {
220 /// Flip the live pointer to `release`.
221 ///
222 /// # Errors
223 ///
224 /// Returns [`StageError::Activate`] when the pointer cannot be flipped.
225 fn activate(&self, release: &ReleaseId) -> Result<(), StageError>;
226}
227
228impl<F> Activator for F
229where
230 F: Fn(&ReleaseId) -> Result<(), StageError>,
231{
232 fn activate(&self, release: &ReleaseId) -> Result<(), StageError> {
233 self(release)
234 }
235}
236
237/// Probes a freshly-applied version to decide whether it commits or rolls back.
238pub trait HealthCheck {
239 /// Probe the applied version. [`Health::Healthy`] commits; anything else
240 /// triggers the auto-rollback.
241 fn check(&self) -> Health;
242}
243
244impl<F> HealthCheck for F
245where
246 F: Fn() -> Health,
247{
248 fn check(&self) -> Health {
249 self()
250 }
251}
252
253/// The apply substrate: stage → verify → apply → (commit | auto-rollback).
254///
255/// Holds the live version and the previous version behind it, plus the four
256/// seams that touch the outside world. [`Stager::stage_and_apply`] drives the
257/// whole sequence; [`Stager::live`] and [`Stager::previous`] expose the current
258/// pointer state.
259pub struct Stager<S, V, A, H> {
260 source: S,
261 verifier: V,
262 activator: A,
263 health: H,
264 live: ReleaseId,
265 previous: Option<ReleaseId>,
266}
267
268impl<S, V, A, H> Stager<S, V, A, H>
269where
270 S: UpdateSource,
271 V: SignatureVerifier,
272 A: Activator,
273 H: HealthCheck,
274{
275 /// Build a stager over its four seams, starting from `initial_live` as the
276 /// version already serving.
277 pub const fn new(
278 source: S,
279 verifier: V,
280 activator: A,
281 health: H,
282 initial_live: ReleaseId,
283 ) -> Self {
284 Self {
285 source,
286 verifier,
287 activator,
288 health,
289 live: initial_live,
290 previous: None,
291 }
292 }
293
294 /// The version currently serving — the committed live pointer.
295 #[must_use]
296 pub const fn live(&self) -> &ReleaseId {
297 &self.live
298 }
299
300 /// The previous version, kept staged so a rollback is a pointer flip. `None`
301 /// until the first update commits.
302 #[must_use]
303 pub const fn previous(&self) -> Option<&ReleaseId> {
304 self.previous.as_ref()
305 }
306
307 /// Stage `release`, verify it, apply it, and commit or auto-roll-back on the
308 /// health check.
309 ///
310 /// The sequence is fixed:
311 ///
312 /// 1. download the release to a side location;
313 /// 2. verify its signature — an unverified bundle returns
314 /// [`StageError::Unverified`] with the live pointer untouched;
315 /// 3. flip the live pointer to the new version;
316 /// 4. run the health check — on [`Health::Healthy`] commit and return
317 /// [`Outcome::Committed`]; on [`Health::Unhealthy`] flip the pointer back
318 /// to the previous version and return [`Outcome::RolledBack`].
319 ///
320 /// The committed live pointer ([`Stager::live`]) only advances in step 4 on
321 /// a passed health check, so a refused or rolled-back attempt leaves the
322 /// stager exactly where it started.
323 ///
324 /// # Errors
325 ///
326 /// Returns [`StageError::Download`] if the source cannot stage the release,
327 /// [`StageError::Unverified`] if the signature does not verify, or
328 /// [`StageError::Activate`] if a pointer flip fails (applying the new
329 /// version, or — worse — flipping back during a rollback).
330 pub fn stage_and_apply(&mut self, release: &ReleaseId) -> Result<Outcome, StageError> {
331 // 1. Download to a side location — never the live path.
332 let artifact = self.source.fetch(release)?;
333
334 // 2. Verify BEFORE anything is applied. An unverified bundle is refused
335 // here, with the live pointer and staged previous both untouched.
336 if !self.verifier.verify(&artifact) {
337 return Err(StageError::Unverified);
338 }
339
340 // 3. Apply: flip the live pointer to the new version. The version that
341 // was live stays staged as the rollback target. `self.live` is not
342 // advanced yet — it only commits once the health check passes.
343 let rollback_to = self.live.clone();
344 self.activator.activate(&artifact.release)?;
345
346 // 4. Health check decides commit vs. auto-rollback.
347 match self.health.check() {
348 Health::Healthy => {
349 self.previous = Some(rollback_to);
350 self.live = artifact.release.clone();
351 Ok(Outcome::Committed {
352 version: artifact.release,
353 })
354 }
355 Health::Unhealthy(reason) => {
356 // Auto-rollback: flip the pointer back to the previous version.
357 // The committed `self.live` never moved, so we are honestly back
358 // where we started.
359 self.activator.activate(&rollback_to)?;
360 Ok(Outcome::RolledBack {
361 stayed_on: rollback_to,
362 reason,
363 })
364 }
365 }
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
372
373 use std::cell::RefCell;
374 use std::rc::Rc;
375
376 use super::*;
377 use crate::compat::RuntimeTarget;
378
379 /// A recording activator whose flip log is shared with the test after it is
380 /// moved into the stager, so the sequence of pointer flips is observable.
381 #[derive(Clone)]
382 struct RecordingActivator {
383 flips: Rc<RefCell<Vec<ReleaseId>>>,
384 fail_on: Option<ReleaseId>,
385 }
386
387 impl RecordingActivator {
388 fn new() -> Self {
389 Self {
390 flips: Rc::new(RefCell::new(Vec::new())),
391 fail_on: None,
392 }
393 }
394
395 fn failing_on(release: ReleaseId) -> Self {
396 Self {
397 flips: Rc::new(RefCell::new(Vec::new())),
398 fail_on: Some(release),
399 }
400 }
401
402 fn flips(&self) -> Vec<ReleaseId> {
403 self.flips.borrow().clone()
404 }
405 }
406
407 impl Activator for RecordingActivator {
408 fn activate(&self, release: &ReleaseId) -> Result<(), StageError> {
409 if self.fail_on.as_ref() == Some(release) {
410 return Err(StageError::Activate(format!("cannot flip to {release}")));
411 }
412 self.flips.borrow_mut().push(release.clone());
413 Ok(())
414 }
415 }
416
417 fn artifact_for(release: &ReleaseId) -> StagedArtifact {
418 StagedArtifact {
419 release: release.clone(),
420 // A side path — the download never touches the live path.
421 staged_path: PathBuf::from(format!("/var/lib/polychrome/staged/{release}")),
422 bundle: StagedBundle::new(
423 RuntimeTarget::new(3, 7, "polychrome.dev/v1"),
424 format!("catalog-{release}"),
425 ),
426 signed_bytes: format!("bytes-of-{release}").into_bytes(),
427 signature: vec![0xAB; 4],
428 signer_public_key: vec![0xCD; 4],
429 }
430 }
431
432 /// A source that always stages the requested release from a side location.
433 fn ok_source(release: &ReleaseId) -> Result<StagedArtifact, StageError> {
434 Ok(artifact_for(release))
435 }
436
437 #[test]
438 fn verified_healthy_update_commits_and_keeps_previous_staged() {
439 let activator = RecordingActivator::new();
440 let mut stager = Stager::new(
441 ok_source,
442 |_: &StagedArtifact| true,
443 activator.clone(),
444 || Health::Healthy,
445 ReleaseId::new("v1"),
446 );
447
448 let outcome = stager.stage_and_apply(&ReleaseId::new("v2")).unwrap();
449
450 assert_eq!(
451 outcome,
452 Outcome::Committed {
453 version: ReleaseId::new("v2")
454 }
455 );
456 // The live pointer advanced to the new version...
457 assert_eq!(stager.live(), &ReleaseId::new("v2"));
458 // ...and the version that was live stays staged as the rollback target.
459 assert_eq!(stager.previous(), Some(&ReleaseId::new("v1")));
460 // A healthy apply flips the pointer exactly once, forward.
461 assert_eq!(activator.flips(), vec![ReleaseId::new("v2")]);
462 }
463
464 #[test]
465 fn unverified_bundle_is_never_applied() {
466 let activator = RecordingActivator::new();
467 let mut stager = Stager::new(
468 ok_source,
469 // Signature does not verify.
470 |_: &StagedArtifact| false,
471 activator.clone(),
472 // A health check that would panic proves it is never reached.
473 || panic!("health check must not run for an unverified bundle"),
474 ReleaseId::new("v1"),
475 );
476
477 let err = stager.stage_and_apply(&ReleaseId::new("v2")).unwrap_err();
478
479 assert!(matches!(err, StageError::Unverified));
480 // Nothing was applied: the pointer never flipped and the live version is
481 // unchanged.
482 assert!(activator.flips().is_empty());
483 assert_eq!(stager.live(), &ReleaseId::new("v1"));
484 assert_eq!(stager.previous(), None);
485 }
486
487 #[test]
488 fn failed_health_check_auto_rolls_back_to_previous() {
489 let activator = RecordingActivator::new();
490 let mut stager = Stager::new(
491 ok_source,
492 |_: &StagedArtifact| true,
493 activator.clone(),
494 || Health::Unhealthy("readiness probe timed out".to_owned()),
495 ReleaseId::new("v1"),
496 );
497
498 let outcome = stager.stage_and_apply(&ReleaseId::new("v2")).unwrap();
499
500 assert_eq!(
501 outcome,
502 Outcome::RolledBack {
503 stayed_on: ReleaseId::new("v1"),
504 reason: "readiness probe timed out".to_owned(),
505 }
506 );
507 // The committed live pointer never moved off the previous version.
508 assert_eq!(stager.live(), &ReleaseId::new("v1"));
509 assert_eq!(stager.previous(), None);
510 // The pointer flipped to the new version, then back — a rollback is a
511 // pointer flip, not a re-download.
512 assert_eq!(
513 activator.flips(),
514 vec![ReleaseId::new("v2"), ReleaseId::new("v1")]
515 );
516 }
517
518 #[test]
519 fn rollback_outcome_surfaces_an_honest_summary() {
520 let outcome = Outcome::RolledBack {
521 stayed_on: ReleaseId::new("v1"),
522 reason: "readiness probe timed out".to_owned(),
523 };
524 let summary = outcome.summary();
525
526 assert_eq!(
527 summary,
528 "Stayed on v1 — the new version failed its health check (readiness probe timed out).",
529 );
530 // Copy rules: no apology or filler words.
531 for banned in ["sorry", "please", "unfortunately"] {
532 assert!(
533 !summary.to_lowercase().contains(banned),
534 "rollback summary must not contain {banned:?}",
535 );
536 }
537 }
538
539 #[test]
540 fn committed_outcome_summary_names_the_new_version() {
541 let summary = Outcome::Committed {
542 version: ReleaseId::new("v2"),
543 }
544 .summary();
545 assert_eq!(summary, "Now running v2.");
546 }
547
548 #[test]
549 fn download_failure_applies_nothing() {
550 let activator = RecordingActivator::new();
551 let mut stager = Stager::new(
552 |_: &ReleaseId| Err(StageError::Download("side location is full".to_owned())),
553 |_: &StagedArtifact| panic!("verify must not run when the download fails"),
554 activator.clone(),
555 || panic!("health check must not run when the download fails"),
556 ReleaseId::new("v1"),
557 );
558
559 let err = stager.stage_and_apply(&ReleaseId::new("v2")).unwrap_err();
560
561 assert!(matches!(err, StageError::Download(_)));
562 assert!(activator.flips().is_empty());
563 assert_eq!(stager.live(), &ReleaseId::new("v1"));
564 }
565
566 #[test]
567 fn apply_flip_failure_leaves_live_untouched() {
568 // The activator refuses to flip to the new version; the live pointer
569 // must stay on the previous one and nothing commits.
570 let activator = RecordingActivator::failing_on(ReleaseId::new("v2"));
571 let mut stager = Stager::new(
572 ok_source,
573 |_: &StagedArtifact| true,
574 activator.clone(),
575 || Health::Healthy,
576 ReleaseId::new("v1"),
577 );
578
579 let err = stager.stage_and_apply(&ReleaseId::new("v2")).unwrap_err();
580
581 assert!(matches!(err, StageError::Activate(_)));
582 assert!(activator.flips().is_empty());
583 assert_eq!(stager.live(), &ReleaseId::new("v1"));
584 assert_eq!(stager.previous(), None);
585 }
586
587 #[test]
588 fn a_second_update_restages_the_prior_live_as_previous() {
589 let activator = RecordingActivator::new();
590 let mut stager = Stager::new(
591 ok_source,
592 |_: &StagedArtifact| true,
593 activator.clone(),
594 || Health::Healthy,
595 ReleaseId::new("v1"),
596 );
597
598 stager.stage_and_apply(&ReleaseId::new("v2")).unwrap();
599 stager.stage_and_apply(&ReleaseId::new("v3")).unwrap();
600
601 assert_eq!(stager.live(), &ReleaseId::new("v3"));
602 // The rollback target tracks the most recent committed version.
603 assert_eq!(stager.previous(), Some(&ReleaseId::new("v2")));
604 assert_eq!(
605 activator.flips(),
606 vec![ReleaseId::new("v2"), ReleaseId::new("v3")]
607 );
608 }
609
610 #[test]
611 fn staged_artifact_carries_its_classified_bundle() {
612 // The download seam lands the artifact on a side path and carries the
613 // StagedBundle from the compat classifier straight through to apply.
614 let artifact = artifact_for(&ReleaseId::new("v2"));
615 assert!(
616 artifact
617 .staged_path
618 .starts_with("/var/lib/polychrome/staged")
619 );
620 assert_eq!(artifact.bundle.catalog_hash, "catalog-v2");
621 }
622}