Skip to main content

tatara_vm/
boot.rs

1//! Compose a tatara-os `SystemConfig` + a tatara-init binary path into a
2//! complete bootable VM manifest — kernel derivation + initrd derivation +
3//! `VmSpec` ready to hand to `VfkitEmitter`.
4//!
5//! The goal: one call takes everything a user has typed in Lisp
6//! (`(defsystem …)`, `(definit …)`, `(defvm …)`) and gives back the
7//! on-disk artifacts that `vfkit --config vm.json` can boot.
8
9use 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
17/// The full set of on-disk-buildable artifacts for one VM boot.
18pub struct BootManifest {
19    /// Kernel derivation — bridged to `linuxPackages.kernel` by default, or
20    /// whatever the caller's `SystemConfig::kernel` pointed at.
21    pub kernel: Derivation,
22    /// Initrd derivation — our rootfs.cpio.gz with tatara-init + init.lisp.
23    pub initrd: Derivation,
24    /// The VM manifest, wired to those two derivations. Use
25    /// [`VfkitEmitter::with_kernel_path`] / `with_rootfs_path` to substitute
26    /// the realized /nix/store paths.
27    pub vm: VmSpec,
28}
29
30/// Build a `BootManifest` from a tatara-os system configuration.
31///
32/// - `sys` — the tatara-os `SystemConfig` (authored with `(defsystem …)`).
33/// - `init_binary_path` — `/nix/store` path of the `tatara-init` binary. In
34///   practice you pass the realization of `tatara.packages.${system}.init`.
35/// - `vm` — VM-shape overrides (cpus, memory, network, shares). If `None`,
36///   uses `VmSpec::plex_default(<hostname>)` with sensible defaults.
37pub 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    // 1. Kernel — honor whatever the SystemConfig said.
46    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    // Peek at the VM's shares so (definit :mounts …) reflects them.
65    // Fall back to plex_default's empty shares when no VmSpec override given.
66    let shares_for_init: Vec<crate::config::ShareSpec> = vm
67        .as_ref()
68        .map(|v| v.shares.clone())
69        .unwrap_or_default();
70
71    // 2. Initrd — tatara-init + init.lisp synthesized from services + shares.
72    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    // /etc/hostname is useful inside the guest.
76    rootfs = rootfs.with_file("/etc/hostname", format!("{}\n", sys.hostname));
77    // /etc files the user declared.
78    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    // sshd — bridge openssh, bake sshd_config + authorized_keys + host key.
91    if let Some(sshd) = &sys.sshd {
92        rootfs = rootfs.with_sshd(sshd.clone());
93    }
94    // Userspace packages — each SystemConfig.packages entry is a nixpkgs
95    // attr_path. Closure is baked into the initrd; bin/* lands in /bin/.
96    if !sys.packages.is_empty() {
97        rootfs = rootfs.with_packages(sys.packages.iter().cloned());
98    }
99    let initrd = rootfs.derivation();
100
101    // 3. VmSpec — default or user-provided, but always pointed at our
102    // kernel/initrd derivations via the Bridge mechanism (fleshed out by
103    // `VfkitEmitter::with_*_path` at emit time when the caller has the
104    // realized store paths on hand).
105    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
125/// Derive the init.lisp content from the system's service list + the VM's
126/// shares. Mirrors the shape tatara-init expects:
127///   (definit :services (…) :mounts (…))
128///
129/// When `sys.sshd` is set, an `sshd` service is prepended automatically.
130/// Each `ShareSpec` becomes a `virtiofs` mount in the init config — the
131/// mount tag equals the share's guest path with non-alphanumerics replaced
132/// by underscores (keeps the tag POSIX-safe while derivable from the path).
133fn 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    // Prepend sshd if the SystemConfig asked for it.
141    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    // :mounts — one entry per declared share. mountTag is derived from
168    // the guest path; tatara-vmctl uses the same derivation so the two
169    // sides agree without extra coordination.
170    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
188/// Derive a virtiofs mount tag from a guest path. Tatara-vmctl uses the
189/// same derivation at the vfkit CLI layer so tag assignments agree.
190pub 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    // Trim leading underscores so /nix/store → nix_store (not _nix_store).
200    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
220// ── BootSynthesizer — one SystemConfig, full artifact tree ────────────────
221
222/// Multi-file emitter for a full boot artifact set.
223///
224/// Input: one tatara-os `SystemConfig` (typically parsed from `(defsystem …)`).
225/// Output: every file a user needs to reach `vfkit --config vm.json`:
226///
227///   - `vm.json` / `boot.sh` — the VmSpec rendered for vfkit
228///   - `kernel.nix` / `initrd.nix` — standalone Nix expressions that
229///     `nix build -f kernel.nix` (etc.) realize into `/nix/store` paths
230///   - `init.lisp` — the supervisor config baked into the initrd
231///   - `system.json` — the typed config, re-serialized for audit
232///   - `README.md` — the boot instructions, keyed to the guest spec
233pub struct BootSynthesizer {
234    /// Path we presume tatara-init will live at inside the guest. Used only
235    /// when the init binary hasn't been realized yet — real deployments
236    /// substitute a `/nix/store/...-tatara-init/bin/tatara-init` path.
237    pub init_binary_path: String,
238    /// VmSpec overrides (cpus, memory, shares). If None, defaults by hostname.
239    pub vm_override: Option<VmSpec>,
240    /// Output prefix for all emitted files. Artifact paths are relative.
241    pub out_prefix: String,
242    /// Include busybox + applets in the initrd. Default true (matches
243    /// LinuxRootfs default); set false when building on a non-Linux host
244    /// without a linux-builder (e.g. naive Darwin-only dev flow).
245    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        // Honor the BootSynthesizer busybox flag by regenerating the initrd
291        // without busybox if asked.
292        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        // vm.json + boot.sh come from the vfkit emitter. Path placeholders
326        // here — real deployments realize bm.kernel and bm.initrd and pass
327        // the resulting store paths back through VfkitEmitter::with_*_path.
328        let vfkit = VfkitEmitter::new();
329        let mut arts = vfkit.generate_all(&bm.vm);
330        // Rewrite the vfkit paths from `vm/<name>/…` to our prefix.
331        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        // kernel.nix + initrd.nix — standalone buildable Nix expressions.
338        // The kernel reference (e.g. `linuxPackages.kernel`) is meaningful only
339        // on Linux platforms — `aarch64-linux`, `x86_64-linux`. When the boot
340        // artifacts are emitted on Darwin (typical for Apple-Silicon hosts
341        // using vfkit), we MUST pass `system = "${cfg.system}"` to the
342        // nixpkgs import so eval picks the Linux variant of the package set.
343        // Without this, `nix build -f kernel.nix` on Darwin trips the
344        // `meta.platforms` assertion ("not in […linux platforms]") and
345        // `launch.sh` exits before the VM ever starts.
346        //
347        // We only override when the bridge target has no explicit pkg_set —
348        // if the user supplied one, we trust them.
349        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        // init.lisp — extracted from the initrd expression for auditability.
372        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        // system.json — canonical typed view of the system.
383        if let Ok(json) = serde_json::to_string_pretty(cfg) {
384            arts.push(Artifact::new(format!("{prefix}/system.json"), json));
385        }
386
387        // README.md — human-friendly boot instructions.
388        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()]; // no init=
484        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    // ── BootSynthesizer ────────────────────────────────────────────────
511
512    #[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}