vmette_proto/boot.rs
1//! The **host→guest boot contract**. `BootParams` is the single owner of the
2//! configuration the host hands the guest's PID-1 (`/init`) at boot: the exec
3//! command, environment, rootfs mode, extra shares, and workload strategy.
4//!
5//! Today this state is smuggled as `vmette.*=` tokens on the kernel cmdline and
6//! re-parsed by hand in pure shell — an untyped channel with a ~3000-char budget
7//! and a cross-file `scratch_dev`/attach-order invariant held together only by a
8//! comment. `BootParams` replaces that: the host serializes it to a small
9//! `KEY=VALUE` envelope written onto the `ctl` virtio-fs share, and the guest
10//! reads that one file. The cmdline shrinks to the handful of tokens the *kernel*
11//! itself consumes plus `vmette.boot=ctl`.
12//!
13//! Like the rest of `vmette-proto`, this module owns the **types**, not the I/O.
14//! The `KEY=VALUE` codec (`to_env`/`from_env`) lives with its transport in the
15//! `vmette` crate — the same convention by which the vsock frame codec lives in
16//! `vmette::desktop` and the daemon's JSON loop lives in `vmette-daemon`, not
17//! here. Keeping the codec out of this leaf preserves its minimal dependency
18//! surface (the encoding of `exec`/`env` needs base64, which belongs with the
19//! transport, not the contract).
20//!
21//! [`BOOT_PROTO_VERSION`] is carried in every envelope; the guest refuses to
22//! boot on a mismatch (a stale initramfs fails *closed*, loudly, instead of
23//! silently ignoring an unknown shape — today's failure mode).
24
25use serde::{Deserialize, Serialize};
26
27/// The version of the boot contract this build speaks. Bump on ANY breaking
28/// change to the field set or its semantics. The guest asserts the envelope's
29/// `proto_version` equals its own and aborts the boot otherwise.
30pub const BOOT_PROTO_VERSION: u32 = 1;
31
32/// How the guest root is provided. Mirrors the host's mutually-exclusive
33/// `RootfsShare` / `RootfsBlock`, but as one closed enum so "which rootfs" is a
34/// single value the guest matches on rather than two booleans it has to
35/// reconcile.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub enum RootfsSpec {
38 /// virtio-fs directory share (tag `rootfs`), overlaid in the guest. The host
39 /// always mounts it read-only; `read_only` here is whether the *guest*
40 /// presents a read-only root (`--rootfs-ro`) vs. a writable tmpfs/scratch
41 /// overlay.
42 Share { read_only: bool },
43 /// Block image (e.g. squashfs) on `/dev/vda`, overlaid in the guest.
44 /// `fstype` is the filesystem to mount it as (e.g. `"squashfs"`).
45 Block { fstype: String },
46}
47
48/// The guest workload, replacing the cmdline's `vmette.desktop`/`vmette.display`
49/// and `vmette.snapshot_mode`/`vmette.guest_vsock_port` tokens.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub enum Strategy {
52 /// Run the exec command and power off.
53 OneShot,
54 /// Boot the persistent desktop (Xvfb at `width`x`height` + the computer-use
55 /// agent).
56 Agent { width: u32, height: u32 },
57 /// Snapshot-build "server" mode — an Apple-Silicon feature (Phase 5;
58 /// `saveMachineStateToURL:` is arm64-gated). The guest execs `vsock-runner`,
59 /// which signals READY and blocks on `accept()` so the host can save the
60 /// VM's memory state, then runs the command delivered on resume.
61 /// `guest_vsock_port` is the in-guest listen port; the host-side vsock port
62 /// rides the kernel cmdline (`vmette.vsock_port`).
63 Snapshot { guest_vsock_port: u32 },
64}
65
66/// The complete host→guest boot configuration. Built host-side from the
67/// `vmette::Config`, serialized to the `ctl` share's `boot.env`, and consumed
68/// once by the guest's `/init`.
69///
70/// `exec` and `env_exports` carry *raw* shell text (a possibly multi-line
71/// command; pre-rendered `export KEY='VALUE'` lines). The `KEY=VALUE` codec
72/// base64-encodes them so they survive the line-oriented envelope intact — the
73/// guest already has busybox `base64 -d`. Transport-bootstrap values that the
74/// guest may need before (or independent of) the `ctl` mount — the vsock port —
75/// deliberately stay on the kernel cmdline, not here.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct BootParams {
78 /// Contract version; checked against [`BOOT_PROTO_VERSION`] by the guest.
79 pub proto_version: u32,
80 /// How the root filesystem is provided and overlaid.
81 pub rootfs: RootfsSpec,
82 /// Guest device name of the ephemeral scratch disk (`vda`/`vdb`/…), assigned
83 /// host-side from the virtio-blk attach order; `None` when no scratch disk is
84 /// attached (RAM-backed tmpfs overlay).
85 pub scratch_dev: Option<String>,
86 /// Extra user share tags (the `--share` mounts), mounted at `/mnt/<tag>`. The
87 /// implicit `ctl` share is excluded — the guest already knows about it.
88 pub shares: Vec<String>,
89 /// The raw exec command (possibly multi-line); `None` drops to an
90 /// interactive shell.
91 pub exec: Option<String>,
92 /// Pre-rendered shell `export KEY='VALUE'` lines (caller `--env` merged over
93 /// image env, by the host's single env renderer); `None` when empty.
94 pub env_exports: Option<String>,
95 /// `switch_root` into the new root instead of `chroot` (block/large rootfs).
96 pub switch_root: bool,
97 /// Bring up guest networking (NAT).
98 pub net: bool,
99 /// One-shot exec vs. persistent desktop agent.
100 pub strategy: Strategy,
101 /// Capture mode: the host wired a dedicated clean console (`hvc0`) for the
102 /// exec's combined stdout+stderr and moved the kernel console + `/init`
103 /// chatter to a discarded second console (`hvc1`). The guest runs the user
104 /// command with its output redirected to `/dev/hvc0`, so the host reads a
105 /// clean stream with no kernel/init noise (replacing marker-scraping). When
106 /// `false`, the exec inherits the single console as before.
107 pub capture: bool,
108}
109
110impl BootParams {
111 /// Construct with [`BOOT_PROTO_VERSION`] and the given rootfs, leaving the
112 /// rest at their empty/false defaults. Callers set the remaining fields.
113 pub fn new(rootfs: RootfsSpec) -> Self {
114 Self {
115 proto_version: BOOT_PROTO_VERSION,
116 rootfs,
117 scratch_dev: None,
118 shares: Vec::new(),
119 exec: None,
120 env_exports: None,
121 switch_root: false,
122 net: false,
123 strategy: Strategy::OneShot,
124 capture: false,
125 }
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn new_carries_current_version_and_defaults() {
135 let p = BootParams::new(RootfsSpec::Share { read_only: false });
136 assert_eq!(p.proto_version, BOOT_PROTO_VERSION);
137 assert_eq!(p.rootfs, RootfsSpec::Share { read_only: false });
138 assert!(p.scratch_dev.is_none());
139 assert!(p.shares.is_empty());
140 assert!(p.exec.is_none());
141 assert!(p.env_exports.is_none());
142 assert!(!p.switch_root);
143 assert!(!p.net);
144 assert_eq!(p.strategy, Strategy::OneShot);
145 assert!(!p.capture);
146 }
147
148 #[test]
149 fn json_round_trips() {
150 // serde is not the wire format (that's the env codec in `vmette`), but a
151 // round-trip guards the type shape and is the proto crate's convention.
152 let p = BootParams {
153 proto_version: BOOT_PROTO_VERSION,
154 rootfs: RootfsSpec::Block {
155 fstype: "squashfs".into(),
156 },
157 scratch_dev: Some("vdb".into()),
158 shares: vec!["work".into(), "data".into()],
159 exec: Some("echo hi\nuname -a".into()),
160 env_exports: Some("export FOO='bar'\n".into()),
161 switch_root: true,
162 net: true,
163 strategy: Strategy::Agent {
164 width: 1280,
165 height: 800,
166 },
167 capture: true,
168 };
169 let j = serde_json::to_string(&p).unwrap();
170 let back: BootParams = serde_json::from_str(&j).unwrap();
171 assert_eq!(p, back);
172 }
173
174 #[test]
175 fn rootfs_and_strategy_variants_are_distinct() {
176 assert_ne!(
177 RootfsSpec::Share { read_only: true },
178 RootfsSpec::Share { read_only: false }
179 );
180 assert_ne!(
181 Strategy::Agent {
182 width: 1,
183 height: 2
184 },
185 Strategy::OneShot
186 );
187 }
188}