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>
239 lines
14 KiB
Markdown
239 lines
14 KiB
Markdown
# 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 0–6 (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.
|