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}