Files
hammer/docs/11-bootstrap.md
T
sergioandClaude Opus 4.8 7bc2ee6019 builder: --swap del toolchain — montar make hammer sobre Alpine (variante b, pieza 1)
Segundo paso del auto-alojamiento *puro* (SDD 11 §7.2b): tras sellar GNU make
4.4.1 desde fuente (commit 749ea9e), ahora el builder puede *usarlo*. El swap
reemplaza una a una las piezas que el builder toma de Alpine por recetas hammer,
con Stage 2 reverificando que el byte-output no cambia.

- `BuilderSpec.swaps: Vec<ToolchainSwap{name,artifact,rel_path}>`: monta el binario
  sellado sobre el path Alpine en /toolchain (estático musl ⇒ sin shim del loader).
- `swaps_digest` (ordenado por nombre) entra al hash lógico del builder ⇒ la
  procedencia deja de ser "todo Alpine" y se vuelve auditable para el log de
  transparencia. Vacío ⇒ digest "" (compat hacia atrás: builder pura-Alpine
  conserva su hash previo).
- CLI: `hammer bootstrap builder --swap make=<hash>[:rel_path]` (repetible;
  rel_path por defecto usr/bin/<name>).
- selfhost-verify.sh: opt-in `SWAP_MAKE=1` (construye recipes/make.toml y lo
  swapea) + `SWAPS="name=hash …"` para swaps extra. Default off ⇒ corrida
  pura-Alpine idéntica a la baseline conocida-buena.

Validado en el host contra el store real: pura `b3:8a370f5d…` vs swapped
`b3:e7e2282c…`, y /toolchain/usr/bin/make queda hardlinkeado al artefacto
fbad44ac… (ELF estático, no el dinámico de Alpine). 3 tests nuevos
(swap aplica, hash cambia, error si falta el binario). 37 tests verdes.

