Files
takana/scripts/servidor-image.sh
T
SergioandClaude Opus 5 d611f8577d [[user]] en la receta: la otra mitad del SDD 30 — un demonio que no corre como root necesita cuenta
El SDD 30 sacó las Cards de una constante de Rust y las puso en la receta. Los USUARIOS se quedaron
donde estaban las Cards: `/etc/passwd` de la imagen es `takana_bootstrap::PRODUCT_PASSWD`, un literal
con `root` y `sshd`, y añadir un tercero era editar Rust y recompilar takana.

No es simetría por elegancia, es un servicio que no arranca: `gitea` se niega a correr como root, su
Card hace `setuidgid gitea`, y sin la cuenta arranca, muere y reintenta para siempre con un log que
dice `unknown user` — no «a la imagen le falta una cuenta».

    [[user]]
    name = "gitea"
    uid  = 916
    home = "/var/lib/gitea"

**El uid se declara, no se asigna**, por la misma razón que el ULID de la Card: un «primero libre a
partir de 1000» hace que dos imágenes del mismo perfil salgan con dueños distintos y el rootfs deje
de reproducir SIN QUE NADA FALLE — los ficheros se ven iguales y `ls -l` dice otro número. Fuera de
`hash_inputs`, medido: el hash de gitea no se movió (`b3:391a613e…` antes y después).

`takana users <recetas…> [--merge <rootfs>]` es el gemelo de `service-cards`, y fusiona **por clave,
no por línea entera** — componer dos veces no duplica, y `grep -q` de la línea completa no serviría
porque la misma cuenta con otro GECOS se leería como nueva. Tres decisiones con su control:

· Una cuenta ya presente con OTRA línea es CONFLICTO y no se pisa: sale ≠0. Pisarla es cambiarle el
  uid a ficheros que ya son de alguien, y eso se descubre dentro de la VM.
· Se planea todo y sólo entonces se escribe. Fichero a fichero, un conflicto en `passwd` dejaba el
  `group` ya escrito: media cuenta es peor que ninguna, porque parece que está. Comprobado con el
  caso exacto — `group` sin la cuenta, `passwd` con otro uid — y los DOS ficheros quedan intactos.
· Un `passwd` ausente es un error, no un fichero a crear: crearlo dejaría una imagen SIN `root`.

La validación rechaza lo que rompe tarde: `root`/`sshd`/`nobody` y sus uids, uid fuera de
100..=65533, `home` relativo y un `:` en cualquier campo — que partiría la línea y fallaría lejos.

Y `--user-paths` NO es `--service-paths` con otro nombre: aquél lista los servicios HABILITADOS,
éste los paquetes INSTALADOS que declaran cuentas. `postgres` instalado y sin levantar necesita su
usuario igual, porque los ficheros de la imagen ya son suyos.

`scripts/servidor-image.sh` lo aplica entre hidratar el perfil y sellar la imagen, y aborta si hay
conflicto: un rootfs con el uid equivocado produce ficheros de un dueño que no existe.

Verde: 6 tests del módulo, core 234, cli 88, bootstrap 42, `targets.py --selftest` 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn
2026-09-14 15:39:52 +00:00

126 lines
7.8 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
# ── 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'"