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
Historia
Quién tocó qué
crudo
selfhost-verify: pieza 4 (bwrap swap) + materialización de build-deps en el lab
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}