microvms-protocol 1.2.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
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
// SPDX-License-Identifier: Apache-2.0
//! Exec wire types: the bodies, queries, and SSE event payloads of `/v1/exec/*`.
//!
//! Every type here derives both halves of serde. The daemon needs only one half of
//! each and a client needs the other, and a type that carries one half is a type the
//! other side has to hand-write — which is the drift this crate exists to prevent.
//! The published `docs/schema.json` is generated from these same attributes under
//! both contracts, so a shape change is a schema diff rather than a surprise.

use std::borrow::Cow;
use std::collections::HashMap;

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

/// Where an exec sits in its lifecycle. Mirrors `ExecPhase` in the model crate.
///
/// `JsonSchema` rides along with `Serialize` on every type from here down that
/// crosses the wire. schemars reads the same `#[serde(...)]` attributes serde
/// does, so the published schema describes what the daemon actually emits — the
/// `rename_all` below is the reason this matters rather than a formality.
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
    /// Child spawned, still running (or its pipes still held by a grandchild).
    Running,
    /// Child exited and output is buffered and readable.
    Exited,
    /// Caller acked; output has been released and the entry awaits collection.
    Acked,
}

impl Phase {
    /// Every phase, in lifecycle order.
    ///
    /// Public because a client that publishes the closed set — both bindings do, in
    /// their `session_constants` — needs the list from the type rather than a spelled-out
    /// copy that goes stale the first time a phase is added. The round-trip test below
    /// holds `ALL` complete by exhaustive match.
    pub const ALL: [Phase; 3] = [Phase::Running, Phase::Exited, Phase::Acked];

    /// The wire spelling — the exact string serde writes under `rename_all` above.
    ///
    /// Here rather than in each client because two bindings each grew their own
    /// three-arm match over this enum; a variant renamed on the wire must change
    /// exactly one table, and the test below is what keeps this one equal to serde's.
    pub const fn as_str(self) -> &'static str {
        match self {
            Phase::Running => "running",
            Phase::Exited => "exited",
            Phase::Acked => "acked",
        }
    }
}

/// Captured output and exit status of a finished exec.
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct Outcome {
    /// Exit code, or `None` when the child died to a signal.
    pub exit_code: Option<i32>,
    /// Signal number that killed the child, when one did.
    pub signal: Option<i32>,
    /// The daemon execution deadline fired. Absent on older daemons, which cannot
    /// distinguish deadline expiry from other signal deaths.
    #[serde(default)]
    pub timed_out: bool,
    pub stdout: String,
    pub stderr: String,
    /// Set when either stream hit `max_output_bytes` and was cut. An explicit
    /// flag rather than a sentinel string in the output: a marker inside the
    /// bytes is indistinguishable from output that happens to contain it.
    pub truncated: bool,
    /// Set when the post-exit linger deadline expired with the pipes still open,
    /// meaning some grandchild is alive and may write more that nobody will see.
    /// Reported rather than hidden, because a harness that sees empty output from
    /// a command it knows produced some needs to be able to tell why.
    pub writers_may_be_alive: bool,
}

/// Which pipe a streamed chunk came from. Both share one offset space, so a
/// client holds one cursor rather than two that can disagree about ordering.
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamKind {
    Stdout,
    Stderr,
}

impl StreamKind {
    /// Both kinds, in the order the daemon documents them.
    ///
    /// Same reason as [`Phase::ALL`]: the bindings publish this closed set, and a
    /// list they spell themselves is a list the enum can outgrow.
    pub const ALL: [StreamKind; 2] = [StreamKind::Stdout, StreamKind::Stderr];

    /// The wire spelling — the exact string serde writes under `rename_all` above.
    pub const fn as_str(self) -> &'static str {
        match self {
            StreamKind::Stdout => "stdout",
            StreamKind::Stderr => "stderr",
        }
    }
}

/// A user or group as a start request names it: a numeric id, or a name the daemon
/// resolves against the guest's `/etc/passwd` or `/etc/group`.
///
/// Untagged, so the wire carries a bare JSON integer or a bare JSON string and nothing
/// else. An integer serializes exactly as the `u32` this field used to be, which is what
/// keeps a client that only ever sends ids byte-compatible with a daemon that predates
/// names. A string sent to such a daemon is refused as `malformed_request` rather than
/// misread.
///
/// A string that names no row and is all ASCII digits is read as that id, the way
/// `docker exec -u 1000` reads it: a harness that carries a uid as a string reaches the
/// same account an integer would.
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(untagged)]
pub enum NameOrId {
    /// A numeric uid or gid, applied as given.
    Id(u32),
    /// A user or group name, resolved in the guest before anything is spawned.
    Name(String),
}

