Skip to main content

tatara_init/
config.rs

1//! Typed configuration — the tatara-init equivalent of a systemd unit list.
2
3use serde::{Deserialize, Serialize};
4use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
7pub enum RestartPolicy {
8    /// Do not restart on exit.
9    Never,
10    /// Restart on any non-zero exit.
11    OnFailure,
12    /// Restart unconditionally (the classic daemon loop).
13    Always,
14}
15
16impl Default for RestartPolicy {
17    fn default() -> Self {
18        Self::OnFailure
19    }
20}
21
22#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Service {
25    pub name: String,
26    /// Command line. Shell-unescaped; split on whitespace for argv.
27    /// Ignored when `body` is present (tatara-init synthesizes the exec
28    /// line to invoke its own `--eval` subcommand with the body form).
29    #[serde(default)]
30    pub exec: String,
31    /// Tatara-lisp form evaluated by tatara-init's embedded interpreter.
32    /// When set, tatara-init's supervisor spawns `/bin/tatara-init --eval
33    /// '<form>'` as the service — no shell, no external binary, just our
34    /// own tatara-eval loop running in a forked child.
35    #[serde(default)]
36    pub body: Option<String>,
37    /// How to react to exits.
38    #[serde(default)]
39    pub restart: RestartPolicy,
40    /// Environment pairs.
41    #[serde(default)]
42    pub env: Vec<(String, String)>,
43    /// Optional working directory. Defaults to `/`.
44    #[serde(default)]
45    pub workdir: Option<String>,
46    /// Auto-start at boot? Default true.
47    #[serde(default = "default_true")]
48    pub enable: bool,
49}
50
51impl Service {
52    /// Resolve the command line that `LinuxSupervisor::spawn` should exec.
53    /// When `body` is set, this substitutes the real exec with a
54    /// `tatara-init --eval …` invocation of our own binary.
55    pub fn resolved_exec(&self) -> String {
56        match &self.body {
57            Some(form) => {
58                // Escape single quotes by closing + escaping + reopening,
59                // POSIX-shell-style.
60                let escaped = form.replace('\'', "'\\''");
61                format!("/bin/tatara-init --eval '{escaped}'")
62            }
63            None => self.exec.clone(),
64        }
65    }
66}
67
68fn default_true() -> bool {
69    true
70}
71
72/// One declarative mount beyond the canonical /proc /sys /dev /run /tmp set.
73/// Typical use: virtio-fs shares from the host (e.g. /nix/store mounted in
74/// the guest readonly so services can exec binaries by absolute store path
75/// without bloating the initrd with the closure).
76#[derive(DeriveTataraDomain, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77#[tatara(keyword = "defmount")]
78pub struct MountSpec {
79    /// For virtiofs: the mount tag. For block devices: /dev path.
80    pub source: String,
81    /// Mount point inside the guest. Created if missing.
82    pub target: String,
83    /// Filesystem type (`virtiofs`, `ext4`, `tmpfs`, …).
84    pub fstype: String,
85    /// Comma-separated mount options (`ro`, `nosuid`, …). Optional.
86    #[serde(default)]
87    pub options: Option<String>,
88}
89
90/// The single root document. Parses from `(definit …)` forms.
91#[derive(DeriveTataraDomain, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92#[tatara(keyword = "definit")]
93pub struct InitConfig {
94    /// Human label for the init config (logs + attestation).
95    #[serde(default = "default_name")]
96    pub name: String,
97    /// Services to supervise. Starts in declaration order; stops in reverse.
98    #[serde(default)]
99    pub services: Vec<Service>,
100    /// Mounts to set up after CANONICAL_MOUNTS, before services start.
101    /// Mount failures are logged but don't abort boot.
102    #[serde(default)]
103    pub mounts: Vec<MountSpec>,
104    /// Reap orphaned children (the canonical PID-1 duty). Default on.
105    #[serde(default = "default_true")]
106    pub reap_zombies: bool,
107    /// On receipt of `SIGHUP`, re-read `/etc/tatara/init.lisp` and diff-apply.
108    #[serde(default = "default_true")]
109    pub reload_on_sighup: bool,
110}
111
112fn default_name() -> String {
113    "tatara-init".into()
114}
115
116impl Default for InitConfig {
117    fn default() -> Self {
118        Self {
119            name: default_name(),
120            services: vec![],
121            mounts: vec![],
122            reap_zombies: true,
123            reload_on_sighup: true,
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use tatara_lisp::{domain::TataraDomain, read};
132
133    #[test]
134    fn empty_definit_parses() {
135        // Note: the tatara-lisp-derive macro currently doesn't honor serde's
136        // per-field `default = "fn"`, so omitted bools come back as `false`.
137        // Callers that care should use `InitConfig::default()` and modify
138        // only what they need, or pass values via the other (typed) API.
139        let forms = read(r#"(definit :name "plex-boot")"#).unwrap();
140        let c = InitConfig::compile_from_sexp(&forms[0]).unwrap();
141        assert_eq!(c.name, "plex-boot");
142        assert!(c.services.is_empty());
143    }
144
145    #[test]
146    fn services_round_trip_through_lisp() {
147        let forms = read(
148            r#"(definit
149                 :name "plex-boot"
150                 :services ((:name "sshd"     :exec "/bin/sshd -D")
151                            (:name "fumi"     :exec "/bin/fumi"  :enable #f)))"#,
152        )
153        .unwrap();
154        let c = InitConfig::compile_from_sexp(&forms[0]).unwrap();
155        assert_eq!(c.services.len(), 2);
156        assert_eq!(c.services[0].name, "sshd");
157        assert!(c.services[0].enable);
158        assert_eq!(c.services[1].name, "fumi");
159        assert!(!c.services[1].enable);
160    }
161
162    #[test]
163    fn restart_policy_defaults_to_on_failure() {
164        let svc = Service {
165            name: "x".into(),
166            exec: "/x".into(),
167            body: None,
168            restart: Default::default(),
169            env: vec![],
170            workdir: None,
171            enable: true,
172        };
173        assert!(matches!(svc.restart, RestartPolicy::OnFailure));
174    }
175
176    #[test]
177    fn resolved_exec_uses_body_when_present() {
178        let svc = Service {
179            name: "greet".into(),
180            exec: String::new(),
181            body: Some("(println 42)".into()),
182            restart: Default::default(),
183            env: vec![],
184            workdir: None,
185            enable: true,
186        };
187        assert_eq!(
188            svc.resolved_exec(),
189            "/bin/tatara-init --eval '(println 42)'"
190        );
191    }
192
193    #[test]
194    fn resolved_exec_falls_back_to_exec_when_body_absent() {
195        let svc = Service {
196            name: "x".into(),
197            exec: "/bin/x arg1 arg2".into(),
198            body: None,
199            restart: Default::default(),
200            env: vec![],
201            workdir: None,
202            enable: true,
203        };
204        assert_eq!(svc.resolved_exec(), "/bin/x arg1 arg2");
205    }
206
207    #[test]
208    fn resolved_exec_escapes_embedded_single_quotes() {
209        let svc = Service {
210            name: "quoted".into(),
211            exec: String::new(),
212            body: Some("(a 'b c)".into()),
213            restart: Default::default(),
214            env: vec![],
215            workdir: None,
216            enable: true,
217        };
218        // Single quotes in the body must get escaped so the outer shell
219        // single-quoted string terminates correctly.
220        assert!(svc.resolved_exec().contains("'\\''"));
221    }
222}