Files
takana/docs/11-bootstrap.md
T
SergioandClaude Opus 4.8 f17a8fa301 bootstrap: builder rootfs — ensambla el rebuild in-rootfs (Stage 2 pleno, §7)
El único sub-ítem que le quedaba a Stage 2: el rebuild *dentro* del rootfs. El
Stage 1 que booteamos es runtime (musl+busybox+hammerd+arje-zero), sin compilador
— no puede reconstruirse. El builder rootfs es Stage 1 + el toolchain adentro.

Implementa `hammer_bootstrap::builder_rootfs` (variante a pragmática, SDD 11 §7.2):
sobre el Stage 1 rootfs hidratado monta /toolchain (rootfs Alpine = 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 apunta HAMMER_ROOTFS=/toolchain, corre `bootstrap stage1` y compara stage1'
contra la referencia con `stage2 --verify` (lee SEED_HASH/SEED_KIND/REF_CONTENT
de /etc/hammer/rebuild.env).

- BuilderSpec/BuilderReport + builder_hash lógico (insumos: stage1+semilla+
  recetas+binario+tag toolchain+driver) — reproducible y auditable sin hashear el
  árbol Alpine; anota línea stage 2 en el manifiesto.
- link_or_copy_tree (hardlink-or-copy, sin chmod: no toca permisos del .dev-fs).
- El builder no se sella (toolchain Alpine no es content-addressed); se ensambla
  en out_dir para empaquetar como initramfs.
- CLI `hammer bootstrap builder --stage1 H --seed-hash H [--hammer-bin] [--toolchain]
  [--ref-content] [--work-cache] [--out]`. +4 tests (assemble, hash determinista,
  sin-ref, stage1 no sellado).

Validado contra el store real: Stage 1 73d7a9be… + semilla 3ce721ec… ⇒ builder
81dad3d9… (1.2 GB con el toolchain). Lo que queda es operacional: bootear en la
VM y correr rebuild-stage1 — runbook §8c documenta la receta (hammer estático
musl, initramfs, qemu -cpu Broadwell, --work-cache para rebuild offline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:32:14 +00:00

239 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SDD 11 — Bootstrap from-scratch (track posterior)
> **Estado:** diseño. Abre el *track posterior* del [SDD 10](10-roadmap.md): bajar de "hammer
> sobre Alpine" a "hammer sobre sí mismo". No bloquea ninguna Fase 06 (ya cerradas); reusa
> el laboratorio de build ([SDD 02](02-build-lab.md)) 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](adr/0002-alpine-first.md)): 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](09-trust-model.md).
## 2. Principios (heredados, no nuevos)
- **Todo es una receta sellada.** Cada pieza del bootstrap es un `Recipe` ([SDD 02 §1](02-build-lab.md))
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](adr/0006-pinned-commits.md)),
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](adr/0003-zig-cc-builder.md)): 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](adr/0007-arje-como-init-propio.md)) | 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](adr/0007-arje-como-init-propio.md)), 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](09-trust-model.md) §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](09-trust-model.md)): 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.
```rust
// 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
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](adr/0007-arje-como-init-propio.md)) 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](12-init-real.md) y **validado en QEMU**
(ver [runbook](runbooks/stage1-vm-boot.md)).
`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. El rebuild
in-rootfs es el sub-ítem que queda (corre en la VM destino).
`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
hammer bootstrap --all # las tres + reporte de reproducibilidad # ☐
```
## 6. Decisiones (fijadas en [ADR 0008](adr/0008-bootstrap-stages.md))
- **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 ya está operativa y probada: `of_tree` (content-hash
de bytes) + `verify_against`, y los 4 componentes reconstruyen **bit-idéntico** (ver
[runbook §8/§8b](runbooks/stage1-vm-boot.md)). Lo que falta es el **rebuild *dentro* del rootfs
usando sólo sus herramientas** — y ahí aparece el obstáculo real:
> 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.
### 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: el ensamblado del builder, hecho ✅
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 que queda es **operacional**:
bootear el builder en la VM y correr `rebuild-stage1` (ver [runbook §8c](runbooks/stage1-vm-boot.md)).
## 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.