impl From<u32> for NameOrId {
    fn from(id: u32) -> Self {
        NameOrId::Id(id)
    }
}

impl From<&str> for NameOrId {
    fn from(name: &str) -> Self {
        NameOrId::Name(name.to_string())
    }
}

impl From<String> for NameOrId {
    fn from(name: String) -> Self {
        NameOrId::Name(name)
    }
}

impl std::str::FromStr for NameOrId {
    type Err = std::convert::Infallible;

    /// A command-line spelling: all digits is an id, anything else is a name.
    ///
    /// An id is sent as an integer rather than as its digits so a daemon that predates
    /// names still accepts it.
    fn from_str(raw: &str) -> Result<Self, Self::Err> {
        Ok(match raw.parse::<u32>() {
            Ok(id) if raw.bytes().all(|b| b.is_ascii_digit()) => NameOrId::Id(id),
            _ => NameOrId::Name(raw.to_string()),
        })
    }
}

impl std::fmt::Display for NameOrId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NameOrId::Id(id) => write!(f, "{id}"),
            NameOrId::Name(name) => f.write_str(name),
        }
    }
}

/// How a start request's `command` is run: `false` (an argv), `true` (`/bin/sh -c`), or
/// the name of a shell the daemon resolves and runs as `<shell> -c`.
///
/// Untagged for [`NameOrId`]'s reason: a boolean serializes exactly as the field always
/// did, so an old daemon keeps accepting it, and a string sent to an old daemon is refused
/// rather than misread.
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(untagged)]
pub enum Shell {
    /// `false` execs `command` as an argv; `true` runs it under `/bin/sh -c`.
    Flag(bool),
    /// A shell name such as `"bash"` (resolved on the child's `PATH`, the image's `PATH`,
    /// `/bin` and `/usr/bin`) or an absolute path to one.
    Named(String),
}

impl Default for Shell {
    fn default() -> Self {
        Shell::Flag(false)
    }
}

impl From<bool> for Shell {
    fn from(flag: bool) -> Self {
        Shell::Flag(flag)
    }
}

impl From<&str> for Shell {
    fn from(name: &str) -> Self {
        Shell::Named(name.to_string())
    }
}

impl From<String> for Shell {
    fn from(name: String) -> Self {
        Shell::Named(name)
    }
}

impl Shell {
    /// Whether `command` is a script for a shell rather than an argv.
    pub fn is_shell(&self) -> bool {
        !matches!(self, Shell::Flag(false))
    }
}

