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}