Skip to main content

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}