/// A start request. `command` is either an argv array or, with `shell` set, a single
/// script string.
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
#[non_exhaustive]
pub struct StartRequest {
    /// Caller-minted idempotency key. Harbor retries, and a retry must not
    /// produce a second child.
    pub exec_id: String,
    /// argv when `shell` is false, or the script when it is set.
    pub command: Vec<String>,
    /// `false`, `true` (`/bin/sh -c`), or a shell name such as `"bash"`. A name the guest
    /// cannot resolve answers 400 `unknown_shell` before anything is spawned.
    #[serde(default)]
    pub shell: Shell,
    /// Omitted means inherit the daemon's working directory. See the module docs.
    #[serde(default)]
    pub cwd: Option<String>,
    #[serde(default)]
    pub env: HashMap<String, String>,
    /// The user to demote to: a numeric uid, or a name resolved against the guest's
    /// `/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved to
    /// the passwd row its name or uid matches, with that row's primary gid, or the daemon's
    /// own user when the image sets none. A name the guest does not have, or a default with
    /// no row, answers 400 `unknown_user` before anything is spawned.
    ///
    /// A user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME`
    /// from the row, beneath the launch environment and `env`. A user given by name also
    /// gets the row's primary gid when `group` is omitted; a numeric uid keeps the
    /// daemon's group, as it always did.
    #[serde(default)]
    pub user: Option<NameOrId>,
    /// The group to demote to: a numeric gid, or a name resolved against `/etc/group`. A
    /// name the guest does not have answers 400 `unknown_group`.
    #[serde(default)]
    pub group: Option<NameOrId>,
    /// Wall-clock budget. Validated before the child spawns — the predecessor
    /// raised on a bad value inside the waiter thread, by which point the child
    /// was already running and became an orphan.
    #[serde(default)]
    pub timeout_sec: Option<f64>,
    /// Whether to give the child a writable stdin pipe. Defaults to false, which
    /// keeps `Stdio::null()`.
    ///
    /// Opt-in rather than always-on, and not only for tidiness: a child holding an
    /// open stdin pipe nobody will ever write to is a child that blocks forever
    /// the first time it reads. `/bin/sh` reading a script from stdin, `git`
    /// deciding it can prompt, any tool that probes for input — all of them behave
    /// differently against a pipe than against `/dev/null`. Every existing caller
    /// gets today's behavior by not setting this.
    #[serde(default)]
    pub stdin: bool,
    /// Whether to signal the exec's whole process group once its own child exits.
    ///
    /// Off by default, and the default is the contract this daemon has always had:
    /// a backgrounded grandchild that inherited the output pipe keeps running and
    /// keeps writing, and the linger deadline is what bounds how long the daemon
    /// waits on it. That is the "backgrounded server keeps logging" guarantee, and
    /// callers rely on it. Set to `true`, the daemon runs the same SIGTERM-then-
    /// SIGKILL escalation `POST /v1/exec/{id}/kill` uses against the group as soon
    /// as the child's exit is observed, so nothing the command left behind outlives
    /// it and the linger sees EOF rather than `writers_may_be_alive`. A timed-out
    /// exec is already escalated and is not escalated twice.
    #[serde(default)]
    pub reap_group_on_exit: bool,
    /// Power off the guest after this exec's original process group has no live members.
    /// Off by default. Requires advertised daemon support and its CAP_SYS_BOOT privilege.
    /// Other work in the VM ends too; the start acknowledgment and exit record may be lost.
    /// Processes that leave the group are outside this condition. This does not keep an
    /// idle guest awake, and a terminated state does not establish when billing stops.
    #[serde(default, skip_serializing_if = "is_false")]
    pub poweroff_on_exit: bool,
    /// Whether the child's environment starts from the image's `ENV`.
    ///
    /// Off by default, which keeps the property the daemon has always had: nothing from
    /// its own environment reaches a child. Set, the environment the daemon inherited as
    /// the container `CMD`, minus every `AGENTD_*` variable, is the lowest layer, under
    /// the passwd identity, the launch environment and `env`. The agent token is never in
    /// that snapshot: it arrives in the run hook, after the snapshot is taken.
    ///
    /// A daemon that predates this field ignores it, as serde ignores any unknown key; a
    /// caller that depends on it can read `image_env_keys` on `GET /v1/health`, which such
    /// a daemon does not report.
    #[serde(default)]
    pub inherit_image_env: bool,
}

/// `POST /v1/exec/{id}/stdin` body.
///
/// Both fields optional and both meaningful together: a final chunk plus EOF in
/// one request is the common case for feeding a prompt, and forcing two round
/// trips would leave a window where the child has the bytes but not the EOF that
/// tells it the input is complete.
#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct StdinRequest {
    /// Base64 so arbitrary bytes survive JSON. A JSON string cannot carry
    /// non-UTF-8, and stdin is bytes.
    #[serde(default)]
    pub data_b64: Option<String>,
    /// `"eof"` closes the pipe after any `data_b64` is written. Named rather than
    /// a bare boolean so the field has somewhere to grow.
    #[serde(default)]
    pub signal: Option<String>,
}

/// `POST /v1/exec/{id}/stdin` response.
///
/// `pub` rather than private, like every other type in this module the schema
/// route publishes: the generator names them by type, so a response shape that
/// stays private is a shape a consumer cannot be told about.
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct StdinResponse {
    pub exec_id: String,
    pub written: usize,
    pub eof: bool,
}

/// `GET /v1/exec/{id}/stream` query.
#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct StreamQuery {
    /// Byte offset to resume from. Absent means 0, i.e. everything still in the
    /// replay window.
    #[serde(default)]
    pub offset: Option<u64>,
}

