tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ 6db3e3e302bd750d73eaa5062fd5c8ca726ddadd crates/hammer-build/src/sandbox.rs Rust · 273 lines · 9.6 KB History Who touched what raw

Fase 5: BuildFailed.log_tail con la cola real del log del sandbox 6db3e3e30 · Sergio ·

1//! El sandbox hermético de build (bubblewrap). Ver `docs/02-build-lab.md` §3 y §4.
2//!
3//! Capas:
4//!   - Base read-only: rootfs Alpine (`.dev-fs/alpine`) montado como overlay con capa
5//!     tmpfs encima, así el build puede escribir en /tmp/etc sin ensuciar el host.
6//!   - Compilador inyectado: `zig` (`.dev-fs/tools/zig`) en `/opt/zig`.
7//!   - Fuentes: bind RW en `/src` (deben venir ya copiadas a un dir host descartable).
8//!   - Salida: bind RW en `/out` (DESTDIR).
9//!   - Red: aislada (`--unshare-all` incluye `--unshare-net`).
10
11use std::collections::VecDeque;
12use std::io::{BufRead, BufReader, Write};
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::sync::{Arc, Mutex};
16
17/// Cuántas líneas finales del log del sandbox conservamos para `BuildFailed.log_tail`.
18const TAIL_LINES: usize = 50;
19
20/// Fallo de una fase de build, con la cola del log del lab si la pudimos capturar. Viaja
21/// dentro de `hammer_core::Error::Other(anyhow)`; el bus la recupera por `downcast_ref` para
22/// poblar `Event::BuildFailed.log_tail`. Ver `docs/07-agent-bus.md`.
23#[derive(Debug, Clone)]
24pub struct BuildFailure {
25    pub reason: String,
26    pub log_tail: Option<String>,
27}
28
29impl std::fmt::Display for BuildFailure {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        write!(f, "{}", self.reason)?;
32        if let Some(tail) = &self.log_tail {
33            write!(f, "\n--- log (últimas líneas) ---\n{tail}")?;
34        }
35        Ok(())
36    }
37}
38
39impl std::error::Error for BuildFailure {}
40
41impl BuildFailure {
42    /// Recupera un `BuildFailure` de un error de hammer-core, si lo lleva dentro. El bus lo
43    /// usa para separar `reason` de `log_tail`.
44    pub fn from_error(e: &hammer_core::Error) -> Option<&BuildFailure> {
45        match e {
46            hammer_core::Error::Other(a) => a.downcast_ref::<BuildFailure>(),
47            _ => None,
48        }
49    }
50}
51
52/// Empuja `line` a un ring buffer acotado a `cap` líneas (descarta la más antigua).
53fn push_tail(buf: &mut VecDeque<String>, line: String, cap: usize) {
54    if buf.len() == cap {
55        buf.pop_front();
56    }
57    buf.push_back(line);
58}
59
60#[derive(Debug, Clone)]
61pub struct Sandbox {
62    pub rootfs: PathBuf,
63    pub zig_dir: PathBuf,
64    pub src_dir: PathBuf,
65    pub out_dir: PathBuf,
66    /// Si Some, se bindea RW como `/cache` y se setea `ZIG_GLOBAL_CACHE_DIR=/cache/zig`.
67    /// Persistir esta caché entre builds evita que `zig cc` recompile musl cada vez (~30s).
68    pub cache_dir: Option<PathBuf>,
69    /// Vars de entorno extra inyectadas además del set por defecto.
70    pub env: Vec<(String, String)>,
71}
72
73impl Sandbox {
74    /// Ejecuta `cmd` dentro del sandbox bajo `sh -c`. Hace *tee* de stdout/stderr al padre
75    /// (para no perder el streaming en vivo) y a la vez retiene las últimas [`TAIL_LINES`]
76    /// líneas; si el exit code es != 0, devuelve un [`BuildFailure`] con esa cola en
77    /// `log_tail`, envuelto en `hammer_core::Error::Other`.
78    pub fn run(&self, cmd: &str) -> hammer_core::Result<()> {
79        let args = self.bwrap_args(cmd);
80        tracing::debug!(?args, "bwrap");
81        let mut child = Command::new("bwrap")
82            .args(&args)
83            .stdin(Stdio::null())
84            .stdout(Stdio::piped())
85            .stderr(Stdio::piped())
86            .spawn()
87            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn bwrap: {e}")))?;
88
89        // Ring buffer compartido por ambos pumps: la cola combinada de stdout+stderr.
90        let tail = Arc::new(Mutex::new(VecDeque::<String>::with_capacity(TAIL_LINES)));
91        let out = child.stdout.take().expect("stdout piped");
92        let err = child.stderr.take().expect("stderr piped");
93        let h_out = spawn_pump(out, tail.clone(), false);
94        let h_err = spawn_pump(err, tail.clone(), true);
95
96        let status = child
97            .wait()
98            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait bwrap: {e}")))?;
99        // Aseguramos drenar ambos streams antes de leer la cola.
100        let _ = h_out.join();
101        let _ = h_err.join();
102
103        if !status.success() {
104            let tail_str = {
105                let g = tail.lock().expect("tail mutex");
106                if g.is_empty() {
107                    None
108                } else {
109                    Some(g.iter().cloned().collect::<Vec<_>>().join("\n"))
110                }
111            };
112            let reason = format!(
113                "build phase falló (exit {}): {cmd}",
114                status.code().map(|c| c.to_string()).unwrap_or_else(|| "?".into())
115            );
116            return Err(hammer_core::Error::Other(anyhow::Error::new(BuildFailure {
117                reason,
118                log_tail: tail_str,
119            })));
120        }
121        Ok(())
122    }
123
124    fn bwrap_args(&self, cmd: &str) -> Vec<String> {
125        let mut args: Vec<String> = vec![
126            "--overlay-src".into(),
127            self.rootfs.display().to_string(),
128            "--tmp-overlay".into(),
129            "/".into(),
130            "--proc".into(),
131            "/proc".into(),
132            "--dev".into(),
133            "/dev".into(),
134            "--tmpfs".into(),
135            "/tmp".into(),
136            "--ro-bind".into(),
137            self.zig_dir.display().to_string(),
138            "/opt/zig".into(),
139            "--bind".into(),
140            self.src_dir.display().to_string(),
141            "/src".into(),
142            "--bind".into(),
143            self.out_dir.display().to_string(),
144            "/out".into(),
145            "--chdir".into(),
146            "/src".into(),
147            "--unshare-all".into(),
148            "--die-with-parent".into(),
149        ];
150
151        if let Some(cache) = &self.cache_dir {
152            args.push("--bind".into());
153            args.push(cache.display().to_string());
154            args.push("/cache".into());
155        }
156
157        // Entorno mínimo y predecible.
158        let mut defaults: Vec<(&str, &str)> = vec![
159            ("PATH", "/opt/zig:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"),
160            ("HOME", "/tmp"),
161            ("DESTDIR", "/out"),
162            ("PREFIX", "/usr"),
163            ("CC", "zig cc"),
164            ("CXX", "zig c++"),
165            ("AR", "zig ar"),
166            ("LC_ALL", "C"),
167            ("LANG", "C"),
168        ];
169        if self.cache_dir.is_some() {
170            defaults.push(("ZIG_GLOBAL_CACHE_DIR", "/cache/zig"));
171            defaults.push(("ZIG_LOCAL_CACHE_DIR", "/cache/zig"));
172        }
173        for (k, v) in defaults {
174            args.push("--setenv".into());
175            args.push(k.into());
176            args.push(v.into());
177        }
178        for (k, v) in &self.env {
179            args.push("--setenv".into());
180            args.push(k.clone());
181            args.push(v.clone());
182        }
183
184        // Comando final.
185        args.push("/bin/sh".into());
186        args.push("-c".into());
187        args.push(cmd.into());
188        args
189    }
190}
191
192/// Lanza un thread que lee `reader` línea a línea, la reenvía al stream del padre
193/// (`to_stderr` decide cuál) y la acumula en el ring `tail`. Devuelve el handle para join.
194fn spawn_pump<R: std::io::Read + Send + 'static>(
195    reader: R,
196    tail: Arc<Mutex<VecDeque<String>>>,
197    to_stderr: bool,
198) -> std::thread::JoinHandle<()> {
199    std::thread::spawn(move || {
200        let buf = BufReader::new(reader);
201        for line in buf.lines() {
202            let line = match line {
203                Ok(l) => l,
204                Err(_) => break,
205            };
206            // Tee en vivo al padre (sin bufferizar de más: el build puede tardar).
207            if to_stderr {
208                let _ = writeln!(std::io::stderr(), "{line}");
209            } else {
210                let _ = writeln!(std::io::stdout(), "{line}");
211            }
212            if let Ok(mut g) = tail.lock() {
213                push_tail(&mut g, line, TAIL_LINES);
214            }
215        }
216    })
217}
218
219/// Errores tempranos: comprueba que las rutas base existen antes de invocar el build.
220pub fn ensure_layout(rootfs: &Path, zig_dir: &Path) -> hammer_core::Result<()> {
221    // bin/sh en Alpine es un symlink absoluto a /bin/busybox, que desde el host no resuelve.
222    // Comprobamos el archivo regular real para no engañarnos.
223    if !rootfs.join("bin/busybox").is_file() {
224        return Err(hammer_core::Error::Other(anyhow::anyhow!(
225            "rootfs inválido: no encuentro {}/bin/busybox — ¿corriste el bootstrap de Alpine?",
226            rootfs.display()
227        )));
228    }
229    if !zig_dir.join("zig").exists() {
230        return Err(hammer_core::Error::Other(anyhow::anyhow!(
231            "zig no encontrado en {}/zig — ¿instalaste zig en .dev-fs/tools/zig?",
232            zig_dir.display()
233        )));
234    }
235    Ok(())
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn push_tail_keeps_last_n() {
244        let mut buf = VecDeque::new();
245        for i in 0..10 {
246            push_tail(&mut buf, format!("line {i}"), 3);
247        }
248        let got: Vec<_> = buf.iter().cloned().collect();
249        assert_eq!(got, vec!["line 7", "line 8", "line 9"]);
250    }
251
252    #[test]
253    fn build_failure_roundtrips_through_core_error() {
254        // El contrato que usa el bus: envolver en Error::Other y recuperar por downcast.
255        let bf = BuildFailure {
256            reason: "exit 2".into(),
257            log_tail: Some("undefined reference to `foo'".into()),
258        };
259        let err = hammer_core::Error::Other(anyhow::Error::new(bf));
260        let recovered = BuildFailure::from_error(&err).expect("debe recuperarse");
261        assert_eq!(recovered.reason, "exit 2");
262        assert_eq!(
263            recovered.log_tail.as_deref(),
264            Some("undefined reference to `foo'")
265        );
266    }
267
268    #[test]
269    fn from_error_none_for_plain_errors() {
270        let err = hammer_core::Error::Recipe("nope".into());
271        assert!(BuildFailure::from_error(&err).is_none());
272    }
273}