Skip to main content

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    pub stdout: String,
64    pub stderr: String,
65    /// Set when either stream hit `max_output_bytes` and was cut. An explicit
66    /// flag rather than a sentinel string in the output: a marker inside the
67    /// bytes is indistinguishable from output that happens to contain it.
68    pub truncated: bool,
69    /// Set when the post-exit linger deadline expired with the pipes still open,
70    /// meaning some grandchild is alive and may write more that nobody will see.
71    /// Reported rather than hidden, because a harness that sees empty output from
72    /// a command it knows produced some needs to be able to tell why.
73    pub writers_may_be_alive: bool,
74}
75
76/// Which pipe a streamed chunk came from. Both share one offset space, so a
77/// client holds one cursor rather than two that can disagree about ordering.
78#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
79#[serde(rename_all = "snake_case")]
80pub enum StreamKind {
81    Stdout,
82    Stderr,
83}
84
85impl StreamKind {
86    /// Both kinds, in the order the daemon documents them.
87    ///
88    /// Same reason as [`Phase::ALL`]: the bindings publish this closed set, and a
89    /// list they spell themselves is a list the enum can outgrow.
90    pub const ALL: [StreamKind; 2] = [StreamKind::Stdout, StreamKind::Stderr];
91
92    /// The wire spelling — the exact string serde writes under `rename_all` above.
93    pub const fn as_str(self) -> &'static str {
94        match self {
95            StreamKind::Stdout => "stdout",
96            StreamKind::Stderr => "stderr",
97        }
98    }
99}
100
101/// A start request. `command` is either an argv array or, with `shell: true`, a
102/// single script string.
103#[derive(Debug, Deserialize, JsonSchema, Serialize)]
104pub struct StartRequest {
105    /// Caller-minted idempotency key. Harbor retries, and a retry must not
106    /// produce a second child.
107    pub exec_id: String,
108    /// argv when `shell` is false, or the script when it is true.
109    pub command: Vec<String>,
110    #[serde(default)]
111    pub shell: bool,
112    /// Omitted means inherit the daemon's working directory. See the module docs.
113    #[serde(default)]
114    pub cwd: Option<String>,
115    #[serde(default)]
116    pub env: HashMap<String, String>,
117    /// Numeric uid to demote to. Optional; omitted means run as the daemon's own
118    /// user.
119    #[serde(default)]
120    pub user: Option<u32>,
121    #[serde(default)]
122    pub group: Option<u32>,
123    /// Wall-clock budget. Validated before the child spawns — the predecessor
124    /// raised on a bad value inside the waiter thread, by which point the child
125    /// was already running and became an orphan.
126    #[serde(default)]
127    pub timeout_sec: Option<f64>,
128    /// Whether to give the child a writable stdin pipe. Defaults to false, which
129    /// keeps `Stdio::null()`.
130    ///
131    /// Opt-in rather than always-on, and not only for tidiness: a child holding an
132    /// open stdin pipe nobody will ever write to is a child that blocks forever
133    /// the first time it reads. `/bin/sh` reading a script from stdin, `git`
134    /// deciding it can prompt, any tool that probes for input — all of them behave
135    /// differently against a pipe than against `/dev/null`. Every existing caller
136    /// gets today's behavior by not setting this.
137    #[serde(default)]
138    pub stdin: bool,
139}
140
141/// `POST /v1/exec/{id}/stdin` body.
142///
143/// Both fields optional and both meaningful together: a final chunk plus EOF in
144/// one request is the common case for feeding a prompt, and forcing two round
145/// trips would leave a window where the child has the bytes but not the EOF that
146/// tells it the input is complete.
147#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
148pub struct StdinRequest {
149    /// Base64 so arbitrary bytes survive JSON. A JSON string cannot carry
150    /// non-UTF-8, and stdin is bytes.
151    #[serde(default)]
152    pub data_b64: Option<String>,
153    /// `"eof"` closes the pipe after any `data_b64` is written. Named rather than
154    /// a bare boolean so the field has somewhere to grow.
155    #[serde(default)]
156    pub signal: Option<String>,
157}
158
159/// `POST /v1/exec/{id}/stdin` response.
160///
161/// `pub` rather than private, like every other type in this module the schema
162/// route publishes: the generator names them by type, so a response shape that
163/// stays private is a shape a consumer cannot be told about.
164#[derive(Debug, Deserialize, JsonSchema, Serialize)]
165pub struct StdinResponse {
166    pub exec_id: String,
167    pub written: usize,
168    pub eof: bool,
169}
170
171/// `GET /v1/exec/{id}/stream` query.
172#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
173pub struct StreamQuery {
174    /// Byte offset to resume from. Absent means 0, i.e. everything still in the
175    /// replay window.
176    #[serde(default)]
177    pub offset: Option<u64>,
178}
179
180/// One `output` SSE event.
181#[derive(Debug, Deserialize, JsonSchema, Serialize)]
182pub struct OutputEvent {
183    pub offset: u64,
184    pub stream: StreamKind,
185    pub output: String,
186}
187
188/// One `gap` SSE event: the byte range a lagging or late subscriber lost.
189#[derive(Debug, Deserialize, JsonSchema, Serialize)]
190pub struct GapEvent {
191    pub from: u64,
192    pub to: u64,
193}
194
195/// The terminal `exit` SSE event. Emitted before the stream ends, so a client
196/// that sees the body close without one knows the connection failed rather than
197/// the command finishing.
198#[derive(Debug, Deserialize, JsonSchema, Serialize)]
199pub struct ExitEvent {
200    pub exit_code: Option<i32>,
201    pub signal: Option<i32>,
202    pub truncated: bool,
203    pub writers_may_be_alive: bool,
204    /// Total bytes published, so a client can assert it saw all of them.
205    pub offset: u64,
206}
207
208#[derive(Debug, Deserialize, JsonSchema, Serialize)]
209pub struct StartResponse {
210    pub exec_id: String,
211    pub phase: Phase,
212}
213
214/// `POST /v1/exec/{id}/kill` response.
215///
216/// A named type rather than the `serde_json::json!` literal this used to be: an
217/// ad-hoc `Value` has no schema to derive, so the one route whose body a client
218/// most needs to branch on — `killed` distinguishes "signalled" from "the group
219/// was already gone", and both are 200 — would have been the one route the
220/// published document could not describe.
221#[derive(Debug, Deserialize, JsonSchema, Serialize)]
222pub struct KillResponse {
223    pub exec_id: String,
224    /// Whether a signal was actually delivered. `false` with a 200 means the
225    /// process group had already exited, which is the outcome a kill wanted.
226    pub killed: bool,
227}
228
229#[derive(Debug, Deserialize, JsonSchema, Serialize)]
230pub struct PollResponse {
231    pub exec_id: String,
232    pub phase: Phase,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    #[serde(flatten)]
235    pub result: Option<Outcome>,
236}
237
238/// The JSON error body every failing control route returns.
239///
240/// `error` is a stable machine-readable slug and `detail` is prose for a human
241/// reading a log. A client branches on `error` and the status code, never on
242/// `detail` — which is why the slug is one of the constants below, chosen at each
243/// call site, rather than a formatted string. `Cow` so the daemon keeps naming a
244/// `&'static str` while a client deserializes into an owned one.
245#[derive(Debug, Deserialize, JsonSchema, Serialize)]
246pub struct ErrorBody {
247    pub error: Cow<'static, str>,
248    pub detail: String,
249}
250
251/// The `event:` names on `GET /v1/exec/{id}/stream`.
252///
253/// Named rather than spelled at each `Event::default().event(..)` call: the daemon
254/// writes these and a client matches on them, and a typo on either side is a
255/// stream that silently carries events nobody dispatches.
256pub const EVENT_OUTPUT: &str = "output";
257pub const EVENT_GAP: &str = "gap";
258pub const EVENT_EXIT: &str = "exit";
259
260// The `ErrorBody::error` slugs. Every failing control route returns one of these,
261// they are the part of the surface a client branches on, and they are `&'static
262// str` for the same reason the status code is a number: the pairing of the two is
263// the contract, and neither is derivable from the other.
264
265/// The body is not a valid request for this route. Always 400, never 404.
266pub const ERROR_MALFORMED_REQUEST: &str = "malformed_request";
267/// No exec is registered under this id, or its entry was collected after an ack.
268pub const ERROR_UNKNOWN_EXEC: &str = "unknown_exec";
269/// The child could not be spawned. 500, deliberately not 404.
270pub const ERROR_SPAWN_FAILED: &str = "spawn_failed";
271/// The exec has not exited, so acking would drop output still being written.
272pub const ERROR_STILL_RUNNING: &str = "still_running";
273/// An earlier ack already released the output.
274pub const ERROR_ALREADY_ACKED: &str = "already_acked";
275/// The exec was started without `stdin: true`. Fixable at start time, hence 409.
276pub const ERROR_STDIN_NOT_REQUESTED: &str = "stdin_not_requested";
277/// stdin was closed by an earlier eof, or the child stopped reading. 410: a retry
278/// will never succeed.
279pub const ERROR_STDIN_CLOSED: &str = "stdin_closed";
280/// The child did not read within the configured window. Retryable, and some bytes
281/// may already have been written.
282pub const ERROR_STDIN_WRITE_TIMEOUT: &str = "stdin_write_timeout";
283/// The decoded write exceeds the configured per-write cap.
284pub const ERROR_STDIN_WRITE_TOO_LARGE: &str = "stdin_write_too_large";
285/// The write to the pipe failed for a reason other than a broken pipe.
286pub const ERROR_STDIN_WRITE_FAILED: &str = "stdin_write_failed";
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    /// The `rename_all` is the reason these types are shared rather than mirrored:
293    /// a client that spelled the variants from the Rust identifiers would send
294    /// `Running` where the daemon emits `running`.
295    #[test]
296    fn phase_and_stream_kind_round_trip_through_their_serde_names() {
297        assert_eq!(
298            serde_json::to_string(&Phase::Running).expect("serializes"),
299            "\"running\""
300        );
301        assert_eq!(
302            serde_json::from_str::<Phase>("\"acked\"").expect("deserializes"),
303            Phase::Acked
304        );
305        assert_eq!(
306            serde_json::to_string(&StreamKind::Stderr).expect("serializes"),
307            "\"stderr\""
308        );
309    }
310
311    /// `as_str` is a second spelling of what serde already writes, so the two must be
312    /// equal for every variant or the bindings publish names the wire never carries.
313    ///
314    /// `ALL` is held complete by exhaustion rather than by a length check: the matches
315    /// below have no wildcard arm, so a variant added to either enum fails to compile
316    /// here until it is added to its `ALL` too.
317    #[test]
318    fn as_str_agrees_with_serde_for_every_phase_and_stream_kind() {
319        for phase in Phase::ALL {
320            assert_eq!(
321                serde_json::to_string(&phase).expect("serializes"),
322                format!("\"{}\"", phase.as_str()),
323                "{phase:?} spells differently through as_str and serde"
324            );
325            match phase {
326                Phase::Running | Phase::Exited | Phase::Acked => {}
327            }
328        }
329        for kind in StreamKind::ALL {
330            assert_eq!(
331                serde_json::to_string(&kind).expect("serializes"),
332                format!("\"{}\"", kind.as_str()),
333                "{kind:?} spells differently through as_str and serde"
334            );
335            match kind {
336                StreamKind::Stdout | StreamKind::Stderr => {}
337            }
338        }
339    }
340
341    /// Both halves of serde on one type is the whole point of the crate: what the
342    /// daemon writes is what a client reads, checked rather than assumed.
343    #[test]
344    fn a_response_the_daemon_writes_deserializes_on_the_client_side() {
345        let written = serde_json::to_string(&StartResponse {
346            exec_id: "e1".into(),
347            phase: Phase::Running,
348        })
349        .expect("serializes");
350        let read: StartResponse = serde_json::from_str(&written).expect("deserializes");
351        assert_eq!(read.exec_id, "e1");
352        assert_eq!(read.phase, Phase::Running);
353    }
354
355    /// A request the client writes is one the daemon's extractor accepts, including
356    /// every serde default — the fields a caller omits are the ones most likely to
357    /// disagree across a hand-written mirror.
358    #[test]
359    fn a_start_request_omitting_every_defaulted_field_deserializes() {
360        let request: StartRequest =
361            serde_json::from_str(r#"{"exec_id":"e1","command":["true"]}"#).expect("deserializes");
362        assert!(!request.shell);
363        assert!(request.cwd.is_none());
364        assert!(request.env.is_empty());
365        assert!(request.timeout_sec.is_none());
366        assert!(!request.stdin);
367    }
368
369    /// The flatten-plus-skip on `PollResponse.result` is the one shape whose two
370    /// serde contracts genuinely differ — absent on the way out while running, and
371    /// the same absence has to read back as `None` on the way in. Both directions,
372    /// because a client that read a running exec's missing outcome as an error would
373    /// fail on every poll before the first one that mattered.
374    #[test]
375    fn a_poll_response_round_trips_with_and_without_an_outcome() {
376        let running = serde_json::to_string(&PollResponse {
377            exec_id: "e1".into(),
378            phase: Phase::Running,
379            result: None,
380        })
381        .expect("serializes");
382        assert_eq!(running, r#"{"exec_id":"e1","phase":"running"}"#);
383        let read: PollResponse = serde_json::from_str(&running).expect("deserializes");
384        assert!(read.result.is_none());
385
386        let exited = serde_json::to_string(&PollResponse {
387            exec_id: "e1".into(),
388            phase: Phase::Exited,
389            result: Some(Outcome {
390                exit_code: Some(0),
391                stdout: "hi".into(),
392                ..Outcome::default()
393            }),
394        })
395        .expect("serializes");
396        let read: PollResponse = serde_json::from_str(&exited).expect("deserializes");
397        let outcome = read.result.expect("the outcome is flattened into the body");
398        assert_eq!(outcome.exit_code, Some(0));
399        assert_eq!(outcome.stdout, "hi");
400    }
401
402    /// `error` is a `Cow`, so the daemon's borrowed slug and a client's owned string
403    /// are the same type on the wire.
404    #[test]
405    fn an_error_body_carries_a_borrowed_slug_and_reads_back_owned() {
406        let written = serde_json::to_string(&ErrorBody {
407            error: Cow::Borrowed(ERROR_UNKNOWN_EXEC),
408            detail: "e1".into(),
409        })
410        .expect("serializes");
411        assert_eq!(written, r#"{"error":"unknown_exec","detail":"e1"}"#);
412        let read: ErrorBody = serde_json::from_str(&written).expect("deserializes");
413        assert_eq!(read.error, ERROR_UNKNOWN_EXEC);
414    }
415}