/// One `output` SSE event.
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct OutputEvent {
    pub offset: u64,
    pub stream: StreamKind,
    pub output: String,
}

/// One `gap` SSE event: the byte range a lagging or late subscriber lost.
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct GapEvent {
    pub from: u64,
    pub to: u64,
}

/// The terminal `exit` SSE event. Emitted before the stream ends, so a client
/// that sees the body close without one knows the connection failed rather than
/// the command finishing.
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct ExitEvent {
    pub exit_code: Option<i32>,
    pub signal: Option<i32>,
    /// The daemon execution deadline fired. Absent on older daemons, which cannot
    /// distinguish deadline expiry from other signal deaths.
    #[serde(default)]
    pub timed_out: bool,
    pub truncated: bool,
    pub writers_may_be_alive: bool,
    /// Total bytes published, so a client can assert it saw all of them.
    pub offset: u64,
}

#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct StartResponse {
    pub exec_id: String,
    pub phase: Phase,
}

/// `POST /v1/exec/{id}/kill` response.
///
/// A named type rather than the `serde_json::json!` literal this used to be: an
/// ad-hoc `Value` has no schema to derive, so the one route whose body a client
/// most needs to branch on — `killed` distinguishes "signalled" from "the group
/// was already gone", and both are 200 — would have been the one route the
/// published document could not describe.
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct KillResponse {
    pub exec_id: String,
    /// Whether a signal was actually delivered. `false` with a 200 means the
    /// process group had already exited, which is the outcome a kill wanted.
    pub killed: bool,
}

/// One entry of `GET /v1/procs`: an exec's process group as the daemon sees it.
///
/// The exec registry knows the group (`pgid`) and whether the direct child has
/// exited; `/proc` knows which pids are still in that group. Both halves are here
/// because the interesting case is their disagreement — `child_exited: true` with a
/// non-empty `pids` is a command that finished while something it started did not,
/// which is exactly what a caller asking "did this step leave anything behind"
/// needs to see and could not before.
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
pub struct ProcGroup {
    pub exec_id: String,
    /// The process group id captured at spawn. `None` when the child was reaped
    /// before it could be read, in which case `pids` is empty because there is no
    /// group to scan for.
    pub pgid: Option<u32>,
    /// Seconds since the epoch on the daemon's clock when the child was spawned.
    /// The same convention as a hook observation's `fired_at`.
    pub started_at: u64,
    /// Whether the exec's own child has exited. Read from the terminal marker an
    /// ack cannot take, so an acked exec still reads as exited here.
    pub child_exited: bool,
    /// Whether the exec was started with `reap_group_on_exit`.
    pub reap: bool,
    /// Live pids whose process group is `pgid`, read from `/proc`. Zombies are not
    /// live and are not listed. Empty once the group is gone, and empty for an
    /// entry whose `pgid` is `None`.
    pub pids: Vec<u32>,
}

/// `GET /v1/procs` response.
#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct ProcsResponse {
    /// Every registered exec, in registry order, whether or not anything in its
    /// group is still alive. An exec that exited cleanly with nothing left behind
    /// is listed with `child_exited: true` and `pids: []`.
    pub procs: Vec<ProcGroup>,
}

#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct PollResponse {
    pub exec_id: String,
    pub phase: Phase,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(flatten)]
    pub result: Option<Outcome>,
}

/// The JSON error body every failing control route returns.
///
/// `error` is a stable machine-readable slug and `detail` is prose for a human
/// reading a log. A client branches on `error` and the status code, never on
/// `detail` — which is why the slug is one of the constants below, chosen at each
/// call site, rather than a formatted string. `Cow` so the daemon keeps naming a
/// `&'static str` while a client deserializes into an owned one.
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct ErrorBody {
    pub error: Cow<'static, str>,
    pub detail: String,
}

/// The `event:` names on `GET /v1/exec/{id}/stream`.
///
/// Named rather than spelled at each `Event::default().event(..)` call: the daemon
/// writes these and a client matches on them, and a typo on either side is a
/// stream that silently carries events nobody dispatches.
pub const EVENT_OUTPUT: &str = "output";
pub const EVENT_GAP: &str = "gap";
pub const EVENT_EXIT: &str = "exit";

