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}
90
91/// The disk half of [`Health`].
92#[derive(Debug, Deserialize, JsonSchema, Serialize)]
93pub struct DiskHealth {
94    /// Bytes available to an unprivileged writer, from `statvfs` `f_bavail`.
95    pub available_bytes: u64,
96    /// Bytes that must stay free before a write is refused. Zero means the guard is
97    /// disabled.
98    pub reserve_bytes: u64,
99    /// Whether a write would be refused right now. Precomputed rather than left to
100    /// the client, so every consumer applies the same comparison the write path does.
101    pub under_pressure: bool,
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    /// `disk: null` is not `disk` absent: unmeasurable free space is distinct from
109    /// zero, and a client that read a missing key as zero would page on a missing
110    /// `statvfs`.
111    #[test]
112    fn health_reports_an_unmeasurable_disk_as_an_explicit_null() {
113        let written = serde_json::to_string(&Health {
114            version: Cow::Borrowed("0.1.0"),
115            bootstrapped: true,
116            disk: None,
117            identity_degraded: false,
118            identity_repaired: true,
119            busy: false,
120            execs: 0,
121        })
122        .expect("serializes");
123        assert!(written.contains(r#""disk":null"#), "{written}");
124
125        let read: Health = serde_json::from_str(&written).expect("deserializes");
126        assert_eq!(read.version, "0.1.0");
127        assert!(read.disk.is_none());
128    }
129
130    /// `busy` and `execs` round-trip, and they are separate facts: a VM holding
131    /// unacked results is not busy, and an orchestrator that conflated the two would
132    /// either keep a finished VM alive or terminate one whose output nobody read.
133    #[test]
134    fn health_carries_busy_and_the_exec_count_as_two_separate_facts() {
135        let written = serde_json::to_string(&Health {
136            version: Cow::Borrowed("0.1.0"),
137            bootstrapped: true,
138            disk: None,
139            identity_degraded: false,
140            identity_repaired: true,
141            busy: false,
142            execs: 7,
143        })
144        .expect("serializes");
145        assert!(written.contains(r#""busy":false"#), "{written}");
146        assert!(written.contains(r#""execs":7"#), "{written}");
147
148        let read: Health = serde_json::from_str(&written).expect("deserializes");
149        assert!(!read.busy);
150        assert_eq!(read.execs, 7);
151    }
152}