1# SDD 11 — Bootstrap from-scratch (track posterior)
2
3> **Estado:** diseño. Abre el *track posterior* del [SDD 10](10-roadmap.md): bajar de "hammer
4> sobre Alpine" a "hammer sobre sí mismo". No bloquea ninguna Fase 0–6 (ya cerradas); reusa
5> el laboratorio de build ([SDD 02](02-build-lab.md)) sin retrabajo.
6
7## 1. El problema: el cordón umbilical
8
9Hoy el userland que `hammer` muta es el de Alpine (musl + busybox ya cocidos). Eso fue
10deliberado ([ADR 0002](adr/0002-alpine-first.md)): validar la capa AI-nativa antes de gastar
11meses en un bootstrap. Validada esa capa, el track posterior responde una sola pregunta:
12
13> **¿Puede `hammer` compilar el sistema completo que ejecuta, sin pedirle nada al host más
14> que un toolchain semilla fijado por hash?**
15
16Si la respuesta es sí *y* el resultado es bit-reproducible, hammer deja de heredar la
17confianza de Alpine y la deriva de su propio [log de transparencia](09-trust-model.md).
18
19## 2. Principios (heredados, no nuevos)
20
21- **Todo es una receta sellada.** Cada pieza del bootstrap es un `Recipe` ([SDD 02 §1](02-build-lab.md))
22 cuyo artefacto se direcciona por hash en el `Store`. El bootstrap es un *subgrafo* del grafo
23 de dependencias que ya construye el lab, no un mecanismo aparte.
24- **Nada del host entra al hash.** El único insumo externo es el **toolchain semilla**, y entra
25 como un artefacto importado con su sha256 **fijado** ([ADR 0006](adr/0006-pinned-commits.md)),
26 igual que un tarball upstream. El host aporta un kernel para correr procesos y nada más.
27- **`zig cc` es la semilla primaria** ([ADR 0003](adr/0003-zig-cc-builder.md)): un binario
28 hermético = compilador C/C++ + musl + headers, cross al target. Escotilla `musl-cross-make`
29 (gcc+musl) detrás de la misma interfaz de receta (`compiler = "gcc"`) para los paquetes con
30 gcc-ismos.
31
32## 3. Las tres etapas
33
34```
35 host (sólo kernel + seed pinned)
36 │
37 ┌─────────────▼─────────────┐
38 │ Stage 0 — toolchain semilla│ zig (o musl-cross-make) ingerido al store,
39 │ sellado por hash │ fijado por sha256. Cross → x86_64-linux-musl.
40 └─────────────┬─────────────┘
41 │ artifact_hash(seed) ← dependencia de build de todo lo demás
42 ┌─────────────▼─────────────┐
43 │ Stage 1 — userland mínimo │ musl, busybox/toybox, init arje, hammerd,
44 │ cross-compilado │ cross-compilados CON Stage 0. Sale un rootfs sellado.
45 └─────────────┬─────────────┘
46 │ rootfs_hash(stage1)
47 ┌─────────────▼─────────────┐
48 │ Stage 2 — rebuild nativo │ DENTRO del rootfs Stage 1 (bwrap/chroot/VM),
49 │ y verificación │ recompila toolchain+userland con las herramientas
50 └────────────────────────────┘ de Stage 1, NO las del host. Compara hashes.
51```
52
53### Stage 0 — Toolchain semilla
54
55Modelamos el toolchain semilla como una **fuente fijada**, no como un build: un `Source` de
56tipo tarball con `sha256` pinned (`zig-<ver>-linux-x86_64.tar.xz`, o el tarball de
57`musl-cross-make`). `hammer-bootstrap` lo descarga (fuera del sandbox, como `hammer_build::download`
58ya hace para `patch_url`/`content_url`), verifica el sha256 **antes** de sellarlo, y lo deposita
59en el store con un `artifact_hash` derivado de ese sha256. A partir de ahí el resto del
60bootstrap depende del *hash del store*, no de la ruta del host.
61
62Salida: `seed_hash` (artefacto del toolchain en el store).
63
64### Stage 1 — Userland mínimo
65
66Un conjunto de recetas cuyo `[deps].build` incluye `seed_hash`. El conjunto mínimo que arranca
67y se administra solo:
68
69| Pieza | Por qué | Compilador |
70|---|---|---|
71| `musl` | libc del target | zig cc (estático) |
72| `busybox` o `toybox` | coreutils + shell | zig cc (estático) |
73| `arje` | init: PID 1 + supervisión ([ADR 0007](adr/0007-arje-como-init-propio.md)) | toolchain Rust |
74| `hammerd` | diario + bus de agente + watcher, **servicio supervisado por arje** | toolchain Rust |
75
76Todo se enlaza estático o con rpath controlado (sin depender del loader del host). El resultado
77se ensambla en un **rootfs** (árbol FHS) que a su vez se sella como un artefacto del store
78(`stage1_hash`). El init **no se escribe de cero**: se adopta `arje`
79([ADR 0007](adr/0007-arje-como-init-propio.md)), cuyo PID 1 con supervisión real entrega el
80`CRASHED` real que la Fase 5 dejó diferido — `hammerd` corre como servicio bajo arje.
81
82> El **kernel** no es el foco de este hito: se importa pinned (como la semilla) para poder
83> correr el rootfs. Construirlo desde fuente es un sub-ítem posterior, ortogonal al
84> auto-alojamiento del userland.
85
86### Stage 2 — Rebuild nativo y verificación
87
88El corte del cordón. Dentro del rootfs Stage 1 (primero vía `bwrap`/`chroot`, luego en la VM
89destino), se reconstruyen Stage 0' y Stage 1' usando **sólo** las herramientas de Stage 1.
90Se compara:
91
92```
93hash(stage1) vs hash(stage1')
94```
95
96- **Iguales** ⇒ el sistema se compila a sí mismo bit a bit: reproducible y auto-alojado. Hito
97 cumplido.
98- **Distintos** ⇒ hay no-determinismo (timestamps, paths embebidos, orden de enlace). Se
99 caza y se elimina; es exactamente el trabajo que [SDD 09](09-trust-model.md) §2 exige.
100
101## 4. Manifiesto de bootstrap (embrión del log de transparencia) ✅
102
103Cada etapa anota una línea `(stage, recipe_hash, artifact_hash, seed_hash, ts)` en un
104`bootstrap.json` versionado (en la raíz del store). Ese manifiesto **es** la semilla del log de
105transparencia ([SDD 09 §4](09-trust-model.md)): un tercero reproduce el bootstrap y verifica que
106sus hashes coinciden con los publicados, sin confiar en el binario del autor.
107
108Implementado en `hammer-bootstrap::manifest` (`BootstrapManifest` / `StageEntry`): append-only e
109**idempotente** (de-dup por `(stage, artifact_hash)`), escritura atómica, y `ts` que **no** entra
110en ningún hash (dos reproducciones del mismo bootstrap difieren a lo sumo en ese campo). Stage 0
111ya anota su línea: `recipe_hash = None` (la semilla se ingiere, no se compila) y
112`artifact_hash == seed_hash` (la semilla es a la vez insumo y producto).
113
114## 5. Interfaz
115
116Nuevo crate `hammer-bootstrap` (orquestador) sobre `hammer-build` + `hammer-core::Store`.
117No introduce mecanismo nuevo de build: encadena recetas y persiste el manifiesto.
118
119```rust
120// hammer-bootstrap
121pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash>; // ✅ toolchain semilla
122pub fn stage1(spec: &Stage1Spec, cfg: &BuildConfig, store: &Store)
123 -> Result<RootfsHash>; // ◑ musl+busybox+hammerd; init arje ☐
124pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result<VerifyReport>; // ✅ ancla+verifica; rebuild in-rootfs en VM ✓ REPRODUCIBLE
125pub fn all(seed: &SeedSpec, store: &Store) -> Result<BootstrapManifest>; // ☐
126```
127
128`stage1` cross-compila `musl` + `busybox` + `hammerd` con la semilla (no el zig del host),
129ensambla un rootfs FHS por hardlink y lo sella; el `RootfsHash` se deriva del **contenido** (hashes
130de componentes + init), no del árbol en disco, así que es reproducible. El PID 1 provisional es el
131`init` de busybox vía `/etc/inittab`, que arranca `hammerd` como servicio respawn. `hammerd` es una
132receta Cargo (repo pinned + deps vendoreadas en el fetch para build `--offline`). `arje`
133([ADR 0007](adr/0007-arje-como-init-propio.md)) ya tiene su **receta puente** `recipes/arje-zero.toml`
134(Cargo, fuente = monorepo tawasuyu pinned al commit de la migración del CAS a BLAKE3, `-p arje-zero`):
135prueba que el lab construye el init, pero todavía **no** es PID 1. El paso "init real" —arje-zero
136como PID 1 del rootfs con su seed card, `arje-bus` y hammerd como Card de servicio supervisada, que
137entrega el `CRASHED` real— está diseñado en el [SDD 12](12-init-real.md) y **validado en QEMU**
138(ver [runbook](runbooks/stage1-vm-boot.md)).
139
140`stage2` ancla el **content-hash** del rootfs de Stage 1 (`ArtifactHash::of_tree` — los bytes
141reales, no el hash input-addressed del store) como la referencia a reproducir, y la anota en el
142manifiesto. `verify_against` la compara con un rebuild `stage1'` producido **dentro** del rootfs (en
143la VM, recompilando con sólo las herramientas de Stage 1): iguales ⇒ auto-alojado bit a bit. El
144determinismo necesario lo dan las rutas fijas (`/src`) + `SOURCE_DATE_EPOCH` del sandbox +
145`CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1` (impuesto por el sandbox tras cazar el no-determinismo del
146codegen paralelo de rustc en `arje-zero`). **El rebuild in-rootfs ya se ejecutó end-to-end**: `KVM=1
147MEM=24576 ./scripts/selfhost-verify.sh` reconstruyó los 4/4 dentro del rootfs y su
148`of_tree(stage1') = b3:0039b2b9…` igualó la referencia ⇒ `✓ REPRODUCIBLE: stage1' == stage1`
149(ver [runbook](runbooks/stage1-vm-boot.md) §8).
150
151`SeedSpec { kind, version, url, sha256 }` es la identidad pinned de la semilla; `seed_hash()`
152deriva el `ArtifactHash` de `(kind, version, sha256)` — no del `url` ni del host, así que
153cualquier espejo del mismo tarball produce el mismo artefacto.
154
155CLI (implementado lo de Stage 0; el resto pendiente):
156
157```
158hammer bootstrap stage0 --url URL --sha256 HEX --version V [--seed zig|musl-cross-make] # ✅
159hammer bootstrap stage1 --seed-hash HASH [--seed zig] [--recipes DIR] # ✅ booteado en QEMU
160hammer bootstrap stage2 --rootfs HASH [--verify CONTENT_HASH] # ✅ ancla+verifica; rebuild en VM ✓ REPRODUCIBLE
161hammer bootstrap --all # las tres + reporte de reproducibilidad # ☐
162```
163
164## 6. Decisiones (fijadas en [ADR 0008](adr/0008-bootstrap-stages.md))
165
166- **Semilla primaria:** `zig` por hermeticidad (ADR 0003), con `musl-cross-make` como escotilla
167 por receta. ✅
168- **coreutils+shell = `busybox`** (no toybox): el Stage 1 es una balsa desechable hacia el
169 userland nativo en Rust/Zig; se prioriza compatibilidad de flags y velocidad, y su GPLv2 no
170 contamina el sistema final. ✅
171- **init `arje` diferido:** Stage 1 con init mínimo provisional; `arje` (y el `CRASHED` real) entran
172 como receta git pinned en un lote posterior. ✅
173- **Layout de la semilla:** ingesta pura en Stage 0 + resolución del toolchain al usarlo
174 (`SeedSpec::toolchain_dir`, localiza `zig` en raíz o hijo versionado). ✅
175- **Particionado/montaje** de `/store` y `/var/lib/hammer` en la imagen destino: track posterior
176 del roadmap. ☐
177- **Kernel:** importado pinned ahora; from-source después. ☐
178
179## 7. Auto-alojamiento: el builder rootfs (camino a Stage 2 pleno)
180
181La verificación de reproducibilidad de Stage 2 está operativa, probada y **ejecutada end-to-end**:
182`of_tree` (content-hash de bytes) + `verify_against`, los 4 componentes reconstruyen
183**bit-idéntico**, y el **rebuild *dentro* del rootfs** ya corrió en la VM dando `✓ REPRODUCIBLE`
184(variante (a) abajo; ver [runbook §8](runbooks/stage1-vm-boot.md)). El obstáculo que hubo que
185resolver para llegar ahí:
186
187> El Stage 1 que booteamos es un **runtime** (musl+busybox+hammerd+arje-zero): **no trae compilador**
188> (ni `zig`, ni `make`/autotools, ni `cargo`/rust, ni `linux-headers`, ni `bwrap`). No puede
189> reconstruir nada. El rebuild in-rootfs exige un **builder rootfs**: Stage 1 + el toolchain adentro.
190
191### 7.1 Qué necesita el builder rootfs
192
193Para correr `hammer bootstrap stage1` **dentro** de sí mismo: el binario `hammer` (estático), las
194recetas, la **semilla** (`zig`, ya un artefacto sellado), `make`+autotools, `cargo`+rust,
195`linux-headers`, y `bwrap` (el lab anida un sandbox ⇒ userns sin privilegios dentro del chroot/VM).
196
197### 7.2 Dos niveles de pureza (fasable)
198
199- **(a) Builder pragmático** — el toolchain entra al rootfs **desde Alpine** (apk), no construido por
200 hammer. Demuestra el **mecanismo** (rebuild in-rootfs + `of_tree(stage1)==of_tree(stage1')`) y
201 cierra la reproducibilidad *end-to-end*, sin ser aún auto-alojamiento puro.
202- **(b) Auto-alojamiento puro** — el toolchain lo **construye hammer desde fuente** (recetas para
203 make/autotools, y el gran tramo: rust/llvm o un rustc bootstrappeado). Es el end-state; el más
204 caro (construir rust desde cero). Se llega **incrementalmente**, reemplazando una a una las piezas
205 Alpine por componentes hammer, con Stage 2 verificando cada paso.
206 - **Pieza 1 — GNU make `4.4.1`** (`recipes/make.toml`): arranque de (b). Build estático musl con
207 `zig cc` (mismo camino que `grep`: tarball release con `configure` → AutoconfReady), sellado en
208 `b3:fbad44ac…` y **reproducible bit-a-bit** (dos builds en stores distintos ⇒ árbol idéntico).
209 Es la herramienta base de toda receta autotools. **Swap implementado:** `BuilderSpec.swaps`
210 (`ToolchainSwap { name, artifact, rel_path }`) monta el binario sellado sobre el path Alpine en
211 `/toolchain` (estático musl ⇒ sin shim del loader) y lo ancla en el **hash lógico** del builder
212 (`swaps_digest`, ordenado por nombre): la procedencia deja de ser "todo Alpine" y se vuelve
213 auditable para el log de transparencia. CLI: `hammer bootstrap builder --swap make=<hash>[:rel]`
214 (repetible; `rel_path` por defecto `usr/bin/<name>`). En el host: pura `b3:8a370f5d…` vs swapped
215 `b3:e7e2282c…`, y `/toolchain/usr/bin/make` queda hardlinkeado al artefacto `fbad44ac…`. El
216 `selfhost-verify.sh` lo expone opt-in con `SWAP_MAKE=1` (construye make y lo swapea). **Validado en
217 host:** con make de hammer los 4/4 reproducen `of_tree=9adefb82…` (idéntico al baseline determinista).
218 - **Pieza 2 — busybox `1.36.1`** (ya es componente de Stage 1, `recipes/busybox.toml`): provee el
219 `sh`+coreutils que el build usa del `/toolchain`. El `/toolchain` de Alpine es **mixto** — busybox
220 para `sh/sed/grep/awk/tar/find` (symlinks → `/bin/busybox`) y GNU coreutils para `cp/mkdir/install`.
221 El swap monta el busybox estático de hammer sobre `/toolchain/bin/busybox` (`--swap
222 busybox=<hash>:bin/busybox`; sin código nuevo — el mecanismo `--swap` ya soporta `rel_path`): los
223 applets busybox-backed pasan a usar hammer-busybox, coreutils GNU intacto. `SWAP_BUSYBOX=1` en el
224 verify. **Validado en host:** con make+busybox de hammer los 4/4 (incl. busybox compilándose a sí
225 mismo con hammer-busybox de shell) reproducen `of_tree=9adefb82…`.
226 - **Determinismo (transversal):** `codegen-units=1` NO bastaba — el backend paralelo de rustc/LLVM
227 (ThinLTO) divergía ~62 KB en `arje-zero` a >1 CPU. Fix: `CARGO_BUILD_JOBS=1` en el sandbox
228 serializa el jobserver ⇒ reproducible e independiente del nº de CPUs (SDD 09 §2). El baseline
229 reproducible verdadero es `of_tree=9adefb82…` (el viejo `0039b2b9…` era un build paralelo no-fiable).
230 **Pendiente:** correr Stage 2 in-VM con los swaps para el `✓ REPRODUCIBLE` end-to-end; luego
231 linux-headers → bwrap → el gran tramo rust/llvm.
232
233### 7.3 Enganche con lo ya hecho
234
235`stage2` ya ancla `of_tree(stage1)`; el builder produce `stage1'` y `verify_against` emite el
236veredicto. El determinismo necesario está cubierto (paths fijos `/src`, `SOURCE_DATE_EPOCH`, locks
237deterministas). El sub-ítem era: **ensamblar el builder** (variante de `assemble_rootfs` que hidrata
238el toolchain) y **correr el rebuild anidado** en la VM.
239
240### 7.4 Estado: builder ensamblado + rebuild in-VM ejecutado ✅ ✓ REPRODUCIBLE
241
242El **ensamblado** del builder está implementado (`hammer_bootstrap::builder_rootfs`, variante a):
243
244```
245hammer bootstrap builder --stage1 <H> --seed-hash <H> \
246 [--toolchain .dev-fs/alpine] [--toolchain-tag alpine-3.23.4] \
247 [--hammer-bin <static-musl-hammer>] [--ref-content of_tree(stage1)] \
248 [--work-cache work/] [--out work/builder-rootfs]
249```
250
251Sobre el rootfs de Stage 1 (hidratado como base) monta: `/toolchain` (el rootfs Alpine, el sandbox
252de build), `/store` con la **semilla replicada** (el `hammer` de adentro resuelve zig por hash),
253`/usr/bin/hammer` + `/etc/hammer/recipes`, y el driver **`/usr/bin/rebuild-stage1`** que lee
254`/etc/hammer/rebuild.env` (`SEED_HASH`/`SEED_KIND`/`REF_CONTENT`), apunta `HAMMER_ROOTFS=/toolchain`,
255corre `hammer bootstrap stage1` y compara `stage1'` contra la referencia con `stage2 --verify`.
256
257El builder **no se sella** (su `/toolchain` Alpine no es content-addressed); se ensambla en un
258`out_dir` para empaquetar como initramfs. Su identidad sí es reproducible: un `ArtifactHash` *lógico*
259de sus insumos (stage1 + semilla + recetas + binario + tag del toolchain + el driver), anotado en el
260manifiesto (línea stage 2). Validado contra el store real (Stage 1 `73d7a9be…` + semilla
261`3ce721ec…` ⇒ builder `81dad3d9…`, 1.2 GB con el toolchain). **El paso operacional ya se ejecutó:**
262`scripts/selfhost-verify.sh` empaqueta el builder, lo bootea con KVM y corre `rebuild-stage1`
263no-interactivo; in-VM reconstruyó los 4/4 (arje-zero cu=1 en ~27 min) y `of_tree(stage1') =
264b3:0039b2b9…` igualó la referencia ⇒ `✓ REPRODUCIBLE: stage1' == stage1`. Lo que queda es la
265variante (b), el auto-alojamiento *puro* (toolchain construido por hammer desde fuente, §7.2).
266**Arrancada:** la pieza 1, GNU make `4.4.1` (`recipes/make.toml`), ya se construye desde fuente
267con el lab — estática, reproducible bit-a-bit (§7.2b). Ver [runbook §8](runbooks/stage1-vm-boot.md).
268
269## 8. Hecho cuando
270
271`hammer bootstrap --all` produce un rootfs que, ejecutado en la VM destino, **reconstruye su
272propio toolchain y userland con hashes idénticos a los publicados**, sin que ninguna
273herramienta del host entre al resultado. Ese día Alpine deja de ser una dependencia y pasa a
274ser, a lo sumo, una conveniencia de desarrollo.
275
276**Estado (2026-06-12):** el **mecanismo** está demostrado end-to-end con la variante (a) — el
277rootfs reconstruye su userland in-VM con `of_tree` idéntico (`✓ REPRODUCIBLE`, §7.4). Falta sólo
278la variante (b): que el **toolchain** dentro del builder lo construya hammer desde fuente (no
279provenga de Alpine), reemplazando una a una las piezas con Stage 2 verificando cada paso. Ese es
280el corte pleno del cordón.
11-bootstrap.md
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 0–6 (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). Validado en
host: con make de hammer los 4/4 reproducen of_tree=9adefb82… (idéntico al baseline determinista).
- Pieza 2 — busybox
1.36.1 (ya es componente de Stage 1, recipes/busybox.toml): provee el
sh+coreutils que el build usa del /toolchain. El /toolchain de Alpine es mixto — busybox
para sh/sed/grep/awk/tar/find (symlinks → /bin/busybox) y GNU coreutils para cp/mkdir/install.
El swap monta el busybox estático de hammer sobre /toolchain/bin/busybox (--swap busybox=<hash>:bin/busybox; sin código nuevo — el mecanismo --swap ya soporta rel_path): los
applets busybox-backed pasan a usar hammer-busybox, coreutils GNU intacto. SWAP_BUSYBOX=1 en el
verify. Validado en host: con make+busybox de hammer los 4/4 (incl. busybox compilándose a sí
mismo con hammer-busybox de shell) reproducen of_tree=9adefb82….
- Determinismo (transversal):
codegen-units=1 NO bastaba — el backend paralelo de rustc/LLVM
(ThinLTO) divergía ~62 KB en arje-zero a >1 CPU. Fix: CARGO_BUILD_JOBS=1 en el sandbox
serializa el jobserver ⇒ reproducible e independiente del nº de CPUs (SDD 09 §2). El baseline
reproducible verdadero es of_tree=9adefb82… (el viejo 0039b2b9… era un build paralelo no-fiable).
Pendiente: correr Stage 2 in-VM con los swaps para el ✓ REPRODUCIBLE end-to-end; luego
linux-headers → bwrap → el gran tramo rust/llvm.
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.