Skip to main content

appcore_update/
coordinator.rs

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