Files
hammer/docs/13-release-engineering.md
T
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

14 KiB
Raw Blame History

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.cfglinux /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 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 idempotentesbackup_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.