Skip to main content

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}