Una imagen se escribe con `dd` sobre un disco casi siempre más grande que ella. La del perfil servidor son 7 G; la caja hcloud tiene 76,3 G. **Sobraban 69 G que nadie podía usar**, y la caja arrancaba perfecta con el disco a un décimo — el fallo que no falla, otra vez. Dos cambios que van juntos: 1. **El store pasa a ser la ÚLTIMA partición** (p1 bios, p2 `/`, p3 `/var/lib/hammer`, p4 `/store`). Sólo la última puede extenderse sin mover datos, y el store es justamente la que crece con el uso. Con él en el medio, la partición extensible era la de ESTADO, de 512 M, que no le sirve a nadie. El cambio es seguro porque nada referencia números de partición: `root=PARTLABEL=hammer-root` y el wrapper monta por etiqueta con `findfs`. 2. **El wrapper de `/sbin/init` la extiende en el primer arranque**, antes de montarla: `sfdisk -N <n> ', +'` → `partx -u` → `e2fsck -pf` → `resize2fs`. Idempotente: si ya llega al final, los dos últimos no hacen nada. Guardado tras `-x /sbin/sfdisk` para no romper un rootfs que no lo traiga, y el aviso va a `/dev/kmsg`, que es donde se lee. Probado en QEMU volcando la imagen de 7 G en un disco de 20 G: el arranque imprime `init: store: /dev/sda4 extendido al final de /dev/sda` y `/store` queda en **13,3 G** con 12,6 G libres, sin tocar nada a mano. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
243 lines
14 KiB
Bash
Executable File
243 lines
14 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)"
|
|
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
|