appcore_update/
activation.rs1use crate::{
14 ActivationReceiptV2, ArtifactDescriptor, RecoveryAction, RecoveryDecision, StagedArtifact,
15 UpdateError, UpdateResult,
16};
17
18const MAX_ATTEMPT_ID_BYTES: usize = 128;
19const MAX_HOST_BINDING_BYTES: usize = 512;
20
21#[derive(Clone, PartialEq, Eq)]
23pub struct ActivationRequest {
24 pub attempt_id: String,
26 pub descriptor: ArtifactDescriptor,
28 pub previous: Option<ArtifactDescriptor>,
30 pub host_binding: Option<String>,
32}
33
34impl ActivationRequest {
35 pub fn validate(&self) -> UpdateResult<()> {
37 validate_text("attempt_id", &self.attempt_id, MAX_ATTEMPT_ID_BYTES)?;
38 self.descriptor.validate()?;
39 if let Some(previous) = &self.previous {
40 previous.validate()?;
41 }
42 if let Some(binding) = &self.host_binding {
43 validate_text("host_binding", binding, MAX_HOST_BINDING_BYTES)?;
44 }
45 Ok(())
46 }
47}
48
49#[derive(Clone, PartialEq, Eq)]
51pub struct ActivationEvidence {
52 pub attempt_id: String,
54 pub descriptor: ArtifactDescriptor,
56 pub host_binding: Option<String>,
58}
59
60impl ActivationEvidence {
61 pub fn validate(&self) -> UpdateResult<()> {
63 validate_text("attempt_id", &self.attempt_id, MAX_ATTEMPT_ID_BYTES)?;
64 self.descriptor.validate()?;
65 if let Some(binding) = &self.host_binding {
66 validate_text("host_binding", binding, MAX_HOST_BINDING_BYTES)?;
67 }
68 Ok(())
69 }
70
71 pub fn into_receipt(
73 self,
74 previous: Option<ArtifactDescriptor>,
75 created_at_ms: u64,
76 ) -> UpdateResult<ActivationReceiptV2> {
77 self.validate()?;
78 if let Some(previous) = &previous {
79 previous.validate()?;
80 }
81 Ok(ActivationReceiptV2 {
82 format_version: crate::ACTIVATION_V2_FORMAT_VERSION,
83 attempt_id: self.attempt_id,
84 phase: crate::ActivationPhaseV2::ActivatedPendingHealth,
85 activated: self.descriptor,
86 previous,
87 created_at_ms,
88 updated_at_ms: created_at_ms,
89 host_binding: self.host_binding,
90 failure_reason: None,
91 })
92 }
93}
94
95pub trait ActivationAdapter: Send + Sync {
101 fn prepare(&self, request: &ActivationRequest, staged: &StagedArtifact) -> UpdateResult<()>;
103 fn activate(
105 &self,
106 request: &ActivationRequest,
107 staged: &StagedArtifact,
108 ) -> UpdateResult<ActivationEvidence>;
109 fn healthcheck(&self, evidence: &ActivationEvidence) -> UpdateResult<()>;
111 fn commit(&self, receipt: &ActivationReceiptV2) -> UpdateResult<()>;
113 fn rollback(&self, receipt: &ActivationReceiptV2) -> UpdateResult<()>;
115 fn recover(&self, decision: &RecoveryDecision) -> UpdateResult<RecoveryAction>;
117}
118
119fn validate_text(name: &str, value: &str, max: usize) -> UpdateResult<()> {
120 if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
121 return Err(UpdateError::Recovery(format!(
122 "activation {name} is empty, too long or contains control characters"
123 )));
124 }
125 Ok(())
126}
127
128impl std::fmt::Debug for ActivationRequest {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 f.debug_struct("ActivationRequest")
131 .field("has_previous", &self.previous.is_some())
132 .field("has_host_binding", &self.host_binding.is_some())
133 .finish_non_exhaustive()
134 }
135}
136impl std::fmt::Debug for ActivationEvidence {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 f.debug_struct("ActivationEvidence")
139 .field("has_host_binding", &self.host_binding.is_some())
140 .finish_non_exhaustive()
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use appcore_contracts::{ApplicationId, BuildId};
148
149 fn descriptor() -> ArtifactDescriptor {
150 ArtifactDescriptor::new(
151 ApplicationId::new("app-a").unwrap(),
152 "1.1.0",
153 BuildId::new("build-a").unwrap(),
154 "stable",
155 ">=0.6.0, <1.0.0",
156 "1",
157 "memory:build-a",
158 "00".repeat(32),
159 1,
160 )
161 .unwrap()
162 }
163
164 #[test]
165 fn activation_evidence_becomes_pending_v2_receipt() {
166 let evidence = ActivationEvidence {
167 attempt_id: "attempt-a".to_string(),
168 descriptor: descriptor(),
169 host_binding: Some("host-a".to_string()),
170 };
171 let receipt = evidence.into_receipt(None, 100).unwrap();
172 assert_eq!(
173 receipt.phase,
174 crate::ActivationPhaseV2::ActivatedPendingHealth
175 );
176 assert_eq!(receipt.attempt_id, "attempt-a");
177 }
178
179 #[test]
180 fn activation_context_rejects_unbounded_identifiers() {
181 let request = ActivationRequest {
182 attempt_id: "bad\nattempt".to_string(),
183 descriptor: descriptor(),
184 previous: None,
185 host_binding: None,
186 };
187 assert!(matches!(
188 request.validate(),
189 Err(UpdateError::Recovery(message)) if message.contains("attempt_id")
190 ));
191 }
192}