Skip to main content

tatara_vm/
vfkit.rs

1//! `vfkit` config emitter.
2//!
3//! vfkit is a minimal CLI over Apple Virtualization.framework. It reads a JSON
4//! config (via `--config`) describing CPUs, memory, kernel, initrd, devices.
5//! We emit that JSON from a typed `VmSpec` so the whole guest definition
6//! survives round-trip from tatara-lisp to a bootable VM.
7//!
8//! This emitter is Darwin-friendly but the Rust build is host-agnostic — we
9//! don't link against Virtualization.framework directly. vfkit does.
10
11use serde::{Deserialize, Serialize};
12
13use tatara_nix::{Artifact, MultiSynthesizer, Synthesizer};
14
15use crate::config::{GuestKernel, GuestRootfs, Hypervisor, NetworkKind, VmSpec};
16
17/// JSON shape vfkit expects. Keep it narrow — vfkit has many optional fields
18/// we don't use yet.
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "kebab-case")]
21pub struct VfkitJson {
22    pub cpus: u32,
23    pub memory_mib: u32,
24    pub kernel: String,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub initrd: Option<String>,
27    pub cmdline: String,
28    pub devices: Vec<VfkitDevice>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(tag = "device", rename_all = "kebab-case")]
33pub enum VfkitDevice {
34    VirtioBlk {
35        image: String,
36    },
37    VirtioNet {
38        mode: String,
39        #[serde(skip_serializing_if = "Option::is_none")]
40        subnet: Option<String>,
41        /// Stable MAC address (form `xx:xx:xx:xx:xx:xx`). Derived from the
42        /// guest hostname by default so `arp -a` can find the guest between
43        /// boots without racing DHCP.
44        #[serde(skip_serializing_if = "Option::is_none", rename = "mac-address")]
45        mac_address: Option<String>,
46    },
47    VirtioFs {
48        host: String,
49        guest: String,
50        read_only: bool,
51    },
52    VirtioConsole,
53    VirtioRng,
54}
55
56/// JSON shape for native-arch Darwin guests (Apple Virtualization.framework
57/// macOS mode). Completely different top-level from the Linux path — Darwin
58/// boots via a bootloader descriptor pointing at an IPSW + auxiliary image,
59/// not a kernel + initrd pair.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "kebab-case")]
62pub struct VfkitDarwinJson {
63    pub cpus: u32,
64    pub memory_mib: u32,
65    pub bootloader: VfkitDarwinBootloader,
66    pub devices: Vec<VfkitDevice>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "kebab-case")]
71pub struct VfkitDarwinBootloader {
72    /// Always `"macos"` today; reserved for future Darwin variants.
73    #[serde(rename = "type")]
74    pub kind: String,
75    /// Filesystem path (or placeholder) to the Apple restore image (`.ipsw`).
76    /// Used on first boot to install macOS into the root disk.
77    pub restore_image: String,
78    /// 16-byte machine identifier, base64 (unique per guest, persisted).
79    pub machine_identifier: String,
80    /// Auxiliary storage image path — 64 MiB persistent boot state slot.
81    pub auxiliary_storage: String,
82}
83
84/// Emits a `VfkitJson` from a `VmSpec`. Resolves bridged paths *lazily*: a
85/// caller who realizes the kernel/rootfs derivations separately can fill in
86/// the resulting paths via `with_kernel_path` / `with_rootfs_path`.
87pub struct VfkitEmitter {
88    pub kernel_path: Option<String>,
89    pub rootfs_path: Option<String>,
90    pub initrd_path: Option<String>,
91    // Darwin-guest extras — ignored for Linux guests.
92    pub darwin_aux_path: Option<String>,
93    pub darwin_machine_identifier_b64: Option<String>,
94}
95
96impl Default for VfkitEmitter {
97    fn default() -> Self {
98        Self {
99            kernel_path: None,
100            rootfs_path: None,
101            initrd_path: None,
102            darwin_aux_path: None,
103            darwin_machine_identifier_b64: None,
104        }
105    }
106}
107
108impl VfkitEmitter {
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    pub fn with_kernel_path(mut self, p: impl Into<String>) -> Self {
114        self.kernel_path = Some(p.into());
115        self
116    }
117
118    pub fn with_rootfs_path(mut self, p: impl Into<String>) -> Self {
119        self.rootfs_path = Some(p.into());
120        self
121    }
122
123    pub fn with_initrd_path(mut self, p: impl Into<String>) -> Self {
124        self.initrd_path = Some(p.into());
125        self
126    }
127
128    pub fn with_darwin_aux_path(mut self, p: impl Into<String>) -> Self {
129        self.darwin_aux_path = Some(p.into());
130        self
131    }
132
133    pub fn with_darwin_machine_identifier(mut self, b64: impl Into<String>) -> Self {
134        self.darwin_machine_identifier_b64 = Some(b64.into());
135        self
136    }
137
138    /// Render the Darwin guest shape. Only meaningful when
139    /// `vm.hypervisor == Hypervisor::VfkitDarwin` and
140    /// `vm.kernel == GuestKernel::DarwinIpsw { .. }`.
141    pub fn synthesize_darwin(&self, vm: &VmSpec) -> VfkitDarwinJson {
142        let ipsw = match &vm.kernel {
143            GuestKernel::DarwinIpsw { ipsw_path } => ipsw_path.clone(),
144            _ => self
145                .kernel_path
146                .clone()
147                .unwrap_or_else(|| "<unset-ipsw>".into()),
148        };
149        let aux = self
150            .darwin_aux_path
151            .clone()
152            .unwrap_or_else(|| "<bundle>/aux.img".into());
153        let machine_id = self
154            .darwin_machine_identifier_b64
155            .clone()
156            .unwrap_or_else(|| "<bundle>/machine-id.b64".into());
157        let rootfs = self
158            .rootfs_path
159            .clone()
160            .unwrap_or_else(|| "<bundle>/disk.img".into());
161
162        let mut devs = vec![VfkitDevice::VirtioBlk { image: rootfs }];
163        if !matches!(vm.network.kind, NetworkKind::None) {
164            devs.push(VfkitDevice::VirtioNet {
165                mode: match vm.network.kind {
166                    NetworkKind::Nat => "nat".into(),
167                    NetworkKind::Bridge => "bridge".into(),
168                    NetworkKind::None => unreachable!(),
169                },
170                subnet: vm.network.subnet.clone(),
171                mac_address: Some(deterministic_mac(&vm.name)),
172            });
173        }
174        for s in &vm.shares {
175            devs.push(VfkitDevice::VirtioFs {
176                host: s.host.clone(),
177                guest: s.guest.clone(),
178                read_only: s.read_only,
179            });
180        }
181        devs.push(VfkitDevice::VirtioConsole);
182        devs.push(VfkitDevice::VirtioRng);
183
184        VfkitDarwinJson {
185            cpus: vm.cpus,
186            memory_mib: vm.memory_mib,
187            bootloader: VfkitDarwinBootloader {
188                kind: "macos".into(),
189                restore_image: ipsw,
190                machine_identifier: machine_id,
191                auxiliary_storage: aux,
192            },
193            devices: devs,
194        }
195    }
196
197    /// Pretty-printed JSON for the Darwin guest shape.
198    pub fn render_darwin(&self, vm: &VmSpec) -> String {
199        serde_json::to_string_pretty(&self.synthesize_darwin(vm)).unwrap_or_default()
200    }
201
202    fn kernel_placeholder(vm: &VmSpec) -> String {
203        match &vm.kernel {
204            GuestKernel::Bridge { attr_path } => {
205                format!("<bridge:{attr_path}/bzImage>")
206            }
207            GuestKernel::Custom { derivation } => {
208                format!("<custom:{}/bzImage>", derivation.name)
209            }
210            // Darwin guests don't use `bzImage`; the IPSW is the kernel blob.
211            // Emit the path so boot.sh can drive the Darwin-guest flavor.
212            GuestKernel::DarwinIpsw { ipsw_path } => ipsw_path.clone(),
213        }
214    }
215
216    fn rootfs_placeholder(vm: &VmSpec) -> String {
217        match &vm.rootfs {
218            GuestRootfs::System { name } => format!("<system:{name}/rootfs.img>"),
219            GuestRootfs::Image { derivation } => {
220                format!("<image:{}/rootfs.img>", derivation.name)
221            }
222            GuestRootfs::Bridge { attr_path } => {
223                format!("<bridge:{attr_path}/rootfs.img>")
224            }
225        }
226    }
227
228    fn emit_devices(&self, vm: &VmSpec) -> Vec<VfkitDevice> {
229        let mut devs = Vec::new();
230        // Root disk
231        let rootfs = self
232            .rootfs_path
233            .clone()
234            .unwrap_or_else(|| Self::rootfs_placeholder(vm));
235        devs.push(VfkitDevice::VirtioBlk { image: rootfs });
236        // Network
237        if !matches!(vm.network.kind, NetworkKind::None) {
238            devs.push(VfkitDevice::VirtioNet {
239                mode: match vm.network.kind {
240                    NetworkKind::Nat => "nat".into(),
241                    NetworkKind::Bridge => "bridge".into(),
242                    NetworkKind::None => unreachable!(),
243                },
244                subnet: vm.network.subnet.clone(),
245                mac_address: Some(deterministic_mac(&vm.name)),
246            });
247        }
248        // Shared folders via virtiofs
249        for s in &vm.shares {
250            devs.push(VfkitDevice::VirtioFs {
251                host: s.host.clone(),
252                guest: s.guest.clone(),
253                read_only: s.read_only,
254            });
255        }
256        // Console + RNG — essential for a usable Linux guest.
257        devs.push(VfkitDevice::VirtioConsole);
258        devs.push(VfkitDevice::VirtioRng);
259        devs
260    }
261}
262
263/// Derive a stable MAC address from the VM name via BLAKE3. First byte is
264/// masked `0x02` (locally-administered, unicast) to stay out of the IANA
265/// space; `arp -a` then shows the guest reliably across boots.
266fn deterministic_mac(name: &str) -> String {
267    let h = blake3::hash(name.as_bytes());
268    let b = h.as_bytes();
269    format!(
270        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
271        (b[0] & 0xfe) | 0x02,
272        b[1],
273        b[2],
274        b[3],
275        b[4],
276        b[5],
277    )
278}
279
280impl Synthesizer for VfkitEmitter {
281    type Input = VmSpec;
282    type Ast = VfkitJson;
283    type Output = String;
284
285    fn synthesize(&self, vm: &VmSpec) -> VfkitJson {
286        let kernel = self
287            .kernel_path
288            .clone()
289            .unwrap_or_else(|| Self::kernel_placeholder(vm));
290        let initrd = self.initrd_path.clone().or_else(|| {
291            vm.initrd
292                .as_ref()
293                .map(|d| format!("<initrd:{}/initrd>", d.name))
294        });
295        let cmdline = vm.cmdline.join(" ");
296        let devices = self.emit_devices(vm);
297        VfkitJson {
298            cpus: vm.cpus,
299            memory_mib: vm.memory_mib,
300            kernel,
301            initrd,
302            cmdline,
303            devices,
304        }
305    }
306
307    fn render(&self, ast: &VfkitJson) -> String {
308        serde_json::to_string_pretty(ast).unwrap_or_default()
309    }
310}
311
312/// Multi-file emission for a full `defvm`: writes `vm.json` + `boot.sh`
313/// helper that drives vfkit with the right flags. Auto-selects the Linux
314/// vs Darwin JSON shape based on `vm.hypervisor`.
315impl MultiSynthesizer for VfkitEmitter {
316    type Input = VmSpec;
317
318    fn generate_all(&self, vm: &VmSpec) -> Vec<Artifact> {
319        let prefix = format!("vm/{}", vm.name);
320        match vm.hypervisor {
321            Hypervisor::Vfkit => {
322                let json = Synthesizer::generate(self, vm);
323                let boot_sh = format!(
324                    "#!/bin/sh\n# tatara-vm boot helper for '{name}' (Linux guest)\nset -eu\nexec vfkit --config \"$(dirname \"$0\")/vm.json\" \"$@\"\n",
325                    name = vm.name,
326                );
327                vec![
328                    Artifact::new(format!("{prefix}/vm.json"), json),
329                    Artifact::new(format!("{prefix}/boot.sh"), boot_sh),
330                ]
331            }
332            Hypervisor::VfkitDarwin => {
333                let json = self.render_darwin(vm);
334                let boot_sh = format!(
335                    "#!/bin/sh\n# tatara-vm boot helper for '{name}' (native Darwin guest)\nset -eu\nexec vfkit --config \"$(dirname \"$0\")/vm-darwin.json\" \"$@\"\n",
336                    name = vm.name,
337                );
338                vec![
339                    Artifact::new(format!("{prefix}/vm-darwin.json"), json),
340                    Artifact::new(format!("{prefix}/boot.sh"), boot_sh),
341                ]
342            }
343            _ => vec![Artifact::new(
344                "ERROR.txt".to_string(),
345                format!(
346                    "VfkitEmitter only renders Hypervisor::Vfkit / VfkitDarwin; got {:?}",
347                    vm.hypervisor
348                ),
349            )],
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    fn vm() -> VmSpec {
359        let mut v = VmSpec::plex_default("plex-guest");
360        v.cpus = 4;
361        v.memory_mib = 4096;
362        v.shares = vec![crate::config::ShareSpec {
363            host: "/Users/drzzln/code".into(),
364            guest: "/mnt/code".into(),
365            read_only: false,
366        }];
367        v
368    }
369
370    #[test]
371    fn synthesize_maps_core_fields() {
372        let emitter = VfkitEmitter::new();
373        let json = emitter.synthesize(&vm());
374        assert_eq!(json.cpus, 4);
375        assert_eq!(json.memory_mib, 4096);
376        assert!(json.cmdline.contains("init=/bin/tatara-init"));
377        // devices include block + net + virtiofs + console + rng
378        assert_eq!(json.devices.len(), 5);
379    }
380
381    #[test]
382    fn placeholders_reflect_bridge_attrs() {
383        let emitter = VfkitEmitter::new();
384        let json = emitter.synthesize(&vm());
385        assert!(json.kernel.contains("linuxPackages.kernel"));
386        match &json.devices[0] {
387            VfkitDevice::VirtioBlk { image } => {
388                assert!(image.contains("minimal-rootfs"));
389            }
390            _ => panic!("expected VirtioBlk first"),
391        }
392    }
393
394    #[test]
395    fn realized_paths_replace_placeholders() {
396        let emitter = VfkitEmitter::new()
397            .with_kernel_path("/nix/store/xxx-kernel/bzImage")
398            .with_rootfs_path("/nix/store/yyy-rootfs/rootfs.img");
399        let json = emitter.synthesize(&vm());
400        assert_eq!(json.kernel, "/nix/store/xxx-kernel/bzImage");
401        match &json.devices[0] {
402            VfkitDevice::VirtioBlk { image } => {
403                assert_eq!(image, "/nix/store/yyy-rootfs/rootfs.img");
404            }
405            _ => panic!(),
406        }
407    }
408
409    #[test]
410    fn render_emits_pretty_json() {
411        let emitter = VfkitEmitter::new();
412        let output = emitter.generate(&vm());
413        assert!(output.contains("\"cpus\":"));
414        assert!(output.contains("\"memory-mib\":"));
415    }
416
417    #[test]
418    fn multi_synth_emits_vm_json_and_boot_sh() {
419        let emitter = VfkitEmitter::new();
420        let arts = emitter.generate_all(&vm());
421        assert_eq!(arts.len(), 2);
422        let paths: Vec<_> = arts.iter().map(|a| a.path.as_str()).collect();
423        assert!(paths.contains(&"vm/plex-guest/vm.json"));
424        assert!(paths.contains(&"vm/plex-guest/boot.sh"));
425        let boot = arts.iter().find(|a| a.path.ends_with("boot.sh")).unwrap();
426        assert!(boot.content.starts_with("#!/bin/sh"));
427        assert!(boot.content.contains("exec vfkit --config"));
428    }
429
430    #[test]
431    fn non_vfkit_hypervisor_produces_explicit_error() {
432        let mut v = vm();
433        v.hypervisor = Hypervisor::Qemu;
434        let emitter = VfkitEmitter::new();
435        let arts = emitter.generate_all(&v);
436        assert_eq!(arts[0].path, "ERROR.txt");
437        assert!(arts[0].content.contains("Qemu"));
438    }
439}