// The `ErrorBody::error` slugs. Every failing control route returns one of these,
// they are the part of the surface a client branches on, and they are `&'static
// str` for the same reason the status code is a number: the pairing of the two is
// the contract, and neither is derivable from the other.

/// The body is not a valid request for this route. Always 400, never 404.
pub const ERROR_MALFORMED_REQUEST: &str = "malformed_request";
/// No exec is registered under this id, or its entry was collected after an ack.
pub const ERROR_UNKNOWN_EXEC: &str = "unknown_exec";
/// The child could not be spawned. 500, deliberately not 404.
pub const ERROR_SPAWN_FAILED: &str = "spawn_failed";
/// The requested shutdown is unavailable with the daemon's current privileges.
pub const ERROR_POWEROFF_UNAVAILABLE: &str = "poweroff_unavailable";
/// The exec has not exited, so acking would drop output still being written.
pub const ERROR_STILL_RUNNING: &str = "still_running";
/// An earlier ack already released the output.
pub const ERROR_ALREADY_ACKED: &str = "already_acked";
/// The exec was started without `stdin: true`. Fixable at start time, hence 409.
pub const ERROR_STDIN_NOT_REQUESTED: &str = "stdin_not_requested";
/// stdin was closed by an earlier eof, or the child stopped reading. 410: a retry
/// will never succeed.
pub const ERROR_STDIN_CLOSED: &str = "stdin_closed";
/// The child did not read within the configured window. Retryable, and some bytes
/// may already have been written.
pub const ERROR_STDIN_WRITE_TIMEOUT: &str = "stdin_write_timeout";
/// The decoded write exceeds the configured per-write cap.
pub const ERROR_STDIN_WRITE_TOO_LARGE: &str = "stdin_write_too_large";
/// The write to the pipe failed for a reason other than a broken pipe.
pub const ERROR_STDIN_WRITE_FAILED: &str = "stdin_write_failed";
/// A start request named a user the guest's `/etc/passwd` does not contain. 400, and
/// nothing was spawned; `detail` names the user.
pub const ERROR_UNKNOWN_USER: &str = "unknown_user";
/// A start request named a group the guest's `/etc/group` does not contain. 400, and
/// nothing was spawned; `detail` names the group.
pub const ERROR_UNKNOWN_GROUP: &str = "unknown_group";
/// A start request named a shell that no searched directory holds as an executable
/// file. 400, and nothing was spawned; `detail` names the shell and where it looked.
pub const ERROR_UNKNOWN_SHELL: &str = "unknown_shell";

fn is_false(value: &bool) -> bool {
    !value
}

impl StartRequest {
    /// A request to run `command` as an argv under the idempotency key `exec_id`, with
    /// every other field at the default a body that omits it gets.
    ///
    /// `exec_id` has no default here: minting one needs a clock and a random source,
    /// which this crate doesn't have, and a shared fixed key would answer a second exec
    /// from the first one's record.
    ///
    /// This and the `with_*` setters are the only way to build a request outside this
    /// crate, because `StartRequest` is `#[non_exhaustive]`. The builder is where every
    /// optional field gets its one default, the same one serde gives a body that omits it,
    /// so a client can't pick its own `timeout_sec`, `reap_group_on_exit` or
    /// `inherit_image_env` by writing the struct out, and a field added here reaches every
    /// caller as its default instead of as a build error in each client. (The struct's own
    /// doc comment is the published schema's description, so this lives here.)
    ///
    /// ```
    /// # use microvms_protocol::exec::StartRequest;
    /// let request: StartRequest = StartRequest::new("e1", vec!["true".into()]);
    /// # assert!(!request.reap_group_on_exit);
    /// ```
    ///
    /// The same request as a struct expression doesn't build outside this crate (E0639):
    ///
    /// ```compile_fail,E0639
    /// # use microvms_protocol::exec::StartRequest;
    /// let request: StartRequest = StartRequest { ..StartRequest::new("e1", vec!["true".into()]) };
    /// # assert!(!request.reap_group_on_exit);
    /// ```
    pub fn new(exec_id: impl Into<String>, command: Vec<String>) -> Self {
        StartRequest {
            exec_id: exec_id.into(),
            command,
            shell: Shell::default(),
            cwd: None,
            env: HashMap::new(),
            user: None,
            group: None,
            timeout_sec: None,
            stdin: false,
            reap_group_on_exit: false,
            poweroff_on_exit: false,
            inherit_image_env: false,
        }
    }

