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 /// Every lifecycle-hook invocation the daemon observed, oldest first.
90 ///
91 /// The platform writes no CloudWatch logs for the validate hook, so "did my
92 /// validate hook even run?" has no answer anywhere else. Each entry is the
93 /// daemon's own observation — the hook path that was posted and the daemon's
94 /// clock when it arrived — never anything the guest printed.
95 ///
96 /// One trust caveat travels with this field and is stated rather than implied:
97 /// the hook routes are unauthenticated and reachable over loopback from inside
98 /// the guest, so a hostile workload can *forge additional* entries by posting
99 /// the hook paths itself. It cannot remove or alter real ones — the daemon
100 /// records before it responds and the log is append-capped, keeping the
101 /// earliest entries — so the platform's real firings are the front of the list
102 /// and later spam is what the cap drops.
103 ///
104 /// Defaulted for `busy`'s reason: an older daemon omits the field, and an empty
105 /// list is the honest reading of a daemon that reports no observations.
106 #[serde(default)]
107 pub hooks: Vec<HookObservation>,
108 /// How many hook invocations were dropped once the log reached its cap.
109 ///
110 /// Non-zero means the list above is the *earliest* invocations only — the cap
111 /// keeps first-N so a guest spamming the unauthenticated hook routes cannot
112 /// grow daemon memory or push the platform's real firings out of the record.
113 ///
114 /// Defaulted like `hooks`: an older daemon omits it, and zero is the honest
115 /// reading of a daemon that dropped nothing it could tell us about.
116 #[serde(default)]
117 pub hooks_dropped: u64,
118 /// Each identity-repair step and its outcome, in the order they ran.
119 ///
120 /// Empty until repair runs, which is at the first successful run hook: repair at
121 /// daemon start would run in the image-build VM and be captured by the snapshot,
122 /// giving every VM the same "repaired" identity. `identity_degraded` says whether
123 /// any step failed; this says which one and why.
124 ///
125 /// Defaulted for `busy`'s reason: an older daemon omits it.
126 #[serde(default)]
127 pub identity_steps: Vec<IdentityStep>,
128 /// How many variables the daemon's image-environment snapshot holds, or `None` when
129 /// it holds no snapshot.
130 ///
131 /// The snapshot is the environment the daemon inherited as the container `CMD`, minus
132 /// every `AGENTD_*` variable, taken once at startup; a start request with
133 /// `inherit_image_env` starts its child from it. A count and never the values: this
134 /// route answers without the agent token, and an image's `ENV` can hold anything its
135 /// author put there.
136 ///
137 /// Defaulted for `busy`'s reason, and `None` is also what a daemon that predates
138 /// `inherit_image_env` reads as, which is how a caller tells whether the flag will be
139 /// honoured: such a daemon ignores the flag rather than refusing it.
140 #[serde(default)]
141 pub image_env_keys: Option<usize>,
142}
143
144/// One identity-repair step, as the daemon ran it.
145#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
146pub struct IdentityStep {
147 /// `machine-id`, `hostname`, `boot-id`, `random-seed`, or `cached-credential`.
148 pub name: String,
149 /// `repaired`, `not_applicable`, or `failed`.
150 pub outcome: String,
151 /// The OS error for a failed step. Names a path or an errno, never a value.
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub error: Option<String>,
154}
155
156/// What a workload's handler for a lifecycle hook did.
157///
158/// The daemon runs `<hooks dir>/<hook>` when the image carries one. Output is not
159/// here: `/v1/health` is readable without the agent token, so a handler's output
160/// goes to the daemon's log instead.
161#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
162pub struct HandlerOutcome {
163 /// The handler's exit code, or `None` when it was killed or never started.
164 pub exit_code: Option<i32>,
165 /// The signal that ended it, if one did.
166 #[serde(default)]
167 pub signal: Option<i32>,
168 /// Whether the daemon killed it for exceeding its time budget.
169 #[serde(default)]
170 pub timed_out: bool,
171 /// Wall-clock milliseconds from spawn to exit or kill.
172 pub duration_ms: u64,
173 /// Why the handler could not run at all: not executable, or spawn failed.
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub error: Option<String>,
176}
177
178impl HandlerOutcome {
179 /// Whether the handler ran and exited 0.
180 pub fn succeeded(&self) -> bool {
181 self.exit_code == Some(0) && !self.timed_out && self.error.is_none()
182 }
183}
184
185/// One lifecycle-hook invocation, as the daemon observed it.
186///
187/// A daemon-reported fact in the same trust class as an exec's exit code: the
188/// daemon's word about a request it served, carrying nothing the guest printed.
189#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
190pub struct HookObservation {
191 /// Which hook, spelled the way the platform's routes spell it: `ready`,
192 /// `validate`, `run`, `suspend`, `resume`, or `terminate`.
193 pub hook: String,
194 /// Seconds since the epoch on the daemon's clock when the invocation arrived.
195 ///
196 /// The daemon's clock rather than any caller's, and recorded before the handler
197 /// does any work — a run hook that was invoked and refused still fired.
198 pub fired_at: u64,
199 /// The workload handler's outcome, when the image carries a handler for this
200 /// hook. Absent on the wire otherwise, so an entry without one reads exactly as
201 /// it did before handlers existed.
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub handler: Option<HandlerOutcome>,
204}
205
206/// The disk half of [`Health`].
207#[derive(Debug, Deserialize, JsonSchema, Serialize)]
208pub struct DiskHealth {
209 /// Bytes available to an unprivileged writer, from `statvfs` `f_bavail`.
210 pub available_bytes: u64,
211 /// Bytes that must stay free before a write is refused. Zero means the guard is
212 /// disabled.
213 pub reserve_bytes: u64,
214 /// Whether a write would be refused right now. Precomputed rather than left to
215 /// the client, so every consumer applies the same comparison the write path does.
216 pub under_pressure: bool,
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 /// `disk: null` is not `disk` absent: unmeasurable free space is distinct from
224 /// zero, and a client that read a missing key as zero would page on a missing
225 /// `statvfs`.
226 #[test]
227 fn health_reports_an_unmeasurable_disk_as_an_explicit_null() {
228 let written = serde_json::to_string(&Health {
229 version: Cow::Borrowed("0.1.0"),
230 bootstrapped: true,
231 disk: None,
232 identity_degraded: false,
233 identity_repaired: true,
234 busy: false,
235 execs: 0,
236 hooks: Vec::new(),
237 hooks_dropped: 0,
238 identity_steps: Vec::new(),
239 image_env_keys: None,
240 })
241 .expect("serializes");
242 assert!(written.contains(r#""disk":null"#), "{written}");
243
244 let read: Health = serde_json::from_str(&written).expect("deserializes");
245 assert_eq!(read.version, "0.1.0");
246 assert!(read.disk.is_none());
247 }
248
249 /// `busy` and `execs` round-trip, and they are separate facts: a VM holding
250 /// unacked results is not busy, and an orchestrator that conflated the two would
251 /// either keep a finished VM alive or terminate one whose output nobody read.
252 #[test]
253 fn health_carries_busy_and_the_exec_count_as_two_separate_facts() {
254 let written = serde_json::to_string(&Health {
255 version: Cow::Borrowed("0.1.0"),
256 bootstrapped: true,
257 disk: None,
258 identity_degraded: false,
259 identity_repaired: true,
260 busy: false,
261 execs: 7,
262 hooks: Vec::new(),
263 hooks_dropped: 0,
264 identity_steps: Vec::new(),
265 image_env_keys: None,
266 })
267 .expect("serializes");
268 assert!(written.contains(r#""busy":false"#), "{written}");
269 assert!(written.contains(r#""execs":7"#), "{written}");
270
271 let read: Health = serde_json::from_str(&written).expect("deserializes");
272 assert!(!read.busy);
273 assert_eq!(read.execs, 7);
274 }
275
276 /// The hook observations round-trip, spelled as the routes spell them, with the
277 /// dropped count beside them.
278 #[test]
279 fn health_carries_hook_observations_and_the_dropped_count() {
280 let written = serde_json::to_string(&Health {
281 version: Cow::Borrowed("0.1.0"),
282 bootstrapped: true,
283 disk: None,
284 identity_degraded: false,
285 identity_repaired: true,
286 busy: false,
287 execs: 0,
288 hooks: vec![
289 HookObservation {
290 hook: "validate".to_string(),
291 fired_at: 1_756_500_000,
292 handler: None,
293 },
294 HookObservation {
295 hook: "run".to_string(),
296 fired_at: 1_756_500_100,
297 handler: None,
298 },
299 ],
300 hooks_dropped: 3,
301 identity_steps: Vec::new(),
302 image_env_keys: None,
303 })
304 .expect("serializes");
305 // The wire keys are pinned to the response's own convention — snake_case,
306 // like `identity_degraded` beside them. The CLI's history line respells
307 // `fired_at` as `firedAt` because the history file is camelCase; a spelling
308 // only one side writes fails exactly when someone reads it.
309 assert!(
310 written.contains(r#""hooks":[{"hook":"validate","fired_at":1756500000}"#),
311 "{written}"
312 );
313 assert!(written.contains(r#""hooks_dropped":3"#), "{written}");
314
315 let read: Health = serde_json::from_str(&written).expect("deserializes");
316 assert_eq!(read.hooks.len(), 2);
317 assert_eq!(read.hooks[0].hook, "validate");
318 assert_eq!(read.hooks[0].fired_at, 1_756_500_000);
319 assert_eq!(read.hooks_dropped, 3);
320 }
321
322 /// An old daemon's envelope — no `hooks`, no `hooks_dropped` — still parses, with
323 /// the empty defaults. The daemon is baked into an image while the client is
324 /// installed separately, so this is the compatibility floor, not an edge case.
325 #[test]
326 fn an_old_daemons_envelope_parses_with_no_hook_fields() {
327 let read: Health = serde_json::from_str(
328 r#"{"version": "0.1.0", "bootstrapped": true, "disk": null,
329 "identity_degraded": false, "identity_repaired": true}"#,
330 )
331 .expect("deserializes");
332 assert!(read.hooks.is_empty());
333 assert_eq!(read.hooks_dropped, 0);
334 assert!(!read.busy);
335 assert_eq!(read.execs, 0);
336 }
337
338 /// A handler outcome and the identity steps round-trip, and a hook with no
339 /// handler keeps the exact spelling older clients parse.
340 #[test]
341 fn handler_outcomes_and_identity_steps_round_trip() {
342 let written = serde_json::to_string(&Health {
343 version: Cow::Borrowed("0.1.0"),
344 bootstrapped: true,
345 disk: None,
346 identity_degraded: true,
347 identity_repaired: true,
348 busy: false,
349 execs: 0,
350 hooks: vec![
351 HookObservation {
352 hook: "run".to_string(),
353 fired_at: 1,
354 handler: None,
355 },
356 HookObservation {
357 hook: "suspend".to_string(),
358 fired_at: 2,
359 handler: Some(HandlerOutcome {
360 exit_code: None,
361 signal: Some(9),
362 timed_out: true,
363 duration_ms: 20_000,
364 error: None,
365 }),
366 },
367 ],
368 hooks_dropped: 0,
369 identity_steps: vec![IdentityStep {
370 name: "boot-id".to_string(),
371 outcome: "failed".to_string(),
372 error: Some("EPERM: Operation not permitted".to_string()),
373 }],
374 image_env_keys: None,
375 })
376 .expect("serializes");
377 assert!(
378 written.contains(r#"{"hook":"run","fired_at":1}"#),
379 "{written}"
380 );
381 let read: Health = serde_json::from_str(&written).expect("deserializes");
382 let handler = read.hooks[1].handler.as_ref().expect("a handler outcome");
383 assert!(handler.timed_out && !handler.succeeded());
384 assert_eq!(read.identity_steps[0].outcome, "failed");
385 }
386
387 /// AGENTD-13 on the wire: a count, and an older daemon's missing key reads as `None`,
388 /// the same answer as a daemon with no snapshot, so a caller cannot mistake a daemon
389 /// that would ignore `inherit_image_env` for one that honours it.
390 #[test]
391 fn the_image_env_count_round_trips_and_an_older_daemon_reads_as_none() {
392 let written = serde_json::to_value(Health {
393 version: Cow::Borrowed("0.1.0"),
394 bootstrapped: true,
395 disk: None,
396 identity_degraded: false,
397 identity_repaired: true,
398 busy: false,
399 execs: 0,
400 hooks: Vec::new(),
401 hooks_dropped: 0,
402 identity_steps: Vec::new(),
403 image_env_keys: Some(4),
404 })
405 .expect("serializes");
406 assert_eq!(written["image_env_keys"], 4);
407 let read: Health = serde_json::from_value(written).expect("deserializes");
408 assert_eq!(read.image_env_keys, Some(4));
409
410 let older: Health = serde_json::from_str(
411 r#"{"version":"0.9.0","bootstrapped":true,"disk":null,"identity_degraded":false,"identity_repaired":true}"#,
412 )
413 .expect("an older daemon's body deserializes");
414 assert_eq!(older.image_env_keys, None);
415 }
416}