microvms-protocol 0.11.0

Wire types shared by the microvms-agentd daemon and its clients
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
// SPDX-License-Identifier: Apache-2.0
//! The `GET /v1/health` response.

use std::borrow::Cow;

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// `GET /v1/health` response.
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct Health {
    // The daemon's own version, distinct from `PROTOCOL_VERSION`. `Cow` so the
    // daemon reports its `CARGO_PKG_VERSION` borrowed while a client deserializes
    // into an owned string.
    //
    // Not a doc comment, deliberately: schemars publishes doc comments as
    // `description` and `docs/schema.json` is byte-compared, so adding one here is a
    // schema change. The field was undocumented before the extraction and stays so.
    pub version: Cow<'static, str>,
    pub bootstrapped: bool,
    /// Free space on the daemon's working filesystem, and the reserve it is judged
    /// against.
    ///
    /// Reported so disk pressure is something an orchestrator *watches* rather than
    /// something it discovers from a failed write. anthropics/claude-code#59856
    /// filled two 10 GB disks to 100% with never-collected session directories and
    /// the first symptom was `useradd: No space left on device` — by which point
    /// every writer in the sandbox was already broken. A number on a health endpoint
    /// is what makes that curve visible while there is still time to act.
    ///
    /// `None` when free space could not be measured, which is deliberately distinct
    /// from zero: unmeasurable is not full, and a monitor that conflated them would
    /// page on a missing `statvfs`.
    pub disk: Option<DiskHealth>,
    /// Whether any startup identity repair step failed. True means the VM is serving
    /// with a value from the shared image still in place — a duplicate machine-id or
    /// boot_id — which is a security-relevant condition an operator may want to
    /// drain the VM over, but is never a reason for the daemon to refuse to serve.
    pub identity_degraded: bool,
    /// False when identity repair was switched off by config. Distinguished from a
    /// repair that ran and found nothing so a monitor can tell "opted out" from
    /// "nothing to do".
    pub identity_repaired: bool,
    /// Whether any exec is still running right now.
    ///
    /// Here so that an orchestrator *outside* the VM can decide whether to keep the
    /// VM alive, and the "outside" is the whole design. The platform measures
    /// idleness by inbound traffic through the endpoint proxy, and that proxy
    /// terminates outside the guest and forwards over loopback (measured;
    /// `docs/PLATFORM.md`, "The platform's own hook arrives over loopback"). A
    /// request a guest process sends to the daemon's own port never reaches the
    /// proxy, so no amount of in-guest traffic can reset the idle timer. A route
    /// that promised otherwise would be a keepalive that does not keep anything
    /// alive, discovered when a multi-hour run auto-suspends mid-work.
    ///
    /// What does reset it is a poll from outside, and this field is what makes such
    /// a poll *informed* rather than unconditional: the orchestrator polls, which is
    /// itself the inbound traffic, and reads whether the workload is busy to decide
    /// whether to keep polling. The assertion is therefore repeated and explicitly
    /// the caller's, which is what the daemon self-keepaliving would not be — a hung
    /// process would then bill to the 8-hour ceiling with nobody asking.
    ///
    /// Computed from the exec registry rather than remembered: true iff at least one
    /// registered exec has not yet published a result. An exec that exited and is
    /// waiting to be acked is not busy — its output is being *held*, not produced —
    /// so an orchestrator does not keep a VM alive for a command that finished.
    ///
    /// `#[serde(default)]`, unlike every field above it, and the asymmetry is not an
    /// oversight. The daemon is baked into an image while the client is installed
    /// separately, so a current client routinely talks to a daemon from whenever that
    /// image was built — and a required field would make `health()` fail outright
    /// against a daemon that predates it, turning a missing signal into an
    /// unreachable VM. False is also the right absence: a daemon that cannot say
    /// whether it is busy has not asserted that it is.
    #[serde(default)]
    pub busy: bool,
    /// How many execs are registered, in any phase.
    ///
    /// Alongside `busy` because the two answer different questions and a monitor
    /// wants both: `busy: false, execs: 0` is a fresh or drained VM, while
    /// `busy: false, execs: 7` is a VM holding seven unacked results that somebody
    /// still has to collect. Terminating the second loses output nobody read.
    ///
    /// Defaulted for the same reason as `busy`: a client routinely talks to a daemon
    /// baked into an older image, and zero is the honest reading of a daemon that
    /// does not report a count.
    #[serde(default)]
    pub execs: usize,
    /// Every lifecycle-hook invocation the daemon observed, oldest first.
    ///
    /// The platform writes no CloudWatch logs for the validate hook, so "did my
    /// validate hook even run?" has no answer anywhere else. Each entry is the
    /// daemon's own observation — the hook path that was posted and the daemon's
    /// clock when it arrived — never anything the guest printed.
    ///
    /// One trust caveat travels with this field and is stated rather than implied:
    /// the hook routes are unauthenticated and reachable over loopback from inside
    /// the guest, so a hostile workload can *forge additional* entries by posting
    /// the hook paths itself. It cannot remove or alter real ones — the daemon
    /// records before it responds and the log is append-capped, keeping the
    /// earliest entries — so the platform's real firings are the front of the list
    /// and later spam is what the cap drops.
    ///
    /// Defaulted for `busy`'s reason: an older daemon omits the field, and an empty
    /// list is the honest reading of a daemon that reports no observations.
    #[serde(default)]
    pub hooks: Vec<HookObservation>,
    /// How many hook invocations were dropped once the log reached its cap.
    ///
    /// Non-zero means the list above is the *earliest* invocations only — the cap
    /// keeps first-N so a guest spamming the unauthenticated hook routes cannot
    /// grow daemon memory or push the platform's real firings out of the record.
    ///
    /// Defaulted like `hooks`: an older daemon omits it, and zero is the honest
    /// reading of a daemon that dropped nothing it could tell us about.
    #[serde(default)]
    pub hooks_dropped: u64,
    /// Each identity-repair step and its outcome, in the order they ran.
    ///
    /// Empty until repair runs, which is at the first successful run hook: repair at
    /// daemon start would run in the image-build VM and be captured by the snapshot,
    /// giving every VM the same "repaired" identity. `identity_degraded` says whether
    /// any step failed; this says which one and why.
    ///
    /// Defaulted for `busy`'s reason: an older daemon omits it.
    #[serde(default)]
    pub identity_steps: Vec<IdentityStep>,
    /// How many variables the daemon's image-environment snapshot holds, or `None` when
    /// it holds no snapshot.
    ///
    /// The snapshot is the environment the daemon inherited as the container `CMD`, minus
    /// every `AGENTD_*` variable, taken once at startup; a start request with
    /// `inherit_image_env` starts its child from it. A count and never the values: this
    /// route answers without the agent token, and an image's `ENV` can hold anything its
    /// author put there.
    ///
    /// Defaulted for `busy`'s reason, and `None` is also what a daemon that predates
    /// `inherit_image_env` reads as, which is how a caller tells whether the flag will be
    /// honoured: such a daemon ignores the flag rather than refusing it.
    #[serde(default)]
    pub image_env_keys: Option<usize>,
}

