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…
⎇ 6db3e3e302bd750d73eaa5062fd5c8ca726ddadd
crates/hammer-build/src/sandbox.rs
Historia
Quién tocó qué
crudo
Fase 5: BuildFailed.log_tail con la cola real del log del sandbox
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}