Skip to main content

microvms_app/
history.rs

1// SPDX-License-Identifier: Apache-2.0
2//! What was asked of one MicroVM and what the platform reported back, as a record that
3//! outlives the VM.
4//!
5//! # A second file, not a wider ledger
6//!
7//! The obvious home for this was [`crate::ledger`], which already owns the state directory
8//! and the camelCase wire shape. It was assessed and refused: the ledger's `clear()`
9//! **deletes the file** when nothing leaked, because leak accounting wants records gone the
10//! moment they are resolved — a stale entry is a phantom leak `microvm ls` reports forever.
11//! History's whole value is the opposite property: the record survives terminate, because a
12//! caller attesting over a run needs it precisely after the VM is gone. One file serving
13//! both masters would corrupt one semantic to serve the other, so the two live apart and
14//! neither touches the other's files.
15//!
16//! # Append-only, and nothing ever deletes a history file
17//!
18//! There is no `clear()` here and no remove anywhere in this module. A terminated VM's
19//! history is the point, not the leftover.
20//!
21//! # `seq` is derived by counting, and single-writer is the assumption
22//!
23//! Each append reads the file and counts its lines. That is racy under two concurrent
24//! writers to the same VM's history — both would claim one `seq` — and it is accepted,
25//! stated here rather than papered over with a lock file: the realistic shape is one
26//! process driving one VM at a time, and a lock file in the state directory would be one
27//! more thing an interrupted process leaves behind. A reader that sees a duplicated `seq`
28//! is seeing two writers, which is itself information.
29//!
30//! # Every value is the platform's
31//!
32//! Events carry what the control plane and the daemon reported — identifiers, endpoints,
33//! exit codes, teardown verdicts — and never anything the guest printed. A record built
34//! from guest output would be a record the workload can forge.
35//!
36//! [`Event::HookObserved`] carries one caveat that must be stated rather than implied.
37//! The observation is the daemon's — a hook path posted, and the daemon's clock when it
38//! arrived — so no guest-printed byte ever enters the line, and the letter of the rule
39//! above holds. But the daemon's hook routes are unauthenticated and reachable over
40//! loopback from inside the guest (`docs/PROTOCOL.md`, "Trust boundary"), so a hostile
41//! workload can make the daemon observe *additional* firings by posting the hook paths
42//! itself. It cannot remove or alter real ones: the daemon records before responding,
43//! and its log keeps the earliest entries when capped — the platform's real lifecycle
44//! firings — while later spam is dropped and counted. Read a hook line as "the daemon
45//! saw this hook fire", never as "only the platform could have fired it".
46//!
47//! # Failures are swallowed, like the ledger's
48//!
49//! [`History::append`] runs on teardown paths, and a history write that raised would
50//! replace the caller's real failure with a filesystem one. An unwritable state directory
51//! costs the operator the record and nothing else.
52
53use std::sync::Arc;
54
55use crate::{clock::Clock, run_store::RunStore};
56
57use serde::{Deserialize, Serialize};
58
59/// One thing that happened to a VM, as the platform told it.
60///
61/// Internally tagged on `event` so a line reads `{"seq":0,"at":...,"event":"launched",...}` —
62/// the discriminant is the field a consumer branches on, the same shape the envelope's `type`
63/// takes. `camelCase` on the wire, matching the ledger.
64#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
65#[serde(
66    tag = "event",
67    rename_all = "camelCase",
68    rename_all_fields = "camelCase"
69)]
70pub enum Event {
71    /// This invocation's own build produced the image the VM launched from.
72    ///
73    /// Only `run` writes this, at run time, because only then is there a VM to key the
74    /// record by — a standalone `microvm build` has no VM id and therefore no history file
75    /// to append to.
76    ImageBuilt {
77        image_identifier: String,
78        image_name: String,
79    },
80    /// The launch was accepted. The identifier and endpoint are the service's own answer.
81    Launched {
82        image_identifier: String,
83        endpoint: String,
84        region: String,
85    },
86    /// One exec, as the daemon reported it.
87    ///
88    /// `exit_code` is `None` for a detached start (the outcome is not known yet) and for a
89    /// stream that was cut before its terminal event — reporting 0 for either would turn an
90    /// unfinished command into a passing one. `writers_may_be_alive` travels only when the
91    /// daemon's outcome carried it, which is why it is an `Option` rather than a defaulted
92    /// bool.
93    Exec {
94        exec_id: String,
95        exit_code: Option<i32>,
96        truncated: bool,
97        writers_may_be_alive: Option<bool>,
98    },
99    /// The daemon observed one lifecycle-hook invocation.
100    ///
101    /// Read off a health poll rather than pushed: the CLI appends the observations a
102    /// `GET /v1/health` reported wherever it already holds both a health envelope and a
103    /// VM id, deduplicated on the (hook, firedAt) pair so re-polling appends nothing.
104    /// `hook` is the route's own spelling (`ready`, `validate`, `run`, `suspend`,
105    /// `resume`, `terminate`) and `fired_at` is epoch seconds on the *daemon's* clock —
106    /// distinct from the record's own `at`, which is when this CLI learned of it.
107    /// See the module docs for the additive-forgery caveat this event carries.
108    HookObserved { hook: String, fired_at: u64 },
109    /// The service reported SUSPENDED.
110    Suspended,
111    /// The service reported RUNNING again.
112    Resumed,
113    /// A teardown ran. The fields are the [`crate::sandbox::TeardownReport`] shape:
114    /// whether the terminate call was accepted, and what a delete was asked for and did not
115    /// remove.
116    Terminated {
117        terminate_accepted: bool,
118        undeleted: Vec<String>,
119    },
120}
121
122/// One line on the wire: the counter, the clock, and the event flattened beside them.
123///
124/// `at` is seconds since the epoch, matching the ledger's `run_id` clock — the CLI's one
125/// timestamp convention, so a reader correlating a history line against a ledger file does
126/// no unit conversion.
127#[derive(Debug, Deserialize, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct Record {
130    pub seq: u64,
131    pub at: u64,
132    #[serde(flatten)]
133    pub event: Event,
134}
135
136/// Where one VM's history is appended.
137#[derive(Clone, Debug)]
138pub struct History {
139    vm_id: String,
140    store: Arc<dyn RunStore>,
141    clock: Arc<dyn Clock>,
142}
143
144impl History {
145    /// The history for `vm_id`, using the supplied store and wall clock.
146    ///
147    /// A subdirectory rather than the root itself, so `ledger::read_all`'s `*.json` glob and
148    /// this module's files can never be confused for one another by a tool listing the
149    /// state directory.
150    pub fn from_store(vm_id: &str, store: Arc<dyn RunStore>, clock: Arc<dyn Clock>) -> Self {
151        Self {
152            vm_id: vm_id.to_string(),
153            store,
154            clock,
155        }
156    }
157
158    /// Appends one event. Failures are swallowed.
159    ///
160    /// Deliberately, and for the ledger's reason: this runs on teardown paths, and a history
161    /// write that raised would replace the caller's real failure with a filesystem one. The
162    /// values are still in the envelope; only the durable record is lost.
163    ///
164    /// `seq` is the current line count — see the module docs on the single-writer
165    /// assumption that makes counting sufficient.
166    pub fn append(&self, event: Event) {
167        let seq = self
168            .store
169            .read_history(&self.vm_id)
170            .map(|text| text.lines().count() as u64)
171            .unwrap_or(0);
172        let record = Record {
173            seq,
174            at: self.clock.unix_now().as_secs(),
175            event,
176        };
177        let Ok(line) = serde_json::to_string(&record) else {
178            return;
179        };
180        let _ = self.store.append_history(&self.vm_id, &line);
181    }
182}
183
184/// Appends every hook observation from a health envelope that this VM's history has
185/// not seen, deduplicated on the (hook, firedAt) pair.
186///
187/// The dedup is what makes hook recording safe to run opportunistically — `run`'s
188/// teardown, `resume`, and `microvm health` all call this with whatever the daemon
189/// reported, and a pair already on file is not re-appended, so polling twice appends
190/// nothing. The comparison reads the file through [`read_events`] first, which keeps
191/// the writes inside the single command that owns the moment (the module's
192/// single-writer assumption) rather than adding a lock.
193///
194/// Failures are swallowed like every other append here: this runs on teardown paths.
195pub fn append_unseen_hooks(
196    history: &History,
197    observed: &[crate::protocol::health::HookObservation],
198) {
199    if observed.is_empty() {
200        return;
201    }
202    let mut seen: std::collections::HashSet<(String, u64)> =
203        read_events(history.store.as_ref(), &history.vm_id)
204            .iter()
205            .filter(|event| event["event"] == "hookObserved")
206            .filter_map(|event| {
207                Some((
208                    event["hook"].as_str()?.to_string(),
209                    event["firedAt"].as_u64()?,
210                ))
211            })
212            .collect();
213    for observation in observed {
214        // Inserted as it is appended, so a health body that repeats a pair — which a
215        // hostile guest posting the hook routes can produce — lands once.
216        if seen.insert((observation.hook.clone(), observation.fired_at)) {
217            history.append(Event::HookObserved {
218                hook: observation.hook.clone(),
219                fired_at: observation.fired_at,
220            });
221        }
222    }
223}
224
225/// Every event recorded for `vm_id` in `store`, oldest first.
226///
227/// A missing file is an empty result, not an error: asking about a VM this state dir never
228/// saw is a question, not a mistake — the caller may hold an id from another machine's
229/// state directory, and "no record here" is the honest answer.
230///
231/// An unparseable line becomes a record naming itself rather than being skipped, mirroring
232/// the ledger's unreadable convention: a truncated last line is what a process killed
233/// mid-append leaves, and that process is the one whose history most needs reading.
234pub fn read_events(store: &dyn RunStore, vm_id: &str) -> Vec<serde_json::Value> {
235    let Ok(text) = store.read_history(vm_id) else {
236        return Vec::new();
237    };
238    text.lines()
239        .enumerate()
240        .map(|(index, line)| {
241            serde_json::from_str::<serde_json::Value>(line)
242                .ok()
243                .filter(serde_json::Value::is_object)
244                .unwrap_or_else(|| {
245                    serde_json::json!({
246                        "line": index + 1,
247                        "unreadable": line,
248                    })
249                })
250        })
251        .collect()
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::testing::{MemoryRunStore, TestClock};
258    use serde_json::json;
259    use std::time::Duration;
260
261    #[test]
262    fn appends_preserve_old_lines_and_record_sequence_clock_and_event() {
263        let store = Arc::new(MemoryRunStore::default());
264        let original = "{\"seq\":0,\"at\":12,\"event\":\"resumed\"}\n";
265        store
266            .histories
267            .lock()
268            .unwrap()
269            .insert("vm".into(), original.into());
270        let clock = Arc::new(TestClock::new());
271        let now = clock.unix_now().as_secs();
272        let history = History::from_store("vm", store.clone(), clock.clone());
273        history.append(Event::Suspended);
274        clock.advance(Duration::from_secs(7));
275        history.append(Event::Terminated {
276            terminate_accepted: true,
277            undeleted: vec!["image".into()],
278        });
279        assert!(store.read_history("vm").unwrap().starts_with(original));
280        assert_eq!(
281            read_events(store.as_ref(), "vm"),
282            vec![
283                json!({"seq":0,"at":12,"event":"resumed"}),
284                json!({"seq":1,"at":now,"event":"suspended"}),
285                json!({"seq":2,"at":now+7,"event":"terminated","terminateAccepted":true,"undeleted":["image"]}),
286            ]
287        );
288    }
289
290    #[test]
291    fn reading_preserves_objects_and_reports_each_malformed_line() {
292        let store = MemoryRunStore::default();
293        assert!(read_events(&store, "missing").is_empty());
294        store
295            .histories
296            .lock()
297            .unwrap()
298            .insert("vm".into(), "{\"seq\":0}\nbroken\nfalse\n\n".into());
299        assert_eq!(
300            read_events(&store, "vm"),
301            vec![
302                json!({"seq":0}),
303                json!({"line":2,"unreadable":"broken"}),
304                json!({"line":3,"unreadable":"false"}),
305                json!({"line":4,"unreadable":""}),
306            ]
307        );
308    }
309
310    #[test]
311    fn observed_hooks_deduplicate_against_prior_lines_and_the_same_poll() {
312        let store = Arc::new(MemoryRunStore::default());
313        let history = History::from_store("vm", store.clone(), Arc::new(TestClock::new()));
314        append_unseen_hooks(&history, &[]);
315        assert!(store.histories.lock().unwrap().is_empty());
316        history.append(Event::HookObserved {
317            hook: "ready".into(),
318            fired_at: 100,
319        });
320        let observed = [
321            crate::protocol::health::HookObservation {
322                handler: None,
323                hook: "ready".into(),
324                fired_at: 100,
325            },
326            crate::protocol::health::HookObservation {
327                handler: None,
328                hook: "resume".into(),
329                fired_at: 101,
330            },
331            crate::protocol::health::HookObservation {
332                handler: None,
333                hook: "resume".into(),
334                fired_at: 101,
335            },
336            crate::protocol::health::HookObservation {
337                handler: None,
338                hook: "ready".into(),
339                fired_at: 102,
340            },
341        ];
342        append_unseen_hooks(&history, &observed);
343        append_unseen_hooks(&history, &observed);
344        let events = read_events(store.as_ref(), "vm");
345        assert_eq!(events.len(), 3);
346        assert_eq!(
347            events
348                .iter()
349                .map(|event| (&event["hook"], &event["firedAt"]))
350                .collect::<Vec<_>>(),
351            vec![
352                (&json!("ready"), &json!(100)),
353                (&json!("resume"), &json!(101)),
354                (&json!("ready"), &json!(102))
355            ]
356        );
357    }
358}