Files
takana/scripts/install-image.sh
T
SergioandClaude Opus 5 77cda9a865 install-image: un filesystem etiquetado hammer-work se monta en /work
Convención opcional, no requisito: la imagen no crea esa partición y si la etiqueta no existe el
arranque no hace nada ni avisa.

Existe por un caso concreto. Al mudar el store de la caja de producción al volumen, la partición
local del store —69,8 G de un disco de 76,3— quedó huérfana: re-etiquetada para que el `findfs` del
arranque no la confundiera con el volumen, y sin montar. La caja tenía 70 G de disco ocioso mientras
`/store` iba al 80 %.

`/work` es el destino natural: es donde un HUB pone lo pesado —`work/sources`, los tarballs, el
`CARGO_HOME`—, que en gioser son 85 G y que no cabe ni conviene en una raíz de 6 G.

En la caja: `sda4` re-etiquetada `hammer-work`, REFORMATEADA (traía los 61,5 G del store viejo,
anterior a la cosecha; control antes de borrar: el store vivo tiene 1579 artefactos y el respaldo
3140) y `/opt/takana/work` pasa a ser un enlace a `/work`. Verificado tras reiniciar: se monta solo,
68,1 G con 64,6 G libres, y los manifiestos siguen donde `build-state.py` los busca.

La caja usa ahora su disco entero: `/` 5,8 G · `/var/lib/hammer` 487 M · `/work` 68,1 G local ·
`/store` 97,9 G en el volumen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 14:01:23 +00:00

252 lines
15 KiB
Bash
Executable File

