Skip to main content

tatara_vm/
guest.rs

1//! `GuestSpec` — the superset of Linux-VM and WASM guests.
2//!
3//! Two dispatches:
4//!
5//!   `(defguest :kind (:vm …) …)`   → `GuestKind::Vm(VmSpec)`
6//!   `(defguest :kind (:wasm …) …)` → `GuestKind::Wasm(WasmSpec)`
7//!
8//! Both share `build`, `build_on`, `network`, `mounts`, `services`,
9//! `cmdline`, `resources`. The kind-specific block carries backend +
10//! artifact-shape fields (kernel/initrd/rootfs for VMs; component +
11//! runtime for WASM).
12//!
13//! See `tatara/docs/declarative-guests.md` for the full design.
14
15use serde::{Deserialize, Serialize};
16use tatara_build_remote::{BuildRef, BuildTransportChain};
17use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
18use tatara_wasm::{WasiPreview, WasmCapabilities, WasmFeatures, WasmRuntime};
19
20use crate::config::{NetworkSpec, ShareSpec, VmSpec};
21
22/// `(defguest …)` — the authoritative guest spec.
23#[derive(DeriveTataraDomain, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25#[tatara(keyword = "defguest")]
26pub struct GuestSpec {
27    pub name: String,
28
29    /// Linux VM or WASM module. Discriminated on the `:kind` keyword.
30    pub kind: GuestKind,
31
32    /// Default command-line — currently only used by VMs.
33    #[serde(default)]
34    pub cmdline: Vec<String>,
35
36    /// Shared attachments applicable to both kinds.
37    #[serde(default)]
38    pub network: NetworkSpec,
39
40    #[serde(default)]
41    pub shares: Vec<ShareSpec>,
42
43    /// Resource caps honored by whichever backend hosts the guest.
44    #[serde(default)]
45    pub resources: ResourceLimits,
46
47    /// Where the guest's artifacts are built — Attic / ssh-ng / local.
48    /// Defaults to `BuildTransportChain::quero_lol()`.
49    #[serde(default = "default_build_on")]
50    pub build_on: BuildTransportChain,
51}
52
53fn default_build_on() -> BuildTransportChain {
54    BuildTransportChain::quero_lol()
55}
56
57/// `:kind` — which runtime family hosts this guest.
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(tag = "kind", rename_all = "kebab-case")]
60pub enum GuestKind {
61    /// Linux VM on HVF (primary) or VZ (fallback). Wraps existing `VmSpec`.
62    Vm(VmSpec),
63
64    /// WASI/WASM component on one of five runtimes.
65    Wasm(WasmSpec),
66}
67
68/// `:wasm` guest — runtime + component + WASI contract.
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct WasmSpec {
72    /// Which WASM runtime will host this guest.
73    #[serde(default)]
74    pub runtime: WasmRuntime,
75
76    /// WASI version.
77    #[serde(default)]
78    pub wasi_preview: WasiPreview,
79
80    /// The component artifact — a WASM/WAT/Component Model blob produced
81    /// by a Nix derivation.
82    pub component: BuildRef,
83
84    /// AOT/JIT/SIMD/wasi-http/wasi-nn toggles.
85    #[serde(default)]
86    pub features: WasmFeatures,
87
88    /// What the host grants this guest — stdio, env, argv, preopens, and the
89    /// non-WASI host imports the embedder supplies.
90    ///
91    /// **Defaults to deny-all**, which is a deliberate posture change. The
92    /// engines used to hardcode their own grant (wasmtime handed every guest
93    /// stdout+stderr; wasmi and wasmer handed out nothing), so the capability
94    /// surface was whichever impl you happened to land on. Declaring it here
95    /// makes it a property of the guest instead of the backend.
96    #[serde(default)]
97    pub capabilities: WasmCapabilities,
98}
99
100/// Shared resource caps — both kinds honor these.
101#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
102#[serde(rename_all = "camelCase")]
103pub struct ResourceLimits {
104    /// Max RSS (MiB). `None` = unlimited (subject to host).
105    #[serde(default)]
106    pub memory_mib: Option<u32>,
107
108    /// CPU budget hint — informational for VMs, enforced for WASM.
109    #[serde(default)]
110    pub cpu_ms_budget: Option<u32>,
111
112    /// Max file descriptors.
113    #[serde(default)]
114    pub fd_limit: Option<u32>,
115}
116
117impl GuestSpec {
118    /// Wrap an existing `VmSpec` as a Guest — the compatibility shim.
119    #[must_use]
120    pub fn from_vm(spec: VmSpec) -> Self {
121        Self {
122            name: spec.name.clone(),
123            cmdline: spec.cmdline.clone(),
124            network: spec.network.clone(),
125            shares: spec.shares.clone(),
126            kind: GuestKind::Vm(spec),
127            resources: ResourceLimits::default(),
128            build_on: default_build_on(),
129        }
130    }
131
132    /// Is this a VM?
133    #[must_use]
134    pub const fn is_vm(&self) -> bool {
135        matches!(self.kind, GuestKind::Vm(_))
136    }
137
138    /// Is this a WASM module?
139    #[must_use]
140    pub const fn is_wasm(&self) -> bool {
141        matches!(self.kind, GuestKind::Wasm(_))
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::config::Hypervisor;
149
150    #[test]
151    fn from_vm_wraps_cleanly() {
152        let vm = VmSpec::plex_default("plex");
153        let g = GuestSpec::from_vm(vm.clone());
154        assert_eq!(g.name, "plex");
155        assert!(g.is_vm());
156        assert!(!g.is_wasm());
157        match g.kind {
158            GuestKind::Vm(inner) => assert_eq!(inner.hypervisor, Hypervisor::Vfkit),
159            _ => panic!(),
160        }
161    }
162
163    #[test]
164    fn wasm_guest_serializes_with_runtime_variant() {
165        let g = GuestSpec {
166            name: "fast-fn".into(),
167            kind: GuestKind::Wasm(WasmSpec {
168                runtime: WasmRuntime::Wasmtime,
169                wasi_preview: WasiPreview::P2,
170                component: BuildRef::Flake {
171                    url: "github:pleme-io/cors-proxy".into(),
172                    attr: "wasi".into(),
173                },
174                capabilities: WasmCapabilities::default(),
175                features: WasmFeatures {
176                    simd: true,
177                    wasi_http: true,
178                    ..Default::default()
179                },
180            }),
181            cmdline: vec![],
182            network: NetworkSpec::default(),
183            shares: vec![],
184            resources: ResourceLimits {
185                memory_mib: Some(64),
186                cpu_ms_budget: Some(100),
187                fd_limit: None,
188            },
189            build_on: BuildTransportChain::quero_lol(),
190        };
191        let j = serde_json::to_string(&g).unwrap();
192        assert!(j.contains("\"kind\":\"wasm\""));
193        assert!(j.contains("\"wasmtime\""));
194        assert!(j.contains("\"simd\":true"));
195
196        let back: GuestSpec = serde_json::from_str(&j).unwrap();
197        assert_eq!(back, g);
198    }
199
200    #[test]
201    fn default_build_on_targets_quero_lol() {
202        let d = default_build_on();
203        assert_eq!(d.attic.as_deref(), Some("quero.lol"));
204        assert_eq!(d.remote.as_deref(), Some("ssh://builder.quero.lol"));
205        assert!(d.local);
206    }
207
208    #[test]
209    fn defguest_vm_compiles_from_lisp() {
210        use tatara_lisp::domain::TataraDomain;
211        use tatara_lisp::reader;
212
213        // Minimal — all-kwargs. VmSpec has rename_all="camelCase" so its
214        // own fields are camel; nested GuestKernel/GuestRootfs don't, so
215        // their fields stay snake_case in the JSON bridge.
216        let src = r#"(defguest :name "sample"
217                               :kind (:kind "vm"
218                                      :name "sample"
219                                      :cpus 4
220                                      :memoryMib 4096
221                                      :hypervisor (:kind "Vfkit")
222                                      :kernel (:kind "Bridge" :attr_path "linuxPackages.kernel")
223                                      :rootfs (:kind "Bridge" :attr_path "minimal")
224                                      :network (:kind "Nat")
225                                      :cmdline ("console=hvc0" "init=/bin/tatara-init"))
226                               :cmdline ())"#;
227        let forms = reader::read(src).expect("read");
228        let args = &forms[0].as_list().unwrap()[1..];
229        let guest = GuestSpec::compile_from_args(args).expect("compile defguest");
230        assert_eq!(guest.name, "sample");
231        assert!(guest.is_vm());
232    }
233
234    #[test]
235    fn defguest_wasm_compiles_from_lisp() {
236        use tatara_lisp::domain::TataraDomain;
237        use tatara_lisp::reader;
238
239        let src = r#"(defguest :name "fast-fn"
240                               :kind (:kind "wasm"
241                                      :runtime "wasmtime"
242                                      :wasiPreview "p2"
243                                      :component (:kind "flake"
244                                                  :value (:url "github:pleme-io/cors-proxy"
245                                                          :attr "wasi"))
246                                      :features (:simd #t :wasiHttp #t))
247                               :cmdline ())"#;
248        let forms = reader::read(src).expect("read");
249        let args = &forms[0].as_list().unwrap()[1..];
250        let guest = GuestSpec::compile_from_args(args).expect("compile defguest wasm");
251        assert_eq!(guest.name, "fast-fn");
252        assert!(guest.is_wasm());
253        match &guest.kind {
254            GuestKind::Wasm(w) => {
255                assert_eq!(w.runtime, WasmRuntime::Wasmtime);
256                assert_eq!(w.wasi_preview, WasiPreview::P2);
257                assert!(w.features.simd);
258                assert!(w.features.wasi_http);
259            }
260            _ => panic!(),
261        }
262    }
263}