arnés: el salto por kexec, EJECUTADO — el usuario dejaba de ser el banco de pruebas
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 laptop del usuario. Este arnés lo cierra. Arranca una VM en el worker con linux-metal-kexec —la variante que sí trae el flag—, le da un disco con vecinos de verdad (ESP con un bootmgfw.efi de Microsoft dentro, destino vacío, hueco sin asignar) y corre ahí los TRES verbos de usuario. Once comprobaciones, y el veredicto sale del DISCO y no de lo que la pantalla dijo — la lección del tee, que imprimía «instalado» sin haber formateado nada. El log serial corta exactamente donde tiene que cortar («saltando al instalador — esto no vuelve») y la línea siguiente ya es del otro kernel. Dos comprobaciones nacieron fallando SIN que el instalador tuviera nada que ver, que es la forma más cara de tener un test: - comparaba el listado de mdir del vecino contra sí mismo, y el listado trae el «bytes free» del volumen, que cambia en cuanto se copia cualquier otra cosa en la ESP. Ahora se compara el FICHERO, byte a byte. - usaba cmp, que NO EXISTE en este anfitrión musl. `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. Y el disco viaja comprimido en los dos sentidos: son 4 GiB de los cuales 3,9 son ceros, y por scp tal cual la corrida tardaba 10 minutos de los que 9 eran mover nada. Con --verificar-solo se itera sobre las comprobaciones reusando el disco que ya volvió.
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
1 qillqakuna · +246 −0
- scripts/metal-salto-test.sh +246 −0
diff --git a/scripts/metal-salto-test.sh b/scripts/metal-salto-test.sh
new file mode 100755
index 00000000..f2b7e5da
--- /dev/null
+++ b/scripts/metal-salto-test.sh
@@ -0,0 +1,246 @@
+#!/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=""
+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"
+# 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/"
+
+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
+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" \
+ --unattended --yes > /dev/console 2>&1
+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 </dev/null" > "$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"
+grep -q 'saltando al instalador' "$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