Files
hammer/docs/runbooks/stage1-vm-boot.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

19 KiB
Raw Blame History

Runbook — validar Stage 1 booteando en QEMU

Operativo, no diseño. Valida end-to-end que el rootfs que produce hammer bootstrap stage1 (SDD 11) bootea con arje-zero como PID 1 (SDD 12), levanta hammerd + getty bajo supervisión, y que el CRASHED real funciona.

Esto es un procedimiento, no un transcript verificado. La lógica de ensamblado/hash/seed está cubierta por unit tests (y la seed se validó contra card_core::Card), pero el cross-compile y el boot reales no se han corrido todavía — ese es justo el punto de este runbook. La primera corrida puede destapar ajustes (link, /dev/console, getty/tty); §7 los anticipa.

0. Lo que valida / lo que no

  • Valida: que el árbol sellado por stage1 arranca como sistema: arje-zero monta los pseudo-FS, carga la seed, incarna hammerd y la getty, y al matar hammerd lo reinicia con backoff (el CRASHED que la Fase 5 difirió).
  • No valida (aún): bus único (B.2), overlay del store (I4), atestación (A1/A2), kernel from-source. Aquí el kernel es prestado del host; el overlay no se monta.

1. Prerrequisitos

  • Lab de dev montado: ./scripts/bootstrap-devfs.sh (deja .dev-fs/alpine + .dev-fs/tools/zig).
  • Red: el build de hammerd y arje-zero clona repos git (hammer, tawasuyu) y corre cargo vendor en el fetch. El build hermético del sandbox es --offline, pero el fetch necesita red.
  • Herramientas host: qemu-system-x86_64, un kernel x86_64 (/boot/vmlinuz-*), cpio, gzip.
  • Espacio/tiempo: construir arje-zero vendorea las deps del monorepo tawasuyu entero — es pesado y lento la primera vez (se cachea después).
  • El binario hammer: cargo build --release -p hammer-cli./target/release/hammer.

El store debe vivir en la raíz del repo (./store) para que BuildConfig resuelva .dev-fs/alpine como hermano (store/.. = repo root). El toolchain de Stage 1 no es el de .dev-fs: es la semilla de Stage 0 (paso 2), que stage1 enchufa como zig_dir del sandbox.

2. Stage 0 — ingerir la semilla (zig)

export HAMMER=./target/release/hammer
export STORE=./store

"$HAMMER" --store "$STORE" bootstrap stage0 \
  --url https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz \
  --sha256 70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 \
  --version 0.16.0
# → imprime el seed_hash (b3:...). Guardalo:
SEED="b3:..."   # pegá el hash impreso

Idempotente: re-correr no re-descarga. La semilla queda sellada como seed-zig en el store y anota su línea en bootstrap.json.

3. Stage 1 — construir y sellar el rootfs

"$HAMMER" --store "$STORE" bootstrap stage1 --seed-hash "$SEED" --recipes recipes
# construye musl + busybox + hammerd + arje-zero, ensambla el rootfs FHS y lo sella.
# → imprime el rootfs_hash (b3:...).

