Skip to main content

appcore_update/
coordinator.rs

1use crate::{
2    sha256_hex, ActivationReceipt, ArtifactAuthenticityVerifier, ArtifactDescriptor, ArtifactStore,
3    StagedArtifact, UpdateError, UpdateProvider, UpdateRequest, UpdateResult,
4};
5use semver::Version;
6
7#[cfg(test)]
8struct TestOnlyArtifactVerifier;
9
10#[cfg(test)]
11impl ArtifactAuthenticityVerifier for TestOnlyArtifactVerifier {
12    fn verify(&self, _artifact: &ArtifactDescriptor) -> UpdateResult<()> {
13        Ok(())
14    }
15}
16
17#[cfg(test)]
18static TEST_ONLY_ARTIFACTS: TestOnlyArtifactVerifier = TestOnlyArtifactVerifier;
19
20struct VerifiedArtifact {
21    descriptor: ArtifactDescriptor,
22    bytes: Vec<u8>,
23}
24
25struct ActivatedArtifact {
26    descriptor: ArtifactDescriptor,
27    receipt: ActivationReceipt,
28}
29
30/// Health gate executed after an artifact becomes active.
31pub trait ActivationHealthCheck: Send + Sync {
32    /// Returns success only when the activated application is healthy.
33    fn check(&self, artifact: &ArtifactDescriptor) -> UpdateResult<()>;
34}
35
36/// Controlled lifecycle points available to fault-injection tests.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum UpdateFaultPoint {
39    /// After candidate selection and compatibility validation.
40    AfterSelection,
41    /// After download and checksum verification.
42    AfterVerification,
43    /// After staging but before activation.
44    AfterStaging,
45    /// After activation but before health verification.
46    AfterActivation,
47    /// After health verification but before commit.
48    BeforeCommit,
49}
50
51/// Fault-injection contract used by deterministic update tests.
52pub trait UpdateFaultInjector: Send + Sync {
53    /// Returns an error when execution should fail at `point`.
54    fn check(&self, point: UpdateFaultPoint) -> UpdateResult<()>;
55}
56
57/// Fault injector that never interrupts production execution.
58#[derive(Debug, Clone, Copy, Default)]
59pub struct NoUpdateFaults;
60
61impl UpdateFaultInjector for NoUpdateFaults {
62    fn check(&self, _point: UpdateFaultPoint) -> UpdateResult<()> {
63        Ok(())
64    }
65}
66
67/// Final result of one update attempt.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum UpdateOutcome {
70    /// Provider reported no eligible update.
71    NoUpdate,
72    /// Artifact passed activation and health verification.
73    Applied(ArtifactDescriptor),
74    /// Activation failed and the previous artifact was restored.
75    RolledBack {
76        /// Artifact whose activation failed.
77        attempted: ArtifactDescriptor,
78        /// Controlled failure that triggered rollback.
79        reason: String,
80    },
81}
82
83/// Result of staging and activating an update before process-level health verification.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum UpdatePreparation {
86    /// Provider reported no eligible update.
87    NoUpdate,
88    /// Candidate is active and awaits supervisor health verification.
89    AwaitingHealth(Box<ArtifactDescriptor>),
90}
91
92/// Result of candidate verification and staging before activation.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum UpdateStaging {
95    /// Provider reported no eligible update.
96    NoUpdate,
97    /// Verified candidate is staged but not active.
98    Staged(Box<StagedArtifact>),
99}
100
101/// Coordinates provider, store, integrity, health and rollback boundaries.
102pub struct UpdateCoordinator<'a> {
103    provider: &'a dyn UpdateProvider,
104    store: &'a dyn ArtifactStore,
105    health: Option<&'a dyn ActivationHealthCheck>,
106    authenticity: &'a dyn ArtifactAuthenticityVerifier,
107    max_artifact_bytes: usize,
108}
109
110impl<'a> UpdateCoordinator<'a> {
111    /// Creates a coordinator with an explicit artifact byte bound.
112    pub fn new(
113        provider: &'a dyn UpdateProvider,
114        store: &'a dyn ArtifactStore,
115        health: &'a dyn ActivationHealthCheck,
116        max_artifact_bytes: usize,
117    ) -> UpdateResult<Self> {
118        #[cfg(test)]
119        {
120            Self::new_with_authenticity(
121                provider,
122                store,
123                health,
124                &TEST_ONLY_ARTIFACTS,
125                max_artifact_bytes,
126            )
127        }
128        #[cfg(not(test))]
129        {
130            let _ = (provider, store, health, max_artifact_bytes);
131            Err(UpdateError::Authenticity(
132                "an explicit artifact authenticity verifier is required".to_string(),
133            ))
134        }
135    }
136
137    /// Creates a coordinator with an explicit artifact authenticity policy.
138    pub fn new_with_authenticity(
139        provider: &'a dyn UpdateProvider,
140        store: &'a dyn ArtifactStore,
141        health: &'a dyn ActivationHealthCheck,
142        authenticity: &'a dyn ArtifactAuthenticityVerifier,
143        max_artifact_bytes: usize,
144    ) -> UpdateResult<Self> {
145        if max_artifact_bytes == 0 {
146            return Err(UpdateError::InvalidArtifact(
147                "max_artifact_bytes must be greater than zero".to_string(),
148            ));
149        }
150        Ok(Self {
151            provider,
152            store,
153            health: Some(health),
154            authenticity,
155            max_artifact_bytes,
156        })
157    }
158
159    /// Creates a staging coordinator for process-level health verification.
160    pub fn new_for_preparation(
161        provider: &'a dyn UpdateProvider,
162        store: &'a dyn ArtifactStore,
163        authenticity: &'a dyn ArtifactAuthenticityVerifier,
164        max_artifact_bytes: usize,
165    ) -> UpdateResult<Self> {
166        if max_artifact_bytes == 0 {
167            return Err(UpdateError::InvalidArtifact(
168                "max_artifact_bytes must be greater than zero".to_string(),
169            ));
170        }
171        Ok(Self {
172            provider,
173            store,
174            health: None,
175            authenticity,
176            max_artifact_bytes,
177        })
178    }
179
180    /// Applies an update using the production no-fault path.
181    pub fn apply(
182        &self,
183        request: &UpdateRequest,
184        runtime_version: &str,
185        protocol_version: &str,
186    ) -> UpdateResult<UpdateOutcome> {
187        self.apply_with_faults(request, runtime_version, protocol_version, &NoUpdateFaults)
188    }
189
190    /// Stages and activates an update without committing it.
191    ///
192    /// An application parent uses this two-phase path when the candidate must be
193    /// restarted and probed before [`ArtifactStore::commit`] or rollback.
194    pub fn prepare(
195        &self,
196        request: &UpdateRequest,
197        runtime_version: &str,
198        protocol_version: &str,
199    ) -> UpdateResult<UpdatePreparation> {
200        match self.stage_candidate(request, runtime_version, protocol_version)? {
201            UpdateStaging::NoUpdate => Ok(UpdatePreparation::NoUpdate),
202            UpdateStaging::Staged(staged) => {
203                let receipt = self.store.activate(*staged)?;
204                Ok(UpdatePreparation::AwaitingHealth(Box::new(
205                    receipt.activated,
206                )))
207            }
208        }
209    }
210
211    /// Verifies and stages a candidate without changing the active artifact.
212    pub fn stage_candidate(
213        &self,
214        request: &UpdateRequest,
215        runtime_version: &str,
216        protocol_version: &str,
217    ) -> UpdateResult<UpdateStaging> {
218        self.store.recover()?;
219        let Some(candidate) = self.select_candidate(request, runtime_version, protocol_version)?
220        else {
221            return Ok(UpdateStaging::NoUpdate);
222        };
223        let verified = self.fetch_verified(candidate)?;
224        let staged = self.store.stage(&verified.descriptor, &verified.bytes)?;
225        Ok(UpdateStaging::Staged(Box::new(staged)))
226    }
227
228    /// Applies an update while exposing deterministic lifecycle fault points.
229    pub fn apply_with_faults(
230        &self,
231        request: &UpdateRequest,
232        runtime_version: &str,
233        protocol_version: &str,
234        faults: &dyn UpdateFaultInjector,
235    ) -> UpdateResult<UpdateOutcome> {
236        self.store.recover()?;
237        let Some(candidate) = self.select_candidate(request, runtime_version, protocol_version)?
238        else {
239            return Ok(UpdateOutcome::NoUpdate);
240        };
241        faults.check(UpdateFaultPoint::AfterSelection)?;
242        let verified = self.fetch_verified(candidate)?;
243        faults.check(UpdateFaultPoint::AfterVerification)?;
244        let activated = self.stage_and_activate(verified, faults)?;
245        self.finish_activation(activated, faults)
246    }
247
248    fn select_candidate(
249        &self,
250        request: &UpdateRequest,
251        runtime_version: &str,
252        protocol_version: &str,
253    ) -> UpdateResult<Option<ArtifactDescriptor>> {
254        let Some(candidate) = self.provider.latest(request)? else {
255            return Ok(None);
256        };
257        if candidate.application_id() != &request.application_id {
258            return Err(UpdateError::Incompatible(
259                "artifact application identity differs from the request".to_string(),
260            ));
261        }
262        if candidate.channel() != request.channel {
263            return Err(UpdateError::Incompatible(
264                "artifact update channel differs from the request".to_string(),
265            ));
266        }
267        self.ensure_upgrade(request, &candidate)?;
268        candidate.ensure_compatible(runtime_version, protocol_version)?;
269        Ok(Some(candidate))
270    }
271
272    fn ensure_upgrade(
273        &self,
274        request: &UpdateRequest,
275        candidate: &ArtifactDescriptor,
276    ) -> UpdateResult<()> {
277        let installed = Version::parse(&request.current_version).map_err(|error| {
278            UpdateError::Incompatible(format!("invalid installed application version: {error}"))
279        })?;
280        let candidate_version =
281            Version::parse(candidate.application_version()).map_err(|error| {
282                UpdateError::InvalidArtifact(format!(
283                    "invalid candidate application version: {error}"
284                ))
285            })?;
286        if candidate_version <= installed {
287            return Err(UpdateError::Incompatible(format!(
288                "candidate version {candidate_version} does not advance installed version {installed}"
289            )));
290        }
291        let Some(active) = self.store.current()? else {
292            return Ok(());
293        };
294        if active.application_id() != candidate.application_id() {
295            return Err(UpdateError::Incompatible(
296                "active artifact application identity differs from the candidate".to_string(),
297            ));
298        }
299        if active.build_id() == candidate.build_id() {
300            return Err(UpdateError::Incompatible(
301                "candidate reuses the active build identity".to_string(),
302            ));
303        }
304        let active_version = Version::parse(active.application_version()).map_err(|error| {
305            UpdateError::Store(format!("active artifact version is invalid: {error}"))
306        })?;
307        if candidate_version <= active_version {
308            return Err(UpdateError::Incompatible(format!(
309                "candidate version {candidate_version} does not advance active version {active_version}"
310            )));
311        }
312        Ok(())
313    }
314
315    fn fetch_verified(&self, candidate: ArtifactDescriptor) -> UpdateResult<VerifiedArtifact> {
316        let declared_size =
317            usize::try_from(candidate.size_bytes()).map_err(|_| UpdateError::ArtifactTooLarge {
318                max_bytes: self.max_artifact_bytes,
319            })?;
320        if declared_size > self.max_artifact_bytes {
321            return Err(UpdateError::ArtifactTooLarge {
322                max_bytes: self.max_artifact_bytes,
323            });
324        }
325        let bytes = self.provider.fetch(&candidate, self.max_artifact_bytes)?;
326        if bytes.len() > self.max_artifact_bytes || bytes.len() != declared_size {
327            return Err(UpdateError::ArtifactTooLarge {
328                max_bytes: self.max_artifact_bytes,
329            });
330        }
331        if sha256_hex(&bytes) != candidate.sha256() {
332            return Err(UpdateError::ChecksumMismatch);
333        }
334        self.authenticity.verify(&candidate)?;
335        Ok(VerifiedArtifact {
336            descriptor: candidate,
337            bytes,
338        })
339    }
340
341    fn stage_and_activate(
342        &self,
343        verified: VerifiedArtifact,
344        faults: &dyn UpdateFaultInjector,
345    ) -> UpdateResult<ActivatedArtifact> {
346        let staged = self.store.stage(&verified.descriptor, &verified.bytes)?;
347        faults.check(UpdateFaultPoint::AfterStaging)?;
348        let receipt = self.store.activate(staged)?;
349        Ok(ActivatedArtifact {
350            descriptor: verified.descriptor,
351            receipt,
352        })
353    }
354
355    fn finish_activation(
356        &self,
357        activated: ActivatedArtifact,
358        faults: &dyn UpdateFaultInjector,
359    ) -> UpdateResult<UpdateOutcome> {
360        let health = self.health.ok_or_else(|| {
361            UpdateError::Health("activation health check is not configured".to_string())
362        })?;
363        let result = faults
364            .check(UpdateFaultPoint::AfterActivation)
365            .and_then(|_| health.check(&activated.descriptor))
366            .and_then(|_| faults.check(UpdateFaultPoint::BeforeCommit))
367            .and_then(|_| self.store.commit(&activated.receipt));
368        match result {
369            Ok(()) => Ok(UpdateOutcome::Applied(activated.descriptor)),
370            Err(cause) => self.rollback_after_failure(activated, cause),
371        }
372    }
373
374    fn rollback_after_failure(
375        &self,
376        activated: ActivatedArtifact,
377        cause: UpdateError,
378    ) -> UpdateResult<UpdateOutcome> {
379        match self.store.rollback(&activated.receipt) {
380            Ok(()) => Ok(UpdateOutcome::RolledBack {
381                attempted: activated.descriptor,
382                reason: cause.to_string(),
383            }),
384            Err(rollback) => Err(UpdateError::RollbackFailed {
385                cause: cause.to_string(),
386                rollback: rollback.to_string(),
387            }),
388        }
389    }
390}