#!/bin/sh # servidor-image.sh — arma la imagen de disco del `perfil.servidor` lista para escribir en una caja # Hetzner Cloud por rescue (SDD 28 §3). No inventa nada: encadena `hydrate-profile.py` + # `install-image.sh`, que ya existían, y agrega lo que ninguno de los dos puede saber solo. # # ── LO QUE ESTE SCRIPT EXISTE PARA NO OLVIDAR (todo medido el 2026-09-10) ──────────────────────── # # 1. EL KERNEL. `recipes/linux.toml` NO sirve para hcloud: apaga SCSI (`-d SCSI`) y el disco de una # caja Hetzner lo maneja virtio-SCSI ⇒ arranca y no ve el disco. Va `linux-generic`, que sí trae # `CONFIG_SCSI_VIRTIO=y` — dato que NO está en la receta (lo enciende `make defconfig`) y que sólo # se ve en el `.config` que el artefacto publica. # # 2. EL INIT SE PISA. La clausura del perfil trae `busybox`, y su `/sbin/init -> ../bin/busybox` gana # por hidratarse DESPUÉS del product-rootfs, borrando el `-> /usr/bin/arje-zero`. La imagen # arrancaría perfecta con el init equivocado. Se restaura acá y además `install-image.sh` escribe # su propio wrapper encima; el cmdline no lleva `init=` para que ese wrapper —que monta /store y # /var/lib/hammer— sea el que corre. # # 3. EXDEV. El staging y la hidratación HARDLINKEAN, y un hardlink no cruza un montaje aunque sea el # mismo disco. Con el store en un volumen (el layout normal de este repo) hay que trabajar del # lado del store. Se comprueba y se aborta con un mensaje claro en vez de fallar fichero a fichero. # # 4. LA CLAVE. Sin `authorized_keys` la instalación remota deja una máquina viva e INALCANZABLE, que # es peor que una que no arrancó. Es obligatoria: sin `AUTHKEYS` no se arma la imagen. # # Uso: # AUTHKEYS=~/.ssh/github5.pub ./scripts/servidor-image.sh # # Env: # AUTHKEYS pubkey a instalar para root (OBLIGATORIO) # PERFIL perfil de targets.toml a hidratar (def servidor) # STORE store desde el que hidratar (def /mnt/cosecha/store) # WORKDIR dir de trabajo, MISMO montaje que STORE (def /mnt/cosecha/servidor-build) # IMG imagen de salida (def $WORKDIR/servidor.img) # KERNELPKG paquete del kernel (def linux-generic) # ROOT_SIZE/STORE_SIZE/STATE_SIZE MiB de cada partición (def 6144/512/512) set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT" PERFIL="${PERFIL:-servidor}" STORE="${STORE:-/mnt/cosecha/store}" WORKDIR="${WORKDIR:-/mnt/cosecha/servidor-build}" IMG="${IMG:-$WORKDIR/servidor.img}" KERNELPKG="${KERNELPKG:-linux-generic}" ROOT_SIZE="${ROOT_SIZE:-6144}"; STORE_SIZE="${STORE_SIZE:-512}"; STATE_SIZE="${STATE_SIZE:-512}" [ -n "${AUTHKEYS:-}" ] || { echo "!! falta AUTHKEYS: una imagen sin clave deja la caja INALCANZABLE" >&2; exit 2; } [ -r "$AUTHKEYS" ] || { echo "!! no puedo leer AUTHKEYS=$AUTHKEYS" >&2; exit 2; } # ── guarda de EXDEV: el rootfs se arma con hardlinks contra el store ───────────────────────────── mkdir -p "$WORKDIR" m_store=$(findmnt -no TARGET -T "$STORE") m_work=$(findmnt -no TARGET -T "$WORKDIR") [ "$m_store" = "$m_work" ] || { echo "!! STORE ($STORE → montaje $m_store) y WORKDIR ($WORKDIR → montaje $m_work) están en" >&2 echo " montajes DISTINTOS. El armado hardlinkea y un hardlink no cruza un montaje aunque sea" >&2 echo " el mismo disco. Poné WORKDIR bajo $m_store." >&2 exit 1 } # ── 1. base: el product-rootfs sellado, que es quien trae /sbin/init -> arje-zero ──────────────── PDIR=$(ls -dt "$STORE"/*-product-rootfs 2>/dev/null | head -1) [ -n "$PDIR" ] || { echo "!! no hay product-rootfs sellado en $STORE (corré 'takana bootstrap product')" >&2; exit 1; } KDIR=$(ls -dt "$STORE"/*-"$KERNELPKG" 2>/dev/null | head -1) [ -n "$KDIR" ] && [ -r "$KDIR/boot/bzImage" ] || { echo "!! no hay $KERNELPKG sellado con /boot/bzImage en $STORE" >&2; exit 1; } RFS="$WORKDIR/rootfs" echo "==> base : $(basename "$PDIR")" echo "==> kernel : $(basename "$KDIR")" rm -rf "$RFS"; mkdir -p "$RFS" cp -al "$PDIR/." "$RFS/" # ── 2. userland del perfil encima ──────────────────────────────────────────────────────────────── echo "==> hidratando perfil '$PERFIL'" python3 scripts/hydrate-profile.py "$PERFIL" --into "$RFS" --store "$STORE" --keep # ── 3. reparar el init que la hidratación pisó (ver cabecera, punto 2) ─────────────────────────── antes=$(readlink "$RFS/sbin/init" 2>/dev/null || echo "(fichero)") ln -sf /usr/bin/arje-zero "$RFS/sbin/init" echo "==> init : $antes → $(readlink "$RFS/sbin/init")" [ -x "$RFS/usr/bin/arje-zero" ] || { echo "!! el rootfs no tiene /usr/bin/arje-zero" >&2; exit 1; } # ── 4. la clave ────────────────────────────────────────────────────────────────────────────────── install -d -m 700 "$RFS/root/.ssh" install -m 600 "$AUTHKEYS" "$RFS/root/.ssh/authorized_keys" echo "==> clave : $(wc -l < "$RFS/root/.ssh/authorized_keys") línea(s) en /root/.ssh/authorized_keys" # ── 5. la imagen. SIN `init=`: así corre el wrapper de install-image.sh, que monta /store y el diario # El serial primero y tty0 al final ⇒ /dev/console es tty0, que es lo que muestra la consola web # de Hetzner; los mensajes del kernel salen por los dos. CMDLINE="console=ttyS0,115200 console=tty0 root=PARTLABEL=hammer-root rw" echo "==> cmdline : $CMDLINE" ROOTFS="$RFS" STAGE="$WORKDIR/.stage" KERNEL="$KDIR/boot/bzImage" IMG="$IMG" \ ROOT_SIZE="$ROOT_SIZE" STORE_SIZE="$STORE_SIZE" STATE_SIZE="$STATE_SIZE" CMDLINE="$CMDLINE" \ ./scripts/install-image.sh echo echo "✓ imagen del perfil '$PERFIL': $IMG" echo " escribir en una caja Hetzner (con la caja en RESCUE):" echo " zstd -T2 -3 -c $IMG | ssh root@ 'zstd -d -c | dd of=/dev/sda bs=4M conv=fsync && sync'"