1use tatara_nix::derivation::{BridgeTarget, Derivation, Outputs, Source};
10use tatara_nix::synth::{Artifact, MultiSynthesizer};
11use tatara_os::SystemConfig;
12
13use crate::config::{GuestKernel, GuestRootfs, Hypervisor, VmSpec};
14use crate::rootfs::{InitrdFile, LinuxRootfs};
15use crate::vfkit::VfkitEmitter;
16
17pub struct BootManifest {
19 pub kernel: Derivation,
22 pub initrd: Derivation,
24 pub vm: VmSpec,
28}
29
30pub fn compose(
38 sys: &SystemConfig,
39 init_binary_path: impl Into<String>,
40 vm: Option<VmSpec>,
41) -> BootManifest {
42 let init_path = init_binary_path.into();
43 let hostname = sys.hostname.clone();
44
45 let kernel_attr = match &sys.kernel {
47 tatara_os::KernelSpec::Bridge { attr_path } => attr_path.clone(),
48 tatara_os::KernelSpec::Package { name } => name.clone(),
49 tatara_os::KernelSpec::Custom { .. } => "linuxPackages.kernel".into(),
50 };
51 let kernel = Derivation {
52 name: format!("kernel-{}", sanitize(&hostname)),
53 version: None,
54 inputs: vec![],
55 source: Source::default(),
56 builder: Default::default(),
57 outputs: Outputs::default(),
58 env: vec![],
59 sandbox: Default::default(),
60 bridge: Some(BridgeTarget::nixpkgs(kernel_attr)),
61 nix_expr: None,
62 };
63
64 let shares_for_init: Vec<crate::config::ShareSpec> = vm
67 .as_ref()
68 .map(|v| v.shares.clone())
69 .unwrap_or_default();
70
71 let init_config = synthesize_init_config(sys, &shares_for_init);
73 let mut rootfs = LinuxRootfs::new(&init_path, init_config)
74 .with_name(format!("initrd-{}", sanitize(&hostname)));
75 rootfs = rootfs.with_file("/etc/hostname", format!("{}\n", sys.hostname));
77 for f in &sys.environment.etc_files {
79 let path = if f.path.starts_with('/') {
80 f.path.clone()
81 } else {
82 format!("/etc/{}", f.path)
83 };
84 rootfs.extra_files.push(InitrdFile {
85 path,
86 content: crate::rootfs::InitrdContent::Inline(f.content.clone()),
87 mode: 0o644,
88 });
89 }
90 if let Some(sshd) = &sys.sshd {
92 rootfs = rootfs.with_sshd(sshd.clone());
93 }
94 if !sys.packages.is_empty() {
97 rootfs = rootfs.with_packages(sys.packages.iter().cloned());
98 }
99 let initrd = rootfs.derivation();
100
101 let mut vm = vm.unwrap_or_else(|| VmSpec::plex_default(&hostname));
106 vm.hypervisor = Hypervisor::Vfkit;
107 vm.kernel = GuestKernel::Custom {
108 derivation: kernel.clone(),
109 };
110 vm.rootfs = GuestRootfs::Image {
111 derivation: initrd.clone(),
112 };
113 vm.initrd = Some(initrd.clone());
114 if !vm
115 .cmdline
116 .iter()
117 .any(|s| s.contains("init=/bin/tatara-init"))
118 {
119 vm.cmdline.push("init=/bin/tatara-init".into());
120 }
121
122 BootManifest { kernel, initrd, vm }
123}
124
125fn synthesize_init_config(sys: &SystemConfig, shares: &[crate::config::ShareSpec]) -> String {
134 let mut s = format!(
135 "; auto-generated by tatara-vm::boot for '{}'\n",
136 sys.hostname
137 );
138 s.push_str(&format!("(definit\n :name \"{}-boot\"\n", sys.hostname));
139
140 let sshd_svc = sys.sshd.as_ref().map(|sshd| {
142 format!(
143 " (:name \"sshd\" :exec \"/bin/sshd -D -f /etc/ssh/sshd_config -p {port}\" :enable #t)\n",
144 port = sshd.port,
145 )
146 });
147
148 if sys.services.is_empty() && sshd_svc.is_none() {
149 s.push_str(" :services ()\n");
150 } else {
151 s.push_str(" :services (\n");
152 if let Some(svc) = sshd_svc {
153 s.push_str(&svc);
154 }
155 for svc in &sys.services {
156 let enable = if svc.enable { "#t" } else { "#f" };
157 s.push_str(&format!(
158 " (:name \"{}\" :exec \"{}\" :enable {})\n",
159 svc.name,
160 svc.exec.replace('"', "\\\""),
161 enable
162 ));
163 }
164 s.push_str(" )\n");
165 }
166
167 if shares.is_empty() {
171 s.push_str(" :mounts ()");
172 } else {
173 s.push_str(" :mounts (\n");
174 for sh in shares {
175 let tag = mount_tag_for_guest_path(&sh.guest);
176 let opts = if sh.read_only { "ro" } else { "rw" };
177 s.push_str(&format!(
178 " (:source \"{}\" :target \"{}\" :fstype \"virtiofs\" :options \"{}\")\n",
179 tag, sh.guest, opts
180 ));
181 }
182 s.push_str(" )");
183 }
184 s.push_str(")\n");
185 s
186}
187
188pub fn mount_tag_for_guest_path(guest: &str) -> String {
191 let mut out = String::new();
192 for c in guest.chars() {
193 if c.is_ascii_alphanumeric() {
194 out.push(c);
195 } else {
196 out.push('_');
197 }
198 }
199 let trimmed: String = out.chars().skip_while(|c| *c == '_').collect();
201 if trimmed.is_empty() {
202 "share".into()
203 } else {
204 trimmed
205 }
206}
207
208fn sanitize(s: &str) -> String {
209 s.chars()
210 .map(|c| {
211 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
212 c
213 } else {
214 '-'
215 }
216 })
217 .collect()
218}
219
220pub struct BootSynthesizer {
234 pub init_binary_path: String,
238 pub vm_override: Option<VmSpec>,
240 pub out_prefix: String,
242 pub busybox: bool,
246}
247
248impl Default for BootSynthesizer {
249 fn default() -> Self {
250 Self {
251 init_binary_path: "${pkgs.hello}/bin/hello".into(),
252 vm_override: None,
253 out_prefix: "boot".into(),
254 busybox: true,
255 }
256 }
257}
258
259impl BootSynthesizer {
260 pub fn new() -> Self {
261 Self::default()
262 }
263
264 pub fn with_init_binary_path(mut self, p: impl Into<String>) -> Self {
265 self.init_binary_path = p.into();
266 self
267 }
268
269 pub fn with_out_prefix(mut self, p: impl Into<String>) -> Self {
270 self.out_prefix = p.into();
271 self
272 }
273
274 pub fn with_vm_override(mut self, vm: VmSpec) -> Self {
275 self.vm_override = Some(vm);
276 self
277 }
278
279 pub fn with_busybox(mut self, on: bool) -> Self {
280 self.busybox = on;
281 self
282 }
283}
284
285impl MultiSynthesizer for BootSynthesizer {
286 type Input = SystemConfig;
287
288 fn generate_all(&self, cfg: &SystemConfig) -> Vec<Artifact> {
289 let mut bm = compose(cfg, self.init_binary_path.clone(), self.vm_override.clone());
290 if !self.busybox {
293 let shares = self
294 .vm_override
295 .as_ref()
296 .map(|v| v.shares.clone())
297 .unwrap_or_default();
298 let mut rootfs = crate::rootfs::LinuxRootfs::new(
299 self.init_binary_path.clone(),
300 synthesize_init_config(cfg, &shares),
301 )
302 .with_name(format!("initrd-{}", sanitize(&cfg.hostname)))
303 .without_busybox();
304 rootfs = rootfs.with_file("/etc/hostname", format!("{}\n", cfg.hostname));
305 for f in &cfg.environment.etc_files {
306 let path = if f.path.starts_with('/') {
307 f.path.clone()
308 } else {
309 format!("/etc/{}", f.path)
310 };
311 rootfs.extra_files.push(crate::rootfs::InitrdFile {
312 path,
313 content: crate::rootfs::InitrdContent::Inline(f.content.clone()),
314 mode: 0o644,
315 });
316 }
317 bm.initrd = rootfs.derivation();
318 bm.vm.rootfs = crate::config::GuestRootfs::Image {
319 derivation: bm.initrd.clone(),
320 };
321 bm.vm.initrd = Some(bm.initrd.clone());
322 }
323 let prefix = &self.out_prefix;
324
325 let vfkit = VfkitEmitter::new();
329 let mut arts = vfkit.generate_all(&bm.vm);
330 for a in &mut arts {
332 if let Some(suffix) = a.path.strip_prefix(&format!("vm/{}/", bm.vm.name)) {
333 a.path = format!("{prefix}/{suffix}");
334 }
335 }
336
337 let kernel_expr = match &bm.kernel.bridge {
350 Some(b) if b.pkg_set.is_none() => format!(
351 "# kernel for {}\n(import <nixpkgs> {{ system = \"{}\"; }}).{}\n",
352 cfg.hostname, cfg.system, b.attr_path
353 ),
354 Some(b) => format!(
355 "# kernel for {}\n({}).{}\n",
356 cfg.hostname,
357 b.resolved_pkg_set(),
358 b.attr_path
359 ),
360 None => "# (custom kernel — no bridge)\n".into(),
361 };
362 let initrd_expr = bm
363 .initrd
364 .nix_expr
365 .clone()
366 .unwrap_or_else(|| "# (initrd has no nix_expr — unexpected)\n".into());
367
368 arts.push(Artifact::new(format!("{prefix}/kernel.nix"), kernel_expr));
369 arts.push(Artifact::new(format!("{prefix}/initrd.nix"), initrd_expr));
370
371 let shares_for_init = self
373 .vm_override
374 .as_ref()
375 .map(|v| v.shares.clone())
376 .unwrap_or_default();
377 arts.push(Artifact::new(
378 format!("{prefix}/init.lisp"),
379 synthesize_init_config(cfg, &shares_for_init),
380 ));
381
382 if let Ok(json) = serde_json::to_string_pretty(cfg) {
384 arts.push(Artifact::new(format!("{prefix}/system.json"), json));
385 }
386
387 arts.push(Artifact::new(
389 format!("{prefix}/README.md"),
390 render_readme(cfg, &bm),
391 ));
392
393 arts
394 }
395}
396
397fn render_readme(cfg: &SystemConfig, bm: &BootManifest) -> String {
398 format!(
399 "# tatara-os boot artifact — `{hostname}`\n\n\
400 Generated from a `(defsystem …)` Lisp form via `tatara-vm::BootSynthesizer`.\n\n\
401 ## Files\n\n\
402 - `system.json` — the typed `SystemConfig`\n\
403 - `init.lisp` — the tatara-init supervisor config (baked into the initrd)\n\
404 - `kernel.nix` — `nix build -f kernel.nix` → a Linux kernel derivation\n\
405 - `initrd.nix` — `nix build -f initrd.nix` → `{initrd_name}/initrd.cpio.gz`\n\
406 - `vm.json` — vfkit config with placeholders for the realized paths\n\
407 - `boot.sh` — helper that runs `vfkit --config vm.json`\n\n\
408 ## To boot\n\n\
409 ```sh\n\
410 KERNEL=$(nix build -f kernel.nix --no-link --print-out-paths)/bzImage\n\
411 INITRD=$(nix build -f initrd.nix --no-link --print-out-paths)/initrd.cpio.gz\n\
412 # Substitute paths into vm.json (jq recommended) and run:\n\
413 ./boot.sh\n\
414 ```\n\n\
415 ## Spec\n\n\
416 - Host: `{hostname}` on `{system}`\n\
417 - Init system: `{init:?}` (tatara-init is PID 1 by default)\n\
418 - Services: {n_services}\n\
419 - Kernel: `{kernel_name}`\n\
420 - Initrd: `{initrd_name}`\n\
421 - vfkit CPUs: {cpus}, memory: {mem_mib} MiB\n",
422 hostname = cfg.hostname,
423 system = cfg.system,
424 init = cfg.init,
425 n_services = cfg.services.len(),
426 kernel_name = bm.kernel.name,
427 initrd_name = bm.initrd.name,
428 cpus = bm.vm.cpus,
429 mem_mib = bm.vm.memory_mib,
430 )
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 fn sys() -> SystemConfig {
438 SystemConfig {
439 hostname: "plex".into(),
440 system: "aarch64-linux".into(),
441 kernel: tatara_os::KernelSpec::Bridge {
442 attr_path: "linuxPackages.kernel".into(),
443 },
444 bootloader: Default::default(),
445 init: tatara_os::InitSystem::Tatara,
446 services: vec![
447 tatara_os::ServiceSpec {
448 name: "demo".into(),
449 exec: "/bin/busybox sh -c 'echo tatara'".into(),
450 enable: true,
451 extra: vec![],
452 package_refs: vec![],
453 },
454 tatara_os::ServiceSpec {
455 name: "disabled-one".into(),
456 exec: "/bin/disabled".into(),
457 enable: false,
458 extra: vec![],
459 package_refs: vec![],
460 },
461 ],
462 users: vec![],
463 filesystems: vec![],
464 environment: Default::default(),
465 packages: vec![],
466 sshd: None,
467 }
468 }
469
470 #[test]
471 fn compose_produces_kernel_initrd_and_vm() {
472 let bm = compose(&sys(), "/nix/store/xxx-tatara-init/bin/tatara-init", None);
473 assert_eq!(bm.kernel.name, "kernel-plex");
474 assert!(bm.kernel.bridge.is_some());
475 assert_eq!(bm.initrd.name, "initrd-plex");
476 assert!(bm.initrd.nix_expr.is_some());
477 assert_eq!(bm.vm.name, "plex");
478 }
479
480 #[test]
481 fn cmdline_gets_tatara_init_appended() {
482 let mut custom = VmSpec::plex_default("plex");
483 custom.cmdline = vec!["console=hvc0".into()]; let bm = compose(&sys(), "/nix/store/xxx-init/bin/tatara-init", Some(custom));
485 assert!(bm
486 .vm
487 .cmdline
488 .iter()
489 .any(|s| s.contains("init=/bin/tatara-init")));
490 }
491
492 #[test]
493 fn init_lisp_lists_enabled_services_only_as_enabled() {
494 let bm = compose(&sys(), "/nix/store/xxx-init/bin/tatara-init", None);
495 let expr = bm.initrd.nix_expr.unwrap();
496 assert!(expr.contains("(:name \"demo\" :exec"));
497 assert!(expr.contains(":enable #t"));
498 assert!(expr.contains("(:name \"disabled-one\" :exec"));
499 assert!(expr.contains(":enable #f"));
500 }
501
502 #[test]
503 fn etc_hostname_is_included() {
504 let bm = compose(&sys(), "/nix/store/xxx-init/bin/tatara-init", None);
505 let expr = bm.initrd.nix_expr.unwrap();
506 assert!(expr.contains("root/etc/hostname"));
507 assert!(expr.contains("plex"));
508 }
509
510 #[test]
513 fn synthesizer_emits_full_artifact_tree() {
514 let s = BootSynthesizer::new().with_out_prefix("out");
515 let arts = s.generate_all(&sys());
516 let paths: Vec<&str> = arts.iter().map(|a| a.path.as_str()).collect();
517 for expected in [
518 "out/vm.json",
519 "out/boot.sh",
520 "out/kernel.nix",
521 "out/initrd.nix",
522 "out/init.lisp",
523 "out/system.json",
524 "out/README.md",
525 ] {
526 assert!(
527 paths.contains(&expected),
528 "missing artifact: {expected}\n got: {paths:?}"
529 );
530 }
531 }
532
533 #[test]
534 fn synthesizer_kernel_nix_is_buildable_expression() {
535 let s = BootSynthesizer::new();
536 let arts = s.generate_all(&sys());
537 let kernel = arts
538 .iter()
539 .find(|a| a.path.ends_with("kernel.nix"))
540 .unwrap();
541 assert!(kernel.content.contains("import <nixpkgs>"));
542 assert!(kernel.content.contains(".linuxPackages.kernel"));
543 }
544
545 #[test]
546 fn synthesizer_initrd_nix_is_buildable_expression() {
547 let s = BootSynthesizer::new();
548 let arts = s.generate_all(&sys());
549 let initrd = arts
550 .iter()
551 .find(|a| a.path.ends_with("initrd.nix"))
552 .unwrap();
553 assert!(initrd.content.contains("runCommand"));
554 assert!(initrd.content.contains("initrd.cpio.gz"));
555 assert!(initrd.content.contains("tatara-init"));
556 }
557
558 #[test]
559 fn synthesizer_readme_is_populated_from_spec() {
560 let s = BootSynthesizer::new();
561 let arts = s.generate_all(&sys());
562 let readme = arts.iter().find(|a| a.path.ends_with("README.md")).unwrap();
563 assert!(readme.content.contains("plex"));
564 assert!(readme.content.contains("aarch64-linux"));
565 assert!(readme.content.contains("Services: 2"));
566 }
567
568 #[test]
569 fn custom_kernel_package_propagates_to_bridge() {
570 let mut s = sys();
571 s.kernel = tatara_os::KernelSpec::Bridge {
572 attr_path: "linuxPackages_latest.kernel".into(),
573 };
574 let bm = compose(&s, "/nix/store/x/bin/tatara-init", None);
575 assert_eq!(
576 bm.kernel.bridge.unwrap().attr_path,
577 "linuxPackages_latest.kernel"
578 );
579 }
580}