/// One identity-repair step, as the daemon ran it.
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
pub struct IdentityStep {
    /// `machine-id`, `hostname`, `boot-id`, `random-seed`, or `cached-credential`.
    pub name: String,
    /// `repaired`, `not_applicable`, or `failed`.
    pub outcome: String,
    /// The OS error for a failed step. Names a path or an errno, never a value.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// What a workload's handler for a lifecycle hook did.
///
/// The daemon runs `<hooks dir>/<hook>` when the image carries one. Output is not
/// here: `/v1/health` is readable without the agent token, so a handler's output
/// goes to the daemon's log instead.
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
pub struct HandlerOutcome {
    /// The handler's exit code, or `None` when it was killed or never started.
    pub exit_code: Option<i32>,
    /// The signal that ended it, if one did.
    #[serde(default)]
    pub signal: Option<i32>,
    /// Whether the daemon killed it for exceeding its time budget.
    #[serde(default)]
    pub timed_out: bool,
    /// Wall-clock milliseconds from spawn to exit or kill.
    pub duration_ms: u64,
    /// Why the handler could not run at all: not executable, or spawn failed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

impl HandlerOutcome {
    /// Whether the handler ran and exited 0.
    pub fn succeeded(&self) -> bool {
        self.exit_code == Some(0) && !self.timed_out && self.error.is_none()
    }
}

/// One lifecycle-hook invocation, as the daemon observed it.
///
/// A daemon-reported fact in the same trust class as an exec's exit code: the
/// daemon's word about a request it served, carrying nothing the guest printed.
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
pub struct HookObservation {
    /// Which hook, spelled the way the platform's routes spell it: `ready`,
    /// `validate`, `run`, `suspend`, `resume`, or `terminate`.
    pub hook: String,
    /// Seconds since the epoch on the daemon's clock when the invocation arrived.
    ///
    /// The daemon's clock rather than any caller's, and recorded before the handler
    /// does any work — a run hook that was invoked and refused still fired.
    pub fired_at: u64,
    /// The workload handler's outcome, when the image carries a handler for this
    /// hook. Absent on the wire otherwise, so an entry without one reads exactly as
    /// it did before handlers existed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub handler: Option<HandlerOutcome>,
}

/// The disk half of [`Health`].
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct DiskHealth {
    /// Bytes available to an unprivileged writer, from `statvfs` `f_bavail`.
    pub available_bytes: u64,
    /// Bytes that must stay free before a write is refused. Zero means the guard is
    /// disabled.
    pub reserve_bytes: u64,
    /// Whether a write would be refused right now. Precomputed rather than left to
    /// the client, so every consumer applies the same comparison the write path does.
    pub under_pressure: bool,
}

#[cfg(test)]
mod tests {
    use super::*;