    /// Run `command` as a script under this shell, or as an argv for `false`.
    pub fn with_shell(mut self, shell: impl Into<Shell>) -> Self {
        self.shell = shell.into();
        self
    }

    /// The child's working directory; `None` inherits the daemon's.
    pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
        self.cwd = cwd;
        self
    }

    /// The top layer of the child's environment.
    pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
        self.env = env;
        self
    }

    /// The user to demote to; `None` runs as the image's `AGENTD_DEFAULT_USER`, or as the
    /// daemon's own user when the image sets none.
    pub fn with_user(mut self, user: Option<NameOrId>) -> Self {
        self.user = user;
        self
    }

    /// The group to demote to; `None` follows [`StartRequest::user`]'s rule.
    pub fn with_group(mut self, group: Option<NameOrId>) -> Self {
        self.group = group;
        self
    }

    /// The daemon's wall-clock budget for the child; `None` sets none.
    pub fn with_timeout_sec(mut self, timeout_sec: Option<f64>) -> Self {
        self.timeout_sec = timeout_sec;
        self
    }

    /// Give the child a writable stdin pipe.
    pub fn with_stdin(mut self, stdin: bool) -> Self {
        self.stdin = stdin;
        self
    }

    /// Signal the exec's process group once its own child exits.
    pub fn with_reap_group_on_exit(mut self, reap: bool) -> Self {
        self.reap_group_on_exit = reap;
        self
    }

    /// Power off the guest when this exec's original process group has exited.
    pub fn with_poweroff_on_exit(mut self, poweroff: bool) -> Self {
        self.poweroff_on_exit = poweroff;
        self
    }

    /// Start the child's environment from the image's `ENV`.
    pub fn with_inherit_image_env(mut self, inherit: bool) -> Self {
        self.inherit_image_env = inherit;
        self
    }
}

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

    /// The `rename_all` is the reason these types are shared rather than mirrored:
    /// a client that spelled the variants from the Rust identifiers would send
    /// `Running` where the daemon emits `running`.
    #[test]
    fn phase_and_stream_kind_round_trip_through_their_serde_names() {
        assert_eq!(
            serde_json::to_string(&Phase::Running).expect("serializes"),
            "\"running\""
        );
        assert_eq!(
            serde_json::from_str::<Phase>("\"acked\"").expect("deserializes"),
            Phase::Acked
        );
        assert_eq!(
            serde_json::to_string(&StreamKind::Stderr).expect("serializes"),
            "\"stderr\""
        );
    }

    /// `as_str` is a second spelling of what serde already writes, so the two must be
    /// equal for every variant or the bindings publish names the wire never carries.
    ///
    /// `ALL` is held complete by exhaustion rather than by a length check: the matches
    /// below have no wildcard arm, so a variant added to either enum fails to compile
    /// here until it is added to its `ALL` too.
    #[test]
    fn as_str_agrees_with_serde_for_every_phase_and_stream_kind() {
        for phase in Phase::ALL {
            assert_eq!(
                serde_json::to_string(&phase).expect("serializes"),
                format!("\"{}\"", phase.as_str()),
                "{phase:?} spells differently through as_str and serde"
            );
            match phase {
                Phase::Running | Phase::Exited | Phase::Acked => {}
            }
        }
        for kind in StreamKind::ALL {
            assert_eq!(
                serde_json::to_string(&kind).expect("serializes"),
                format!("\"{}\"", kind.as_str()),
                "{kind:?} spells differently through as_str and serde"
            );
            match kind {
                StreamKind::Stdout | StreamKind::Stderr => {}
            }
        }
    }

    /// Both halves of serde on one type is the whole point of the crate: what the
    /// daemon writes is what a client reads, checked rather than assumed.
    #[test]
    fn a_response_the_daemon_writes_deserializes_on_the_client_side() {
        let written = serde_json::to_string(&StartResponse {
            exec_id: "e1".into(),
            phase: Phase::Running,
        })
        .expect("serializes");
        let read: StartResponse = serde_json::from_str(&written).expect("deserializes");
        assert_eq!(read.exec_id, "e1");
        assert_eq!(read.phase, Phase::Running);
    }

    /// A request the client writes is one the daemon's extractor accepts, including
    /// every serde default — the fields a caller omits are the ones most likely to
    /// disagree across a hand-written mirror.
    #[test]
    fn a_start_request_omitting_every_defaulted_field_deserializes() {
        let request: StartRequest =
            serde_json::from_str(r#"{"exec_id":"e1","command":["true"]}"#).expect("deserializes");
        assert_eq!(request.shell, Shell::Flag(false));
        assert!(!request.shell.is_shell());
        assert!(request.user.is_none() && request.group.is_none());
        assert!(
            !request.inherit_image_env,
            "inheriting the image env is opt-in: an old client keeps the exact launch map"
        );
        assert!(request.cwd.is_none());
        assert!(request.env.is_empty());
        assert!(request.timeout_sec.is_none());
        assert!(!request.stdin);
        assert!(
            !request.reap_group_on_exit,
            "reaping is opt-in: an old client that never heard of the flag keeps today's \
             grandchild-output guarantee"
        );
    }

    /// The builder's defaults are the wire's: a request built with nothing set writes the
    /// same body the daemon reads from one that omits every optional field. A builder that
    /// picked its own default would send a different request than an old client does.
    ///
    /// **Falsification**: 2026-09-27. Defaulting `reap_group_on_exit` to `true` in
    /// `StartRequest::new` turned this red; restored after.
    #[test]
    fn the_builder_defaults_are_the_ones_serde_gives_an_omitted_field() {
        let built = StartRequest::new("e1", vec!["true".into()]);
        let read: StartRequest =
            serde_json::from_str(r#"{"exec_id":"e1","command":["true"]}"#).expect("deserializes");
        assert_eq!(
            serde_json::to_value(&built).expect("serializes"),
            serde_json::to_value(&read).expect("serializes"),
        );
    }

    /// The union fields read both spellings and write each back as it came, so a client
    /// that only ever sends integers and booleans sends the bytes it always did.
    #[test]
    fn user_group_and_shell_read_both_spellings_and_write_them_back() {
        let request: StartRequest = serde_json::from_str(
            r#"{"exec_id":"e1","command":["id"],"user":1000,"group":"staff","shell":"bash","inherit_image_env":true}"#,
        )
        .expect("deserializes");
        assert_eq!(request.user, Some(NameOrId::Id(1000)));
        assert_eq!(request.group, Some(NameOrId::Name("staff".into())));
        assert_eq!(request.shell, Shell::Named("bash".into()));
        assert!(request.inherit_image_env);

        let written = serde_json::to_value(&request).expect("serializes");
        assert_eq!(written["user"], 1000);
        assert_eq!(written["group"], "staff");
        assert_eq!(written["shell"], "bash");

        let legacy: StartRequest =
            serde_json::from_str(r#"{"exec_id":"e1","command":["id"],"shell":true,"user":0}"#)
                .expect("deserializes");
        assert_eq!(legacy.shell, Shell::Flag(true));
        let written = serde_json::to_value(&legacy).expect("serializes");
        assert_eq!(written["shell"], true);
        assert_eq!(written["user"], 0);
    }

    /// Anything but an integer or a string for a user, or a boolean or a string for a
    /// shell, is a malformed body rather than a coerced value.
    #[test]
    fn other_json_types_for_the_union_fields_are_refused() {
        for body in [
            r#"{"exec_id":"e1","command":["id"],"user":-1}"#,
            r#"{"exec_id":"e1","command":["id"],"user":1.5}"#,
            r#"{"exec_id":"e1","command":["id"],"user":["root"]}"#,
            r#"{"exec_id":"e1","command":["id"],"group":{"name":"x"}}"#,
            r#"{"exec_id":"e1","command":["id"],"shell":1}"#,
            r#"{"exec_id":"e1","command":["id"],"shell":null}"#,
        ] {
            assert!(
                serde_json::from_str::<StartRequest>(body).is_err(),
                "accepted {body}"
            );
        }
    }

    /// The command-line spelling: digits are an id and go out as an integer.
    #[test]
    fn a_flag_of_digits_is_an_id_and_anything_else_a_name() {
        assert_eq!("1000".parse::<NameOrId>(), Ok(NameOrId::Id(1000)));
        assert_eq!("0".parse::<NameOrId>(), Ok(NameOrId::Id(0)));
        assert_eq!(
            "agent".parse::<NameOrId>(),
            Ok(NameOrId::Name("agent".into()))
        );
        assert_eq!("+1".parse::<NameOrId>(), Ok(NameOrId::Name("+1".into())));
        assert_eq!(
            "99999999999".parse::<NameOrId>(),
            Ok(NameOrId::Name("99999999999".into()))
        );
    }

    /// The procs body round-trips with every field present, `pgid: null` included, so a
    /// client can tell "no group was ever captured" from "the group is empty".
    #[test]
    fn a_procs_response_round_trips_with_a_missing_pgid() {
        let written = serde_json::to_string(&ProcsResponse {
            procs: vec![ProcGroup {
                exec_id: "e1".into(),
                pgid: None,
                started_at: 1_756_500_000,
                child_exited: true,
                reap: false,
                pids: Vec::new(),
            }],
        })
        .expect("serializes");
        assert_eq!(
            written,
            r#"{"procs":[{"exec_id":"e1","pgid":null,"started_at":1756500000,"child_exited":true,"reap":false,"pids":[]}]}"#
        );
        let read: ProcsResponse = serde_json::from_str(&written).expect("deserializes");
        assert_eq!(read.procs.len(), 1);
        assert_eq!(read.procs[0].pgid, None);
        assert!(read.procs[0].child_exited);
    }

    /// The flatten-plus-skip on `PollResponse.result` is the one shape whose two
    /// serde contracts genuinely differ — absent on the way out while running, and
    /// the same absence has to read back as `None` on the way in. Both directions,
    /// because a client that read a running exec's missing outcome as an error would
    /// fail on every poll before the first one that mattered.
    #[test]
    fn a_poll_response_round_trips_with_and_without_an_outcome() {
        let running = serde_json::to_string(&PollResponse {
            exec_id: "e1".into(),
            phase: Phase::Running,
            result: None,
        })
        .expect("serializes");
        assert_eq!(running, r#"{"exec_id":"e1","phase":"running"}"#);
        let read: PollResponse = serde_json::from_str(&running).expect("deserializes");
        assert!(read.result.is_none());

        let exited = serde_json::to_string(&PollResponse {
            exec_id: "e1".into(),
            phase: Phase::Exited,
            result: Some(Outcome {
                exit_code: Some(0),
                stdout: "hi".into(),
                ..Outcome::default()
            }),
        })
        .expect("serializes");
        let read: PollResponse = serde_json::from_str(&exited).expect("deserializes");
        let outcome = read.result.expect("the outcome is flattened into the body");
        assert_eq!(outcome.exit_code, Some(0));
        assert_eq!(outcome.stdout, "hi");
    }

    /// `error` is a `Cow`, so the daemon's borrowed slug and a client's owned string
    /// are the same type on the wire.
    #[test]
    fn an_error_body_carries_a_borrowed_slug_and_reads_back_owned() {
        let written = serde_json::to_string(&ErrorBody {
            error: Cow::Borrowed(ERROR_UNKNOWN_EXEC),
            detail: "e1".into(),
        })
        .expect("serializes");
        assert_eq!(written, r#"{"error":"unknown_exec","detail":"e1"}"#);
        let read: ErrorBody = serde_json::from_str(&written).expect("deserializes");
        assert_eq!(read.error, ERROR_UNKNOWN_EXEC);
    }

    #[test]
    fn poweroff_defaults_to_absent_and_true_round_trips() {
        let request = StartRequest::new("e1", vec!["true".into()]);
        assert!(!request.poweroff_on_exit);
        let body = serde_json::to_value(&request).unwrap();
        assert!(body.get("poweroff_on_exit").is_none());
        assert!(
            !serde_json::from_value::<StartRequest>(body)
                .unwrap()
                .poweroff_on_exit
        );
        let body = serde_json::to_value(request.with_poweroff_on_exit(true)).unwrap();
        assert_eq!(body["poweroff_on_exit"], true);
        assert!(
            serde_json::from_value::<StartRequest>(body)
                .unwrap()
                .poweroff_on_exit
        );
    }
}