bootstrap: seed como toolchain del sandbox (Lote 3) + decisiones ADR 0008

SeedSpec::toolchain_dir resuelve el dir del compilador dentro de la semilla
sellada, listo para que el sandbox lo bindee como /opt/zig; seed_build_config
deriva una BuildConfig que cross-compila con el seed pinned, no con el zig del
host. Stage 0 sigue siendo ingesta pura: el layout se resuelve al usarlo
(zig en la raíz o en el hijo versionado), con error claro si falta o es ambiguo.

Decisiones del Lote 4 registradas en ADR 0008 / SDD 11:
- coreutils+shell = busybox (balsa desechable; GPLv2 no contamina el final)
- init arje diferido (Stage 1 con init mínimo provisional; CRASHED real luego)
- layout de semilla: ingesta pura + resolución al usar

+5 tests (layouts: raíz / hijo versionado / no-sellada / ambiguo / build_config).
Workspace verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-11 00:28:29 +00:00
co-authored by Claude Opus 4.8
parent 1f1fae3508
commit 989e0d2c7f
3 changed files with 170 additions and 10 deletions
+143 -1
View File
@@ -14,7 +14,7 @@
pub mod manifest;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;
use hammer_build::download;
@@ -83,6 +83,70 @@ impl SeedSpec {
pub fn store_name(&self) -> String {
format!("seed-{}", self.kind.as_str())
}
/// Directorio del toolchain **dentro de la semilla sellada**, listo para que el sandbox lo
/// bindee como `/opt/zig`. Stage 0 sella el árbol del tarball tal cual (ingesta pura, sin
/// aplanar): este método lo resuelve al usarlo, localizando el dir que contiene el ejecutable
/// `zig` —en la raíz si el tarball lo trae plano, o en el único hijo si trae el típico
/// top-level versionado `zig-linux-x86_64-<ver>/`—. Falla si la semilla no está sellada o si
/// el ejecutable no aparece (o es ambiguo).
pub fn toolchain_dir(&self, store: &Store) -> Result<PathBuf> {
let base = store.path_of(&self.seed_hash(), &self.store_name());
if !base.is_dir() {
return Err(Error::Other(format!(
"semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero",
self.kind.as_str(),
base.display()
)));
}
match self.kind {
SeedKind::Zig => locate_tool_dir(&base, "zig"),
SeedKind::MuslCrossMake => Err(Error::Other(
"toolchain_dir para musl-cross-make aún no implementado (escotilla diferida)".into(),
)),
}
}
}
/// Localiza, dentro de `base`, el directorio que contiene **directamente** el ejecutable `tool`.
/// Mira la raíz primero y luego los hijos inmediatos; exige exactamente una coincidencia para no
/// elegir a ciegas entre layouts ambiguos.
fn locate_tool_dir(base: &Path, tool: &str) -> Result<PathBuf> {
if base.join(tool).is_file() {
return Ok(base.to_path_buf());
}
let mut hits = Vec::new();
for entry in std::fs::read_dir(base)? {
let path = entry?.path();
if path.is_dir() && path.join(tool).is_file() {
hits.push(path);
}
}
hits.sort();
match hits.len() {
1 => Ok(hits.pop().unwrap()),
0 => Err(Error::Other(format!(
"no encontré el ejecutable '{tool}' en la raíz ni en los hijos inmediatos de {}",
base.display()
))),
_ => Err(Error::Other(format!(
"ambiguo: '{tool}' aparece en varios directorios de {}: {hits:?}",
base.display()
))),
}
}
/// Deriva una [`hammer_build::BuildConfig`] desde `base` cuyo `zig_dir` apunta a la **semilla
/// sellada** en vez del toolchain del host: así las recetas del Stage 1 cross-compilan con el
/// toolchain fijado por hash (`seed_hash`), no con el `.dev-fs/tools/zig` de desarrollo. El resto
/// de la config (rootfs, work, cache) se hereda intacto.
pub fn seed_build_config(
base: &hammer_build::BuildConfig,
seed: &SeedSpec,
store: &Store,
) -> Result<hammer_build::BuildConfig> {
let zig_dir = seed.toolchain_dir(store)?;
Ok(hammer_build::BuildConfig { zig_dir, ..base.clone() })
}
/// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`.
@@ -309,4 +373,82 @@ mod tests {
"el staging debe quedar limpio tras el fallo"
);
}
/// Sella una semilla cuyo tarball coloca un `zig` ejecutable en `zig_rel` (relativo a la raíz
/// del árbol) y devuelve `(store, seed)` listos para resolver el toolchain.
fn seal_seed_with_zig_at(dir: &Path, zig_rel: &str) -> (Store, SeedSpec) {
let src = dir.join("src");
let zig = src.join(zig_rel);
std::fs::create_dir_all(zig.parent().unwrap()).unwrap();
std::fs::write(&zig, b"#!/bin/sh\necho fake zig\n").unwrap();
let tarball = dir.join("seed.tar.gz");
let st = Command::new("tar")
.args(["-czf"])
.arg(&tarball)
.arg("-C")
.arg(&src)
.arg(".")
.status()
.unwrap();
assert!(st.success());
let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
let store = Store::open(dir.join("store")).unwrap();
let seed = spec(&format!("file://{}", tarball.display()), &sha);
stage0(&seed, &store).unwrap();
(store, seed)
}
#[test]
fn toolchain_dir_finds_zig_in_versioned_subdir() {
let tmp = tempfile::tempdir().unwrap();
let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
let dir = seed.toolchain_dir(&store).unwrap();
assert!(dir.join("zig").is_file(), "el dir resuelto debe contener zig: {}", dir.display());
assert!(dir.ends_with("zig-linux-x86_64-0.13.0"), "debe elegir el hijo versionado: {}", dir.display());
}
#[test]
fn toolchain_dir_finds_zig_at_root() {
let tmp = tempfile::tempdir().unwrap();
let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig");
let dir = seed.toolchain_dir(&store).unwrap();
let base = store.path_of(&seed.seed_hash(), &seed.store_name());
assert_eq!(dir, base, "zig en la raíz ⇒ el dir es la raíz de la semilla");
}
#[test]
fn toolchain_dir_errors_when_unsealed() {
let tmp = tempfile::tempdir().unwrap();
let store = Store::open(tmp.path().join("store")).unwrap();
let seed = spec("file:///nope.tar", &"0".repeat(64)); // nunca sellada
let err = seed.toolchain_dir(&store).unwrap_err().to_string();
assert!(err.contains("no sellada"), "esperaba 'no sellada': {err}");
}
#[test]
fn toolchain_dir_errors_when_ambiguous() {
let tmp = tempfile::tempdir().unwrap();
// Dos hijos, cada uno con su `zig`: el resolutor no debe elegir a ciegas.
let (store, seed) = seal_seed_with_zig_at(tmp.path(), "a/zig");
let base = store.path_of(&seed.seed_hash(), &seed.store_name());
// El store sella read-only; relajamos permisos para inyectar el segundo `zig` del test.
let b = base.join("b");
let _ = std::process::Command::new("chmod").arg("-R").arg("u+w").arg(&base).status();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(b.join("zig"), b"x").unwrap();
let err = seed.toolchain_dir(&store).unwrap_err().to_string();
assert!(err.contains("ambiguo"), "esperaba 'ambiguo': {err}");
}
#[test]
fn seed_build_config_points_zig_dir_at_seed() {
let tmp = tempfile::tempdir().unwrap();
let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
let base = hammer_build::BuildConfig::defaults_for_store(store.root());
let cfg = seed_build_config(&base, &seed, &store).unwrap();
assert_eq!(cfg.zig_dir, seed.toolchain_dir(&store).unwrap());
// El resto de la config se hereda sin cambios.
assert_eq!(cfg.rootfs, base.rootfs);
assert_eq!(cfg.work_root, base.work_root);
}
}
+13 -7
View File
@@ -137,14 +137,20 @@ hammer bootstrap stage2 --verify
hammer bootstrap --all # las tres + reporte de reproducibilidad # ☐
```
## 6. Decisiones abiertas (fijadas en [ADR 0008](adr/0008-bootstrap-stages.md))
## 6. Decisiones (fijadas en [ADR 0008](adr/0008-bootstrap-stages.md))
- **Semilla primaria:** `zig` vs `musl-cross-make`. Inclinación: `zig` por hermeticidad
(ADR 0003), con `musl-cross-make` como escotilla por receta.
- **busybox vs toybox** para el userland mínimo (licencia vs cobertura de comandos).
- **Particionado/montaje** de `/store` y `/var/lib/hammer` en la imagen destino (track posterior
del roadmap).
- **Kernel:** importado pinned ahora; from-source después.
- **Semilla primaria:** `zig` por hermeticidad (ADR 0003), con `musl-cross-make` como escotilla
por receta.
- **coreutils+shell = `busybox`** (no toybox): el Stage 1 es una balsa desechable hacia el
userland nativo en Rust/Zig; se prioriza compatibilidad de flags y velocidad, y su GPLv2 no
contamina el sistema final. ✅
- **init `arje` diferido:** Stage 1 con init mínimo provisional; `arje` (y el `CRASHED` real) entran
como receta git pinned en un lote posterior. ✅
- **Layout de la semilla:** ingesta pura en Stage 0 + resolución del toolchain al usarlo
(`SeedSpec::toolchain_dir`, localiza `zig` en raíz o hijo versionado). ✅
- **Particionado/montaje** de `/store` y `/var/lib/hammer` en la imagen destino: track posterior
del roadmap. ☐
- **Kernel:** importado pinned ahora; from-source después. ☐
## 7. Hecho cuando
+14 -2
View File
@@ -19,8 +19,20 @@ mecanismo de build nuevo):
como una *fuente fijada por sha256*, y se sella en el store. El `ArtifactHash` se deriva de
la **identidad pinned** (clase + versión + sha256), no de rutas del host. **`zig` es la
semilla primaria**; `musl-cross-make` (gcc+musl) es escotilla detrás de la misma interfaz.
1. **Stage 1 — userland mínimo:** cross-compilado con Stage 0 (musl, busybox/toybox, el init
`arje` [ADR 0007](0007-arje-como-init-propio.md), `hammerd`), ensamblado en un rootfs sellado.
1. **Stage 1 — userland mínimo:** cross-compilado con Stage 0 (musl, **busybox**, init, `hammerd`),
ensamblado en un rootfs sellado. Decisiones de implementación fijadas al abrir el Lote 4:
- **coreutils+shell = `busybox`** (no toybox): el Stage 1 es una *balsa desechable* —se sustituye
por el userland multi-call nativo en Rust/Zig más adelante—, así que se optimiza la velocidad
y la compatibilidad de flags POSIX en el pipeline (mismo comportamiento que el rootfs Alpine de
desarrollo). Su GPLv2 queda *atrapada* en una etapa temprana del manifiesto; no contamina el
sistema final mutable.
- **init `arje` diferido:** Stage 1 arranca con musl+busybox+`hammerd` y un init mínimo
provisional. `arje` ([ADR 0007](0007-arje-como-init-propio.md)) —y con él el `CRASHED` real
de la Fase 5— se integra como receta git pinned en un lote posterior, sin retrabajo.
- **layout de la semilla:** Stage 0 sella el árbol del tarball **tal cual** (ingesta pura, no
aplana); el directorio del toolchain se resuelve **al usarlo** localizando el ejecutable `zig`
en la raíz o en su único hijo versionado (`SeedSpec::toolchain_dir`). Stage 0 no adivina
layouts y el sandbox recibe un `zig_dir` correcto sea cual sea la forma del tarball.
2. **Stage 2 — rebuild nativo:** dentro del rootfs Stage 1, recompilar con las herramientas de
Stage 1 y comparar hashes (`stage1` vs `stage1'`). Iguales ⇒ auto-alojado y reproducible.