Skip to main content

tatara_vm/
rootfs.rs

1//! Initrd builder — assembles tatara-init + config + kernel modules into a
2//! bootable cpio-gzip archive that Linux can use as its rootfs.
3//!
4//! The output derivation uses `runCommand` from nixpkgs so cpio + gzip come
5//! from the store (no host-package dependency). Three inputs:
6//!
7//!   - `init_binary` — /nix/store path to a `bin/tatara-init` executable.
8//!     When realizing with `NixStoreRealizer`, pass the path returned from
9//!     realizing `tatara.packages.${system}.init`.
10//!   - `init_config` — the `init.lisp` contents; placed at
11//!     `/etc/tatara/init.lisp` inside the archive.
12//!   - `extra_files` — arbitrary `(path, content)` pairs.
13//!
14//! The archive also:
15//!   - symlinks `/sbin/init` → `/bin/tatara-init` (kernel-default init path)
16//!   - creates `/dev`, `/proc`, `/sys`, `/run`, `/tmp` mount points
17//!   - includes `busybox` from nixpkgs at `/bin/busybox` plus common applets
18//!     via symlink, so the guest has `sh`, `mount`, `mkdir`, etc. available
19//!     before tatara-init starts its services
20//!
21//! Boot sequence:
22//!   1. vfkit → kernel + initrd
23//!   2. Linux decompresses the initrd into a tmpfs rootfs
24//!   3. Kernel execs `/sbin/init` → tatara-init (PID 1)
25//!   4. tatara-init reads `/etc/tatara/init.lisp`, spawns declared services
26//!   5. Guest is running — tatara is init, userspace, everything.
27
28use tatara_nix::derivation::{Derivation, Outputs, Source};
29use tatara_os::SshdSpec;
30
31/// One file that should land in the initrd.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct InitrdFile {
34    /// Absolute path inside the guest (e.g., `/etc/hosts`).
35    pub path: String,
36    /// Inline content or a `/nix/store` source path to copy.
37    pub content: InitrdContent,
38    /// POSIX mode bits (default 0644, or 0755 for Executable).
39    pub mode: u32,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum InitrdContent {
44    Inline(String),
45    StorePath(String),
46}
47
48/// One nixpkgs-attr closure to bake into the initrd. Each package's full
49/// runtime closure is copied into `root/nix/store/` and its `bin/*` entries
50/// (or the subset in `bin_names`, if non-empty) get symlinked into
51/// `/bin/` — so `/bin/htop`, `/bin/curl`, etc. just work inside the guest.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct GuestPackage {
54    /// `pkgs.<attr_path>` — e.g. `"htop"`, `"curl"`, `"python3"`.
55    pub attr_path: String,
56    /// When empty, all `bin/*` entries are linked. Populate to whitelist.
57    pub bin_names: Vec<String>,
58}
59
60impl GuestPackage {
61    pub fn new(attr_path: impl Into<String>) -> Self {
62        Self {
63            attr_path: attr_path.into(),
64            bin_names: vec![],
65        }
66    }
67}
68
69/// The full recipe for a bootable initrd.
70pub struct LinuxRootfs {
71    pub init_binary: String,
72    pub init_config: String,
73    pub extra_files: Vec<InitrdFile>,
74    /// Bridge the busybox binary into the guest. Default: `busybox` from nixpkgs.
75    pub busybox: Option<String>,
76    /// Optional sshd setup. When `Some`, the emitted `runCommand` pulls the
77    /// full `pkgs.openssh` closure into `root/nix/store/`, symlinks
78    /// `/bin/sshd` + `/bin/ssh-keygen`, generates a host key at build time,
79    /// and writes `/etc/ssh/{sshd_config, authorized_keys}`. The caller is
80    /// responsible for adding the sshd service to `init_config`.
81    pub sshd: Option<SshdSpec>,
82    /// Userspace packages to install in the guest. Each package's closure
83    /// is copied into `root/nix/store/`; bin entries land in `/bin/`.
84    pub packages: Vec<GuestPackage>,
85    /// Name baked into the output derivation.
86    pub name: String,
87}
88
89impl Default for LinuxRootfs {
90    fn default() -> Self {
91        Self {
92            init_binary: String::new(),
93            init_config: String::new(),
94            extra_files: vec![],
95            busybox: Some("busybox".into()),
96            sshd: None,
97            packages: vec![],
98            name: "tatara-rootfs".into(),
99        }
100    }
101}
102
103impl LinuxRootfs {
104    pub fn new(init_binary: impl Into<String>, init_config: impl Into<String>) -> Self {
105        Self {
106            init_binary: init_binary.into(),
107            init_config: init_config.into(),
108            ..Default::default()
109        }
110    }
111
112    pub fn with_name(mut self, n: impl Into<String>) -> Self {
113        self.name = n.into();
114        self
115    }
116
117    pub fn with_file(mut self, path: impl Into<String>, content: impl Into<String>) -> Self {
118        self.extra_files.push(InitrdFile {
119            path: path.into(),
120            content: InitrdContent::Inline(content.into()),
121            mode: 0o644,
122        });
123        self
124    }
125
126    pub fn with_file_from_store(
127        mut self,
128        path: impl Into<String>,
129        store_path: impl Into<String>,
130    ) -> Self {
131        self.extra_files.push(InitrdFile {
132            path: path.into(),
133            content: InitrdContent::StorePath(store_path.into()),
134            mode: 0o644,
135        });
136        self
137    }
138
139    pub fn without_busybox(mut self) -> Self {
140        self.busybox = None;
141        self
142    }
143
144    /// Bake openssh + sshd_config + authorized_keys + a generated ed25519
145    /// host key into the initrd.
146    pub fn with_sshd(mut self, spec: SshdSpec) -> Self {
147        self.sshd = Some(spec);
148        self
149    }
150
151    /// Add one userspace package. `bin_names` empty means "symlink every
152    /// `bin/*` entry into `/bin/`".
153    pub fn with_package(mut self, attr_path: impl Into<String>) -> Self {
154        self.packages.push(GuestPackage::new(attr_path));
155        self
156    }
157
158    /// Add many packages at once.
159    pub fn with_packages<I, S>(mut self, attrs: I) -> Self
160    where
161        I: IntoIterator<Item = S>,
162        S: Into<String>,
163    {
164        for a in attrs {
165            self.packages.push(GuestPackage::new(a));
166        }
167        self
168    }
169
170    /// Produce the tatara `Derivation` whose realization is the initrd.cpio.gz.
171    pub fn derivation(&self) -> Derivation {
172        Derivation {
173            name: self.name.clone(),
174            version: None,
175            inputs: vec![],
176            source: Source::default(),
177            builder: Default::default(),
178            outputs: Outputs::default(),
179            env: vec![],
180            sandbox: Default::default(),
181            bridge: None,
182            nix_expr: Some(self.to_nix_expr()),
183        }
184    }
185
186    /// Emit the full `runCommand` Nix expression.
187    pub fn to_nix_expr(&self) -> String {
188        let busybox_line = match &self.busybox {
189            Some(attr) => format!(
190                "  mkdir -p root/bin\n\
191                 \x20 cp ${{pkgs.{attr}}}/bin/busybox root/bin/busybox\n\
192                 \x20 # Install busybox applet symlinks so sh/mount/mkdir/… work\n\
193                 \x20 for app in $(root/bin/busybox --list); do\n\
194                 \x20   ln -sf /bin/busybox root/bin/$app\n\
195                 \x20 done\n"
196            ),
197            None => String::new(),
198        };
199
200        // Userspace packages — copy each package's closure into the initrd
201        // and symlink bin/* into /bin/.
202        let (pkg_prelude, pkg_block) = if self.packages.is_empty() {
203            (String::new(), String::new())
204        } else {
205            let mut prelude = String::from("  guestPackages = [\n");
206            for p in &self.packages {
207                prelude.push_str(&format!("    pkgs.{}\n", p.attr_path));
208            }
209            prelude.push_str("  ];\n");
210            prelude.push_str("  guestClosure = pkgs.closureInfo { rootPaths = guestPackages; };\n");
211
212            let mut block = String::from(
213                "  # userspace packages: pull the full closure into root/nix/store\n\
214                 \x20 mkdir -p root/nix/store root/bin\n\
215                 \x20 while read -r store_path; do\n\
216                 \x20   cp -r \"$store_path\" root/nix/store/\n\
217                 \x20 done < ${guestClosure}/store-paths\n\
218                 \x20 # Symlink each package's bin/* into /bin (best-effort — skip\n\
219                 \x20 # packages with no bin/ dir).\n",
220            );
221            for p in &self.packages {
222                if p.bin_names.is_empty() {
223                    block.push_str(&format!(
224                        "  if [ -d ${{pkgs.{attr}}}/bin ]; then\n\
225                         \x20   for bin in ${{pkgs.{attr}}}/bin/*; do\n\
226                         \x20     [ -e \"$bin\" ] && ln -sf \"$bin\" root/bin/$(basename \"$bin\")\n\
227                         \x20   done\n\
228                         \x20 fi\n",
229                        attr = p.attr_path,
230                    ));
231                } else {
232                    for name in &p.bin_names {
233                        block.push_str(&format!(
234                            "  [ -e ${{pkgs.{attr}}}/bin/{name} ] && ln -sf ${{pkgs.{attr}}}/bin/{name} root/bin/{name}\n",
235                            attr = p.attr_path,
236                            name = name,
237                        ));
238                    }
239                }
240            }
241            (prelude, block)
242        };
243
244        // sshd integration: copy the openssh closure into root/nix/store,
245        // symlink bin entries, write sshd_config + authorized_keys, and
246        // generate an ed25519 host key at build time.
247        let (sshd_prelude, sshd_block) = match &self.sshd {
248            Some(s) => {
249                let auth_keys = s.authorized_keys.join("\n");
250                let permit_root = if s.permit_root { "yes" } else { "no" };
251                let pass_auth = if s.password_authentication {
252                    "yes"
253                } else {
254                    "no"
255                };
256                let cfg = format!(
257                    "Port {port}\n\
258                     HostKey /etc/ssh/ssh_host_ed25519_key\n\
259                     PermitRootLogin {permit_root}\n\
260                     PasswordAuthentication {pass_auth}\n\
261                     PubkeyAuthentication yes\n\
262                     AuthorizedKeysFile /etc/ssh/authorized_keys\n\
263                     StrictModes no\n\
264                     UsePAM no\n\
265                     Subsystem sftp internal-sftp\n",
266                    port = s.port,
267                    permit_root = permit_root,
268                    pass_auth = pass_auth,
269                );
270                // ── prelude injected before runCommand's args attrs ─────
271                let prelude = "  openssh = pkgs.openssh;\n\
272                               \x20 opensshClosure = pkgs.closureInfo { rootPaths = [ pkgs.openssh ]; };\n";
273                // ── build-time commands (run inside runCommand) ─────────
274                let block = format!(
275                    "  # openssh: bring the closure into root/nix/store\n\
276                     \x20 mkdir -p root/nix/store root/bin root/etc/ssh root/var/empty\n\
277                     \x20 while read -r store_path; do\n\
278                     \x20   cp -r \"$store_path\" root/nix/store/\n\
279                     \x20 done < ${{opensshClosure}}/store-paths\n\
280                     \x20 ln -sf ${{openssh}}/bin/sshd root/bin/sshd\n\
281                     \x20 ln -sf ${{openssh}}/bin/ssh-keygen root/bin/ssh-keygen\n\
282                     \x20 cat > root/etc/ssh/sshd_config <<'TATARA_SSHD_CFG_EOF'\n\
283                     {cfg}TATARA_SSHD_CFG_EOF\n\
284                     \x20 cat > root/etc/ssh/authorized_keys <<'TATARA_AUTH_KEYS_EOF'\n\
285                     {auth_keys}\n\
286                     TATARA_AUTH_KEYS_EOF\n\
287                     \x20 chmod 0600 root/etc/ssh/authorized_keys\n\
288                     \x20 # Deterministic(-ish) host key: generate at build.\n\
289                     \x20 ${{openssh}}/bin/ssh-keygen -t ed25519 -N '' \\\n\
290                     \x20   -f root/etc/ssh/ssh_host_ed25519_key \\\n\
291                     \x20   -C \"tatara-os-{name}\"\n\
292                     \x20 chmod 0600 root/etc/ssh/ssh_host_ed25519_key\n",
293                    cfg = cfg,
294                    auth_keys = auth_keys,
295                    name = self.name,
296                );
297                (prelude, block)
298            }
299            None => ("", String::new()),
300        };
301
302        let mut file_cmds = String::new();
303        for f in &self.extra_files {
304            // Ensure the target directory exists, then write the file.
305            let dir = match f.path.rsplit_once('/') {
306                Some((d, _)) if !d.is_empty() => d.to_string(),
307                _ => "".to_string(),
308            };
309            if !dir.is_empty() {
310                file_cmds.push_str(&format!("  mkdir -p root{}\n", nix_path_escape(&dir)));
311            }
312            match &f.content {
313                InitrdContent::Inline(body) => {
314                    // The closing sentinel MUST sit on its own line, so
315                    // guarantee a trailing newline before we emit it.
316                    let body_nl = if body.ends_with('\n') {
317                        body.clone()
318                    } else {
319                        format!("{body}\n")
320                    };
321                    file_cmds.push_str(&format!(
322                        "  cat > root{} <<'TATARA_ROOTFS_EOF'\n{body_nl}TATARA_ROOTFS_EOF\n",
323                        nix_path_escape(&f.path)
324                    ));
325                }
326                InitrdContent::StorePath(sp) => {
327                    file_cmds.push_str(&format!("  cp {sp} root{}\n", nix_path_escape(&f.path)));
328                }
329            }
330            let mode = f.mode;
331            file_cmds.push_str(&format!(
332                "  chmod {mode:o} root{}\n",
333                nix_path_escape(&f.path)
334            ));
335        }
336
337        let init_config_escaped = {
338            let s = heredoc_escape(&self.init_config);
339            if s.ends_with('\n') {
340                s
341            } else {
342                format!("{s}\n")
343            }
344        };
345
346        format!(
347            r#"let
348  pkgs = import <nixpkgs> {{}};
349{pkg_prelude}{sshd_prelude}in
350pkgs.runCommand "{name}" {{
351  buildInputs = [ pkgs.cpio pkgs.gzip pkgs.coreutils pkgs.findutils ];
352}} ''
353  mkdir -p $out
354  mkdir -p root/bin root/sbin root/etc/tatara root/proc root/sys root/dev root/run root/tmp
355  # tatara-init — the PID 1 supervisor
356  cp {init_binary} root/bin/tatara-init
357  chmod 0755 root/bin/tatara-init
358  # Linux looks for /init at initramfs root before honoring kernel cmdline
359  # `init=…`. Symlink both so either path works.
360  ln -sf /bin/tatara-init root/init
361  ln -sf /bin/tatara-init root/sbin/init
362  # init.lisp — the service manifest
363  cat > root/etc/tatara/init.lisp <<'TATARA_INIT_LISP_EOF'
364{init_config_escaped}TATARA_INIT_LISP_EOF
365  chmod 0644 root/etc/tatara/init.lisp
366{busybox_line}{pkg_block}{sshd_block}{file_cmds}  # cpio + gzip into initrd
367  ( cd root && find . -print0 | cpio -o -0 --format=newc ) | gzip -9 > $out/initrd.cpio.gz
368  # Emit the top-level filesystem tree too, for anyone who wants ext4 later.
369  cp -r root $out/rootfs
370''"#,
371            name = self.name,
372            init_binary = self.init_binary,
373            init_config_escaped = init_config_escaped,
374            busybox_line = busybox_line,
375            pkg_prelude = pkg_prelude,
376            pkg_block = pkg_block,
377            sshd_block = sshd_block,
378            sshd_prelude = sshd_prelude,
379            file_cmds = file_cmds,
380        )
381    }
382}
383
384fn nix_path_escape(s: &str) -> String {
385    // Very conservative: pass through, assume valid POSIX paths.
386    // Single quotes inside would break our heredoc; this is init-path code,
387    // those cases aren't expected.
388    s.to_string()
389}
390
391fn heredoc_escape(s: &str) -> String {
392    // Ensure the sentinel doesn't appear in the content.
393    if s.contains("TATARA_INIT_LISP_EOF") {
394        s.replace("TATARA_INIT_LISP_EOF", "TATARA_INIT_LISP_ESC_EOF")
395    } else {
396        s.to_string()
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn minimal_rootfs_emits_expected_shape() {
406        let r = LinuxRootfs::new(
407            "/nix/store/xxx-tatara-init/bin/tatara-init",
408            "(definit :name \"plex\")",
409        );
410        let d = r.derivation();
411        assert_eq!(d.name, "tatara-rootfs");
412        let expr = d.nix_expr.as_ref().unwrap();
413        assert!(expr.contains("pkgs.cpio"));
414        assert!(expr.contains("pkgs.gzip"));
415        assert!(expr.contains("cp /nix/store/xxx-tatara-init/bin/tatara-init root/bin/tatara-init"));
416        assert!(expr.contains("ln -sf /bin/tatara-init root/sbin/init"));
417        assert!(expr.contains("cpio -o -0 --format=newc"));
418        assert!(expr.contains("gzip -9 > $out/initrd.cpio.gz"));
419        assert!(expr.contains("(definit :name \"plex\")"));
420    }
421
422    #[test]
423    fn busybox_applets_get_symlinked_by_default() {
424        let r = LinuxRootfs::new("/nix/store/x/bin/tatara-init", "");
425        let expr = r.derivation().nix_expr.unwrap();
426        assert!(expr.contains("cp ${pkgs.busybox}/bin/busybox root/bin/busybox"));
427        assert!(expr.contains("for app in $(root/bin/busybox --list)"));
428    }
429
430    #[test]
431    fn without_busybox_drops_applet_installation() {
432        let r = LinuxRootfs::new("/nix/store/x/bin/tatara-init", "").without_busybox();
433        let expr = r.derivation().nix_expr.unwrap();
434        assert!(!expr.contains("busybox"));
435    }
436
437    #[test]
438    fn extra_files_get_heredoc_blocks() {
439        let r = LinuxRootfs::new("/nix/store/x/bin/tatara-init", "")
440            .with_file("/etc/hosts", "127.0.0.1 localhost\n")
441            .with_file("/etc/hostname", "plex-guest\n");
442        let expr = r.derivation().nix_expr.unwrap();
443        assert!(expr.contains("mkdir -p root/etc"));
444        assert!(expr.contains("cat > root/etc/hosts <<'TATARA_ROOTFS_EOF'"));
445        assert!(expr.contains("127.0.0.1 localhost"));
446        assert!(expr.contains("cat > root/etc/hostname <<'TATARA_ROOTFS_EOF'"));
447    }
448
449    #[test]
450    fn store_path_files_get_cp_commands() {
451        let r = LinuxRootfs::new("/nix/store/x/bin/tatara-init", "").with_file_from_store(
452            "/etc/ssl/certs/ca-cert.pem",
453            "/nix/store/y-ca-bundle/cert.pem",
454        );
455        let expr = r.derivation().nix_expr.unwrap();
456        assert!(expr.contains("cp /nix/store/y-ca-bundle/cert.pem root/etc/ssl/certs/ca-cert.pem"));
457    }
458
459    #[test]
460    fn init_config_with_sentinel_is_escaped() {
461        let r = LinuxRootfs::new(
462            "/nix/store/x/bin/tatara-init",
463            "line1\nTATARA_INIT_LISP_EOF\nline3",
464        );
465        let expr = r.derivation().nix_expr.unwrap();
466        // The original sentinel should no longer appear as a standalone token
467        // (it's renamed so the heredoc closes correctly).
468        assert!(expr.contains("TATARA_INIT_LISP_ESC_EOF"));
469    }
470
471    #[test]
472    fn custom_name_propagates_to_derivation() {
473        let r = LinuxRootfs::new("/nix/store/x/bin/tatara-init", "").with_name("plex-guest-initrd");
474        let d = r.derivation();
475        assert_eq!(d.name, "plex-guest-initrd");
476        let expr = d.nix_expr.unwrap();
477        assert!(expr.contains(r#"runCommand "plex-guest-initrd""#));
478    }
479}