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