Skip to main content

update_rs/
state.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::fmt::Display;
4use std::path::PathBuf;
5
6/// The phase of the three-phase update process which an [`UpdateState`] is in.
7///
8/// The update is driven forward by relaunching the application between phases,
9/// passing a serialized [`UpdateState`] each time (see
10/// [`RESUME_FLAG`](crate::RESUME_FLAG)). Each phase runs from a *different*
11/// binary so that the running executable is never asked to overwrite itself.
12#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
13pub enum UpdatePhase {
14    /// No update is in progress (the default, used when resuming with no state).
15    #[default]
16    #[serde(rename = "no-update")]
17    NoUpdate,
18    /// The original application has downloaded the new binary and is about to
19    /// launch it to perform the `replace` phase.
20    #[serde(rename = "prepare")]
21    Prepare,
22    /// The freshly downloaded binary overwrites the original application and
23    /// launches it to perform the `cleanup` phase.
24    #[serde(rename = "replace")]
25    Replace,
26    /// The updated original application removes the temporary downloaded binary.
27    #[serde(rename = "cleanup")]
28    Cleanup,
29}
30
31impl Display for UpdatePhase {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            UpdatePhase::NoUpdate => write!(f, "no-update"),
35            UpdatePhase::Prepare => write!(f, "prepare"),
36            UpdatePhase::Replace => write!(f, "replace"),
37            UpdatePhase::Cleanup => write!(f, "cleanup"),
38        }
39    }
40}
41
42/// The serializable state which is threaded through the three phases of an
43/// update by relaunching the application with the
44/// [`RESUME_FLAG`](crate::RESUME_FLAG) followed by this value as JSON.
45#[derive(Debug, Default, Clone, Serialize, Deserialize, Eq, PartialEq)]
46pub struct UpdateState {
47    /// The path to the application binary which is being updated.
48    #[serde(rename = "app", default, skip_serializing_if = "Option::is_none")]
49    pub target_application: Option<PathBuf>,
50
51    /// The path to the temporary binary which the new release was downloaded to.
52    #[serde(rename = "update", default, skip_serializing_if = "Option::is_none")]
53    pub temporary_application: Option<PathBuf>,
54
55    /// A trace-context propagation carrier (e.g. the W3C `traceparent` /
56    /// `tracestate` headers) captured from the phase that relaunched the
57    /// application. With the `opentelemetry` feature it lets the phases of an
58    /// update — which each run in a separate process — continue a single
59    /// distributed trace. The field is always part of the wire format (so states
60    /// stay compatible across feature configurations) but is only populated and
61    /// consumed when that feature is enabled.
62    #[serde(rename = "trace", default, skip_serializing_if = "Option::is_none")]
63    pub trace_context: Option<HashMap<String, String>>,
64
65    /// The phase of the update process which this state represents.
66    pub phase: UpdatePhase,
67}
68
69impl UpdateState {
70    /// Produce a copy of this state advanced to the provided [`UpdatePhase`],
71    /// preserving the application paths and any captured trace context.
72    pub fn for_phase(&self, phase: UpdatePhase) -> Self {
73        UpdateState {
74            target_application: self.target_application.clone(),
75            temporary_application: self.temporary_application.clone(),
76            trace_context: self.trace_context.clone(),
77            phase,
78        }
79    }
80
81    /// Capture the active OpenTelemetry trace context into this state so the next
82    /// phase — launched as a separate process — can continue the same
83    /// distributed trace.
84    ///
85    /// Only the *global* propagator
86    /// (`opentelemetry::global::get_text_map_propagator`) is consulted, so this
87    /// honours whatever propagation the host application configured. It is a
88    /// no-op when the `opentelemetry` feature is disabled, or when no propagator
89    /// is installed (in which case nothing is captured and the state stays
90    /// untouched).
91    #[cfg(feature = "opentelemetry")]
92    pub(crate) fn capture_trace_context(&mut self) {
93        use tracing_opentelemetry::OpenTelemetrySpanExt;
94
95        let context = tracing::Span::current().context();
96        let mut carrier = HashMap::new();
97        opentelemetry::global::get_text_map_propagator(|propagator| {
98            propagator.inject_context(&context, &mut carrier);
99        });
100
101        // Only carry a context when the propagator actually produced one, so a
102        // host without OpenTelemetry configured doesn't bloat the state payload.
103        if !carrier.is_empty() {
104            self.trace_context = Some(carrier);
105        }
106    }
107
108    /// No-op stand-in used when the `opentelemetry` feature is disabled.
109    #[cfg(not(feature = "opentelemetry"))]
110    pub(crate) fn capture_trace_context(&mut self) {}
111
112    /// Adopt the trace context carried in this state (if any) as the parent of
113    /// `span`, continuing the distributed trace started by the phase that
114    /// relaunched us.
115    ///
116    /// This **must** be called before `span` is entered: a span's trace identity
117    /// is fixed once it becomes active, so re-parenting afterwards would have no
118    /// effect. It is a no-op when the `opentelemetry` feature is disabled or no
119    /// context was carried.
120    #[cfg(feature = "tracing")]
121    pub(crate) fn adopt_trace_context(&self, span: &tracing::Span) {
122        #[cfg(feature = "opentelemetry")]
123        {
124            use tracing_opentelemetry::OpenTelemetrySpanExt;
125
126            if let Some(carrier) = &self.trace_context {
127                let parent = opentelemetry::global::get_text_map_propagator(|propagator| {
128                    propagator.extract(carrier)
129                });
130                // Best-effort: if no OpenTelemetry layer is installed there is
131                // simply no span to re-parent, which is fine.
132                let _ = span.set_parent(parent);
133            }
134        }
135
136        // Without `opentelemetry` there is no context to adopt.
137        #[cfg(not(feature = "opentelemetry"))]
138        let _ = span;
139    }
140}
141
142impl Display for UpdateState {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        write!(f, "{:?}", self.phase)
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_serialize() {
154        assert_eq!(
155            serde_json::to_string(&UpdateState {
156                target_application: Some(PathBuf::from("/bin/app")),
157                temporary_application: Some(PathBuf::from("/tmp/app-update")),
158                trace_context: None,
159                phase: UpdatePhase::Replace
160            })
161            .unwrap(),
162            r#"{"app":"/bin/app","update":"/tmp/app-update","phase":"replace"}"#
163        );
164
165        assert_eq!(
166            serde_json::to_string(&UpdateState {
167                target_application: None,
168                temporary_application: Some(PathBuf::from("/tmp/app-update")),
169                trace_context: None,
170                phase: UpdatePhase::Cleanup
171            })
172            .unwrap(),
173            r#"{"update":"/tmp/app-update","phase":"cleanup"}"#
174        );
175    }
176
177    #[test]
178    fn test_deserialize() {
179        let update = UpdateState {
180            target_application: None,
181            temporary_application: Some(PathBuf::from("/tmp/app-update")),
182            trace_context: None,
183            phase: UpdatePhase::Cleanup,
184        };
185
186        let deserialized: UpdateState =
187            serde_json::from_str(r#"{"update":"/tmp/app-update","phase":"cleanup"}"#).unwrap();
188        assert_eq!(deserialized, update);
189    }
190
191    #[test]
192    fn test_trace_context_round_trips_through_json() {
193        let mut carrier = HashMap::new();
194        carrier.insert(
195            "traceparent".to_string(),
196            "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01".to_string(),
197        );
198        let state = UpdateState {
199            target_application: Some(PathBuf::from("/bin/app")),
200            temporary_application: Some(PathBuf::from("/tmp/app-update")),
201            trace_context: Some(carrier),
202            phase: UpdatePhase::Replace,
203        };
204
205        let json = serde_json::to_string(&state).unwrap();
206        assert!(
207            json.contains("\"trace\":{"),
208            "the carrier should serialize under the `trace` key: {json}"
209        );
210
211        let restored: UpdateState = serde_json::from_str(&json).unwrap();
212        assert_eq!(
213            restored, state,
214            "the trace context should survive a serialize/deserialize round-trip"
215        );
216    }
217
218    #[test]
219    fn test_to_string() {
220        assert_eq!(UpdatePhase::Prepare.to_string(), "prepare");
221        assert_eq!(UpdatePhase::Replace.to_string(), "replace");
222        assert_eq!(UpdatePhase::Cleanup.to_string(), "cleanup");
223        assert_eq!(UpdatePhase::NoUpdate.to_string(), "no-update");
224    }
225
226    #[test]
227    fn test_for_phase() {
228        let mut carrier = HashMap::new();
229        carrier.insert("traceparent".to_string(), "abc".to_string());
230        let update = UpdateState {
231            target_application: Some(PathBuf::from("/bin/app")),
232            temporary_application: Some(PathBuf::from("/tmp/app-update")),
233            trace_context: Some(carrier),
234            phase: UpdatePhase::Replace,
235        };
236
237        let new_update = update.for_phase(UpdatePhase::Cleanup);
238        assert_eq!(new_update.target_application, update.target_application);
239        assert_eq!(
240            new_update.temporary_application,
241            update.temporary_application
242        );
243        assert_eq!(
244            new_update.trace_context, update.trace_context,
245            "the trace context should be carried forward to the next phase"
246        );
247        assert_eq!(
248            update.phase,
249            UpdatePhase::Replace,
250            "the old update entry should not be modified"
251        );
252        assert_eq!(
253            new_update.phase,
254            UpdatePhase::Cleanup,
255            "the new update entry should have the correct phase"
256        );
257    }
258}
259
260/// End-to-end check that the active trace context survives a (simulated)
261/// relaunch when the `opentelemetry` feature is enabled: capture it under one
262/// span, round-trip the state through JSON as it would cross the process
263/// boundary, then adopt it under a fresh span and confirm the trace continues.
264#[cfg(all(test, feature = "opentelemetry"))]
265mod opentelemetry_tests {
266    use super::*;
267    use opentelemetry::trace::TracerProvider as _;
268    use opentelemetry_sdk::propagation::TraceContextPropagator;
269    use opentelemetry_sdk::trace::SdkTracerProvider;
270    use tracing_subscriber::prelude::*;
271
272    /// The trace id embedded in a W3C `traceparent` header
273    /// (`00-<trace id>-<span id>-<flags>`).
274    fn trace_id_of(carrier: &HashMap<String, String>) -> String {
275        carrier
276            .get("traceparent")
277            .expect("the W3C propagator should emit a traceparent header")
278            .split('-')
279            .nth(1)
280            .expect("a traceparent has a trace-id segment")
281            .to_string()
282    }
283
284    fn subscriber() -> impl tracing::Subscriber {
285        let tracer = SdkTracerProvider::builder()
286            .build()
287            .tracer("update-rs-test");
288        tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer))
289    }
290
291    #[test]
292    fn trace_context_continues_across_a_relaunch() {
293        opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
294
295        // Phase N captures the context of the currently active span.
296        let captured = tracing::subscriber::with_default(subscriber(), || {
297            let span = tracing::info_span!("phase-n");
298            let _enter = span.enter();
299            let mut state = UpdateState {
300                phase: UpdatePhase::Replace,
301                ..Default::default()
302            };
303            state.capture_trace_context();
304            state
305        });
306
307        let carrier = captured
308            .trace_context
309            .clone()
310            .expect("an active span should produce a trace context");
311        let expected_trace_id = trace_id_of(&carrier);
312
313        // The state crosses the process boundary as JSON.
314        let json = serde_json::to_string(&captured).unwrap();
315        let resumed: UpdateState = serde_json::from_str(&json).unwrap();
316
317        // Phase N+1 adopts the carried context onto a fresh span *before*
318        // entering it (mirroring `UpdateManager::resume`); a span opened within
319        // it then belongs to the same trace, which we observe by re-capturing.
320        let continued_trace_id = tracing::subscriber::with_default(subscriber(), || {
321            let span = tracing::info_span!("phase-n-plus-1");
322            resumed.adopt_trace_context(&span);
323            span.in_scope(|| {
324                let mut next = UpdateState {
325                    phase: UpdatePhase::Cleanup,
326                    ..Default::default()
327                };
328                next.capture_trace_context();
329                trace_id_of(
330                    &next
331                        .trace_context
332                        .expect("the adopted span should produce a trace context"),
333                )
334            })
335        });
336
337        assert_eq!(
338            continued_trace_id, expected_trace_id,
339            "the resumed phase should continue the trace captured before the relaunch"
340        );
341    }
342}