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…

⎇ e9f535a1bbf5c62d9db81f5d578210fd5f5c55df crates/hammer-build/src/sandbox.rs Rust · 362 líneas · 15.5 KB Historia Quién tocó qué crudo

selfhost-verify: pieza 4 (bwrap swap) + materialización de build-deps en el lab e9f535a1b · 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    /// Directorios sellados de las `deps.build` (artefactos ya construidos en el store). Cada uno se
70    /// apila como capa overlay read-only BAJO el rootfs Alpine, así sus `usr/{include,lib,lib/pkgconfig}`
71    /// aparecen en las rutas estándar de `/usr` — el compilador y pkgconf los ven sin plumbing de flags.
72    /// Vacío ⇒ ninguna capa extra ⇒ el sandbox es idéntico al de una receta sin deps (baseline intacto).
73    pub deps: Vec<PathBuf>,
74    /// Vars de entorno extra inyectadas además del set por defecto.
75    pub env: Vec<(String, String)>,
76}
77
78impl Sandbox {
79    /// Ejecuta `cmd` dentro del sandbox bajo `sh -c`. Hace *tee* de stdout/stderr al padre
80    /// (para no perder el streaming en vivo) y a la vez retiene las últimas [`TAIL_LINES`]
81    /// líneas; si el exit code es != 0, devuelve un [`BuildFailure`] con esa cola en
82    /// `log_tail`, envuelto en `hammer_core::Error::Other`.
83    pub fn run(&self, cmd: &str) -> hammer_core::Result<()> {
84        let args = self.bwrap_args(cmd);
85        tracing::debug!(?args, "bwrap");
86        let mut child = Command::new("bwrap")
87            .args(&args)
88            .stdin(Stdio::null())
89            .stdout(Stdio::piped())
90            .stderr(Stdio::piped())
91            .spawn()
92            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn bwrap: {e}")))?;
93
94        // Ring buffer compartido por ambos pumps: la cola combinada de stdout+stderr.
95        let tail = Arc::new(Mutex::new(VecDeque::<String>::with_capacity(TAIL_LINES)));
96        let out = child.stdout.take().expect("stdout piped");
97        let err = child.stderr.take().expect("stderr piped");
98        let h_out = spawn_pump(out, tail.clone());
99        let h_err = spawn_pump(err, tail.clone());
100
101        let status = child
102            .wait()
103            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait bwrap: {e}")))?;
104        // Aseguramos drenar ambos streams antes de leer la cola.
105        let _ = h_out.join();
106        let _ = h_err.join();
107
108        if !status.success() {
109            let tail_str = {
110                let g = tail.lock().expect("tail mutex");
111                if g.is_empty() {
112                    None
113                } else {
114                    Some(g.iter().cloned().collect::<Vec<_>>().join("\n"))
115                }
116            };
117            let reason = format!(
118                "build phase falló (exit {}): {cmd}",
119                status.code().map(|c| c.to_string()).unwrap_or_else(|| "?".into())
120            );
121            return Err(hammer_core::Error::Other(anyhow::Error::new(BuildFailure {
122                reason,
123                log_tail: tail_str,
124            })));
125        }
126        Ok(())
127    }
128
129    fn bwrap_args(&self, cmd: &str) -> Vec<String> {
130        let mut args: Vec<String> = vec![
131            "--overlay-src".into(),
132            self.rootfs.display().to_string(),
133        ];
134        // Capas overlay de las deps de build, apiladas bajo el rootfs (mismo `--tmp-overlay /`). bwrap
135        // admite múltiples `--overlay-src` para un overlay; los `usr/{include,lib}` de cada dep se
136        // funden con los del rootfs en `/usr`. Sólo se emiten si hay deps (compat hacia atrás).
137        for dep in &self.deps {
138            args.push("--overlay-src".into());
139            args.push(dep.display().to_string());
140        }
141        args.extend([
142            "--tmp-overlay".into(),
143            "/".into(),
144            "--proc".into(),
145            "/proc".into(),
146            "--dev".into(),
147            "/dev".into(),
148            "--tmpfs".into(),
149            "/tmp".into(),
150            "--ro-bind".into(),
151            self.zig_dir.display().to_string(),
152            "/opt/zig".into(),
153            "--bind".into(),
154            self.src_dir.display().to_string(),
155            "/src".into(),
156            "--bind".into(),
157            self.out_dir.display().to_string(),
158            "/out".into(),
159            "--chdir".into(),
160            "/src".into(),
161            "--unshare-all".into(),
162            "--die-with-parent".into(),
163        ]);
164
165        if let Some(cache) = &self.cache_dir {
166            args.push("--bind".into());
167            args.push(cache.display().to_string());
168            args.push("/cache".into());
169        }
170
171        // Entorno mínimo y predecible.
172        let mut defaults: Vec<(&str, &str)> = vec![
173            ("PATH", "/opt/zig:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"),
174            ("HOME", "/tmp"),
175            ("DESTDIR", "/out"),
176            ("PREFIX", "/usr"),
177            // `-mcpu=baseline`: codegen al baseline x86-64 genérico, NO al CPU del builder. Sin esto,
178            // `zig cc` default a `-mcpu=native` y hornea la ISA de quien compila (SHA-NI/AVX2 del
179            // host) ⇒ el binario diverge entre CPUs distintos (host vs la VM `-cpu Broadwell`) y
180            // rompe la reproducibilidad — un no-determinismo que Stage 2 cazó (runbook §8c, SDD 09
181            // §2). De paso cierra el SIGILL de AVX en qemu64 (runbook §7). Las rutas SIMD que sí
182            // necesita el runtime (blake3/sha2) son asm con dispatch en runtime: siguen presentes.
183            ("CC", "zig cc -mcpu=baseline"),
184            ("CXX", "zig c++ -mcpu=baseline"),
185            ("AR", "zig ar"),
186            ("LC_ALL", "C"),
187            ("LANG", "C"),
188            // Reproducibilidad (pre-Stage 2): fija los timestamps que muchas herramientas embeben
189            // (tar, ar, gzip, __DATE__/__TIME__ vía SOURCE_DATE_EPOCH). Valor fijo y arbitrario
190            // (1970-01-01 00:00:01). Las rutas ya son deterministas: el source se bindea en `/src`
191            // (constante entre rebuilds, sea cual sea el work dir del host). Ver SDD 11 / runbook.
192            ("SOURCE_DATE_EPOCH", "1"),
193            ("TZ", "UTC"),
194            // Reproducibilidad de builds Cargo: fuerza `codegen-units = 1` para TODAS las crates
195            // del build (el var de cargo gana sobre el `[profile.release]` del repo fuente). Con el
196            // default (16) el codegen paralelo de rustc reparte el crate en N unidades y la salida
197            // varía build-a-build incluso en la MISMA máquina y con entradas idénticas — Stage 2 lo
198            // cazó: `arje-zero` (cuyo workspace tawasuyu no fija perfil) divergía ~10 KB repartidos
199            // por .text/.rodata/.eh_frame, mientras `hammerd` (workspace hammer que SÍ pinea
200            // codegen-units=1) reproducía bit-a-bit. El lab lo impone en vez de confiar en que cada
201            // repo upstream lo declare (SDD 09 §2). Inerte para builds no-Cargo (musl/busybox lo
202            // ignoran). Ver runbook §8c / SDD 11.
203            ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"),
204            // Reproducibilidad de builds Cargo (parte 2): `codegen-units=1` NO basta. El backend
205            // paralelo de rustc/LLVM (ThinLTO + codegen) lanza un pool de hilos dimensionado por la
206            // paralelismo disponible, y sus decisiones (orden de import/inline) varían build-a-build
207            // a >1 hilo ⇒ `arje-zero` divergía ~62 KB entre dos builds host idénticos, mientras un
208            // build serial (la VM tiene 1 vCPU, o `taskset -c 0`) reproducía bit-a-bit. `JOBS=1`
209            // limita el jobserver a 1 token ⇒ rustc serializa el backend ⇒ salida estable e
210            // independiente del nº de CPUs del que reconstruye. Lo cazó Stage 2 variante (b): host
211            // (paralelo) daba `0039b2b9…` no-reproducible vs VM (serial) `9adefb82…`. Ver SDD 09 §2.
212            ("CARGO_BUILD_JOBS", "1"),
213        ];
214        if self.cache_dir.is_some() {
215            defaults.push(("ZIG_GLOBAL_CACHE_DIR", "/cache/zig"));
216            defaults.push(("ZIG_LOCAL_CACHE_DIR", "/cache/zig"));
217        }
218        for (k, v) in defaults {
219            args.push("--setenv".into());
220            args.push(k.into());
221            args.push(v.into());
222        }
223        for (k, v) in &self.env {
224            args.push("--setenv".into());
225            args.push(k.clone());
226            args.push(v.clone());
227        }
228
229        // Comando final.
230        args.push("/bin/sh".into());
231        args.push("-c".into());
232        args.push(cmd.into());
233        args
234    }
235}
236
237/// Lanza un thread que lee `reader` línea a línea, la reenvía a stderr del padre y la
238/// acumula en el ring `tail`. Devuelve el handle para join.
239///
240/// El tee va SIEMPRE a stderr (tanto el stdout como el stderr del build): stdout del proceso
241/// queda reservado para el resultado legible-por-máquina (el content-hash que imprime la CLI,
242/// p. ej. `bootstrap stage1`). Antes el stdout del build se teeaba al stdout del padre y se
243/// colaba en capturas tipo `PRIME=$(hammer … bootstrap stage1)`; con builds reales (no cacheados,
244/// p. ej. el rebuild in-VM) eso son miles de líneas de `configure`/`make` ⇒ el argumento crece y
245/// la fase de verificación siguiente (`--rootfs "$PRIME"`) revienta el exec con E2BIG
246/// (Argument list too long). Va a stderr, junto a los logs de tracing. Ver runbook §8c.
247fn spawn_pump<R: std::io::Read + Send + 'static>(
248    reader: R,
249    tail: Arc<Mutex<VecDeque<String>>>,
250) -> std::thread::JoinHandle<()> {
251    std::thread::spawn(move || {
252        let buf = BufReader::new(reader);
253        for line in buf.lines() {
254            let line = match line {
255                Ok(l) => l,
256                Err(_) => break,
257            };
258            // Tee en vivo al padre por stderr (sin bufferizar de más: el build puede tardar).
259            let _ = writeln!(std::io::stderr(), "{line}");
260            if let Ok(mut g) = tail.lock() {
261                push_tail(&mut g, line, TAIL_LINES);
262            }
263        }
264    })
265}
266
267/// Errores tempranos: comprueba que las rutas base existen antes de invocar el build.
268pub fn ensure_layout(rootfs: &Path, zig_dir: &Path) -> hammer_core::Result<()> {
269    // bin/sh en Alpine es un symlink absoluto a /bin/busybox, que desde el host no resuelve.
270    // Comprobamos el archivo regular real para no engañarnos.
271    if !rootfs.join("bin/busybox").is_file() {
272        return Err(hammer_core::Error::Other(anyhow::anyhow!(
273            "rootfs inválido: no encuentro {}/bin/busybox — ¿corriste el bootstrap de Alpine?",
274            rootfs.display()
275        )));
276    }
277    if !zig_dir.join("zig").exists() {
278        return Err(hammer_core::Error::Other(anyhow::anyhow!(
279            "zig no encontrado en {}/zig — ¿instalaste zig en .dev-fs/tools/zig?",
280            zig_dir.display()
281        )));
282    }
283    Ok(())
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn push_tail_keeps_last_n() {
292        let mut buf = VecDeque::new();
293        for i in 0..10 {
294            push_tail(&mut buf, format!("line {i}"), 3);
295        }
296        let got: Vec<_> = buf.iter().cloned().collect();
297        assert_eq!(got, vec!["line 7", "line 8", "line 9"]);
298    }
299
300    #[test]
301    fn build_failure_roundtrips_through_core_error() {
302        // El contrato que usa el bus: envolver en Error::Other y recuperar por downcast.
303        let bf = BuildFailure {
304            reason: "exit 2".into(),
305            log_tail: Some("undefined reference to `foo'".into()),
306        };
307        let err = hammer_core::Error::Other(anyhow::Error::new(bf));
308        let recovered = BuildFailure::from_error(&err).expect("debe recuperarse");
309        assert_eq!(recovered.reason, "exit 2");
310        assert_eq!(
311            recovered.log_tail.as_deref(),
312            Some("undefined reference to `foo'")
313        );
314    }
315
316    #[test]
317    fn from_error_none_for_plain_errors() {
318        let err = hammer_core::Error::Recipe("nope".into());
319        assert!(BuildFailure::from_error(&err).is_none());
320    }
321
322    fn fixture(deps: Vec<PathBuf>) -> Sandbox {
323        Sandbox {
324            rootfs: PathBuf::from("/rootfs"),
325            zig_dir: PathBuf::from("/zig"),
326            src_dir: PathBuf::from("/src-host"),
327            out_dir: PathBuf::from("/out-host"),
328            cache_dir: None,
329            deps,
330            env: vec![],
331        }
332    }
333
334    #[test]
335    fn no_deps_emits_single_overlay_src() {
336        // Compat hacia atrás: sin deps, una sola capa overlay (el rootfs) — sandbox idéntico al previo.
337        let args = fixture(vec![]).bwrap_args("true");
338        let n = args.iter().filter(|a| *a == "--overlay-src").count();
339        assert_eq!(n, 1, "sin deps ⇒ un único --overlay-src");
340        // El --tmp-overlay viene inmediatamente tras el rootfs.
341        let i = args.iter().position(|a| a == "--overlay-src").unwrap();
342        assert_eq!(args[i + 1], "/rootfs");
343        assert_eq!(args[i + 2], "--tmp-overlay");
344    }
345
346    #[test]
347    fn deps_stack_as_overlay_layers_under_rootfs() {
348        let args = fixture(vec![PathBuf::from("/dep/libcap"), PathBuf::from("/dep/zlib")])
349            .bwrap_args("true");
350        // rootfs + 2 deps = 3 capas overlay, todas antes del --tmp-overlay.
351        let srcs: Vec<&String> = args
352            .iter()
353            .enumerate()
354            .filter(|(_, a)| *a == "--overlay-src")
355            .map(|(i, _)| &args[i + 1])
356            .collect();
357        assert_eq!(srcs, vec!["/rootfs", "/dep/libcap", "/dep/zlib"]);
358        let to = args.iter().position(|a| a == "--tmp-overlay").unwrap();
359        let last_src = args.iter().rposition(|a| a == "--overlay-src").unwrap();
360        assert!(last_src < to, "todas las capas overlay van antes del --tmp-overlay");
361    }
362}