Skip to main content

tatara_build_remote/
lib.rs

1//! `tatara-build-remote` — layered Nix build transport for tatara guests.
2//!
3//! Takes a `BuildRef` (flake + attr, raw Nix expression, store path, or
4//! OCI image) and resolves it to a concrete `StorePath` using a
5//! priority-ordered chain of transports. **First match wins.** Default
6//! chain:
7//!
8//! 1. **Attic cache** — pulls from a shared Attic instance (e.g.
9//!    `quero.lol`). Fastest path when the artifact is already cached.
10//! 2. **ssh-ng remote builder** — submits to a remote Nix builder over
11//!    `ssh-ng://`. Used when Attic misses and the local machine can't
12//!    or shouldn't build (cross-arch, resource constrained, etc.).
13//! 3. **Local** — `nix build` on the host. Last resort.
14//!
15//! Any transport declared absent in the spec is skipped. If all declared
16//! transports fail, `BuildError::AllTransportsFailed` bubbles up and
17//! hospedeiro refuses to boot the guest — we fail closed.
18//!
19//! # Status
20//!
21//! **Phase H.5 landed.** `AtticTransport`, `SshRemoteTransport`, and
22//! `LocalTransport` all ship in `transports.rs`.
23//! `BuildTransportChain::to_layered()` composes them into a priority-
24//! ordered `LayeredTransport` driven by `(defguest …)`'s `:build-on`
25//! keyword.
26//!
27//! # Why layered, not single-target
28//!
29//! The fleet at `quero.lol` has a shared Attic cache *and* an ssh-ng
30//! builder pool. Cache hits are free; builds are expensive. Layering
31//! lets the common case (pleme-io team members pulling pre-built
32//! artifacts) skip the slow path entirely. Keys + SSH config come from
33//! the cid node's `pangea-builder.nix` — no new auth plumbing.
34
35#![forbid(unsafe_code)]
36
37pub mod transports;
38
39pub use transports::{AtticTransport, LocalTransport, SshRemoteTransport};
40
41use serde::{Deserialize, Serialize};
42use thiserror::Error;
43
44/// A reference to something that becomes a Nix store path.
45#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
46#[serde(tag = "kind", content = "value", rename_all = "kebab-case")]
47pub enum BuildRef {
48    /// `nix build github:pleme-io/tatara-os#kernel`
49    Flake { url: String, attr: String },
50    /// `nix build --expr '(import ./default.nix).thing'`
51    Nix { expr: String },
52    /// Already in the store — skip build entirely.
53    StorePath(String),
54    /// An OCI image to import via `skopeo`/`docker load`/`nix2container`.
55    Oci { image: String, tag: String },
56}
57
58/// Declarative transport chain. A `None` field means "don't try this transport".
59#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
60#[serde(rename_all = "camelCase")]
61pub struct BuildTransportChain {
62    /// Attic cache name. E.g. `"quero.lol"`.
63    pub attic: Option<String>,
64    /// ssh-ng builder URI. E.g. `"ssh://builder.quero.lol"`.
65    pub remote: Option<String>,
66    /// Fall back to local `nix build`. Default `true`.
67    #[serde(default = "yes")]
68    pub local: bool,
69}
70
71fn yes() -> bool {
72    true
73}
74
75impl BuildTransportChain {
76    /// The pleme-io default — Attic, ssh-ng, local, all three against quero.lol.
77    #[must_use]
78    pub fn quero_lol() -> Self {
79        Self {
80            attic: Some("quero.lol".into()),
81            remote: Some("ssh://builder.quero.lol".into()),
82            local: true,
83        }
84    }
85
86    /// Resolve this declarative chain into a concrete `LayeredTransport`
87    /// that can actually `fetch()` a `BuildRef`. The order is always
88    /// Attic → ssh-ng → local; missing transports are skipped.
89    #[must_use]
90    pub fn to_layered(&self) -> LayeredTransport {
91        let mut transports: Vec<Box<dyn BuildTransport + Send + Sync>> = Vec::new();
92        if let Some(cache) = &self.attic {
93            transports.push(Box::new(AtticTransport::new(cache.clone())));
94        }
95        if let Some(ssh) = &self.remote {
96            transports.push(Box::new(SshRemoteTransport::new(ssh.clone())));
97        }
98        if self.local {
99            transports.push(Box::new(LocalTransport::default()));
100        }
101        LayeredTransport { transports }
102    }
103
104    /// Local only — no remote anything.
105    #[must_use]
106    pub fn local_only() -> Self {
107        Self {
108            attic: None,
109            remote: None,
110            local: true,
111        }
112    }
113
114    /// Remote only — refuse local fallback.
115    #[must_use]
116    pub fn remote_only(ssh: impl Into<String>) -> Self {
117        Self {
118            attic: None,
119            remote: Some(ssh.into()),
120            local: false,
121        }
122    }
123}
124
125/// The operation-level transport trait. Phase H.5 implements
126/// `AtticTransport`, `SshRemoteTransport`, and `LocalTransport`.
127pub trait BuildTransport {
128    /// Fetch / build the artifact, returning a store path.
129    ///
130    /// # Errors
131    /// Returns `BuildError` on any failure. The `LayeredTransport`
132    /// swallows individual errors and advances to the next transport.
133    fn fetch(&self, reference: &BuildRef) -> Result<StorePath, BuildError>;
134}
135
136/// A Nix store path — content-addressed handle.
137#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
138pub struct StorePath(pub String);
139
140/// Layered transport — tries each child in order, returns the first success.
141pub struct LayeredTransport {
142    pub transports: Vec<Box<dyn BuildTransport + Send + Sync>>,
143}
144
145impl BuildTransport for LayeredTransport {
146    fn fetch(&self, r: &BuildRef) -> Result<StorePath, BuildError> {
147        let mut last_err = None;
148        for t in &self.transports {
149            match t.fetch(r) {
150                Ok(p) => return Ok(p),
151                Err(e) => last_err = Some(e),
152            }
153        }
154        Err(last_err.unwrap_or(BuildError::AllTransportsFailed))
155    }
156}
157
158#[derive(Debug, Error)]
159pub enum BuildError {
160    #[error("attic: {0}")]
161    Attic(String),
162    #[error("remote ssh-ng: {0}")]
163    Remote(String),
164    #[error("local nix build: {0}")]
165    Local(String),
166    #[error("all transports failed")]
167    AllTransportsFailed,
168    #[error("transport not configured: {0}")]
169    NotConfigured(String),
170}
171
172/// Phase marker. Bumped by each phase that lands a change.
173pub const CRATE_STATUS: &str = "phase-h5";
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn build_ref_json_round_trip() {
181        let r = BuildRef::Flake {
182            url: "github:pleme-io/tatara-os".into(),
183            attr: "kernel".into(),
184        };
185        let j = serde_json::to_string(&r).unwrap();
186        let back: BuildRef = serde_json::from_str(&j).unwrap();
187        assert_eq!(r, back);
188    }
189
190    #[test]
191    fn quero_lol_preset_is_full_chain() {
192        let c = BuildTransportChain::quero_lol();
193        assert_eq!(c.attic.as_deref(), Some("quero.lol"));
194        assert_eq!(c.remote.as_deref(), Some("ssh://builder.quero.lol"));
195        assert!(c.local);
196    }
197
198    #[test]
199    fn remote_only_refuses_local() {
200        let c = BuildTransportChain::remote_only("ssh://foo.example");
201        assert!(!c.local);
202        assert_eq!(c.remote.as_deref(), Some("ssh://foo.example"));
203    }
204
205    #[test]
206    fn local_only_has_no_remote() {
207        let c = BuildTransportChain::local_only();
208        assert!(c.attic.is_none());
209        assert!(c.remote.is_none());
210        assert!(c.local);
211    }
212
213    #[test]
214    fn quero_lol_to_layered_builds_three_transports() {
215        let chain = BuildTransportChain::quero_lol();
216        let layered = chain.to_layered();
217        assert_eq!(layered.transports.len(), 3);
218    }
219
220    #[test]
221    fn remote_only_to_layered_has_one_transport() {
222        let chain = BuildTransportChain::remote_only("ssh://foo.example");
223        let layered = chain.to_layered();
224        assert_eq!(layered.transports.len(), 1);
225    }
226
227    #[test]
228    fn local_only_to_layered_has_one_transport() {
229        let chain = BuildTransportChain::local_only();
230        let layered = chain.to_layered();
231        assert_eq!(layered.transports.len(), 1);
232    }
233
234    #[test]
235    fn empty_chain_to_layered_has_no_transports() {
236        let chain = BuildTransportChain {
237            attic: None,
238            remote: None,
239            local: false,
240        };
241        let layered = chain.to_layered();
242        assert!(layered.transports.is_empty());
243    }
244}