    /// `disk: null` is not `disk` absent: unmeasurable free space is distinct from
    /// zero, and a client that read a missing key as zero would page on a missing
    /// `statvfs`.
    #[test]
    fn health_reports_an_unmeasurable_disk_as_an_explicit_null() {
        let written = serde_json::to_string(&Health {
            version: Cow::Borrowed("0.1.0"),
            bootstrapped: true,
            disk: None,
            identity_degraded: false,
            identity_repaired: true,
            busy: false,
            execs: 0,
            hooks: Vec::new(),
            hooks_dropped: 0,
            identity_steps: Vec::new(),
            image_env_keys: None,
        })
        .expect("serializes");
        assert!(written.contains(r#""disk":null"#), "{written}");

        let read: Health = serde_json::from_str(&written).expect("deserializes");
        assert_eq!(read.version, "0.1.0");
        assert!(read.disk.is_none());
    }

    /// `busy` and `execs` round-trip, and they are separate facts: a VM holding
    /// unacked results is not busy, and an orchestrator that conflated the two would
    /// either keep a finished VM alive or terminate one whose output nobody read.
    #[test]
    fn health_carries_busy_and_the_exec_count_as_two_separate_facts() {
        let written = serde_json::to_string(&Health {
            version: Cow::Borrowed("0.1.0"),
            bootstrapped: true,
            disk: None,
            identity_degraded: false,
            identity_repaired: true,
            busy: false,
            execs: 7,
            hooks: Vec::new(),
            hooks_dropped: 0,
            identity_steps: Vec::new(),
            image_env_keys: None,
        })
        .expect("serializes");
        assert!(written.contains(r#""busy":false"#), "{written}");
        assert!(written.contains(r#""execs":7"#), "{written}");

        let read: Health = serde_json::from_str(&written).expect("deserializes");
        assert!(!read.busy);
        assert_eq!(read.execs, 7);
    }

    /// The hook observations round-trip, spelled as the routes spell them, with the
    /// dropped count beside them.
    #[test]
    fn health_carries_hook_observations_and_the_dropped_count() {
        let written = serde_json::to_string(&Health {
            version: Cow::Borrowed("0.1.0"),
            bootstrapped: true,
            disk: None,
            identity_degraded: false,
            identity_repaired: true,
            busy: false,
            execs: 0,
            hooks: vec![
                HookObservation {
                    hook: "validate".to_string(),
                    fired_at: 1_756_500_000,
                    handler: None,
                },
                HookObservation {
                    hook: "run".to_string(),
                    fired_at: 1_756_500_100,
                    handler: None,
                },
            ],
            hooks_dropped: 3,
            identity_steps: Vec::new(),
            image_env_keys: None,
        })
        .expect("serializes");
        // The wire keys are pinned to the response's own convention — snake_case,
        // like `identity_degraded` beside them. The CLI's history line respells
        // `fired_at` as `firedAt` because the history file is camelCase; a spelling
        // only one side writes fails exactly when someone reads it.
        assert!(
            written.contains(r#""hooks":[{"hook":"validate","fired_at":1756500000}"#),
            "{written}"
        );
        assert!(written.contains(r#""hooks_dropped":3"#), "{written}");

        let read: Health = serde_json::from_str(&written).expect("deserializes");
        assert_eq!(read.hooks.len(), 2);
        assert_eq!(read.hooks[0].hook, "validate");
        assert_eq!(read.hooks[0].fired_at, 1_756_500_000);
        assert_eq!(read.hooks_dropped, 3);
    }

    /// An old daemon's envelope — no `hooks`, no `hooks_dropped` — still parses, with
    /// the empty defaults. The daemon is baked into an image while the client is
    /// installed separately, so this is the compatibility floor, not an edge case.
    #[test]
    fn an_old_daemons_envelope_parses_with_no_hook_fields() {
        let read: Health = serde_json::from_str(
            r#"{"version": "0.1.0", "bootstrapped": true, "disk": null,
                 "identity_degraded": false, "identity_repaired": true}"#,
        )
        .expect("deserializes");
        assert!(read.hooks.is_empty());
        assert_eq!(read.hooks_dropped, 0);
        assert!(!read.busy);
        assert_eq!(read.execs, 0);
    }

    /// A handler outcome and the identity steps round-trip, and a hook with no
    /// handler keeps the exact spelling older clients parse.
    #[test]
    fn handler_outcomes_and_identity_steps_round_trip() {
        let written = serde_json::to_string(&Health {
            version: Cow::Borrowed("0.1.0"),
            bootstrapped: true,
            disk: None,
            identity_degraded: true,
            identity_repaired: true,
            busy: false,
            execs: 0,
            hooks: vec![
                HookObservation {
                    hook: "run".to_string(),
                    fired_at: 1,
                    handler: None,
                },
                HookObservation {
                    hook: "suspend".to_string(),
                    fired_at: 2,
                    handler: Some(HandlerOutcome {
                        exit_code: None,
                        signal: Some(9),
                        timed_out: true,
                        duration_ms: 20_000,
                        error: None,
                    }),
                },
            ],
            hooks_dropped: 0,
            identity_steps: vec![IdentityStep {
                name: "boot-id".to_string(),
                outcome: "failed".to_string(),
                error: Some("EPERM: Operation not permitted".to_string()),
            }],
            image_env_keys: None,
        })
        .expect("serializes");
        assert!(
            written.contains(r#"{"hook":"run","fired_at":1}"#),
            "{written}"
        );
        let read: Health = serde_json::from_str(&written).expect("deserializes");
        let handler = read.hooks[1].handler.as_ref().expect("a handler outcome");
        assert!(handler.timed_out && !handler.succeeded());
        assert_eq!(read.identity_steps[0].outcome, "failed");
    }

    /// AGENTD-13 on the wire: a count, and an older daemon's missing key reads as `None`,
    /// the same answer as a daemon with no snapshot, so a caller cannot mistake a daemon
    /// that would ignore `inherit_image_env` for one that honours it.
    #[test]
    fn the_image_env_count_round_trips_and_an_older_daemon_reads_as_none() {
        let written = serde_json::to_value(Health {
            version: Cow::Borrowed("0.1.0"),
            bootstrapped: true,
            disk: None,
            identity_degraded: false,
            identity_repaired: true,
            busy: false,
            execs: 0,
            hooks: Vec::new(),
            hooks_dropped: 0,
            identity_steps: Vec::new(),
            image_env_keys: Some(4),
        })
        .expect("serializes");
        assert_eq!(written["image_env_keys"], 4);
        let read: Health = serde_json::from_value(written).expect("deserializes");
        assert_eq!(read.image_env_keys, Some(4));

        let older: Health = serde_json::from_str(
            r#"{"version":"0.9.0","bootstrapped":true,"disk":null,"identity_degraded":false,"identity_repaired":true}"#,
        )
        .expect("an older daemon's body deserializes");
        assert_eq!(older.image_env_keys, None);
    }
}