#!/bin/sh
# install-image.sh — Etapa E1 (SDD 13): produce una imagen de disco AUTO-BOOTEABLE. A diferencia de
# scripts/disk-image.sh (B3, que se arranca con `qemu -kernel` para verificar el auto-alojamiento),
# ésta lleva GRUB en el propio disco ⇒ bootea con `qemu-system-x86_64 -drive file=img` SIN `-kernel` ni
# firmware extra (SeaBIOS de QEMU → MBR → core.img → kernel), como en hardware real.
#
# Layout (GPT, BIOS):
# /dev/vda1 BIOS boot (ef02, ~2 MiB, sin fs) core.img de GRUB
# /dev/vda2 / ext4 [takana-root] + /boot/bzImage + /boot/grub
# /dev/vda3 /store ext4 [takana-store]
# /dev/vda4 /var/lib/hammer ext4 [takana-state]
#
# Instalación de GRUB en USERSPACE (sin root ni loop): grub-mkimage arma core.img; grub-bios-setup
# escribe boot.img→MBR + core.img→partición BIOS sobre el fichero imagen (I/O de bloques). Los módulos
# de GRUB + el kernel van en /boot de la root, que mke2fs -d puebla desde un directorio.
#
# Uso:
# ./scripts/install-image.sh # crea work/takana-install.img
# BOOT=1 KVM=1 ./scripts/install-image.sh # además lo arranca (auto-boot, sin -kernel)
#
# Target IN-PLACE (Etapa E2): si IMG es un block device (o PREALLOC=1 sobre un fichero pre-dimensionado),
# se instala EN SITIO — no se trunca ni se borra el nodo, sólo se valida capacidad. Esa es la base de
# `takana install /dev/sdX` (ver scripts/takana-install.sh): misma lógica, apuntando a un disco real.
#
# Variables:
# ROOTFS dir rootfs a empaquetar (default work/builder-rootfs)
# STAGE dir de staging (default work/.install-stage) — DEBE estar en el
# mismo montaje que ROOTFS: el staging hardlinkea y un hardlink no cruza montajes
# PREALLOC 1 ⇒ trata IMG como target pre-dimensionado in-place (no trunca; prueba el camino device)
# KERNEL bzImage a embeber en /boot (default el del store: store/*-linux/boot/bzImage)
# IMG imagen de salida (default work/takana-install.img)
# ROOT_SIZE MiB de / (default 4096)
# STORE_SIZE MiB de /store (default 2048)
# STATE_SIZE MiB de /var/lib/hammer (default 2048)
# CMDLINE línea de comando del kernel (default "console=ttyS0 root=/dev/vda2 rw rdinit=/sbin/init")
# BOOT 1 ⇒ arranca la imagen con QEMU tras crearla (auto-boot, sin -kernel)
# KVM/MEM passthrough al arranque de prueba
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
ROOTFS="${ROOTFS:-work/builder-rootfs}"
IMG="${IMG:-work/takana-install.img}"
ROOT_SIZE="${ROOT_SIZE:-4096}"
STORE_SIZE="${STORE_SIZE:-2048}"
STATE_SIZE="${STATE_SIZE:-2048}"
CMDLINE="${CMDLINE:-console=ttyS0 root=/dev/vda2 rw rdinit=/sbin/init}"
KERNEL="${KERNEL:-$(ls store/*-linux/boot/bzImage 2>/dev/null | head -1)}"
GRUB_LIB="${GRUB_LIB:-/usr/lib/grub/i386-pc}"
[ -d "$ROOTFS" ] || { echo "no existe ROOTFS: $ROOTFS (corré selfhost-verify para armar el builder)" >&2; exit 1; }
# `-e` sigue el symlink y lo resuelve contra el HOST, no contra el rootfs. El `/sbin/init` del
# product-rootfs es un symlink ABSOLUTO a `/usr/bin/arje-zero`, que en el host no existe ⇒ `-e` da
# falso y esta guarda rechazaba el rootfs CORRECTO. Peor: un rootfs con el init equivocado
# (`../bin/busybox`, symlink RELATIVO que sí resuelve dentro del árbol) la pasaba sin chistar.
# O sea que la guarda estaba exactamente invertida. `-L` acepta el symlink por lo que es.
[ -e "$ROOTFS/sbin/init" ] || [ -L "$ROOTFS/sbin/init" ] || { echo "ROOTFS sin /sbin/init (arje-zero)" >&2; exit 1; }
[ -n "$KERNEL" ] && [ -r "$KERNEL" ] || { echo "no encuentro el kernel a embeber (set KERNEL=<bzImage>)" >&2; exit 1; }
[ -d "$GRUB_LIB" ] && [ -r "$GRUB_LIB/boot.img" ] || { echo "faltan los módulos GRUB i386-pc en $GRUB_LIB" >&2; exit 1; }
for t in sfdisk mke2fs grub-mkimage grub-bios-setup; do
command -v "$t" >/dev/null 2>&1 || { echo "falta $t" >&2; exit 1; }
done
unshare -r true 2>/dev/null || { echo "se requiere 'unshare -r' (ficheros root-owned sin sudo)" >&2; exit 1; }
# El staging ENLAZA (hardlink) desde $ROOTFS, y un hardlink no cruza un montaje aunque sea el mismo
# disco. Con el store en un volumen aparte —el layout normal de este repo: `store` y `work/out` son
# bind-mounts de /dev/sdb y `work/` está en /dev/sdc— el rootfs vive del otro lado y `cp -al` muere
# con EXDEV fichero por fichero. Por eso es env: ponelo bajo el MISMO montaje que $ROOTFS.
STAGE="${STAGE:-work/.install-stage}"
rm -rf "$STAGE"
mkdir -p "$STAGE"
ROOT_TREE="$STAGE/root"
ROOT_IMG="$STAGE/root.img"
STORE_IMG="$STAGE/store.img"
STATE_IMG="$STAGE/state.img"
GRUB_STAGE="$STAGE/grub"
# --- árbol root: hardlinks del rootfs SIN store/estado + /boot (kernel + GRUB) + wrapper /sbin/init ---
echo "==> staging root: rootfs (hardlinks) + /boot/bzImage + /boot/grub + wrapper /sbin/init"
cp -al "$ROOTFS" "$ROOT_TREE"
rm -rf "$ROOT_TREE/store" "$ROOT_TREE/var/lib/hammer"
mkdir -p "$ROOT_TREE/store" "$ROOT_TREE/var/lib/hammer" "$ROOT_TREE/boot/grub/i386-pc"
cp "$KERNEL" "$ROOT_TREE/boot/bzImage"; chmod 0644 "$ROOT_TREE/boot/bzImage"
# Módulos GRUB que core.img cargará en tiempo de boot desde /boot/grub/i386-pc.
cp "$GRUB_LIB"/*.mod "$GRUB_LIB"/*.lst "$ROOT_TREE/boot/grub/i386-pc/" 2>/dev/null || true
# grub.cfg con consola serie (para -nographic) y el menuentry del kernel embebido.
cat > "$ROOT_TREE/boot/grub/grub.cfg" <<CFG
set timeout=2
set default=0
serial --unit=0 --speed=115200
terminal_input serial console
terminal_output serial console
insmod part_gpt
insmod ext2
menuentry "hammer" {
linux /boot/bzImage $CMDLINE
}
CFG
# Wrapper /sbin/init: monta las particiones dedicadas y hace exec del init real (igual que B3).
rm -f "$ROOT_TREE/sbin/init"
cat > "$ROOT_TREE/sbin/init" <<'INIT'
#!/bin/sh
# Wrapper PID1 (Etapa E/B3): el kernel montó la raíz; montamos las particiones dedicadas.
#
# Se busca por ETIQUETA, que es la que este mismo script pone con `mke2fs -L`. Las dos alternativas
# que se probaron y NO sirven acá:
# · `/dev/vda3` cableado — vale sólo con virtio-blk. En una caja Hetzner Cloud la controladora es
# virtio-SCSI y el disco es `sda`, así que los montajes fallaban.
# · derivar el disco de `/proc/mounts` — MEDIDO que no funciona: PID1 corre ANTES de que nadie
# monte /proc, así que la lectura sale vacía. (Queda de respaldo, montando /proc primero.)
# `findfs` sí funciona sin /proc: /dev ya está poblado por el kernel (DEVTMPFS_MOUNT=y).
#
# Y los avisos van a /dev/kmsg: un `echo` a stdout en PID1 se pierde antes de que haya consola —
# por eso este wrapper falló en silencio la primera vez.
_say() { echo "init: $*" > /dev/kmsg 2>/dev/null || echo "init: $*"; }
_store=$(/bin/busybox findfs LABEL=hammer-store 2>/dev/null)
_state=$(/bin/busybox findfs LABEL=hammer-state 2>/dev/null)
if [ -z "$_store" ]; then
_say "findfs no resolvió las etiquetas; derivo del disco de la raíz"
/bin/busybox mount -t proc proc /proc 2>/dev/null
_root=$(/bin/busybox awk '$2=="/" {print $1; exit}' /proc/mounts 2>/dev/null)
_disk=${_root%[0-9]}
[ -n "$_disk" ] && { _store="${_disk}3"; _state="${_disk}4"; }
fi
# ── Crecer el store al disco entero, ANTES de montarlo ──────────────────────────────────────
# Una imagen se escribe con `dd` sobre un disco casi siempre MÁS GRANDE que ella: la del perfil
# servidor son 7 G sobre los 76 G de una caja hcloud, o sea 69 G que nadie podía usar. Sin esto la
# caja arranca perfecta y con el disco a un décimo — de nuevo el fallo que no falla.
# Es idempotente: si la partición ya llega al final, `sfdisk` y `resize2fs` no hacen nada. Y sólo
# toca la ÚLTIMA partición (por eso el store va último en el layout): extenderla no mueve datos.
if [ -n "$_store" ] && [ -x /sbin/sfdisk ]; then
_disk=$(echo "$_store" | /bin/busybox sed 's/[0-9]*$//; s/p$//')
_num=$(echo "$_store" | /bin/busybox sed 's/^.*[^0-9]//')
if [ -n "$_disk" ] && [ -n "$_num" ]; then
if echo ', +' | /sbin/sfdisk --no-reread --force -N "$_num" "$_disk" >/dev/null 2>&1; then
[ -x /usr/sbin/partx ] && /usr/sbin/partx -u "$_disk" >/dev/null 2>&1
[ -x /sbin/e2fsck ] && /sbin/e2fsck -p -f "$_store" >/dev/null 2>&1
[ -x /usr/sbin/resize2fs ] && /usr/sbin/resize2fs "$_store" >/dev/null 2>&1 \
&& _say "store: $_store extendido al final de $_disk"
fi
fi
fi
[ -n "$_store" ] && /bin/busybox mount -t ext4 "$_store" /store || _say "no pude montar /store ($_store)"
[ -n "$_state" ] && /bin/busybox mount -t ext4 "$_state" /var/lib/hammer || _say "no pude montar /var/lib/hammer ($_state)"
# ── OPCIONAL: un filesystem etiquetado `hammer-work` se monta en /work ──────────────────────────
# Convención, no requisito: la imagen no crea esa partición. Existe para el caso de un HUB, donde el
# trabajo pesado —`work/sources`, los tarballs, el `CARGO_HOME`— no cabe ni conviene en la raíz. En
# gioser eso son 85 G. Si la etiqueta no existe, no pasa nada y no se avisa: no es un error.
_work=$(/bin/busybox findfs LABEL=hammer-work 2>/dev/null)
if [ -n "$_work" ]; then
/bin/busybox mkdir -p /work
/bin/busybox mount -t ext4 "$_work" /work || _say "no pude montar /work ($_work)"
fi
exec /usr/bin/arje-zero
INIT
chmod +x "$ROOT_TREE/sbin/init"
# --- GPT: p1=BIOS boot (ef02), p2=/, p3=/var/lib/hammer, p4=/store ---
#
# EL STORE VA ÚLTIMO, Y ES A PROPÓSITO. Una imagen se escribe con `dd` sobre un disco que casi
# siempre es MÁS GRANDE que ella (la del perfil servidor son 7 G sobre los 76 G de una caja hcloud),
# y sólo la ÚLTIMA partición puede crecer sin mover nada. Con el store en el medio —como estaba— el
# disco quedaba con 69 G inalcanzables y la partición que de verdad crece, la del store, congelada en
# su tamaño de fábrica. El wrapper de /sbin/init la extiende en el primer arranque.
SECT_MIB=2048
BIOS_SECT=4096 # 2 MiB para core.img
ROOT_SECT=$(( ROOT_SIZE * SECT_MIB ))
STATE_SECT=$(( STATE_SIZE * SECT_MIB ))
STORE_SECT=$(( STORE_SIZE * SECT_MIB ))
START1=2048
START2=$(( START1 + BIOS_SECT ))
START3=$(( START2 + ROOT_SECT ))
START4=$(( START3 + STATE_SECT ))
TOTAL_SECT=$(( START4 + STORE_SECT + 2048 ))
BIOS_GUID="21686148-6449-6E6F-744E-656564454649" # GPT "BIOS boot partition"
# Target = block device (instalación in-place, Etapa E2 `takana install /dev/sdX`) o fichero imagen.
# Un device tiene tamaño fijo: NO se trunca ni se borra el nodo; se valida capacidad. PREALLOC=1 fuerza
# este modo sobre un fichero pre-dimensionado (para probar el camino de device sin hardware real).
TOTAL_BYTES=$(( TOTAL_SECT * 512 ))
if [ -b "$IMG" ] || [ "${PREALLOC:-0}" = 1 ]; then
cur=$(blockdev --getsize64 "$IMG" 2>/dev/null || stat -c%s "$IMG" 2>/dev/null || echo 0)
[ "$cur" -ge "$TOTAL_BYTES" ] || { echo "target $IMG es ${cur}B < ${TOTAL_BYTES}B requeridos" >&2; exit 1; }
echo "==> target IN-PLACE: $IMG ($(( cur/1024/1024 ))M disponibles; root ${ROOT_SIZE}M + store ${STORE_SIZE}M + estado ${STATE_SIZE}M)"
else
echo "==> imagen : $IMG (GPT BIOS-boot, root ${ROOT_SIZE}M + store ${STORE_SIZE}M + estado ${STATE_SIZE}M)"
rm -f "$IMG"
truncate -s "$TOTAL_BYTES" "$IMG"
fi
sfdisk --quiet "$IMG" >/dev/null <<EOF
label: gpt
start=$START1, size=$BIOS_SECT, type=$BIOS_GUID, name="bios-boot"
start=$START2, size=$ROOT_SECT, type=linux, name="hammer-root"
start=$START3, size=$STATE_SECT, type=linux, name="hammer-state"
start=$START4, size=$STORE_SECT, type=linux, name="hammer-store"
EOF
# --- poblar las 3 particiones ext4 (root-owned vía unshare -r) y empalmar en su offset ---
echo "==> mke2fs + empalme de las particiones ext4 (unshare -r, sin sudo)"
unshare -r sh -eu <<EOF
mke2fs -q -t ext4 -L hammer-root -E root_owner=0:0 -d "$ROOT_TREE" "$ROOT_IMG" ${ROOT_SIZE}M
mke2fs -q -t ext4 -L hammer-store -E root_owner=0:0 -d "$ROOTFS/store" "$STORE_IMG" ${STORE_SIZE}M
mke2fs -q -t ext4 -L hammer-state -E root_owner=0:0 -d "$ROOTFS/var/lib/hammer" "$STATE_IMG" ${STATE_SIZE}M
dd if="$ROOT_IMG" of="$IMG" bs=512 seek=$START2 conv=sparse,notrunc status=none
dd if="$STATE_IMG" of="$IMG" bs=512 seek=$START3 conv=sparse,notrunc status=none
dd if="$STORE_IMG" of="$IMG" bs=512 seek=$START4 conv=sparse,notrunc status=none
EOF
# --- instalar GRUB en userspace SIN root/loop: grub-mkimage arma core.img, y un patch binario
# determinista coloca boot.img→MBR + core.img→BIOS boot partition (vda1) ajustando los punteros.
# (`grub-bios-setup` se descarta: sondea el disco FÍSICO del host —/dev/nvme…, 660 root:disk— para
# "adivinar el root device" del dir -d, y falla sin root; el patch manual evita ese sondeo.)
echo "==> instalar GRUB (grub-mkimage + patch binario boot/core, sin root/loop)"
mkdir -p "$GRUB_STAGE"
# core.img: prefix a (hd0,gpt2)/boot/grub (root = 2ª partición GPT); embebemos lo justo para leer
# GPT+ext2, cargar grub.cfg (normal/configfile) y el kernel (linux).
grub-mkimage -O i386-pc -p '(hd0,gpt2)/boot/grub' -o "$GRUB_STAGE/core.img" \
biosdisk part_gpt ext2 normal configfile linux echo ls boot
# Punteros (ABI estable de GRUB i386-pc):
# boot.img off 0x5c kernel_sector (u64 LE): LBA del 1er sector de core.img → START1
# core.img off 0x1F4 blocklist.start (u64 LE): LBA del RESTO de core.img → START1+1
# Verificamos los valores por defecto (1 y 2: core.img asumido contiguo tras el MBR) antes de tocar.
START1="$START1" GRUB_LIB="$GRUB_LIB" GRUB_STAGE="$GRUB_STAGE" IMG="$IMG" python3 - <<'PY'
import os, struct
s1 = int(os.environ["START1"])
img = os.environ["IMG"]
boot = open(os.path.join(os.environ["GRUB_LIB"], "boot.img"), "rb").read()
core = bytearray(open(os.path.join(os.environ["GRUB_STAGE"], "core.img"), "rb").read())
boot = bytearray(boot)
assert struct.unpack_from("<Q", boot, 0x5c)[0] == 1, "boot.img kernel_sector != 1 (ABI GRUB cambió)"
assert struct.unpack_from("<Q", core, 0x1f4)[0] == 2, "core.img blocklist.start != 2 (ABI GRUB cambió)"
struct.pack_into("<Q", boot, 0x5c, s1) # core.img empieza en START1
struct.pack_into("<Q", core, 0x1f4, s1 + 1) # el resto de core.img, justo después
with open(img, "r+b") as f:
f.seek(s1 * 512); f.write(core) # core.img → BIOS boot partition (vda1)
f.seek(0); f.write(boot[:440]) # boot.img → MBR, sin pisar la GPT protective (446+)
print(f" boot.img→MBR (kernel_sector={s1}), core.img→sector {s1} ({len(core)} B), blocklist.start={s1+1}")
PY
rm -rf "$STAGE"
echo "==> imagen instalable creada: $(du -h "$IMG" | cut -f1) en disco (sparse), $(( TOTAL_SECT * 512 / 1024 / 1024 ))M virtuales"
echo " auto-boot: qemu-system-x86_64 -drive file=$IMG,format=raw -nographic (sin -kernel)"
if [ "${BOOT:-0}" = 1 ]; then
echo "==> arranque de prueba (auto-boot, sin -kernel)"
accel=""
[ "${KVM:-0}" = 1 ] && [ -w /dev/kvm ] && accel="-enable-kvm -cpu host"
# shellcheck disable=SC2086
exec qemu-system-x86_64 -m "${MEM:-4096}" -no-reboot -nographic $accel \
-drive file="$IMG",format=raw,if=virtio
fi