#!/bin/sh # instalador-variantes.sh — las tres variantes del instalador, en sus dos medios. # # ══ QUÉ SON ═══════════════════════════════════════════════════════════════════════════════════ # base sólo el piso de arranque: arranca a una consola y nada más # servidor el piso de arranque + el userland base + el perfil `servidor` # mirada el piso de arranque + el userland base + el escritorio propio de la suite # full el userland, el toolbox (`cli`), los servicios y **los cinco escritorios** —mirada, # sway, cosmic, gnome y kde—. Pide un pendrive de 32 GB. # # ── LA COMPOSICIÓN, Y POR QUÉ NO ES UN PERFIL SOLO ──────────────────────────────────────────── # ⚠ **«base» son DOS cosas con el mismo nombre**, y confundirlas da un rootfs que no arranca: # # · el **piso de ARRANQUE** — `product-rootfs`: arje-zero como PID 1 + busybox. Es lo único que # hace que la imagen encienda, y NO está en ningún perfil de `targets.toml`. # · el **userland base** — `[perfil.base]`: bash, git, sudo, dhcpcd, coreutils… 359 binarios y # **ni busybox ni arje-zero adentro** (medido: `ls base/bin` no los tiene). # # El [SDD 27 §1](../docs/27-perfiles-instalables-y-el-instalador.md) ya había medido que los # escritorios NO heredan `base`; esto es la otra mitad del mismo hueco. Cada variante se compone # entonces así, y en este orden: # # product-rootfs ∪ perfil.base ∪ # # ── DOS MONTAJES, DOS COPIAS, Y NO ES DESPERDICIO ───────────────────────────────────────────── # `hydrate` proyecta por HARDLINK y `linkat()` no cruza un punto de montaje: la composición tiene # que armarse **bajo el montaje del store**. Y el ensamblado de la imagen vuelve a enlazar, esta vez # hacia `work/`, que está en OTRO disco — así que la composición se copia una vez a este lado. Las # dos restricciones son la misma física y ya mordieron dos veces en este repo. # # Uso: scripts/instalador-variantes.sh [servidor|mirada|full]... (sin argumentos: las tres) # Vars: KERNEL · SALIDA (def work/variantes) · STORE (def /store) set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT" STORE="${STORE:-/store}" SALIDA="${SALIDA:-work/variantes}" COMPOSICION="$STORE/.perfiles" LOCAL="work/.rootfs-local" # ── perfiles_de ──────────────────────────────────────────────────────────── # ⚠ **El medio manda sobre el contenido, y `servidor` es el caso donde se nota.** El fichero único # carga el sistema ENTERO en memoria —es lo que le permite soltar el disco para poder formatearlo— # así que cada MiB del rootfs es RAM exigida en la máquina de quien instala. El USB no tiene ese # límite: corre desde el propio medio. # # Por eso `servidor` no es la misma cosa en los dos: # · por el archivo único va `servidor-minimo` — los mismos servicios, sin la cadena de compilar # (`rust` 364 MiB, `lld21` 109) ni el toolbox de `cli` (54 raíces). 1,6 GiB contra 3,2. # · por USB va `servidor` entero, que es el destino de verdad. # # No es una opinión sobre qué debe llevar un servidor: es una restricción física del medio, y está # escrita en los dos sitios —acá y en el `descripcion` del perfil— para que nadie la lea como un # recorte arbitrario. perfiles_de() { case "$1:${2:-usb}" in # `base` no compone NINGÚN perfil: es el piso de arranque solo, que es lo que la variante # promete. Devuelve vacío a propósito y el bucle de hidratación no da ni una vuelta. base:*) echo "" ;; servidor:run) echo "base servidor-minimo" ;; servidor:*) echo "base servidor" ;; mirada:*) echo "base escritorio-mirada" ;; # ⚠ **GNOME estuvo fuera medio día, por dos cosas suyas, y las dos están resueltas.** # · `accountsservice` trae `/var/lib/AccountsService/users` en 0700 ⇒ hidratar como # usuario normal no podía leerlo y el nodo no proyectaba. Se arma con privilegio, que es # lo que ya hacía falta igual por los 0500 de ModemManager. # · `usr/libexec/libgtop_server2` llega 4555 desde el tarball de upstream y el perfil no lo # declara. El aviso del auditor decía «o entra a un `[[setuid]]` con su motivo, **o # sale**» y hasta ese día sólo existía la primera mitad. Ahora `takana setuid --apply` # RETIRA el bit no declarado, rompiendo el enlace duro: el store queda intacto y se dice # cuál se quitó. Es la opción segura —quita privilegio, nunca lo da— y la coherente con # el invariante: `of_tree` no hashea ese bit, así que un artefacto que lo trae está # fuera de contrato. full:*) echo "base cli escritorio-mirada escritorio-sway escritorio-cosmic escritorio-gnome escritorio-kde servidor" ;; *) echo "variante desconocida: $1 (servidor|mirada|full)" >&2; return 1 ;; esac } # ── QUÉ MEDIOS TIENE CADA VARIANTE, Y POR QUÉ `full` NO TIENE FICHERO ÚNICO ──────────────────────────── # El fichero único carga el instalador ENTERO en memoria —es lo que le permite soltar el disco— y # en RAM viven a la vez el initramfs comprimido y su contenido desempaquetado. El generador ya lo # comprueba y se niega por encima del 40 % de la RAM; con `full` (3,2 GiB) eso pide ~8 GiB en la # máquina donde se instale, o sea que el fichero único sería uno de 1,5 GiB que en la mitad de las # máquinas no se puede usar. **El USB no tiene ese límite**: corre desde el propio medio. # # ⚠ Y hay un defecto conocido en esa guarda: mide la RAM de la máquina que CONSTRUYE, no la de la # que instala. Acá da el resultado correcto por casualidad —el hub tiene 7,6 GiB, parecido a un # portátil— pero construir en una caja grande dejaría pasar una imagen que allá no arranca. Está # anotado y no barrido. medios_de() { case "$1" in full) echo "usb" ;; *) echo "run usb" ;; esac } VARIANTES="${*:-base mirada servidor full}" for v in $VARIANTES; do perfiles_de "$v" usb >/dev/null || exit 2; done KERNEL="${KERNEL:-$(ls -dt "$STORE"/*-linux-metal/boot/bzImage 2>/dev/null | head -1)}" [ -n "${KERNEL:-}" ] && [ -r "$KERNEL" ] || { echo "no encuentro un bzImage de linux-metal (set KERNEL=)" >&2; exit 1; } PISO="${PISO:-$(ls -dt "$STORE"/*-product-rootfs 2>/dev/null | head -1)}" [ -n "${PISO:-}" ] && [ -d "$PISO" ] || { echo "no encuentro un product-rootfs (set PISO=)" >&2; exit 1; } echo "==> piso de arranque: $PISO" echo "==> kernel: $KERNEL" mkdir -p "$SALIDA" "$COMPOSICION" "$LOCAL" compone() { # → deja el árbol local en $LOCAL/- _cv="$1"; _cm="$2" C="$COMPOSICION/$_cv-$_cm" # `cp -al`: el piso entra por hardlinks, que en el mismo filesystem es gratis. Un `cp -a` de # 265 MiB por variante es tiempo y disco que no hacen falta. rm -rf "$C"; cp -al "$PISO" "$C" chmod -R u+w "$C" 2>/dev/null || true # ⚠ **Cada perfil se hidrata en SU PROPIO árbol y después se funden**, y no uno encima de otro # con `--keep`. La razón es la auditoría del bit de privilegio, que corre contra las # declaraciones `[[setuid]]` DEL PERFIL QUE SE ESTÁ HIDRATANDO: al poner `escritorio-mirada` # encima de `base`, veía el `doas` 4755 que dejó `base` —declarado, legítimo— y lo denunciaba # como «un bit que nadie declaró». La auditoría tiene razón en lo suyo; el que estaba mal era # el método. Medido el 2026-09-23. for p in $(perfiles_de "$_cv" "$_cm"); do echo " ── perfil $p" H="$COMPOSICION/$_cv-$_cm-$p"; rm -rf "$H" python3 scripts/hydrate-profile.py "$p" --into "$H" --store "$STORE" \ > "work/.variante-$_cv-$p.log" 2>&1 || { echo " ✗ falló la hidratación de $p — ver work/.variante-$_cv-$p.log" >&2 grep -A6 'SIN artefacto\|✗' "work/.variante-$_cv-$p.log" | head -14 >&2 exit 1; } # `-n` (no pisar): si dos perfiles traen el mismo fichero es el mismo artefacto, así que # gana el primero y da igual cuál sea. Pisar daría el mismo resultado y un error si el # hardlink ya existe. chmod -R u+w "$H" 2>/dev/null || true # ⚠ **Un `-n` silencioso es una decisión escondida.** Cuando dos perfiles traen el MISMO # camino gana el primero, y con un solo escritorio eso es siempre el mismo artefacto y da # igual. Con cinco escritorios en la misma imagen ya no: dos de ellos pueden querer el # mismo `/etc/xdg/...` o el mismo `.desktop`, y quedarse callado significa que uno pisa al # otro sin que nadie lo sepa. No se resuelve acá —hace falta saber CUÁL gana y por qué— # pero se CUENTA, que es lo mínimo para que exista la pregunta. _antes=$(find "$C" ! -type d 2>/dev/null | wc -l) _trae=$(find "$H" ! -type d 2>/dev/null | wc -l) ( cd "$H" && find . -type d -exec mkdir -p "$C/{}" \; ) 2>/dev/null || true # ── UN SYMLINK A BUSYBOX no tapa el binario REAL que necesita el bit ────────────────────── # Ningún perfil declara `busybox`, pero el PISO lo trae horneado con sus ~331 symlinks, y el # piso entra primero: con `-n`, `bin/su → busybox` gana sobre el `bin/su` de shadow. El bit # de privilegio se aplicó sobre el árbol del perfil —donde ESE `bin/su` es el binario real y # quedó 4755— y la fusión lo descartaba, dejando la capacidad muerta. Medido el 2026-10-15: # `su`, `passwd`, `mount` y `umount` eran symlinks a un busybox sin bit. # # Se liberan SÓLO las rutas con `[[setuid]]` declarado, y el criterio es duro: **a un symlink # no se le puede poner el bit**, así que un symlink ahí es una capacidad muerta por # construcción. Los otros ~325 no se tocan — reemplazar un applet por un binario con otras # banderas puede romper un script del arranque, y eso es el trabajo de # `docs/plan-botar-busybox.md`, con su propia medición. _recetas_p="" for _pk in $(python3 scripts/targets.py "$p" 2>/dev/null); do [ -f "recipes/$_pk.toml" ] && _recetas_p="$_recetas_p recipes/$_pk.toml" done _liberados=0 if [ -n "$_recetas_p" ] && [ -x ./target/release/takana ]; then for _sp in $(./target/release/takana setuid $_recetas_p --json 2>/dev/null \ | python3 -c 'import json,sys try: print("\n".join(x["path"] for x in json.load(sys.stdin))) except Exception: pass' 2>/dev/null); do [ -L "$C/$_sp" ] || continue [ -f "$H/$_sp" ] && [ ! -L "$H/$_sp" ] || continue case "$(readlink "$C/$_sp")" in *busybox*) rm -f "$C/$_sp" echo " · $_sp: el symlink a busybox del piso NO tapa al de $p (el bit no va en un symlink)" _liberados=$((_liberados + 1)) ;; esac done fi [ "$_liberados" -gt 0 ] && echo " ✓ $_liberados binario(s) privilegiado(s) liberado(s) del symlink heredado" ( cd "$H" && find . ! -type d -exec cp -a -l -n {} "$C/{}" \; ) 2>/dev/null || true _despues=$(find "$C" ! -type d 2>/dev/null | wc -l) _choques=$(( _antes + _trae - _despues )) [ "$_choques" -gt 0 ] && echo " ⚠ $_choques fichero(s) ya estaban y NO se pisaron (gana el primero)" rm -rf "$H" echo " $(grep -oE '[0-9]+/[0-9]+' "work/.variante-$_cv-$p.log" | tail -1) artefactos · acumulado $(du -sm "$C" | cut -f1) MiB" done # ── el árbol tiene que quedar en el MISMO montaje que el stage del ensamblado ───────────── # ⚠ **Algunos artefactos traen ficheros que el usuario normal no puede LEER** —ModemManager # instala sus `fcc-unlock.available.d/*` en 0500, que es como vienen de upstream— así que copiar # el árbol pide privilegio. No se hace `chown`: `cp -a` como root conserva dueño y modo, que es # justo lo que la imagen tiene que llevar. Aparece en cuanto entra un escritorio (sway, cosmic, # kde); las variantes chicas nunca lo tocaron. Medido el 2026-09-23. L="$LOCAL/$_cv-$_cm" rm -rf "$L" 2>/dev/null || sudo rm -rf "$L" if ! cp -a "$C" "$L" 2>/dev/null; then rm -rf "$L" 2>/dev/null || sudo rm -rf "$L" echo " (hay ficheros que sólo root puede leer: copiando con privilegio)" sudo cp -a "$C" "$L" || { echo " ✗ no pude copiar el árbol ni con privilegio" >&2; exit 1; } fi rm -rf "$C" 2>/dev/null || sudo rm -rf "$C" echo " árbol local: $(du -sm "$L" | cut -f1) MiB" } for v in $VARIANTES; do echo echo "════ $v ════════════════════════════════════════════════════════════════════════" if echo " $(medios_de "$v") " | grep -q ' run '; then echo " ── el fichero que se baja (.sh) — perfiles: $(perfiles_de "$v" run)" compone "$v" run ROOTFS="$L" KERNEL="$KERNEL" SALIDA="$SALIDA/paquete-$v" ./scripts/instalador-paquete.sh \ > "work/.variante-$v-paquete.log" 2>&1 || { echo " ✗ falló el paquete — ver work/.variante-$v-paquete.log" >&2; tail -6 "work/.variante-$v-paquete.log" >&2; exit 1; } PAQUETE="$SALIDA/paquete-$v" SALIDA="$SALIDA/takana-instalador-$v.sh" ./scripts/instalador-autoextraible.sh \ > "work/.variante-$v-run.log" 2>&1 || { echo " ✗ falló el autoextraíble — ver work/.variante-$v-run.log" >&2; tail -6 "work/.variante-$v-run.log" >&2; exit 1; } echo " $SALIDA/takana-instalador-$v.sh $(( $(wc -c < "$SALIDA/takana-instalador-$v.sh") / 1048576 )) MiB" rm -rf "$L" else echo " ── sin fichero único: este rootfs no entra en RAM en una máquina normal (sólo USB)" fi echo " ── la imagen USB — perfiles: $(perfiles_de "$v" usb)" compone "$v" usb ROOTFS="$L" KERNEL="$KERNEL" IRD_TREE="work/.arbol-$v" \ IMG="$SALIDA/takana-instalador-usb-$v.img" ./scripts/instalador-usb.sh \ > "work/.variante-$v-usb.log" 2>&1 || { echo " ✗ falló el USB — ver work/.variante-$v-usb.log" >&2; tail -6 "work/.variante-$v-usb.log" >&2; exit 1; } echo " $SALIDA/takana-instalador-usb-$v.img $(( $(wc -c < "$SALIDA/takana-instalador-usb-$v.img") / 1048576 )) MiB" rm -rf "$L" done echo echo "✓ en $SALIDA:" ls -l "$SALIDA"/*.sh "$SALIDA"/*.img 2>/dev/null | awk '{printf " %-52s %s MiB\n", $9, int($5/1048576)}'