Files
hammer/docs/13-release-engineering.md
sergioandClaude Opus 4.8 be881ad099 auto-recover al arranque: crate hammer-recover + hook /sbin/init (E4 #4b)
El modelo de generaciones es in-place (sin menu NixOS que ofrecer en GRUB); lo
que encaja es auto-sanar un upgrade interrumpido al boot. crates/hammer-recover
= mini-binario static-musl (lo unico que el producto necesita, sin el CLI hammer
completo): sin pending.json es no-op; con uno completa (roll-forward) o deshace
(roll-back) -> FHS siempre consistente; nunca aborta el boot. iso-image.sh
INSTALLER=1 lo compila (target musl) y bundlea al payload; hammer-live-install.sh
lo copia a /usr/sbin/hammer-recover y el wrapper /sbin/init instalado lo corre
tras montar /store y /var/lib/hammer, antes de incarnar arje-zero. iso-install-
test.sh valida el hook al boot (marker 'hammer-recover: sin upgrade interrumpido').

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

155 lines
14 KiB
Markdown
Raw Permalink 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 13 — Release engineering (Etapa E): imagen auto-booteable, instalador, mirror, upgrades
> Estado: **EN CURSO**. Etapa E del roadmap (`docs/10-roadmap.md`, memoria distro-completion-roadmap).
> Es trabajo nuevo: las Etapas AD dejaron un sistema que (A) se orquesta, (B) bootea de disco
> particionado real, (C) tiene userland Rust-nativo y (D) atesta confianza. Falta convertir eso en algo
> que un tercero **instale y arranque sin el host de dev**: una imagen que bootea sola (sin
> `qemu -kernel`), un instalador a disco físico, un mirror del store y un camino de upgrade.
## 1. El gap concreto que abre la Etapa E
Hasta B3 la imagen de disco (`scripts/disk-image.sh`, GPT con `/`, `/store`, `/var/lib/hammer`
dedicados) **no se auto-bootea**: el `drive-rebuild.py` la arranca con `qemu -kernel <bzImage>
-append root=/dev/vdaN` — el kernel lo carga QEMU, no la imagen. Eso sirve para verificar el
auto-alojamiento, pero un disco así no arranca en hardware real ni en una VM "normal": le falta un
**bootloader** en el propio disco.
## 2. Decisión de bootloader: GRUB BIOS (i386-pc) ahora, UEFI después
- **GRUB BIOS (i386-pc) — ELEGIDO para el primer corte.** QEMU trae SeaBIOS built-in ⇒ un disco con
GRUB en el MBR + una *BIOS boot partition* (GPT type `ef02`) bootea con `qemu-system-x86_64 -drive
file=img` **sin firmware extra ni `-kernel`**. Se instala **en userspace, sin root ni loop**:
`grub-mkimage` arma `core.img` y `grub-bios-setup` escribe `boot.img`→MBR + `core.img`→partición
BIOS sobre el *fichero* imagen (es I/O de bloques, no necesita montar). Los módulos de GRUB y el
kernel van en `/boot` de la partición root, que `mke2fs -d` puebla desde un directorio.
- **UEFI (EFI-stub + ESP FAT) — diferido.** El kernel ya trae `CONFIG_EFI_STUB=y`, pero (a) el host de
dev no tiene firmware OVMF usable para verificar el arranque, y (b) poblar un ESP FAT sin root exige
`mtools` (ausente). Camino futuro cuando haya OVMF + mtools (o se construyan): ESP con
`EFI/BOOT/BOOTX64.EFI` = el bzImage (EFI-stub), cmdline por `loader.conf`/UKI.
## 3. Layout de la imagen instalable (GPT, BIOS)
```
/dev/vda1 BIOS boot (ef02, ~2 MiB, sin fs) core.img de GRUB
/dev/vda2 / ext4 [hammer-root] + /boot/bzImage + /boot/grub
/dev/vda3 /store ext4 [hammer-store] artefactos CAS (inmutable)
/dev/vda4 /var/lib/hammer ext4 [hammer-state] estado mutable (journal, overlays)
```
Cadena de arranque: SeaBIOS → MBR (boot.img) → core.img (BIOS boot part) → lee `(hd0,gpt2)/boot/grub/
grub.cfg``linux /boot/bzImage root=/dev/vda2 rdinit=/sbin/init` → wrapper `/sbin/init` monta
vda3/vda4 → `exec /usr/bin/arje-zero` (PID 1). El wrapper es el mismo puente de B3 (arje todavía no lee
fstab/cards de montaje por sí mismo).
`scripts/install-image.sh` produce esta imagen. Reusa la técnica de particionado sin-root de B3
(sfdisk + `mke2fs -d` bajo `unshare -r` + `dd conv=sparse,notrunc`) y añade la BIOS boot partition, el
kernel en `/boot`, los módulos GRUB en `/boot/grub/i386-pc` y los pasos `grub-mkimage`/`grub-bios-setup`.
## 4. Piezas pendientes de la Etapa E (orden tentativo)
- **E1 — imagen auto-booteable (GRUB BIOS): ✅ primer corte** (`scripts/install-image.sh`). Bootea en
QEMU sin `-kernel` hasta la shell de arje-zero. Hoy empaqueta el *builder rootfs* (con toolchain);
un *product rootfs* lean es refinamiento.
- **E2 — instalador a disco físico:** un `hammer install <device>` que particiona un disco real y
vuelca las tres particiones + GRUB (la misma lógica, apuntando a `/dev/sdX` en vez de un fichero).
- **E3 — mirror del store: ✅ primer corte** (crate `hammer-mirror` + CLI `hammer mirror push|pull|status`).
Replica `/store` content-addressed (BLAKE3) entre máquinas; el hash ES la dirección, así que un mirror es
un CAS replicado + resolución por hash. La integridad se ancla en `of_tree` (content-hash del árbol): el
receptor **recomputa** `of_tree` sobre lo recibido y exige que case el del índice **antes** de sellar
(`Store::seal` atómico + read-only) — una transferencia corrupta/manipulada se **rechaza**, no se instala.
`push`/`pull` son `sync(src,dst)` (idempotente, salta presentes, reporta conflictos = mismo hash con otro
contenido). Transporte de esta entrega = **sistema de ficheros** (el remoto es una ruta a otro store, p.ej.
un montaje sshfs); el transporte sobre SSH/red que el producto ya expone es un envoltorio posterior.
Endurecimiento pendiente: anclar el `of_tree` a una raíz firmada (log de transparencia `bootstrap.json` /
atestación de Etapa D) para defenderse de un origen plenamente malicioso (hoy el invariante forzado es
"el contenido recibido hashea a lo que el índice anuncia", que ataja corrupción de transporte).
- **E4 — upgrades: ✅ primer corte** (crate `hammer-upgrade` + CLI `hammer upgrade apply|rollback|status`).
Aplica un árbol Stage1/producto **nuevo** (artefacto sellado del store, CAS BLAKE3) sobre el FHS vivo
sin reinstalar, dejando una **generación**, y revierte exactamente al árbol anterior con `rollback`.
**Modelo de generaciones** (inspirado en NixOS, in-place al FHS porque el boot lee el root directo, no
un symlink indirecto): cada generación graba bajo `<state>/generations/<N>/` su `manifest.json`
(tree_dir del store, `of_tree`, parent, lista de ficheros, cambios) y un `backup/` con los bytes
**previos** de todo path que pisó o retiró. **Atomicidad:** proyección fichero-a-fichero (escritura a
temporal + `rename` ⇒ cada fichero conmuta atómicamente), con `current` (id de la generación viva) como
commit-point; un corte a mitad deja `current` en la vieja y los backups intactos ⇒ re-apply/rollback
recuperan. **Diff vs el árbol previo:** el apply añade/pisa los ficheros del árbol nuevo y **retira** los
que la generación viva aportaba y el nuevo no tiene. **Rollback:** deshace en orden inverso (borra lo
añadido, restaura desde `backup/` lo pisado/retirado) y retrocede `current` al padre — restauración
*exacta*, incluso al estado pre-upgrades. Cada cambio se registra en el [journal](05-journal.md) como
`HammerHydrate{artifact=tree_dir}` (replay-able). Verificación opcional de integridad: si el llamador
anuncia el `of_tree` esperado (de un índice de **mirror E3** o release firmada), el apply lo exige antes
de tocar el root. Maneja ficheros regulares + symlinks; valida E2E en host con `scripts/upgrade-e2e-test.sh`
(apply v1→v2→rollback→rollback contra el binario real). **GC de generaciones ✅** (`hammer upgrade
prune [--keep N]` / `hammer_upgrade::prune`): borra las generaciones que el rollback ya no alcanza
(huérfanas tras un rollback — las de la [cadena viva](#) siempre se conservan); `--keep N` además
recorta la cadena a sus N más nuevas (limita la profundidad de rollback, como `delete-generations` en
NixOS). **Journal de intención + replay ✅** (`hammer upgrade recover [--rollback]` /
`hammer_upgrade::{pending,recover}`): el apply escribe el **plan completo** a `pending.json` ANTES de
proyectar y lo limpia al commitear; si un corte/reinicio lo interrumpe, `pending.json` sobrevive y el
FHS quedó a medias. La proyección es **re-entrante** (`project_plan`, backups *idempotentes*
`backup_existing_once` nunca pisa el original capturado), así `recover` **completa** (roll-forward:
re-ejecuta el plan + commitea, re-verificando el `of_tree`) o **deshace** (roll-back: restaura backups,
borra la generación a medias, `current`→padre). `apply` se **niega** si hay intento pendiente (exige
recover explícito); `status` lo avisa. 5 tests (corte a media proyección → ambos modos) + ejercicio en
`upgrade-e2e-test.sh`.
- **Auto-recover al arranque ✅** (`crates/hammer-recover` — mini-binario static-musl). El modelo de
generaciones es **in-place** (el FHS es la generación viva), así que NO hay menú de generaciones tipo
NixOS que ofrecer en GRUB; lo que encaja es **auto-sanar** un upgrade interrumpido al boot. El producto
no lleva el CLI `hammer` completo ⇒ `hammer-recover` es lo único que el sistema instalado necesita: el
wrapper `/sbin/init` (de `hammer-live-install.sh`) lo corre **tras montar `/store` y `/var/lib/hammer`**
y **antes** de incarnar arje-zero. Sin `pending.json` es no-op; con uno, **completa** (roll-forward) o,
si no puede, **deshace** (roll-back) ⇒ el FHS siempre arranca consistente. Nunca aborta el boot. El
instalador lo bundlea desde el payload a `/usr/sbin/hammer-recover`. Validado in-VM
(`iso-install-test.sh`): el disco imprime `hammer-recover: sin upgrade interrumpido` al boot y sirve SSH.
**Refinamiento #4 (E4 endurecimiento) CERRADO.**
- **E5 — ISO/medio de arranque: ✅ primer corte** (`recipes/xorriso.toml` + `scripts/iso-image.sh` +
`scripts/iso-boot-test.sh`). Medio **live** que arranca ENTERO desde RAM: GRUB (El Torito) carga
kernel + initramfs desde el ISO 9660, el kernel desempaqueta el rootfs del producto a un tmpfs y corre
`/init` — sin tocar disco. Es el medio para correr el instalador (`hammer install /dev/sdX`) en
hardware nuevo o probar el sistema sin instalarlo. **Herramental construido por hammer:** el host no
trae `xorriso` (y `grub-mkrescue` lo exige) ⇒ `recipes/xorriso.toml` lo construye desde fuente (GNU
xorriso 1.5.8 = libburn+libisofs+libisoburn, zig 0.13.0, estático musl, libs opcionales off ⇒ binario
autocontenido). `iso-image.sh` lo **dogfooda**: arma el ISO con el xorriso del store. **Cadena de
boot BIOS:** reusa los módulos GRUB i386-pc de E1 pero con el core El Torito (`grub-mkimage -O
i386-pc-eltorito` + módulos `iso9660`/`biosdisk`) en vez del core de disco (ext2); `xorriso -as
mkisofs -b …/eltorito.img -no-emul-boot -boot-info-table --grub2-boot-info --grub2-mbr boot_hybrid.img`
⇒ ISO **isohybrid** (booteable como CD *y* USB/disco). El initramfs es el `product-rootfs` lean
empaquetado como `cpio.gz` (live de RAM; la variante con squashfs+overlay para rootfs grandes es
refinamiento). Validado E2E in-VM (`iso-boot-test.sh`, ISO 113M): SeaBIOS → GRUB 2.14 (El Torito) →
Linux 6.16.12 hammer-built → `/init` (marcador `HAMMER-ISO-LIVE-OK`) → arje-zero PID1 → hammerd +
netup (lease DHCP) → **sshd escuchando**: el producto entero corriendo desde el medio live. **Pendiente
(refinamiento):** squashfs+overlay para no cargar todo el rootfs en RAM (diferido: el kernel no trae
SQUASHFS ni CDROM/SR ⇒ exige rebuild de kernel, valor marginal en el producto lean).
- **Arranque UEFI ✅** (`iso-image.sh EFI=1` + `recipes/mtools.toml` + `scripts/efi-boot-test.sh`). El
mismo ISO se vuelve **híbrido BIOS+UEFI**: además del El Torito i386-pc se añade un 2º El Torito EFI
(`-eltorito-alt-boot -e efi.img -no-emul-boot -isohybrid-gpt-basdat`) que arranca una ESP FAT con
`EFI/BOOT/BOOTX64.EFI` = un `grubx64.efi` (`grub-mkimage -O x86_64-efi`). GRUB EFI lee el mismo
`grub.cfg` y carga kernel (EFI_STUB, ya en el kernel — sin rebuild) + initrd vía el protocolo EFI.
**Herramental:** poblar la ESP FAT sin root exige `mtools` (`mformat`/`mcopy`), ausente en el host ⇒
`recipes/mtools.toml` lo construye (GNU mtools 4.0.49, estático musl, dogfoodeado por iso-image.sh).
**GOTCHA:** `mformat -F` fuerza **FAT32** (mínimo ~33 MiB); sobre una ESP chica deja un FAT inválido
que la firmware no lee ("failed to load … Not Found") ⇒ sin `-F`, mformat auto-elige FAT12/16.
Validado E2E: el MISMO ISO bootea por UEFI (QEMU+OVMF → grubx64.efi → "Loaded initrd from
LINUX_EFI_INITRD_MEDIA_GUID" → arje-zero → sshd) y por BIOS (SeaBIOS, El Torito). **Refinamiento #3
CERRADO.** (Falta como pulido: rama EFI del INSTALADOR a disco — GPT+ESP en `hammer-install`, hoy el
instalado es MBR-BIOS.)
- **Instalar DESDE el live ✅** (`scripts/iso-image.sh INSTALLER=1` + `scripts/hammer-live-install.sh`
`/usr/bin/hammer-install` + `scripts/iso-install-test.sh`). Cierra el lazo **ISO live → disco
instalado → bootea solo**. El ISO instalador bundlea un *payload de disco* en el initramfs bajo
`/usr/lib/hammer/install/` (kernel + GRUB `boot.img`/`core.img` MBR-prefix `(hd0,msdos1)/boot/grub`
+ módulos, armado en build-time con `grub-mkimage`). `hammer-install <dev>` corre **dentro del live
como root real** (sin los rodeos rootless de E1) usando sólo lo que el producto ya trae (busybox
`fdisk`/`mke2fs`/`mount`/`dd` + uutils `cp`): particiona **MBR** (p1=/, p2=/store, p3=/var/lib/hammer),
formatea ext2 (montable ext4), copia la **propia raíz del live** (autoinstalador) excluyendo
virtuales/payload, escribe `/boot` + wrapper `/sbin/init`, e instala GRUB con dos `dd` (boot.img→MBR
+ core.img→hueco post-MBR). **GOTCHAS:** (a) busybox `fdisk` alinea a CHS (sector 63 ⇒ hueco de 62
sectores) y el core.img de GRUB (~281 sectores) lo DESBORDA pisando el FS de p1 ⇒ GRUB cae a `grub>`;
fix = **sectores de inicio/fin EXPLÍCITOS** (p1@2048, hueco de 2047) porque el default de busybox
ofrece el sector libre más bajo (mete p2 en el hueco pre-p1). (b) Con install contiguo MBR los
punteros por defecto de GRUB (kernel_sector=1, blocklist=2) **ya valen** ⇒ NO hace falta el parcheo
binario del caso GPT de E1. (c) copiar el top-level de `/` ENUMERÁNDOLO (no una lista fija) o se
escapa `/ente/seed.card.json` ⇒ arje-zero aborta "seed.card no encontrada". `AUTO_INSTALL=<dev>`
hace el `/init` desatendido (install + poweroff) = instalación unattended + scaffolding del test.
Validado E2E in-VM: boot ISO+disco en blanco → HAMMER-INSTALL-OK → boot del disco solo → GRUB→kernel
→arje-zero→sshd, /store y /var/lib/hammer montadas. **Refinamiento #1 CERRADO.**