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}