Skip to main content

microvms_protocol/
health.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The `GET /v1/health` response.
3
4use std::borrow::Cow;
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9/// Health response marker for an image-configured exec user. Its value is `1`;
10/// absent means the historical daemon/root default. Kept outside `Health` so
11/// older clients can still construct and decode that exhaustive wire type.
12pub const DEFAULT_USER_CONFIGURED_HEADER: &str = "microvms-default-user-configured";
13
14/// A value of `1` advertises guest power-off support and the daemon's current privilege.
15/// Absence is an older peer or an unsupported environment; clients must not send the opt-in.
16pub const POWEROFF_ON_EXIT_HEADER: &str = "microvm-poweroff-on-exit";
17
18/// `GET /v1/health` response.
19#[derive(Debug, Deserialize, JsonSchema, Serialize)]
20pub struct Health {
21    // The daemon's own version, distinct from `PROTOCOL_VERSION`. `Cow` so the
22    // daemon reports its `CARGO_PKG_VERSION` borrowed while a client deserializes
23    // into an owned string.
24    //
25    // Not a doc comment, deliberately: schemars publishes doc comments as
26    // `description` and `docs/schema.json` is byte-compared, so adding one here is a
27    // schema change. The field was undocumented before the extraction and stays so.
28    pub version: Cow<'static, str>,
29    pub bootstrapped: bool,
30    /// Free space on the daemon's working filesystem, and the reserve it is judged
31    /// against.
32    ///
33    /// Reported so disk pressure is something an orchestrator *watches* rather than
34    /// something it discovers from a failed write. anthropics/claude-code#59856
35    /// filled two 10 GB disks to 100% with never-collected session directories and
36    /// the first symptom was `useradd: No space left on device` — by which point
37    /// every writer in the sandbox was already broken. A number on a health endpoint
38    /// is what makes that curve visible while there is still time to act.
39    ///
40    /// `None` when free space could not be measured, which is deliberately distinct
41    /// from zero: unmeasurable is not full, and a monitor that conflated them would
42    /// page on a missing `statvfs`.
43    pub disk: Option<DiskHealth>,
44    /// Whether any startup identity repair step failed. True means the VM is serving
45    /// with a value from the shared image still in place — a duplicate machine-id or
46    /// boot_id — which is a security-relevant condition an operator may want to
47    /// drain the VM over, but is never a reason for the daemon to refuse to serve.
48    pub identity_degraded: bool,
49    /// False when identity repair was switched off by config. Distinguished from a
50    /// repair that ran and found nothing so a monitor can tell "opted out" from
51    /// "nothing to do".
52    pub identity_repaired: bool,
53    /// Whether any exec is still running right now.
54    ///
55    /// Here so that an orchestrator *outside* the VM can decide whether to keep the
56    /// VM alive, and the "outside" is the whole design. The platform measures
57    /// idleness by inbound traffic through the endpoint proxy, and that proxy
58    /// terminates outside the guest and forwards over loopback (measured;
59    /// `docs/PLATFORM.md`, "The platform's own hook arrives over loopback"). A
60    /// request a guest process sends to the daemon's own port never reaches the
61    /// proxy, so no amount of in-guest traffic can reset the idle timer. A route
62    /// that promised otherwise would be a keepalive that does not keep anything
63    /// alive, discovered when a multi-hour run auto-suspends mid-work.
64    ///
65    /// What does reset it is a poll from outside, and this field is what makes such
66    /// a poll *informed* rather than unconditional: the orchestrator polls, which is
67    /// itself the inbound traffic, and reads whether the workload is busy to decide
68    /// whether to keep polling. The assertion is therefore repeated and explicitly
69    /// the caller's, which is what the daemon self-keepaliving would not be — a hung
70    /// process would then bill to the 8-hour ceiling with nobody asking.
71    ///
72    /// Computed from the exec registry rather than remembered: true iff at least one
73    /// registered exec has not yet published a result. An exec that exited and is
74    /// waiting to be acked is not busy — its output is being *held*, not produced —
75    /// so an orchestrator does not keep a VM alive for a command that finished.
76    ///
77    /// `#[serde(default)]`, unlike every field above it, and the asymmetry is not an
78    /// oversight. The daemon is baked into an image while the client is installed
79    /// separately, so a current client routinely talks to a daemon from whenever that
80    /// image was built — and a required field would make `health()` fail outright
81    /// against a daemon that predates it, turning a missing signal into an
82    /// unreachable VM. False is also the right absence: a daemon that cannot say
83    /// whether it is busy has not asserted that it is.
84    #[serde(default)]
85    pub busy: bool,
86    /// How many execs are registered, in any phase.
87    ///
88    /// Alongside `busy` because the two answer different questions and a monitor
89    /// wants both: `busy: false, execs: 0` is a fresh or drained VM, while
90    /// `busy: false, execs: 7` is a VM holding seven unacked results that somebody
91    /// still has to collect. Terminating the second loses output nobody read.
92    ///
93    /// Defaulted for the same reason as `busy`: a client routinely talks to a daemon
94    /// baked into an older image, and zero is the honest reading of a daemon that
95    /// does not report a count.
96    #[serde(default)]
97    pub execs: usize,
98    /// Hook requests kept in the daemon's capped log, oldest first.
99    ///
100    /// Each entry records the requested hook path and the daemon's clock when
101    /// it recorded the request. The platform writes no CloudWatch logs for the
102    /// validate hook, so a kept entry provides a record of its receipt.
103    ///
104    /// Hook routes are unauthenticated and reachable over loopback from inside
105    /// the guest. An entry shows receipt, not the request's source identity.
106    /// The log keeps its earliest entries and counts later requests in
107    /// `hooks_dropped` without storing them, including later platform requests.
108    ///
109    /// Defaulted for `busy`'s reason: an older daemon omits the field, and an empty
110    /// list is the honest reading of a daemon that reports no observations.
111    #[serde(default)]
112    pub hooks: Vec<HookObservation>,
113    /// How many hook invocations were dropped once the log reached its cap.
114    ///
115    /// The log preserves its earliest entries. Later requests are counted here
116    /// without being stored or running a workload handler. Their routes still
117    /// perform other work and return their normal status.
118    ///
119    /// Defaulted like `hooks`: an older daemon omits it, and zero is the honest
120    /// reading of a daemon that dropped nothing it could tell us about.
121    #[serde(default)]
122    pub hooks_dropped: u64,
123    /// Each identity-repair step and its outcome, in the order they ran.
124    ///
125    /// Empty until repair runs, which is at the first successful run hook: repair at
126    /// daemon start would run in the image-build VM and be captured by the snapshot,
127    /// giving every VM the same "repaired" identity. `identity_degraded` says whether
128    /// any step failed; this says which one and why.
129    ///
130    /// Defaulted for `busy`'s reason: an older daemon omits it.
131    #[serde(default)]
132    pub identity_steps: Vec<IdentityStep>,
133    /// How many variables the daemon's image-environment snapshot holds, or `None` when
134    /// it holds no snapshot.
135    ///
136    /// The snapshot is the environment the daemon inherited as the container `CMD`, minus
137    /// every `AGENTD_*` variable, taken once at startup; a start request with
138    /// `inherit_image_env` starts its child from it. A count and never the values: this
139    /// route answers without the agent token, and an image's `ENV` can hold anything its
140    /// author put there.
141    ///
142    /// Defaulted for `busy`'s reason, and `None` is also what a daemon that predates
143    /// `inherit_image_env` reads as, which is how a caller tells whether the flag will be
144    /// honoured: such a daemon ignores the flag rather than refusing it.
145    #[serde(default)]
146    pub image_env_keys: Option<usize>,
147}
148
149/// One identity-repair step, as the daemon ran it.
150#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
151pub struct IdentityStep {
152    /// `machine-id`, `hostname`, `boot-id`, `random-seed`, or `cached-credential`.
153    pub name: String,
154    /// `repaired`, `not_applicable`, or `failed`.
155    pub outcome: String,
156    /// The OS error for a failed step. Names a path or an errno, never a value.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub error: Option<String>,
159}
160
161/// What a workload's handler for a lifecycle hook did.
162///
163/// The daemon runs `<hooks dir>/<hook>` when the image carries one. Output is not
164/// here: `/v1/health` is readable without the agent token, so a handler's output
165/// goes to the daemon's log instead.
166#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
167pub struct HandlerOutcome {
168    /// The handler's exit code, or `None` when it was killed or never started.
169    pub exit_code: Option<i32>,
170    /// The signal that ended it, if one did.
171    #[serde(default)]
172    pub signal: Option<i32>,
173    /// Whether the daemon killed it for exceeding its time budget.
174    #[serde(default)]
175    pub timed_out: bool,
176    /// Wall-clock milliseconds from spawn to exit or kill.
177    pub duration_ms: u64,
178    /// Why the handler could not run at all: not executable, or spawn failed.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub error: Option<String>,
181}
182
183impl HandlerOutcome {
184    /// Whether the handler ran and exited 0.
185    pub fn succeeded(&self) -> bool {
186        self.exit_code == Some(0) && !self.timed_out && self.error.is_none()
187    }
188}
189
190/// One lifecycle-hook invocation, as the daemon observed it.
191///
192/// A daemon-reported fact in the same trust class as an exec's exit code: the
193/// daemon's word about a request it served, carrying nothing the guest printed.
194#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
195pub struct HookObservation {
196    /// Which hook, spelled the way the platform's routes spell it: `ready`,
197    /// `validate`, `run`, `suspend`, `resume`, or `terminate`.
198    pub hook: String,
199    /// Seconds since the epoch on the daemon's clock when the invocation arrived.
200    ///
201    /// The daemon's clock rather than any caller's, and recorded before the handler
202    /// does any work — a run hook that was invoked and refused still fired.
203    pub fired_at: u64,
204    /// The workload handler's outcome, when the image carries a handler for this
205    /// hook. Absent on the wire otherwise, so an entry without one reads exactly as
206    /// it did before handlers existed.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub handler: Option<HandlerOutcome>,
209}
210
211/// The disk half of [`Health`].
212#[derive(Debug, Deserialize, JsonSchema, Serialize)]
213pub struct DiskHealth {
214    /// Bytes available to an unprivileged writer, from `statvfs` `f_bavail`.
215    pub available_bytes: u64,
216    /// Bytes that must stay free before a write is refused. Zero means the guard is
217    /// disabled.
218    pub reserve_bytes: u64,
219    /// Whether a write would be refused right now. Precomputed rather than left to
220    /// the client, so every consumer applies the same comparison the write path does.
221    pub under_pressure: bool,
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    /// `disk: null` is not `disk` absent: unmeasurable free space is distinct from
229    /// zero, and a client that read a missing key as zero would page on a missing
230    /// `statvfs`.
231    #[test]
232    fn health_reports_an_unmeasurable_disk_as_an_explicit_null() {
233        let written = serde_json::to_string(&Health {
234            version: Cow::Borrowed("0.1.0"),
235            bootstrapped: true,
236            disk: None,
237            identity_degraded: false,
238            identity_repaired: true,
239            busy: false,
240            execs: 0,
241            hooks: Vec::new(),
242            hooks_dropped: 0,
243            identity_steps: Vec::new(),
244            image_env_keys: None,
245        })
246        .expect("serializes");
247        assert!(written.contains(r#""disk":null"#), "{written}");
248
249        let read: Health = serde_json::from_str(&written).expect("deserializes");
250        assert_eq!(read.version, "0.1.0");
251        assert!(read.disk.is_none());
252    }
253
254    /// `busy` and `execs` round-trip, and they are separate facts: a VM holding
255    /// unacked results is not busy, and an orchestrator that conflated the two would
256    /// either keep a finished VM alive or terminate one whose output nobody read.
257    #[test]
258    fn health_carries_busy_and_the_exec_count_as_two_separate_facts() {
259        let written = serde_json::to_string(&Health {
260            version: Cow::Borrowed("0.1.0"),
261            bootstrapped: true,
262            disk: None,
263            identity_degraded: false,
264            identity_repaired: true,
265            busy: false,
266            execs: 7,
267            hooks: Vec::new(),
268            hooks_dropped: 0,
269            identity_steps: Vec::new(),
270            image_env_keys: None,
271        })
272        .expect("serializes");
273        assert!(written.contains(r#""busy":false"#), "{written}");
274        assert!(written.contains(r#""execs":7"#), "{written}");
275
276        let read: Health = serde_json::from_str(&written).expect("deserializes");
277        assert!(!read.busy);
278        assert_eq!(read.execs, 7);
279    }
280
281    /// The hook observations round-trip, spelled as the routes spell them, with the
282    /// dropped count beside them.
283    #[test]
284    fn health_carries_hook_observations_and_the_dropped_count() {
285        let written = serde_json::to_string(&Health {
286            version: Cow::Borrowed("0.1.0"),
287            bootstrapped: true,
288            disk: None,
289            identity_degraded: false,
290            identity_repaired: true,
291            busy: false,
292            execs: 0,
293            hooks: vec![
294                HookObservation {
295                    hook: "validate".to_string(),
296                    fired_at: 1_756_500_000,
297                    handler: None,
298                },
299                HookObservation {
300                    hook: "run".to_string(),
301                    fired_at: 1_756_500_100,
302                    handler: None,
303                },
304            ],
305            hooks_dropped: 3,
306            identity_steps: Vec::new(),
307            image_env_keys: None,
308        })
309        .expect("serializes");
310        // The wire keys are pinned to the response's own convention — snake_case,
311        // like `identity_degraded` beside them. The CLI's history line respells
312        // `fired_at` as `firedAt` because the history file is camelCase; a spelling
313        // only one side writes fails exactly when someone reads it.
314        assert!(
315            written.contains(r#""hooks":[{"hook":"validate","fired_at":1756500000}"#),
316            "{written}"
317        );
318        assert!(written.contains(r#""hooks_dropped":3"#), "{written}");
319
320        let read: Health = serde_json::from_str(&written).expect("deserializes");
321        assert_eq!(read.hooks.len(), 2);
322        assert_eq!(read.hooks[0].hook, "validate");
323        assert_eq!(read.hooks[0].fired_at, 1_756_500_000);
324        assert_eq!(read.hooks_dropped, 3);
325    }
326
327    /// An old daemon's envelope — no `hooks`, no `hooks_dropped` — still parses, with
328    /// the empty defaults. The daemon is baked into an image while the client is
329    /// installed separately, so this is the compatibility floor, not an edge case.
330    #[test]
331    fn an_old_daemons_envelope_parses_with_no_hook_fields() {
332        let read: Health = serde_json::from_str(
333            r#"{"version": "0.1.0", "bootstrapped": true, "disk": null,
334                 "identity_degraded": false, "identity_repaired": true}"#,
335        )
336        .expect("deserializes");
337        assert!(read.hooks.is_empty());
338        assert_eq!(read.hooks_dropped, 0);
339        assert!(!read.busy);
340        assert_eq!(read.execs, 0);
341    }
342
343    /// A handler outcome and the identity steps round-trip, and a hook with no
344    /// handler keeps the exact spelling older clients parse.
345    #[test]
346    fn handler_outcomes_and_identity_steps_round_trip() {
347        let written = serde_json::to_string(&Health {
348            version: Cow::Borrowed("0.1.0"),
349            bootstrapped: true,
350            disk: None,
351            identity_degraded: true,
352            identity_repaired: true,
353            busy: false,
354            execs: 0,
355            hooks: vec![
356                HookObservation {
357                    hook: "run".to_string(),
358                    fired_at: 1,
359                    handler: None,
360                },
361                HookObservation {
362                    hook: "suspend".to_string(),
363                    fired_at: 2,
364                    handler: Some(HandlerOutcome {
365                        exit_code: None,
366                        signal: Some(9),
367                        timed_out: true,
368                        duration_ms: 20_000,
369                        error: None,
370                    }),
371                },
372            ],
373            hooks_dropped: 0,
374            identity_steps: vec![IdentityStep {
375                name: "boot-id".to_string(),
376                outcome: "failed".to_string(),
377                error: Some("EPERM: Operation not permitted".to_string()),
378            }],
379            image_env_keys: None,
380        })
381        .expect("serializes");
382        assert!(
383            written.contains(r#"{"hook":"run","fired_at":1}"#),
384            "{written}"
385        );
386        let read: Health = serde_json::from_str(&written).expect("deserializes");
387        let handler = read.hooks[1].handler.as_ref().expect("a handler outcome");
388        assert!(handler.timed_out && !handler.succeeded());
389        assert_eq!(read.identity_steps[0].outcome, "failed");
390    }
391
392    /// AGENTD-13 on the wire: a count, and an older daemon's missing key reads as `None`,
393    /// the same answer as a daemon with no snapshot, so a caller cannot mistake a daemon
394    /// that would ignore `inherit_image_env` for one that honours it.
395    #[test]
396    fn the_image_env_count_round_trips_and_an_older_daemon_reads_as_none() {
397        let written = serde_json::to_value(Health {
398            version: Cow::Borrowed("0.1.0"),
399            bootstrapped: true,
400            disk: None,
401            identity_degraded: false,
402            identity_repaired: true,
403            busy: false,
404            execs: 0,
405            hooks: Vec::new(),
406            hooks_dropped: 0,
407            identity_steps: Vec::new(),
408            image_env_keys: Some(4),
409        })
410        .expect("serializes");
411        assert_eq!(written["image_env_keys"], 4);
412        let read: Health = serde_json::from_value(written).expect("deserializes");
413        assert_eq!(read.image_env_keys, Some(4));
414
415        let older: Health = serde_json::from_str(
416            r#"{"version":"0.9.0","bootstrapped":true,"disk":null,"identity_degraded":false,"identity_repaired":true}"#,
417        )
418        .expect("an older daemon's body deserializes");
419        assert_eq!(older.image_env_keys, None);
420    }
421}