# El árbol sellado (read-only) del rootfs:
ROOTFS_DIR=$(ls -d "$STORE"/*-stage1-rootfs)
echo "$ROOTFS_DIR"
ls "$ROOTFS_DIR"/ente/seed.card.json "$ROOTFS_DIR"/sbin/init "$ROOTFS_DIR"/usr/bin/{hammerd,arje-zero}

Necesita red (git clone + cargo vendor). Si un build Cargo falla en el link (-lgcc_s) o por static-PIE, ver §7 — el zig cc del lab debería resolver -lgcc_s (bitácora M2 del plan arje↔hammer).

4. Empaquetar el rootfs como initramfs

El árbol del store ya trae /sbin/init/usr/bin/arje-zero y /ente/seed.card.json. Se empaqueta como cpio newc + gzip (formato initramfs):

( cd "$ROOTFS_DIR" && find . -print0 | cpio --null -o --format=newc ) | gzip -9 > /tmp/stage1.cpio.gz
ls -lh /tmp/stage1.cpio.gz

cpio newc preserva symlinks y hardlinks. El symlink /sbin/init/usr/bin/arje-zero es absoluto: en un initramfs resuelve dentro de su propia raíz. (Si el kernel no auto-monta devtmpfs, ver §7 por el nodo /dev/console.)

5. Bootear en QEMU

qemu-system-x86_64 -m 512 -nographic \
  -kernel /boot/vmlinuz-linux \
  -initrd /tmp/stage1.cpio.gz \
  -append "console=ttyS0 rdinit=/sbin/init"
  • rdinit=/sbin/init — el default de un initramfs es /init; lo sobreescribimos a /sbin/init (→arje-zero). Alternativa directa: rdinit=/usr/bin/arje-zero.
  • console=ttyS0 + -nographic — consola serie, donde arje escribe y la getty se engancha.
  • Salir de QEMU: Ctrl-A luego X.

6. Criterios de éxito (qué observar)

  1. PID 1: en kmsg/consola, arje-zero loguea "despierta como PID 1".
  2. Pseudo-FS: monta /proc /sys /dev sin error fatal (best-effort; un EBUSY se ignora).
  3. Seed: carga /ente/seed.card.json sin "card inválida" (ya validada contra card_core::Card).
  4. hammerd: arranca como Ente — su tracing aparece y/o crea /run/agent.sock.
  5. getty: aparece un prompt de shell (/bin/sh) en ttyS0.
  6. CRASHED real — la prueba clave. En el shell:
    kill "$(pidof hammerd)"
    
    arje registra el on_death(ExitStatus) y reinicia hammerd tras el backoff (200 ms → ×2…). Eso es el CRASHED que la Fase 5 dejó diferido, ahora manejado por el init propio.

7. Troubleshooting (síntoma → causa → fix)

Síntoma Causa probable Fix
Kernel panic … no init found /sbin/init no ejecuta probar rdinit=/usr/bin/arje-zero; verificar que el symlink quedó en el cpio (cpio -tv)
arje cae a rescue shell "card inválida" la seed no validó confirmar que /ente/seed.card.json se empaquetó; comparar con el schema de seeds/arje-qemu.card.json
hammerd/arje-zero: exec format error binario no estático / falta loader confirmar link = "static"; un static-PIE pide /lib/ld-musl-x86_64.so.1 — para init-clásico desactivar PIE (-C relocation-model=static, bitácora M2)
Build Cargo falla en -lgcc_s hueco del toolchain musl dinámico el lab usa zig cc (empaqueta su compiler-rt) → no debería aparecer; verificar compiler = "zig-cc" en la receta
getty sin prompt device console no engancha tty probar argv con ttyS0 en vez de console; revisar que /dev/console existe
No working init / sin /dev/console kernel sin auto-mount de devtmpfs crear el nodo en el cpio (mknod -m 600 dev/console c 5 1 antes de empaquetar) o usar un kernel con CONFIG_DEVTMPFS_MOUNT=y
cargo vendor / git clone falla el fetch necesita red correr Stage 1 con red; el sandbox de build sigue siendo --offline

8. Bitácora — primera corrida real (2026-06-11)

Corrida en el host de desarrollo (Artix). Resultado: el userland C builda end-to-end; el camino Rust está bloqueado por una decisión de toolchain. Hallazgos, en orden:

# Etapa Resultado
1 Lab (bootstrap-devfs.sh) Alpine + zig 0.16.0 + 43 build-tools
2 Stage 0 (ingerir zig) seed_hash sellado, idempotente
3 musl 1.2.5 cross-compila con zig cc y se sella — el cross-compile C hermético funciona
4 busybox 1.36.1 tras tres fixes (abajo); compila y linkea con zig cc, se sella
5 hammerd (Cargo) bloqueado: triple de toolchain (abajo)
6 arje-zero (Cargo) ⏸ no alcanzado (mismo bloqueo; además vendorea el monorepo entero)

Fixes aplicados a recipes/busybox.toml (incompatibilidades GNU-toolchain ↔ zig, esperadas):

  1. gcc hardcodeado. El Kbuild de busybox fija CC = gcc / HOSTCC = gcc con = (ignora el env CC del sandbox). Fix: pasar CC='zig cc' HOSTCC='zig cc' en línea de comando de make.
  2. Flags GNU-ld que zig cc rechaza. El link pasa -Wl,--warn-common, -Wl,-Map,…, -Wl,--verbose; zig cc valida los -Wl, y aborta. Fix: quitarlos de scripts/trylink con sed.
  3. (gcc para herramientas host como fixdep y gcc-version.sh lo cubre el mismo HOSTCC.)

Bloqueo del camino Rust — decisión de toolchain (plan C.2):

  • El vendoring (Lote 6) corre OK en el fetch (host, con red). El cargo build corre en el sandbox.
  • Se añadió rust cargo al rootfs (apk) — pero el rust de Alpine tiene host triple x86_64-alpine-linux-musl, mientras BuildSys::Cargo pide --target x86_64-unknown-linux-musl. Alpine no trae ese std y no hay rustup → error[E0463]: can't find crate for core.
  • Opciones (a decidir): (A) construir nativo sin --target (el sandbox ya es x86_64 musl) — cambio en BuildSys::Cargo; (B) recetas Cargo con target = "x86_64-alpine-linux-musl" — leakage de Alpine; (C) rustup en el sandbox + rustup target add — pierde hermeticidad/pin. Es la misma tensión de "pin de toolchain entra al hash" que el plan C.2 dejó abierta.

Stage 1 completo + intento de boot

Tras los fixes, los 4 componentes buildan y el rootfs se sella (musl+busybox+hammerd+arje-zero, 29 MB; /sbin/init→arje-zero, /ente/seed.card.json, loader musl). Se empaquetó como initramfs (cpio newc+gzip, 11 MB) y se booteó en QEMU. La cadena de hallazgos del boot:

  1. AVX. El CPU default de QEMU (qemu64) no tiene AVX, pero los binarios (zig/rust nativos) emiten vxorpsSIGILL (invalid_op) en el loader → init muere. Fix: -cpu Broadwell (AVX2 bajo TCG; sin /dev/kvm no hay -cpu host). Para un distro real: compilar a un baseline x86-64 genérico, no al CPU del builder.
  2. musl dinámico. El rust de Alpine produce binarios dinámicos (DT_NEEDED libc.musl-x86_64.so.1), no crt-static. El loader corre pero no resuelve memcpy/memset/log2/…: nuestro libc.so (musl 1.2.5 shared con zig cc) tiene 1586 dyn-syms pero no exporta esos símbolos. → Error relocating /sbin/init: memcpy: symbol not found → init muere.

Conclusión. El bootstrap C (musl+busybox+zig cc) y el build Rust nativo (hammerd y arje-zero compilan y se sellan) están validados end-to-end; el kernel arranca el initramfs y ejecuta arje-zero como PID 1. El último bloqueo del boot es el linking dinámico de musl: el fix correcto y golden-path es crt-static — compilar los componentes Rust con -C target-feature=+crt-static -C relocation-model=static para binarios autocontenidos (sin libc.so, sin loader, sin soname, como busybox). Requiere rebuild de hammerd/arje-zero (el de arje-zero vendorea el monorepo: caro en tiempo y disco). Es la realización plena de la "vía de oro" estática de hammer y cierra de paso el problema del soname de Alpine.

Actualización (Opción A implementada). BuildSys::Cargo ahora construye nativo sin --target cuando el target es el del sandbox (SANDBOX_NATIVE_TARGET), con un wrapper .hammer-zig-cc que (a) resuelve el zig cc de dos palabras para cargo/cc-rs y (b) sanea el triple que cc-rs pasa (--target=x86_64-alpine-linux-muslx86_64-linux-musl, que zig sí entiende). Resultado: hammerd compila nativo y se sella ✓ — el camino Rust funciona. arje-zero queda bloqueado por otra razón: tawasuyu no commitea Cargo.lock (git ls-files Cargo.lock vacío), así que el git archive no lo trae y cargo vendor --locked no puede crearlo. Decisión pendiente: commitear el lock en tawasuyu (reproducible, plan C.2 #5) vs relajar --locked en el vendoring de hammer.

Cierres posteriores. (1) Vendoring --locked condicional al lock committeado → arje-zero vendorea sin --locked (genera el lock) y se sella: los 4 componentes buildan. (2) Boot: AVX → -cpu Broadwell; y el linking dinámico de musl (Alpine rust es dinámico por defecto → DT_NEEDED libc.musl-x86_64.so.1; nuestro libc.so no exporta memcpy/…) → fix crt-static. (3) Iteración del crt-static (cada paso destapó el siguiente; hammerd valida barato): +crt-static [-C relocation-model=static] en RUSTFLAGS rompe los proc-macros (dylibs PIC) en build nativo → solución: cargo rustc --release … -- <flags>, que aplica los flags sólo a la crate top-level. musl vuelve a --disable-shared (no se necesita loader).

BOOT LOGRADO (2026-06-11)

Con cargo rustc -- +crt-static -C relocation-model=static, arje-zero y hammerd salen statically linked (ET_EXEC, sin DT_NEEDED, sin interpreter), el rootfs se sella, y el boot en QEMU (-cpu Broadwell) levanta el sistema:

arje_zero: ente-zero despierta como PID 1
arje_zero::seed: Tarjeta Semilla cargada y validada  path=/ente/seed.card.json
arje_zero: Ente #0 entra al bucle primordial  label=hammer-stage1
arje_zero::graph::lifecycle: instanciando genesis  count=2
  Ente encarnado  label=hammerd        pid=Some(Pid(59))
  Ente encarnado  label=console-getty  pid=Some(Pid(60))
~ #                                          (shell en consola)
hammerd: arranque  agent_sock=/run/agent.sock …
hammerd: watcher fanotify activo
hammerd::bus: escuchando  sock=/run/agent.sock

arje-zero es PID 1, carga+valida la seed y supervisa hammerd + getty; hammerd corre con su watcher fanotify y su agent.sock. Stage 1 bootea end-to-end. Comando reproducible: qemu-system-x86_64 -m 512 -no-reboot -nographic -cpu Broadwell -kernel <vmlinuz> -initrd stage1.cpio.gz -append "console=ttyS0 rdinit=/sbin/init".

CRASHED real demostrado (lo que la Fase 5 difirió)

Desde la shell de la consola, kill $(pidof hammerd) y arje-zero lo supervisa de verdad:

kill $(pidof hammerd)
arje_zero::graph::lifecycle: Ente disuelto      label=hammerd  status=Killed(SIGTERM)
arje_zero::graph::lifecycle: Restart programado label=hammerd  delay_ms=200
arje_zero::graph::lifecycle: Ente encarnado     label=hammerd  pid=Some(Pid(64))
hammerd: arranque  agent_sock=/run/agent.sock …

Detecta la muerte con su ExitStatus (Killed(SIGTERM)), programa el restart con backoff (200 ms) y re-encarna hammerd (pid 59→64). Es la supervisión real del init propio — el CRASHED que ADR 0007 prometía y la Fase 5 había diferido. Exponerlo a la capa de IA por agent.sock es B.2 (bus único).

8b. Stage 2 — verificación de reproducibilidad (pre-rebuild-in-rootfs)

El stage2 de hammer ancla el content-hash (ArtifactHash::of_tree, los bytes reales) del rootfs y lo compara con un rebuild. El rebuild dentro del rootfs usando sólo las herramientas de Stage 1 (auto-alojamiento pleno) necesita un builder rootfs — el Stage 1 mínimo es un runtime (no trae zig/make/cargo), así que ese paso es un hito aparte. Lo que se verificó acá: la bit-reproducibilidad del build (dos builds independientes del mismo componente → diff -r):

Componente Reproducible Notas
musl 1.2.5 byte-idéntico el build C con zig cc es determinista de fábrica
busybox 1.36.1 tras dos fixes (ver abajo)
hammerd (Rust) byte-idéntico crt-static + cargo --locked + SOURCE_DATE_EPOCH + paths fijos /src ⇒ rust reproducible
arje-zero (Rust) (indirecto) mismo camino que hammerd; su único riesgo era el lock generado sin --locked — verificado: dos cargo generate-lockfile del commit pinned dan un Cargo.lock byte-idéntico (resolución determinista). Committear el lock lo haría explícito (plan C.2 #5)

La verificación cazó dos no-determinismos reales en busybox (justo lo que SDD 09 §2 pide):

  1. Config interactiva. Tras editar .config, el compile corre silentoldconfig, que ante opciones NEW (WERROR, etc.) prompea leyendo stdin → el set de applets salía distinto por corrida (build no-determinista y flaky). Fix: yes '' | make oldconfig (acepta defaults, determinista; busybox 1.36.1 no tiene olddefconfig).
  2. Headers del kernel. Con el config determinista, los applets de console-tools exigen linux/kd.h (UAPI) que zig no bundlea → linux-headers en el lab (bootstrap-devfs.sh); zig cc nativo los encuentra en /usr/include.

Con ambos, musl y busybox reconstruyen bit-idéntico. Sumados hammerd (directo) y arje-zero (indirecto), los 4 componentes están verificados reproducibles. Lo único que le queda a Stage 2 es el rebuild dentro de un builder rootfs (un Stage 1 que incluya el toolchain) — el auto-alojamiento pleno, un hito propio (el Stage 1 mínimo es runtime, sin compilador).

8c. Builder rootfs — el rebuild in-rootfs (auto-alojamiento, SDD 11 §7)

El ensamblado del builder ya está implementado (hammer bootstrap builder, SDD 11 §7.4; variante a, toolchain desde Alpine). Produce, sobre el Stage 1 rootfs, una imagen con el toolchain adentro lista para reconstruirse a sí misma. Receta operacional:

1. Binario hammer estático (musl). El builder lo bootea como PID-algo en la VM, así que debe ser autocontenido (como hammerd/arje-zero, crt-static — §7):

cargo build --release --target x86_64-unknown-linux-musl -p hammer-cli
# (o cargo rustc -- -C target-feature=+crt-static, según el camino de oro del boot)

2. Ensamblar el builder anclando la referencia of_tree(stage1) (la que imprimió stage2):

hammer --store store bootstrap builder \
    --stage1 b3:<STAGE1> --seed-hash b3:<SEED> \
    --hammer-bin target/x86_64-unknown-linux-musl/release/hammer \
    --toolchain .dev-fs/alpine --toolchain-tag alpine-3.23.4 \
    --ref-content b3:<OF_TREE_STAGE1> \
    --work-cache work \
    --out work/builder-rootfs

--work-cache work embebe los mirrors git / tarballs ya fetcheados en /work ⇒ rebuild offline y determinista en la VM (sin él, el rebuild fetchea por red; la seed card trae networking: full).

3. Empaquetar como initramfs y bootear (igual que §4/§7, -cpu Broadwell por AVX):

( cd work/builder-rootfs && find . | cpio -o -H newc | gzip ) > builder.cpio.gz
qemu-system-x86_64 -m 1024 -no-reboot -nographic -cpu Broadwell \
    -kernel <vmlinuz> -initrd builder.cpio.gz -append "console=ttyS0 rdinit=/sbin/init"

4. Dentro de la VM, desde la shell de la consola, correr el driver embebido:

rebuild-stage1
# >> rebuild stage1 (toolchain in-rootfs, semilla b3:3ce721ec…)
# >> stage1' = b3:…
# ✓ REPRODUCIBLE: stage1' == stage1 (auto-alojado bit a bit)     ← of_tree(stage1')==REF_CONTENT

rebuild-stage1 apunta HAMMER_ROOTFS=/toolchain, corre hammer bootstrap stage1 con la semilla y las recetas de adentro, y compara con stage2 --verify $REF_CONTENT. ✓ REPRODUCIBLE cierra el auto-alojamiento end-to-end de la variante (a); ✗ DIVERGENTE caza un no-determinismo nuevo (SDD 09 §2). La variante (b) — el toolchain construido por hammer desde fuente — reemplaza /toolchain Alpine pieza a pieza, con este mismo lazo verificando cada paso.

9. Cross-check opcional — arje-packager

arje trae su propio empaquetador (03_ukupacha/arje/init/arje-packager): arje-packager --seed <CARD.json> --out <initramfs.cpio.gz> [--bin LABEL=PATH]…. Útil para comparar el initramfs canónico de arje contra el que sale del rootfs de hammer (§4). No reemplaza esta validación: aquí probamos el rootfs que hammer sella, no el que arje arma.