#!/bin/sh # metal-salto-test.sh — el SALTO, de verdad: un Linux vivo se reemplaza a sí mismo sin USB. # # ══ QUÉ PRUEBA, Y POR QUÉ HACÍA FALTA ══════════════════════════════════════════════════════════ # Del camino «instalar sin USB» estaba probado TODO menos el salto: el instalador en RAM se había # arrancado a mano y hacía su trabajo, pero `kexec_file_load` no se había ejecutado NUNCA — ninguna # de nuestras máquinas corre un kernel con `CONFIG_KEXEC_FILE`, así que el primer sitio donde el # salto iba a pasar era la máquina del usuario. Eso convierte al usuario en el banco de pruebas. # # Este arnés lo cierra: arranca una VM con `linux-metal-kexec` —la variante que SÍ trae el flag—, # le da un disco con vecinos de verdad y corre ahí el verbo de usuario: # # takana metal check → takana metal survey → takana metal install # # Lo que tiene que pasar: el sistema ORIGEN muere en el salto, el instalador arranca en RAM, # formatea SÓLO su destino y deja el disco arrancable. Se comprueba sobre el DISCO, no sobre lo que # la pantalla dijo — la lección de `tee`: un pipe alegre imprimía «instalado» sin haber formateado. # # ⚠ **Corre en el WORKER** (`dev.gioser.net`, el LXC prestado): es la única máquina con QEMU. El # disco vuelve al hub y se verifica acá, con las herramientas selladas. # # Uso: scripts/metal-salto-test.sh [--conservar] # Vars: WORKER (def dev.gioser.net) · P (dir de trabajo, def work/.salto-test) · PAQUETE set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT" WORKER="${WORKER:-dev.gioser.net}" P="${P:-work/.salto-test}" PAQUETE="${PAQUETE:-work/instalador-salida}" REMOTO="/tmp/salto-test-$$" CONSERVAR=""; SOLO_VERIFICAR="" # ⚠ `VIA_RUN=1` prueba **el fichero que se publica**, no el paquete. Son cosas distintas: el paquete # es lo que arma el repo y el `.sh` es lo que baja una persona, y entre uno y otro hay una cabecera # `sh`, un offset de bytes, una verificación de sha256 y un desempaquetado — o sea cuatro sitios # nuevos donde romperse, ninguno cubierto por probar el paquete. VIA_RUN="${VIA_RUN:-}" for a in "$@"; do case "$a" in --conservar) CONSERVAR=1 ;; # Reusar el disco que ya volvió de la VM. La corrida entera son ~10 min de los cuales 9 son # mover 4 GiB; iterar sobre las COMPROBACIONES no tiene por qué pagarlos otra vez. --verificar-solo) SOLO_VERIFICAR=1 ;; *) echo "opción desconocida: $a" >&2; exit 2 ;; esac done . "$(dirname "$0")/lib/herramientas-desde-store.sh" SFDISK=$(herramienta util-linux sbin/sfdisk sfdisk) || exit 1 BLKID=$(herramienta util-linux sbin/blkid blkid) || exit 1 MKE2FS=$(herramienta e2fsprogs sbin/mke2fs mke2fs) || exit 1 MFORMAT=$(herramienta mtools usr/bin/mformat mformat) || exit 1 MMD=$(herramienta mtools usr/bin/mmd mmd) || exit 1 MCOPY=$(herramienta mtools usr/bin/mcopy mcopy) || exit 1 MDIR=$(herramienta mtools usr/bin/mdir mdir) || exit 1 CPIO=$(herramienta libarchive usr/bin/bsdcpio cpio) || exit 1 GZIP=$(herramienta gzip bin/gzip gzip) || exit 1 FALLOS=0 ok() { echo " ✓ $*"; } falla() { echo " ✗ $*" >&2; FALLOS=$(( FALLOS + 1 )); } # El kernel del ORIGEN es el único que tiene que traer CONFIG_KEXEC_FILE: el del instalador puede # ser cualquiera, porque ya no salta a ningún lado. KEXEC_ART=$(ls -d /store/*-linux-metal-kexec store/*-linux-metal-kexec 2>/dev/null | head -1) [ -n "${KEXEC_ART:-}" ] || { echo "no encuentro el artefacto linux-metal-kexec en el store" >&2; exit 1; } KEXEC_BZ="$KEXEC_ART/boot/bzImage" grep -q '^CONFIG_KEXEC_FILE=y' "$KEXEC_ART"/boot/config-* || { echo "el kernel del origen NO trae CONFIG_KEXEC_FILE — el test no probaría nada" >&2; exit 1; } [ -r "$PAQUETE/takana-instalador.cpio.gz" ] && [ -r "$PAQUETE/kernel/boot/bzImage" ] || { echo "el paquete $PAQUETE está incompleto (armalo con scripts/instalador-initramfs.sh)" >&2; exit 1; } mkdir -p "$P" D="$P/disco.img" if [ -n "$SOLO_VERIFICAR" ]; then [ -r "$P/disco-despues.img" ] && [ -r "$P/serie.log" ] || { echo "--verificar-solo: no hay una corrida previa en $P" >&2; exit 2; } echo "==> --verificar-solo: reusando $P/disco-despues.img y $P/serie.log" fi # ── 1) el disco: ESP con un vecino dentro, destino vacío, y un hueco sin asignar ─────────────── if [ -z "$SOLO_VERIFICAR" ]; then echo "==> disco de prueba (GPT: ESP 128M + destino 2400M + ~1,5G sin asignar)" rm -f "$D"; truncate -s 4096M "$D" "$SFDISK" --quiet "$D" <<'EOF' label: gpt start=2048, size=262144, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name="EFI System Partition" start=264192, size=4915200, type=linux, name="destino-takana" EOF # La ESP se formatea APARTE y se empalma en su offset: formatearla in-situ con `@@` hace que el FAT # declare el tamaño del fichero entero, y después `df` repite la mentira (medido, ADR del banco). rm -f "$P/esp.img"; truncate -s 128M "$P/esp.img" "$MFORMAT" -F -v VECINO -i "$P/esp.img" :: "$MMD" -i "$P/esp.img" ::/EFI ::/EFI/Microsoft ::/EFI/Microsoft/Boot head -c 400000 /dev/urandom > "$P/ajeno.bin" "$MCOPY" -i "$P/esp.img" "$P/ajeno.bin" ::/EFI/Microsoft/Boot/bootmgfw.efi dd if="$P/esp.img" of="$D" bs=1M seek=1 conv=notrunc status=none PU_ROOT=$("$SFDISK" -d "$D" | awk '/destino-takana/ {for(i=1;i<=NF;i++) if ($i ~ /^uuid=/) {sub(/,$/,"",$i); sub(/^uuid=/,"",$i); print $i}}') PU_ESP=$("$SFDISK" -d "$D" | awk '/EFI System/ {for(i=1;i<=NF;i++) if ($i ~ /^uuid=/) {sub(/,$/,"",$i); sub(/^uuid=/,"",$i); print $i}}') [ -n "$PU_ROOT" ] && [ -n "$PU_ESP" ] || { echo "no pude leer los PARTUUID de la tabla" >&2; exit 1; } echo " destino PARTUUID=$PU_ROOT · ESP PARTUUID=$PU_ESP" # La huella del vecino de la ESP es la del FICHERO, no la del listado. La primera versión de este # arnés comparaba `mdir` contra `mdir` y salía ROJO SIEMPRE: el listado trae el «bytes free» del # volumen, que cambia en cuanto se copia cualquier otra cosa en la ESP. Un test que acusa al # inocente se desactiva a la semana. SHA_VECINO=$(sha256sum "$P/ajeno.bin" | cut -d' ' -f1) # ── 2) el sistema ORIGEN: el Linux que se va a suicidar ─────────────────────────────────────── # Es deliberadamente mínimo: lo único que tiene que hacer es correr el verbo de usuario. Si el # salto anda, nada de este árbol sobrevive. echo "==> initramfs del sistema ORIGEN" T="$P/origen"; rm -rf "$T" mkdir -p "$T/bin" "$T/dev" "$T/proc" "$T/sys" "$T/tmp" "$T/run" "$T/paquete/lib" "$T/paquete/kernel/boot" # Por HASH, no por `ls | head -1`: hay tres artefactos `-busybox` en este store y el primero que # lista el directorio no tiene por qué ser el vigente. La fecha miente; el hash no. BUSYBOX="${BUSYBOX:-$(herramienta busybox bin/busybox busybox)}" || exit 1 [ -r "${BUSYBOX:-}" ] || { echo "no encuentro busybox" >&2; exit 1; } cp "$BUSYBOX" "$T/bin/busybox"; chmod 0755 "$T/bin/busybox" for a in sh mount umount mkdir echo sleep ls cat sed awk grep cut tr basename dirname readlink \ find sync reboot dmesg blkid setsid tee du wc head tail sort printf test id chmod rm cp \ mv stat findfs uname sha256sum; do ln -sf busybox "$T/bin/$a" done cp "$SFDISK" "$T/bin/sfdisk"; chmod 0755 "$T/bin/sfdisk" TAKANA_BIN="${TAKANA_BIN:-$ROOT/target/x86_64-unknown-linux-musl/release/takana}" [ -x "$TAKANA_BIN" ] || TAKANA_BIN="$ROOT/target/release/takana" cp "$TAKANA_BIN" "$T/bin/takana"; chmod 0755 "$T/bin/takana" if [ -n "$VIA_RUN" ]; then RUN="${RUN:-work/takana-instalador.sh}" [ -x "$RUN" ] || { echo "no encuentro $RUN (armalo con scripts/instalador-autoextraible.sh)" >&2; exit 1; } mkdir -p "$T/var/tmp" cp "$RUN" "$T/takana-instalador.sh"; chmod 0755 "$T/takana-instalador.sh" for a in tar gzip gunzip df expr; do ln -sf busybox "$T/bin/$a"; done rm -rf "$T/paquete" else # El PAQUETE tal como lo recibe el usuario: el instalador, su kernel y el censo. cp "$PAQUETE/takana-instalador.cpio.gz" "$T/paquete/" cp "$PAQUETE/kernel/boot/bzImage" "$T/paquete/kernel/boot/" cp scripts/takana-install-partition.sh "$T/paquete/" cp scripts/lib/herramientas-desde-store.sh "$T/paquete/lib/" fi cat > "$T/init" <<'INIT' #!/bin/busybox sh # PID 1 del sistema ORIGEN. No instala nada: corre el verbo de usuario y deja de existir. export PATH=/bin mount -t proc proc /proc 2>/dev/null || true mount -t sysfs sys /sys 2>/dev/null || true mount -t devtmpfs dev /dev 2>/dev/null || true # Sin store adentro, el censo recibe sus herramientas por el override explícito. export TOOL_sfdisk=/bin/sfdisk log() { echo "$*" > /dev/console 2>/dev/null || true; } log "SALTO-TEST: sistema ORIGEN vivo — kernel $(uname -r), uid $(id -u)" i=0 while [ "$i" -lt 30 ]; do hay="" for d in /dev/vd? /dev/sd? /dev/nvme?n?; do [ -b "$d" ] && hay=1; done [ -n "$hay" ] && break i=$(( i + 1 )); sleep 1 done log "SALTO-TEST: discos: $(for d in /dev/vd? /dev/sd?; do [ -b "$d" ] && printf '%s ' "$d"; done)" DEST=""; ESPP="" for w in $(cat /proc/cmdline 2>/dev/null); do case "$w" in test.root=*) DEST="${w#test.root=}" ;; test.esp=*) ESPP="${w#test.esp=}" ;; esac done if [ -x /takana-instalador.sh ]; then # El camino del que BAJA UN FICHERO: la cabecera se desempaqueta sola y corre lo de adentro. log "SALTO-TEST: ───── takana-instalador.sh (el fichero publicado) ─────" # `--jump`: desde el 2026-09-23 el salto sólo ocurre si el destino es la raíz VIVA, y acá no # lo es. Este arnés existe para probar el salto, así que lo pide explícitamente. TAKANA_TMP=/var/tmp /takana-instalador.sh --root "$DEST" --esp "$ESPP" \ --jump --unattended --yes > /dev/console 2>&1 else log "SALTO-TEST: ───── takana metal check ─────" /bin/takana metal check > /dev/console 2>&1; log "SALTO-TEST: check rc=$?" log "SALTO-TEST: ───── takana metal survey ─────" /bin/takana metal survey --payload /paquete > /dev/console 2>&1; log "SALTO-TEST: survey rc=$?" log "SALTO-TEST: ───── takana metal install ─────" /bin/takana metal install --payload /paquete --root "$DEST" --esp "$ESPP" \ --jump --unattended --yes > /dev/console 2>&1 fi log "SALTO-TEST-FALLO: el salto NO ocurrió (install rc=$?) — el ORIGEN sigue vivo" sync; sleep 3; reboot -f INIT chmod 0755 "$T/init" ( cd "$T" && find . -print0 | "$CPIO" --null -o -H newc --owner=root:root 2>/dev/null | "$GZIP" -1 ) > "$P/origen.cpio.gz" echo " origen.cpio.gz $(du -m "$P/origen.cpio.gz" | cut -f1) MiB" # ── 3) QEMU en el worker ────────────────────────────────────────────────────────────────────── echo "==> subiendo al worker ($WORKER)" ssh -o BatchMode=yes "root@$WORKER" "mkdir -p $REMOTO" scp -q -o BatchMode=yes "$KEXEC_BZ" "$P/origen.cpio.gz" "root@$WORKER:$REMOTO/" # El disco viaja COMPRIMIDO. Son 4 GiB de los cuales ~3,9 son ceros: por `scp` tal cual tardó 7 # minutos, gzipeado son segundos. Medido en la primera corrida de este arnés. "$GZIP" -1 -c "$D" | ssh -o BatchMode=yes "root@$WORKER" "gzip -d -c > $REMOTO/disco.img" echo "==> arrancando la VM (kernel del ORIGEN = linux-metal-kexec)" LOG="$P/serie.log" ssh -o BatchMode=yes "root@$WORKER" "cd $REMOTO && timeout 900 qemu-system-x86_64 \ -m 4096 -smp 2 -display none -vga std -serial mon:stdio -no-reboot \ -kernel bzImage -initrd origen.cpio.gz \ -append 'console=tty0 console=ttyS0,115200 test.root=PARTUUID=$PU_ROOT test.esp=PARTUUID=$PU_ESP' \ -drive file=disco.img,format=raw,if=virtio "$LOG" 2>&1 || true echo " log: $LOG ($(wc -l < "$LOG") líneas)" echo "==> trayendo el disco de vuelta" ssh -o BatchMode=yes "root@$WORKER" "gzip -c $REMOTO/disco.img" > "$P/disco-despues.img.gz" [ -n "$CONSERVAR" ] || ssh -o BatchMode=yes "root@$WORKER" "rm -rf $REMOTO" "$GZIP" -d -c "$P/disco-despues.img.gz" > "$P/disco-despues.img" fi LOG="$P/serie.log" SHA_VECINO=$(sha256sum "$P/ajeno.bin" | cut -d' ' -f1) # ── 4) el veredicto sale del DISCO, no de la pantalla ───────────────────────────────────────── echo "==> comprobaciones" A="$P/disco-despues.img" grep -q 'SALTO-TEST: sistema ORIGEN vivo' "$LOG" && ok "el sistema origen arrancó" || falla "el origen no arrancó" grep -q 'puede instalarse SIN USB' "$LOG" && ok "metal check: dice que SÍ (el kernel trae el flag)" \ || falla "metal check no reconoció el kexec en un kernel que lo trae" grep -q 'el terreno de esta máquina' "$LOG" && ok "metal survey: el censo salió por pantalla" \ || falla "metal survey no imprimió el censo" if [ -n "$VIA_RUN" ]; then grep -q 'payload verificado (sha256)' "$LOG" && ok "el .sh verificó su propio payload" \ || falla "el .sh no verificó el sha256 de su payload" grep -q 'desempaquetando' "$LOG" && ok "el .sh se desempaquetó solo" || falla "el .sh no se desempaquetó" fi grep -q '⚡ saltando' "$LOG" && ok "metal install: anunció el salto" || falla "no anunció el salto" grep -q 'SALTO-TEST-FALLO' "$LOG" && falla "EL SALTO NO OCURRIÓ: el origen siguió vivo" \ || ok "EL SALTO OCURRIÓ: el origen no volvió del kexec" grep -q 'TAKANA-INSTALADOR: arrancado en RAM' "$LOG" && ok "el instalador arrancó DESPUÉS del salto" \ || falla "el instalador no arrancó tras el salto" grep -q 'TAKANA-INSTALADOR-OK' "$LOG" && ok "el instalador terminó bien" || falla "el instalador no llegó al final" # La tabla: tenía que quedar exactamente igual (este camino formatea, no reparticiona). # ⚠ Dos trampas, las dos medidas en la primera corrida: (a) `sfdisk -d` de un FICHERO prefija cada # línea con la RUTA del fichero, y las dos rutas son distintas por construcción —el antes y el # después no son el mismo fichero—, así que hay que quitar esa columna o el test es rojo siempre; # (b) `cmp` NO EXISTE en este anfitrión musl, y su ausencia no rompía el test: `cmp: command not # found` sale 127, el `if` lo toma como falso y el test acusaba «la tabla cambió» sin haber # comparado nada. Un test que no puede correr tiene que decirlo, no fallar con el mensaje de otra # cosa. Se compara con el propio shell. tabla_de() { "$SFDISK" -d "$1" 2>/dev/null | grep 'start=' | sed 's/^[^:]*:/:/'; } tabla_de "$A" > "$P/tabla-despues"; tabla_de "$D" > "$P/tabla-antes" if [ ! -s "$P/tabla-antes" ]; then falla "no pude leer la tabla del disco original: no comparé nada" elif [ "$(cat "$P/tabla-antes")" = "$(cat "$P/tabla-despues")" ]; then ok "la tabla de particiones NO cambió" else falla "la tabla cambió"; fi # El destino: ext4 con la etiqueta que busca el initramfs de pivote. Se extrae por OFFSET, que es # lo único que se puede hacer sin loop devices dentro de esta jaula. OFF=$(( 264192 * 512 )) dd if="$A" of="$P/p2.img" bs=1M skip=$(( OFF / 1048576 )) count=2400 status=none if "$BLKID" "$P/p2.img" 2>/dev/null | grep -q 'LABEL="hammer-root"'; then ok "el destino quedó ext4 con LABEL=hammer-root" else falla "el destino no tiene LABEL=hammer-root: $("$BLKID" "$P/p2.img" 2>&1 | head -1)" fi # La ESP: nuestra carpeta de vendor puesta, y la del vecino intacta. ESPOFF="$A@@1048576" if "$MDIR" -i "$ESPOFF" ::/EFI/takana 2>/dev/null | grep -qi 'takanax64'; then ok "la ESP tiene \\EFI\\takana\\takanax64.efi" else falla "la ESP no tiene nuestra carpeta de vendor" fi rm -f "$P/vecino-despues.bin" "$MCOPY" -i "$ESPOFF" ::/EFI/Microsoft/Boot/bootmgfw.efi "$P/vecino-despues.bin" 2>/dev/null || true SHA_DESPUES=$(sha256sum "$P/vecino-despues.bin" 2>/dev/null | cut -d' ' -f1) [ -n "$SHA_DESPUES" ] && [ "$SHA_VECINO" = "$SHA_DESPUES" ] \ && ok "el vecino de la ESP (bootmgfw.efi de Microsoft) quedó byte a byte igual" \ || falla "el vecino de la ESP cambió o desapareció" echo if [ "$FALLOS" = 0 ]; then echo "VERDE — el salto se ejecutó y el disco quedó instalado."; else echo "ROJO — $FALLOS comprobación(es) fallaron. Log: $LOG" >&2; exit 1; fi