1use serde::{Deserialize, Serialize};
12
13use tatara_nix::{Artifact, MultiSynthesizer, Synthesizer};
14
15use crate::config::{GuestKernel, GuestRootfs, Hypervisor, NetworkKind, VmSpec};
16
17#[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 #[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#[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 #[serde(rename = "type")]
74 pub kind: String,
75 pub restore_image: String,
78 pub machine_identifier: String,
80 pub auxiliary_storage: String,
82}
83
84pub struct VfkitEmitter {
88 pub kernel_path: Option<String>,
89 pub rootfs_path: Option<String>,
90 pub initrd_path: Option<String>,
91 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 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 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 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 let rootfs = self
232 .rootfs_path
233 .clone()
234 .unwrap_or_else(|| Self::rootfs_placeholder(vm));
235 devs.push(VfkitDevice::VirtioBlk { image: rootfs });
236 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 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 devs.push(VfkitDevice::VirtioConsole);
258 devs.push(VfkitDevice::VirtioRng);
259 devs
260 }
261}
262
263fn 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
312impl 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 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}