Se armó la imagen del perfil `servidor` con gitea y se arrancó en QEMU. Los tres hallazgos, en el orden en que aparecieron, son los que justifican probar la imagen en vez de dar por buena la receta. 1) ⚠⚠ ESCRIBIR EN EL ROOTFS HIDRATADO ES ESCRIBIR DENTRO DEL STORE `takana users --merge` hacía `fs::write` sobre `<rootfs>/etc/passwd`. Un rootfs hidratado se arma con HARDLINKS contra el store: medido, ese fichero y el del artefacto `product-rootfs` eran **el mismo inode (1225824, 2 links, modo 444)**. Un `write` habría modificado el artefacto SELLADO, y todas las imágenes futuras habrían salido con la cuenta metida dentro del producto. Acá se salvó porque el store es de sólo lectura y salió `Permission denied` — confiar en eso es confiar en un permiso. Ahora `escribir_rompiendo_hardlink()`: temporal + `rename`. Con su control, que afirma lo que importa: tras escribir, el fichero del store **conserva su contenido**, baja a 1 link y el del rootfs tiene otro inode. Verificado también sobre la imagen real. 2) LA IMAGEN TRAÍA EL BINARIO, LA CUENTA… Y NADIE LO ARRANCABA Primera imagen: `gitea` instalado, `gitea:x:916` en `/etc/passwd`, y en el `genesis` de la seed sólo `sshd`, `console-getty`, `hammerd`, `hammer-product`. `servidor-image.sh` no inyectaba las Cards — eso sólo estaba en el camino de las imágenes de escritorio. Y ninguna métrica lo dice: `--services` responde que el perfil lo habilita, y lo habilita; lo que faltaba era el paso que lleva esa declaración a la imagen. Ahora llama a `service-cards` + `inyectar-cards.py` (idempotente por label). 3) ⚡ EL BINARIO MORÍA CON `trap invalid opcode` — Y EL BUG ESTABA EN EL BUILDER Con la card en el genesis y la config puesta, gitea arrancaba y moría al instante: traps: gitea[91] trap invalid opcode ip:79ea992 ... in gitea[...] El sandbox exporta `CC` apuntando a un wrapper que pone `-mcpu=baseline` (`sandbox.rs` ya avisaba: «de paso cierra el SIGILL de AVX en qemu64»), y la fase Go del propio builder lo PISABA con `CC="zig cc"` a secas — que por defecto es `-mcpu=native` y hornea la ISA del que compila. El binario corría perfecto en el worker y moría en QEMU-TCG. No falla al compilar ni al sellar: falla al EJECUTAR en otra CPU. Y el `ArtifactHash` no lo puede cazar, porque la CPU del builder no entra en `hash_inputs` — dos workers distintos sellan bytes distintos bajo la misma dirección. Arreglado en el builder y en la receta; re-hashea las CINCO recetas `cgo = true` (gitea, usql, sq, gocryptfs, naabu), que es correcto: lo que había sellado no es portable. gitea: `b3:391a613e…` → `b3:5edf9c16…`. Lo verificado en la VM: PID 1 = arje-zero · la cuenta de `[[user]]` en `/etc/passwd` y `/etc/group` de la imagen · la card de gitea en el `genesis` · y arje encarnándola, con la guarda saliendo 78 y `/var/log/arje/ente-gitea.log` diciendo exactamente «falta /etc/gitea/app.ini — es config del SITIO». Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn
146 lines
9.2 KiB
Bash
Executable File
146 lines
9.2 KiB
Bash
Executable File
#!/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}"
|
|
# El binario: en un hub de desarrollo está en `target/release`, en una caja instalada es
|
|
# `/usr/bin/takana` y `target/` ni existe. Es el bloqueo que ya costó una vez en la caja nueva
|
|
# («unhashable 875» con el lab bien): los scripts asumían árbol de desarrollo.
|
|
TAKANA="${TAKANA:-$ROOT/target/release/takana}"
|
|
[ -x "$TAKANA" ] || TAKANA="$(command -v takana || true)"
|
|
[ -n "$TAKANA" ] && [ -x "$TAKANA" ] || { echo "!! no encuentro el binario takana (probá TAKANA=/usr/bin/takana)" >&2; exit 2; }
|
|
|
|
[ -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
|
|
|
|
# ── 2 bis. las CUENTAS que los paquetes del perfil declaran (`[[user]]`, SDD 30) ─────────────────
|
|
# El `/etc/passwd` del product-rootfs trae `root` y `sshd` y nada más, porque es una constante de
|
|
# Rust. Un paquete cuyo demonio se niega a correr como root —`gitea` lo hace explícitamente— tiene
|
|
# su Card haciendo `setuidgid`, así que SIN esta línea el servicio arranca, muere y reintenta para
|
|
# siempre, y el log dice «unknown user», no «a la imagen le falta una cuenta».
|
|
#
|
|
# `--merge` fusiona por clave y es idempotente: componer dos veces no duplica. Si una cuenta ya está
|
|
# con OTRA línea, sale ≠0 sin tocar ningún fichero — y acá eso ABORTA la imagen a propósito: un
|
|
# rootfs con el uid equivocado produce ficheros de un dueño que no existe, y eso se descubre dentro
|
|
# de la VM, varios pasos más tarde.
|
|
USERPATHS=$(python3 scripts/targets.py --user-paths "$PERFIL")
|
|
if [ -n "$USERPATHS" ]; then
|
|
echo "==> cuentas : $(echo "$USERPATHS" | wc -l) receta(s) declaran usuarios"
|
|
# shellcheck disable=SC2086
|
|
"$TAKANA" --store "$STORE" users $USERPATHS --merge "$RFS"
|
|
else
|
|
echo "==> cuentas : ninguna receta del perfil declara [[user]]"
|
|
fi
|
|
|
|
# ── 2 ter. las CARDS de los servicios que el perfil arranca (SDD 30 §4c) ─────────────────────────
|
|
# Sin esto la imagen trae el binario, trae la cuenta… y NADIE lo arranca: la seed del producto sólo
|
|
# conoce las cards que `takana-bootstrap` hornea (`sshd`, `console-getty`, `hammerd`). Medido en la
|
|
# primera imagen de este perfil: `gitea` instalado, su usuario en `/etc/passwd`, y en el `genesis`
|
|
# NADA — un servidor que arranca perfecto sin el servicio por el que existe. La métrica no lo ve:
|
|
# `--services` dice que el perfil lo habilita, y lo habilita; lo que faltaba era el paso que lleva
|
|
# esa declaración a la imagen.
|
|
#
|
|
# `service-cards` es la ÚNICA traducción `[[service]]` → Card, y `inyectar-cards.py` sólo compone la
|
|
# seed (idempotente por label, y con `os.replace` porque la seed es un hardlink al store).
|
|
SVCPATHS=$(python3 scripts/targets.py --service-paths "$PERFIL")
|
|
if [ -n "$SVCPATHS" ]; then
|
|
# shellcheck disable=SC2086
|
|
"$TAKANA" --store "$STORE" service-cards $SVCPATHS > "$WORKDIR/cards.json"
|
|
echo "==> cards : $(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$WORKDIR/cards.json") card(s) de $(echo "$SVCPATHS" | wc -l) receta(s)"
|
|
python3 scripts/gnome/inyectar-cards.py "$RFS/ente/seed.card.json" "$WORKDIR/cards.json"
|
|
else
|
|
echo "==> cards : el perfil no arranca ningún servicio de paquete"
|
|
fi
|
|
|
|
# ── 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@<ip> 'zstd -d -c | dd of=/dev/sda bs=4M conv=fsync && sync'"
|