microvms_protocol/exec.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Exec wire types: the bodies, queries, and SSE event payloads of `/v1/exec/*`.
3//!
4//! Every type here derives both halves of serde. The daemon needs only one half of
5//! each and a client needs the other, and a type that carries one half is a type the
6//! other side has to hand-write — which is the drift this crate exists to prevent.
7//! The published `docs/schema.json` is generated from these same attributes under
8//! both contracts, so a shape change is a schema diff rather than a surprise.
9
10use std::borrow::Cow;
11use std::collections::HashMap;
12
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16/// Where an exec sits in its lifecycle. Mirrors `ExecPhase` in the model crate.
17///
18/// `JsonSchema` rides along with `Serialize` on every type from here down that
19/// crosses the wire. schemars reads the same `#[serde(...)]` attributes serde
20/// does, so the published schema describes what the daemon actually emits — the
21/// `rename_all` below is the reason this matters rather than a formality.
22#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum Phase {
25 /// Child spawned, still running (or its pipes still held by a grandchild).
26 Running,
27 /// Child exited and output is buffered and readable.
28 Exited,
29 /// Caller acked; output has been released and the entry awaits collection.
30 Acked,
31}
32
33impl Phase {
34 /// Every phase, in lifecycle order.
35 ///
36 /// Public because a client that publishes the closed set — both bindings do, in
37 /// their `session_constants` — needs the list from the type rather than a spelled-out
38 /// copy that goes stale the first time a phase is added. The round-trip test below
39 /// holds `ALL` complete by exhaustive match.
40 pub const ALL: [Phase; 3] = [Phase::Running, Phase::Exited, Phase::Acked];
41
42 /// The wire spelling — the exact string serde writes under `rename_all` above.
43 ///
44 /// Here rather than in each client because two bindings each grew their own
45 /// three-arm match over this enum; a variant renamed on the wire must change
46 /// exactly one table, and the test below is what keeps this one equal to serde's.
47 pub const fn as_str(self) -> &'static str {
48 match self {
49 Phase::Running => "running",
50 Phase::Exited => "exited",
51 Phase::Acked => "acked",
52 }
53 }
54}
55
56/// Captured output and exit status of a finished exec.
57#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)]
58pub struct Outcome {
59 /// Exit code, or `None` when the child died to a signal.
60 pub exit_code: Option<i32>,
61 /// Signal number that killed the child, when one did.
62 pub signal: Option<i32>,
63 /// The daemon execution deadline fired. Absent on older daemons, which cannot
64 /// distinguish deadline expiry from other signal deaths.
65 #[serde(default)]
66 pub timed_out: bool,
67 pub stdout: String,
68 pub stderr: String,
69 /// Set when either stream hit `max_output_bytes` and was cut. An explicit
70 /// flag rather than a sentinel string in the output: a marker inside the
71 /// bytes is indistinguishable from output that happens to contain it.
72 pub truncated: bool,
73 /// Set when the post-exit linger deadline expired with the pipes still open,
74 /// meaning some grandchild is alive and may write more that nobody will see.
75 /// Reported rather than hidden, because a harness that sees empty output from
76 /// a command it knows produced some needs to be able to tell why.
77 pub writers_may_be_alive: bool,
78}
79
80/// Which pipe a streamed chunk came from. Both share one offset space, so a
81/// client holds one cursor rather than two that can disagree about ordering.
82#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum StreamKind {
85 Stdout,
86 Stderr,
87}
88
89impl StreamKind {
90 /// Both kinds, in the order the daemon documents them.
91 ///
92 /// Same reason as [`Phase::ALL`]: the bindings publish this closed set, and a
93 /// list they spell themselves is a list the enum can outgrow.
94 pub const ALL: [StreamKind; 2] = [StreamKind::Stdout, StreamKind::Stderr];
95
96 /// The wire spelling — the exact string serde writes under `rename_all` above.
97 pub const fn as_str(self) -> &'static str {
98 match self {
99 StreamKind::Stdout => "stdout",
100 StreamKind::Stderr => "stderr",
101 }
102 }
103}
104
105/// A start request. `command` is either an argv array or, with `shell: true`, a
106/// single script string.
107#[derive(Debug, Deserialize, JsonSchema, Serialize)]
108pub struct StartRequest {
109 /// Caller-minted idempotency key. Harbor retries, and a retry must not
110 /// produce a second child.
111 pub exec_id: String,
112 /// argv when `shell` is false, or the script when it is true.
113 pub command: Vec<String>,
114 #[serde(default)]
115 pub shell: bool,
116 /// Omitted means inherit the daemon's working directory. See the module docs.
117 #[serde(default)]
118 pub cwd: Option<String>,
119 #[serde(default)]
120 pub env: HashMap<String, String>,
121 /// Numeric uid to demote to. Optional; omitted means run as the daemon's own
122 /// user.
123 #[serde(default)]
124 pub user: Option<u32>,
125 #[serde(default)]
126 pub group: Option<u32>,
127 /// Wall-clock budget. Validated before the child spawns — the predecessor
128 /// raised on a bad value inside the waiter thread, by which point the child
129 /// was already running and became an orphan.
130 #[serde(default)]
131 pub timeout_sec: Option<f64>,
132 /// Whether to give the child a writable stdin pipe. Defaults to false, which
133 /// keeps `Stdio::null()`.
134 ///
135 /// Opt-in rather than always-on, and not only for tidiness: a child holding an
136 /// open stdin pipe nobody will ever write to is a child that blocks forever
137 /// the first time it reads. `/bin/sh` reading a script from stdin, `git`
138 /// deciding it can prompt, any tool that probes for input — all of them behave
139 /// differently against a pipe than against `/dev/null`. Every existing caller
140 /// gets today's behavior by not setting this.
141 #[serde(default)]
142 pub stdin: bool,
143 /// Whether to signal the exec's whole process group once its own child exits.
144 ///
145 /// Off by default, and the default is the contract this daemon has always had:
146 /// a backgrounded grandchild that inherited the output pipe keeps running and
147 /// keeps writing, and the linger deadline is what bounds how long the daemon
148 /// waits on it. That is the "backgrounded server keeps logging" guarantee, and
149 /// callers rely on it. Set to `true`, the daemon runs the same SIGTERM-then-
150 /// SIGKILL escalation `POST /v1/exec/{id}/kill` uses against the group as soon
151 /// as the child's exit is observed, so nothing the command left behind outlives
152 /// it and the linger sees EOF rather than `writers_may_be_alive`. A timed-out
153 /// exec is already escalated and is not escalated twice.
154 #[serde(default)]
155 pub reap_group_on_exit: bool,
156}
157
158/// `POST /v1/exec/{id}/stdin` body.
159///
160/// Both fields optional and both meaningful together: a final chunk plus EOF in
161/// one request is the common case for feeding a prompt, and forcing two round
162/// trips would leave a window where the child has the bytes but not the EOF that
163/// tells it the input is complete.
164#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
165pub struct StdinRequest {
166 /// Base64 so arbitrary bytes survive JSON. A JSON string cannot carry
167 /// non-UTF-8, and stdin is bytes.
168 #[serde(default)]
169 pub data_b64: Option<String>,
170 /// `"eof"` closes the pipe after any `data_b64` is written. Named rather than
171 /// a bare boolean so the field has somewhere to grow.
172 #[serde(default)]
173 pub signal: Option<String>,
174}
175
176/// `POST /v1/exec/{id}/stdin` response.
177///
178/// `pub` rather than private, like every other type in this module the schema
179/// route publishes: the generator names them by type, so a response shape that
180/// stays private is a shape a consumer cannot be told about.
181#[derive(Debug, Deserialize, JsonSchema, Serialize)]
182pub struct StdinResponse {
183 pub exec_id: String,
184 pub written: usize,
185 pub eof: bool,
186}
187
188/// `GET /v1/exec/{id}/stream` query.
189#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
190pub struct StreamQuery {
191 /// Byte offset to resume from. Absent means 0, i.e. everything still in the
192 /// replay window.
193 #[serde(default)]
194 pub offset: Option<u64>,
195}
196
197/// One `output` SSE event.
198#[derive(Debug, Deserialize, JsonSchema, Serialize)]
199pub struct OutputEvent {
200 pub offset: u64,
201 pub stream: StreamKind,
202 pub output: String,
203}
204
205/// One `gap` SSE event: the byte range a lagging or late subscriber lost.
206#[derive(Debug, Deserialize, JsonSchema, Serialize)]
207pub struct GapEvent {
208 pub from: u64,
209 pub to: u64,
210}
211
212/// The terminal `exit` SSE event. Emitted before the stream ends, so a client
213/// that sees the body close without one knows the connection failed rather than
214/// the command finishing.
215#[derive(Debug, Deserialize, JsonSchema, Serialize)]
216pub struct ExitEvent {
217 pub exit_code: Option<i32>,
218 pub signal: Option<i32>,
219 /// The daemon execution deadline fired. Absent on older daemons, which cannot
220 /// distinguish deadline expiry from other signal deaths.
221 #[serde(default)]
222 pub timed_out: bool,
223 pub truncated: bool,
224 pub writers_may_be_alive: bool,
225 /// Total bytes published, so a client can assert it saw all of them.
226 pub offset: u64,
227}
228
229#[derive(Debug, Deserialize, JsonSchema, Serialize)]
230pub struct StartResponse {
231 pub exec_id: String,
232 pub phase: Phase,
233}
234
235/// `POST /v1/exec/{id}/kill` response.
236///
237/// A named type rather than the `serde_json::json!` literal this used to be: an
238/// ad-hoc `Value` has no schema to derive, so the one route whose body a client
239/// most needs to branch on — `killed` distinguishes "signalled" from "the group
240/// was already gone", and both are 200 — would have been the one route the
241/// published document could not describe.
242#[derive(Debug, Deserialize, JsonSchema, Serialize)]
243pub struct KillResponse {
244 pub exec_id: String,
245 /// Whether a signal was actually delivered. `false` with a 200 means the
246 /// process group had already exited, which is the outcome a kill wanted.
247 pub killed: bool,
248}
249
250/// One entry of `GET /v1/procs`: an exec's process group as the daemon sees it.
251///
252/// The exec registry knows the group (`pgid`) and whether the direct child has
253/// exited; `/proc` knows which pids are still in that group. Both halves are here
254/// because the interesting case is their disagreement — `child_exited: true` with a
255/// non-empty `pids` is a command that finished while something it started did not,
256/// which is exactly what a caller asking "did this step leave anything behind"
257/// needs to see and could not before.
258#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
259pub struct ProcGroup {
260 pub exec_id: String,
261 /// The process group id captured at spawn. `None` when the child was reaped
262 /// before it could be read, in which case `pids` is empty because there is no
263 /// group to scan for.
264 pub pgid: Option<u32>,
265 /// Seconds since the epoch on the daemon's clock when the child was spawned.
266 /// The same convention as a hook observation's `fired_at`.
267 pub started_at: u64,
268 /// Whether the exec's own child has exited. Read from the terminal marker an
269 /// ack cannot take, so an acked exec still reads as exited here.
270 pub child_exited: bool,
271 /// Whether the exec was started with `reap_group_on_exit`.
272 pub reap: bool,
273 /// Live pids whose process group is `pgid`, read from `/proc`. Zombies are not
274 /// live and are not listed. Empty once the group is gone, and empty for an
275 /// entry whose `pgid` is `None`.
276 pub pids: Vec<u32>,
277}
278
279/// `GET /v1/procs` response.
280#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
281pub struct ProcsResponse {
282 /// Every registered exec, in registry order, whether or not anything in its
283 /// group is still alive. An exec that exited cleanly with nothing left behind
284 /// is listed with `child_exited: true` and `pids: []`.
285 pub procs: Vec<ProcGroup>,
286}
287
288#[derive(Debug, Deserialize, JsonSchema, Serialize)]
289pub struct PollResponse {
290 pub exec_id: String,
291 pub phase: Phase,
292 #[serde(skip_serializing_if = "Option::is_none")]
293 #[serde(flatten)]
294 pub result: Option<Outcome>,
295}
296
297/// The JSON error body every failing control route returns.
298///
299/// `error` is a stable machine-readable slug and `detail` is prose for a human
300/// reading a log. A client branches on `error` and the status code, never on
301/// `detail` — which is why the slug is one of the constants below, chosen at each
302/// call site, rather than a formatted string. `Cow` so the daemon keeps naming a
303/// `&'static str` while a client deserializes into an owned one.
304#[derive(Debug, Deserialize, JsonSchema, Serialize)]
305pub struct ErrorBody {
306 pub error: Cow<'static, str>,
307 pub detail: String,
308}
309
310/// The `event:` names on `GET /v1/exec/{id}/stream`.
311///
312/// Named rather than spelled at each `Event::default().event(..)` call: the daemon
313/// writes these and a client matches on them, and a typo on either side is a
314/// stream that silently carries events nobody dispatches.
315pub const EVENT_OUTPUT: &str = "output";
316pub const EVENT_GAP: &str = "gap";
317pub const EVENT_EXIT: &str = "exit";
318
319// The `ErrorBody::error` slugs. Every failing control route returns one of these,
320// they are the part of the surface a client branches on, and they are `&'static
321// str` for the same reason the status code is a number: the pairing of the two is
322// the contract, and neither is derivable from the other.
323
324/// The body is not a valid request for this route. Always 400, never 404.
325pub const ERROR_MALFORMED_REQUEST: &str = "malformed_request";
326/// No exec is registered under this id, or its entry was collected after an ack.
327pub const ERROR_UNKNOWN_EXEC: &str = "unknown_exec";
328/// The child could not be spawned. 500, deliberately not 404.
329pub const ERROR_SPAWN_FAILED: &str = "spawn_failed";
330/// The exec has not exited, so acking would drop output still being written.
331pub const ERROR_STILL_RUNNING: &str = "still_running";
332/// An earlier ack already released the output.
333pub const ERROR_ALREADY_ACKED: &str = "already_acked";
334/// The exec was started without `stdin: true`. Fixable at start time, hence 409.
335pub const ERROR_STDIN_NOT_REQUESTED: &str = "stdin_not_requested";
336/// stdin was closed by an earlier eof, or the child stopped reading. 410: a retry
337/// will never succeed.
338pub const ERROR_STDIN_CLOSED: &str = "stdin_closed";
339/// The child did not read within the configured window. Retryable, and some bytes
340/// may already have been written.
341pub const ERROR_STDIN_WRITE_TIMEOUT: &str = "stdin_write_timeout";
342/// The decoded write exceeds the configured per-write cap.
343pub const ERROR_STDIN_WRITE_TOO_LARGE: &str = "stdin_write_too_large";
344/// The write to the pipe failed for a reason other than a broken pipe.
345pub const ERROR_STDIN_WRITE_FAILED: &str = "stdin_write_failed";
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 /// The `rename_all` is the reason these types are shared rather than mirrored:
352 /// a client that spelled the variants from the Rust identifiers would send
353 /// `Running` where the daemon emits `running`.
354 #[test]
355 fn phase_and_stream_kind_round_trip_through_their_serde_names() {
356 assert_eq!(
357 serde_json::to_string(&Phase::Running).expect("serializes"),
358 "\"running\""
359 );
360 assert_eq!(
361 serde_json::from_str::<Phase>("\"acked\"").expect("deserializes"),
362 Phase::Acked
363 );
364 assert_eq!(
365 serde_json::to_string(&StreamKind::Stderr).expect("serializes"),
366 "\"stderr\""
367 );
368 }
369
370 /// `as_str` is a second spelling of what serde already writes, so the two must be
371 /// equal for every variant or the bindings publish names the wire never carries.
372 ///
373 /// `ALL` is held complete by exhaustion rather than by a length check: the matches
374 /// below have no wildcard arm, so a variant added to either enum fails to compile
375 /// here until it is added to its `ALL` too.
376 #[test]
377 fn as_str_agrees_with_serde_for_every_phase_and_stream_kind() {
378 for phase in Phase::ALL {
379 assert_eq!(
380 serde_json::to_string(&phase).expect("serializes"),
381 format!("\"{}\"", phase.as_str()),
382 "{phase:?} spells differently through as_str and serde"
383 );
384 match phase {
385 Phase::Running | Phase::Exited | Phase::Acked => {}
386 }
387 }
388 for kind in StreamKind::ALL {
389 assert_eq!(
390 serde_json::to_string(&kind).expect("serializes"),
391 format!("\"{}\"", kind.as_str()),
392 "{kind:?} spells differently through as_str and serde"
393 );
394 match kind {
395 StreamKind::Stdout | StreamKind::Stderr => {}
396 }
397 }
398 }
399
400 /// Both halves of serde on one type is the whole point of the crate: what the
401 /// daemon writes is what a client reads, checked rather than assumed.
402 #[test]
403 fn a_response_the_daemon_writes_deserializes_on_the_client_side() {
404 let written = serde_json::to_string(&StartResponse {
405 exec_id: "e1".into(),
406 phase: Phase::Running,
407 })
408 .expect("serializes");
409 let read: StartResponse = serde_json::from_str(&written).expect("deserializes");
410 assert_eq!(read.exec_id, "e1");
411 assert_eq!(read.phase, Phase::Running);
412 }
413
414 /// A request the client writes is one the daemon's extractor accepts, including
415 /// every serde default — the fields a caller omits are the ones most likely to
416 /// disagree across a hand-written mirror.
417 #[test]
418 fn a_start_request_omitting_every_defaulted_field_deserializes() {
419 let request: StartRequest =
420 serde_json::from_str(r#"{"exec_id":"e1","command":["true"]}"#).expect("deserializes");
421 assert!(!request.shell);
422 assert!(request.cwd.is_none());
423 assert!(request.env.is_empty());
424 assert!(request.timeout_sec.is_none());
425 assert!(!request.stdin);
426 assert!(
427 !request.reap_group_on_exit,
428 "reaping is opt-in: an old client that never heard of the flag keeps today's \
429 grandchild-output guarantee"
430 );
431 }
432
433 /// The procs body round-trips with every field present, `pgid: null` included, so a
434 /// client can tell "no group was ever captured" from "the group is empty".
435 #[test]
436 fn a_procs_response_round_trips_with_a_missing_pgid() {
437 let written = serde_json::to_string(&ProcsResponse {
438 procs: vec![ProcGroup {
439 exec_id: "e1".into(),
440 pgid: None,
441 started_at: 1_756_500_000,
442 child_exited: true,
443 reap: false,
444 pids: Vec::new(),
445 }],
446 })
447 .expect("serializes");
448 assert_eq!(
449 written,
450 r#"{"procs":[{"exec_id":"e1","pgid":null,"started_at":1756500000,"child_exited":true,"reap":false,"pids":[]}]}"#
451 );
452 let read: ProcsResponse = serde_json::from_str(&written).expect("deserializes");
453 assert_eq!(read.procs.len(), 1);
454 assert_eq!(read.procs[0].pgid, None);
455 assert!(read.procs[0].child_exited);
456 }
457
458 /// The flatten-plus-skip on `PollResponse.result` is the one shape whose two
459 /// serde contracts genuinely differ — absent on the way out while running, and
460 /// the same absence has to read back as `None` on the way in. Both directions,
461 /// because a client that read a running exec's missing outcome as an error would
462 /// fail on every poll before the first one that mattered.
463 #[test]
464 fn a_poll_response_round_trips_with_and_without_an_outcome() {
465 let running = serde_json::to_string(&PollResponse {
466 exec_id: "e1".into(),
467 phase: Phase::Running,
468 result: None,
469 })
470 .expect("serializes");
471 assert_eq!(running, r#"{"exec_id":"e1","phase":"running"}"#);
472 let read: PollResponse = serde_json::from_str(&running).expect("deserializes");
473 assert!(read.result.is_none());
474
475 let exited = serde_json::to_string(&PollResponse {
476 exec_id: "e1".into(),
477 phase: Phase::Exited,
478 result: Some(Outcome {
479 exit_code: Some(0),
480 stdout: "hi".into(),
481 ..Outcome::default()
482 }),
483 })
484 .expect("serializes");
485 let read: PollResponse = serde_json::from_str(&exited).expect("deserializes");
486 let outcome = read.result.expect("the outcome is flattened into the body");
487 assert_eq!(outcome.exit_code, Some(0));
488 assert_eq!(outcome.stdout, "hi");
489 }
490
491 /// `error` is a `Cow`, so the daemon's borrowed slug and a client's owned string
492 /// are the same type on the wire.
493 #[test]
494 fn an_error_body_carries_a_borrowed_slug_and_reads_back_owned() {
495 let written = serde_json::to_string(&ErrorBody {
496 error: Cow::Borrowed(ERROR_UNKNOWN_EXEC),
497 detail: "e1".into(),
498 })
499 .expect("serializes");
500 assert_eq!(written, r#"{"error":"unknown_exec","detail":"e1"}"#);
501 let read: ErrorBody = serde_json::from_str(&written).expect("deserializes");
502 assert_eq!(read.error, ERROR_UNKNOWN_EXEC);
503 }
504}