Pendiente: correr Stage 2 in-VM con el swap y confirmar que of_tree(stage1') == ref
(el make hammer compila los 4/4 idéntico al de Alpine ⇒ toolchain intercambiable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:38:04 -04:00

17 KiB
Raw Blame History

SDD 11 — Bootstrap from-scratch (track posterior)

Estado: diseño. Abre el track posterior del SDD 10: bajar de "hammer sobre Alpine" a "hammer sobre sí mismo". No bloquea ninguna Fase 06 (ya cerradas); reusa el laboratorio de build (SDD 02) sin retrabajo.

1. El problema: el cordón umbilical

Hoy el userland que hammer muta es el de Alpine (musl + busybox ya cocidos). Eso fue deliberado (ADR 0002): validar la capa AI-nativa antes de gastar meses en un bootstrap. Validada esa capa, el track posterior responde una sola pregunta:

¿Puede hammer compilar el sistema completo que ejecuta, sin pedirle nada al host más que un toolchain semilla fijado por hash?

Si la respuesta es sí y el resultado es bit-reproducible, hammer deja de heredar la confianza de Alpine y la deriva de su propio log de transparencia.

2. Principios (heredados, no nuevos)

  • Todo es una receta sellada. Cada pieza del bootstrap es un Recipe (SDD 02 §1) cuyo artefacto se direcciona por hash en el Store. El bootstrap es un subgrafo del grafo de dependencias que ya construye el lab, no un mecanismo aparte.
  • Nada del host entra al hash. El único insumo externo es el toolchain semilla, y entra como un artefacto importado con su sha256 fijado (ADR 0006), igual que un tarball upstream. El host aporta un kernel para correr procesos y nada más.
  • zig cc es la semilla primaria (ADR 0003): un binario hermético = compilador C/C++ + musl + headers, cross al target. Escotilla musl-cross-make (gcc+musl) detrás de la misma interfaz de receta (compiler = "gcc") para los paquetes con gcc-ismos.

3. Las tres etapas

        host (sólo kernel + seed pinned)
                 │
   ┌─────────────▼─────────────┐
   │ Stage 0 — toolchain semilla│  zig (o musl-cross-make) ingerido al store,
   │   sellado por hash         │  fijado por sha256. Cross → x86_64-linux-musl.
   └─────────────┬─────────────┘
                 │ artifact_hash(seed)  ← dependencia de build de todo lo demás
   ┌─────────────▼─────────────┐
   │ Stage 1 — userland mínimo  │  musl, busybox/toybox, init arje, hammerd,
   │   cross-compilado          │  cross-compilados CON Stage 0. Sale un rootfs sellado.
   └─────────────┬─────────────┘
                 │ rootfs_hash(stage1)
   ┌─────────────▼─────────────┐
   │ Stage 2 — rebuild nativo   │  DENTRO del rootfs Stage 1 (bwrap/chroot/VM),
   │   y verificación           │  recompila toolchain+userland con las herramientas
   └────────────────────────────┘  de Stage 1, NO las del host. Compara hashes.

Stage 0 — Toolchain semilla

Modelamos el toolchain semilla como una fuente fijada, no como un build: un Source de tipo tarball con sha256 pinned (zig-<ver>-linux-x86_64.tar.xz, o el tarball de musl-cross-make). hammer-bootstrap lo descarga (fuera del sandbox, como hammer_build::download ya hace para patch_url/content_url), verifica el sha256 antes de sellarlo, y lo deposita en el store con un artifact_hash derivado de ese sha256. A partir de ahí el resto del bootstrap depende del hash del store, no de la ruta del host.

Salida: seed_hash (artefacto del toolchain en el store).

Stage 1 — Userland mínimo

Un conjunto de recetas cuyo [deps].build incluye seed_hash. El conjunto mínimo que arranca y se administra solo:

Pieza Por qué Compilador
musl libc del target zig cc (estático)
busybox o toybox coreutils + shell zig cc (estático)
arje init: PID 1 + supervisión (ADR 0007) toolchain Rust
hammerd diario + bus de agente + watcher, servicio supervisado por arje toolchain Rust

Todo se enlaza estático o con rpath controlado (sin depender del loader del host). El resultado se ensambla en un rootfs (árbol FHS) que a su vez se sella como un artefacto del store (stage1_hash). El init no se escribe de cero: se adopta arje (ADR 0007), cuyo PID 1 con supervisión real entrega el CRASHED real que la Fase 5 dejó diferido — hammerd corre como servicio bajo arje.

El kernel no es el foco de este hito: se importa pinned (como la semilla) para poder correr el rootfs. Construirlo desde fuente es un sub-ítem posterior, ortogonal al auto-alojamiento del userland.

Stage 2 — Rebuild nativo y verificación

El corte del cordón. Dentro del rootfs Stage 1 (primero vía bwrap/chroot, luego en la VM destino), se reconstruyen Stage 0' y Stage 1' usando sólo las herramientas de Stage 1. Se compara:

hash(stage1)   vs   hash(stage1')
  • Iguales ⇒ el sistema se compila a sí mismo bit a bit: reproducible y auto-alojado. Hito cumplido.
  • Distintos ⇒ hay no-determinismo (timestamps, paths embebidos, orden de enlace). Se caza y se elimina; es exactamente el trabajo que SDD 09 §2 exige.

4. Manifiesto de bootstrap (embrión del log de transparencia)

Cada etapa anota una línea (stage, recipe_hash, artifact_hash, seed_hash, ts) en un bootstrap.json versionado (en la raíz del store). Ese manifiesto es la semilla del log de transparencia (SDD 09 §4): un tercero reproduce el bootstrap y verifica que sus hashes coinciden con los publicados, sin confiar en el binario del autor.

Implementado en hammer-bootstrap::manifest (BootstrapManifest / StageEntry): append-only e idempotente (de-dup por (stage, artifact_hash)), escritura atómica, y ts que no entra en ningún hash (dos reproducciones del mismo bootstrap difieren a lo sumo en ese campo). Stage 0 ya anota su línea: recipe_hash = None (la semilla se ingiere, no se compila) y artifact_hash == seed_hash (la semilla es a la vez insumo y producto).

5. Interfaz

Nuevo crate hammer-bootstrap (orquestador) sobre hammer-build + hammer-core::Store. No introduce mecanismo nuevo de build: encadena recetas y persiste el manifiesto.

// hammer-bootstrap
pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash>;            // ✅ toolchain semilla
pub fn stage1(spec: &Stage1Spec, cfg: &BuildConfig, store: &Store)
    -> Result<RootfsHash>;                                                       // ◑ musl+busybox+hammerd; init arje ☐
pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result<VerifyReport>;        // ✅ ancla+verifica; rebuild in-rootfs en VM ✓ REPRODUCIBLE
pub fn all(seed: &SeedSpec, store: &Store) -> Result<BootstrapManifest>;          // ☐

stage1 cross-compila musl + busybox + hammerd con la semilla (no el zig del host), ensambla un rootfs FHS por hardlink y lo sella; el RootfsHash se deriva del contenido (hashes de componentes + init), no del árbol en disco, así que es reproducible. El PID 1 provisional es el init de busybox vía /etc/inittab, que arranca hammerd como servicio respawn. hammerd es una receta Cargo (repo pinned + deps vendoreadas en el fetch para build --offline). arje (ADR 0007) ya tiene su receta puente recipes/arje-zero.toml (Cargo, fuente = monorepo tawasuyu pinned al commit de la migración del CAS a BLAKE3, -p arje-zero): prueba que el lab construye el init, pero todavía no es PID 1. El paso "init real" —arje-zero como PID 1 del rootfs con su seed card, arje-bus y hammerd como Card de servicio supervisada, que entrega el CRASHED real— está diseñado en el SDD 12 y validado en QEMU (ver runbook).

stage2 ancla el content-hash del rootfs de Stage 1 (ArtifactHash::of_tree — los bytes reales, no el hash input-addressed del store) como la referencia a reproducir, y la anota en el manifiesto. verify_against la compara con un rebuild stage1' producido dentro del rootfs (en la VM, recompilando con sólo las herramientas de Stage 1): iguales ⇒ auto-alojado bit a bit. El determinismo necesario lo dan las rutas fijas (/src) + SOURCE_DATE_EPOCH del sandbox + CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 (impuesto por el sandbox tras cazar el no-determinismo del codegen paralelo de rustc en arje-zero). El rebuild in-rootfs ya se ejecutó end-to-end: KVM=1 MEM=24576 ./scripts/selfhost-verify.sh reconstruyó los 4/4 dentro del rootfs y su of_tree(stage1') = b3:0039b2b9… igualó la referencia ⇒ ✓ REPRODUCIBLE: stage1' == stage1 (ver runbook §8).

SeedSpec { kind, version, url, sha256 } es la identidad pinned de la semilla; seed_hash() deriva el ArtifactHash de (kind, version, sha256) — no del url ni del host, así que cualquier espejo del mismo tarball produce el mismo artefacto.

CLI (implementado lo de Stage 0; el resto pendiente):

hammer bootstrap stage0 --url URL --sha256 HEX --version V [--seed zig|musl-cross-make]   # ✅
hammer bootstrap stage1 --seed-hash HASH [--seed zig] [--recipes DIR]                      # ✅ booteado en QEMU
hammer bootstrap stage2 --rootfs HASH [--verify CONTENT_HASH]                              # ✅ ancla+verifica; rebuild en VM ✓ REPRODUCIBLE
hammer bootstrap --all                # las tres + reporte de reproducibilidad             # ☐

6. Decisiones (fijadas en ADR 0008)

  • 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. Auto-alojamiento: el builder rootfs (camino a Stage 2 pleno)

La verificación de reproducibilidad de Stage 2 está operativa, probada y ejecutada end-to-end: of_tree (content-hash de bytes) + verify_against, los 4 componentes reconstruyen bit-idéntico, y el rebuild dentro del rootfs ya corrió en la VM dando ✓ REPRODUCIBLE (variante (a) abajo; ver runbook §8). El obstáculo que hubo que resolver para llegar ahí:

El Stage 1 que booteamos es un runtime (musl+busybox+hammerd+arje-zero): no trae compilador (ni zig, ni make/autotools, ni cargo/rust, ni linux-headers, ni bwrap). No puede reconstruir nada. El rebuild in-rootfs exige un builder rootfs: Stage 1 + el toolchain adentro.

7.1 Qué necesita el builder rootfs

Para correr hammer bootstrap stage1 dentro de sí mismo: el binario hammer (estático), las recetas, la semilla (zig, ya un artefacto sellado), make+autotools, cargo+rust, linux-headers, y bwrap (el lab anida un sandbox ⇒ userns sin privilegios dentro del chroot/VM).

7.2 Dos niveles de pureza (fasable)

  • (a) Builder pragmático — el toolchain entra al rootfs desde Alpine (apk), no construido por hammer. Demuestra el mecanismo (rebuild in-rootfs + of_tree(stage1)==of_tree(stage1')) y cierra la reproducibilidad end-to-end, sin ser aún auto-alojamiento puro.
  • (b) Auto-alojamiento puro — el toolchain lo construye hammer desde fuente (recetas para make/autotools, y el gran tramo: rust/llvm o un rustc bootstrappeado). Es el end-state; el más caro (construir rust desde cero). Se llega incrementalmente, reemplazando una a una las piezas Alpine por componentes hammer, con Stage 2 verificando cada paso.
    • Pieza 1 — GNU make 4.4.1 (recipes/make.toml): arranque de (b). Build estático musl con zig cc (mismo camino que grep: tarball release con configure → AutoconfReady), sellado en b3:fbad44ac… y reproducible bit-a-bit (dos builds en stores distintos ⇒ árbol idéntico). Es la herramienta base de toda receta autotools. Swap implementado: BuilderSpec.swaps (ToolchainSwap { name, artifact, rel_path }) monta el binario sellado sobre el path Alpine en /toolchain (estático musl ⇒ sin shim del loader) y lo ancla en el hash lógico del builder (swaps_digest, ordenado por nombre): la procedencia deja de ser "todo Alpine" y se vuelve auditable para el log de transparencia. CLI: hammer bootstrap builder --swap make=<hash>[:rel] (repetible; rel_path por defecto usr/bin/<name>). En el host: pura b3:8a370f5d… vs swapped b3:e7e2282c…, y /toolchain/usr/bin/make queda hardlinkeado al artefacto fbad44ac…. El selfhost-verify.sh lo expone opt-in con SWAP_MAKE=1 (construye make y lo swapea). Pendiente: correr Stage 2 in-VM con el swap y confirmar que of_tree(stage1') no cambia (el make hammer compila los 4/4 idéntico al de Alpine ⇒ el toolchain es intercambiable sin perturbar el byte-output).

7.3 Enganche con lo ya hecho

stage2 ya ancla of_tree(stage1); el builder produce stage1' y verify_against emite el veredicto. El determinismo necesario está cubierto (paths fijos /src, SOURCE_DATE_EPOCH, locks deterministas). El sub-ítem era: ensamblar el builder (variante de assemble_rootfs que hidrata el toolchain) y correr el rebuild anidado en la VM.

7.4 Estado: builder ensamblado + rebuild in-VM ejecutado ✓ REPRODUCIBLE

El ensamblado del builder está implementado (hammer_bootstrap::builder_rootfs, variante a):

hammer bootstrap builder --stage1 <H> --seed-hash <H> \
    [--toolchain .dev-fs/alpine] [--toolchain-tag alpine-3.23.4] \
    [--hammer-bin <static-musl-hammer>] [--ref-content of_tree(stage1)] \
    [--work-cache work/] [--out work/builder-rootfs]

Sobre el rootfs de Stage 1 (hidratado como base) monta: /toolchain (el rootfs Alpine, el sandbox de build), /store con la semilla replicada (el hammer de adentro resuelve zig por hash), /usr/bin/hammer + /etc/hammer/recipes, y el driver /usr/bin/rebuild-stage1 que lee /etc/hammer/rebuild.env (SEED_HASH/SEED_KIND/REF_CONTENT), apunta HAMMER_ROOTFS=/toolchain, corre hammer bootstrap stage1 y compara stage1' contra la referencia con stage2 --verify.

El builder no se sella (su /toolchain Alpine no es content-addressed); se ensambla en un out_dir para empaquetar como initramfs. Su identidad sí es reproducible: un ArtifactHash lógico de sus insumos (stage1 + semilla + recetas + binario + tag del toolchain + el driver), anotado en el manifiesto (línea stage 2). Validado contra el store real (Stage 1 73d7a9be… + semilla 3ce721ec… ⇒ builder 81dad3d9…, 1.2 GB con el toolchain). El paso operacional ya se ejecutó: scripts/selfhost-verify.sh empaqueta el builder, lo bootea con KVM y corre rebuild-stage1 no-interactivo; in-VM reconstruyó los 4/4 (arje-zero cu=1 en ~27 min) y of_tree(stage1') = b3:0039b2b9… igualó la referencia ⇒ ✓ REPRODUCIBLE: stage1' == stage1. Lo que queda es la variante (b), el auto-alojamiento puro (toolchain construido por hammer desde fuente, §7.2). Arrancada: la pieza 1, GNU make 4.4.1 (recipes/make.toml), ya se construye desde fuente con el lab — estática, reproducible bit-a-bit (§7.2b). Ver runbook §8.

8. Hecho cuando

hammer bootstrap --all produce un rootfs que, ejecutado en la VM destino, reconstruye su propio toolchain y userland con hashes idénticos a los publicados, sin que ninguna herramienta del host entre al resultado. Ese día Alpine deja de ser una dependencia y pasa a ser, a lo sumo, una conveniencia de desarrollo.

Estado (2026-06-12): el mecanismo está demostrado end-to-end con la variante (a) — el rootfs reconstruye su userland in-VM con of_tree idéntico (✓ REPRODUCIBLE, §7.4). Falta sólo la variante (b): que el toolchain dentro del builder lo construya hammer desde fuente (no provenga de Alpine), reemplazando una a una las piezas con Stage 2 verificando cada paso. Ese es el corte pleno del cordón.