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