diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index b42e25a0..670644e0 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -1,9 +1,10 @@ -# ADR 0015 — Imágenes ajenas: el mundo glibc entra enjaulado, y no entra al store +# ADR 0015 — qorpa: el mundo glibc entra enjaulado, y no entra al store -- **Estado:** PROPUESTO — sin implementar. Este documento decide la frontera; el código viene después. +- **Estado:** PROPUESTO — nombre ADOPTADO (`qorpa`, 2026-09-03); implementación EN CURSO por el + §Orden de trabajo. Este documento decide la frontera; el código viene después. - **Fecha:** 2026-09-03 -- **Frontera (a crear):** `hammer ajenas {traer,crear,correr,exportar,listar,podar}`, - `/var/lib/hammer/ajenas//`, `/var/lib/hammer/instancias//`, +- **Frontera (a crear):** `hammer qorpa {traer,crear,correr,exportar,listar,podar}`, + `/var/lib/hammer/qorpa/imagenes//`, `/var/lib/hammer/qorpa/instancias//`, `instancia.toml` (manifiesto), clase de nodo `ajeno` en `build-state.py`. - **Continúa:** [SDD 04](../04-overlay.md) (overlay), [SDD 16](../16-harkaq-jaula.md) (harkaq), [ADR 0004](0004-no-custom-nix.md) (hammer no usa nix), [SDD 20](../20-catalogo-publicable-y-completa.md) @@ -88,8 +89,8 @@ todo fue bien»). Espacio de nombres paralelo, direccionado por digest, **fuera de `hash_inputs` de todo**: ``` -/var/lib/hammer/ajenas// imagen — INMUTABLE, verificada por digest -/var/lib/hammer/instancias// +/var/lib/hammer/qorpa/imagenes// imagen — INMUTABLE, verificada por digest +/var/lib/hammer/qorpa/instancias// ├── instancia.toml manifiesto — LA VERDAD ├── upper/ capa mutable — CACHÉ └── work/ overlayfs @@ -127,7 +128,7 @@ Si el `upper` con 200 paquetes instalados a mano fuese el activo, tendríamos un justo lo que hammer existe para no tener. La relación correcta es la misma que receta↔artefacto: ```toml -# /var/lib/hammer/instancias/juegos/instancia.toml +# /var/lib/hammer/qorpa/instancias/juegos/instancia.toml base = "sha256:…" # digest de la imagen, inmutable distro = "fedora-43" # informativo paquetes = ["steam", "mesa-dri-drivers", "mesa-vulkan-drivers"] @@ -144,11 +145,11 @@ apps = ["steam.desktop"] ``` Consecuencias que se caen solas: -- **`hammer ajenas recrear `** reconstruye la instancia desde el manifiesto. El `upper` es +- **`hammer qorpa recrear `** reconstruye la instancia desde el manifiesto. El `upper` es descartable. - **Actualizar la base** (Fedora 43 → 44) no es un rebase riesgoso: se cambia el digest y se recrea. - **El respaldo** es el manifiesto (KB), no el `upper` (GB). `respaldo-storagebox.sh` no toca - `/var/lib/hammer/instancias/*/upper` y eso es correcto, no un olvido. + `/var/lib/hammer/qorpa/instancias/*/upper` y eso es correcto, no un olvido. - La poda tiene una regla trivial: **un `upper` siempre se puede borrar.** ### D4 — Granularidad: cuatro tipos, no uno @@ -306,17 +307,21 @@ Se escriben acá para que no se descubran en producción. 1. **Provisionar subuid** y probar `dnf install` en una instancia mínima. Es lo primero que falla (§NO-resuelve 2) y define si el resto es fácil o difícil. -2. `hammer ajenas traer --sha256` + verificación de digest. Reusa el patrón de `lab-image.sh`. +2. `hammer qorpa traer --sha256` + verificación de digest. Reusa el patrón de `lab-image.sh`. 3. Instancia = overlay sobre la imagen + `instancia.toml`; `crear` / `recrear` / `correr`. 4. Concesiones → `PolicySpec` de harkaq. Empezar por **nada** y abrir sólo lo declarado. 5. Shims + `.desktop` generados (`exportar`), y clase `ajeno` en `build-state.py`. 6. **Steam de punta a punta**, con verificación explícita del bwrap anidado. -7. Poda (`hammer ajenas podar`) y renglón en SDD 20 sobre licencias. +7. Poda (`hammer qorpa podar`) y renglón en SDD 20 sobre licencias. 8. Proxy filtrante de Wayland — ticket propio, el más valioso de la lista. -## Nombre +## Nombre — ADOPTADO 2026-09-03 -El subsistema pide un nombre de la familia del repo (harkaq, yupana, arje, wawafs, kikin, churay, -mirada, minga, khipu). Propuesta: **`qorpa`** — *huésped alojado*: alguien que entra a la casa, se -le dan habitaciones concretas y no las demás. Encaja con lo que hace y con cómo se comporta. Queda a -criterio del autor del repo; el resto del ADR no depende del nombre. +El subsistema se llama **`qorpa`** — *huésped alojado*: alguien que entra a la casa, se le dan +habitaciones concretas y no las demás. Elegido por el autor del repo; entra en la familia (harkaq, +yupana, arje, wawafs, kikin, churay, mirada, minga). + +Consecuencias de nombre, ya aplicadas arriba: la frontera es `hammer qorpa {…}`, el espacio de +nombres es `/var/lib/hammer/qorpa/{imagenes,instancias}/` —uno solo, para que la poda de +§NO-resuelve 5 tenga un único árbol que barrer— y el adjetivo del grafo sigue siendo `ajeno` +(clase de nodo), porque describe la **procedencia** del nodo, no el subsistema que lo aloja. diff --git a/scripts/qorpa/qorpa-preflight.sh b/scripts/qorpa/qorpa-preflight.sh new file mode 100755 index 00000000..52f36ff6 --- /dev/null +++ b/scripts/qorpa/qorpa-preflight.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# qorpa-preflight.sh — ¿puede ESTA máquina alojar un huésped? +# +# ── POR QUÉ EXISTE ────────────────────────────────────────────────────────────────────────────── +# El ADR 0015 dice, en su §Orden de trabajo 1, que lo PRIMERO es provisionar subuid «porque es lo +# primero que va a fallar y define si el resto es fácil o difícil». Este script es esa medición, +# hecha como guardián y no a mano: un huésped depende de cinco capacidades del ENTORNO que no +# están en ningún grafo (user namespaces sin privilegios, mapeo multi-id, overlayfs sin root, los +# nodos del borde, y disco), y las cinco fallan de formas que se parecen entre sí desde arriba +# —«bwrap: Operation not permitted»— pero se arreglan de formas distintas. +# +# Es deliberadamente el patrón `yupana preflight`: PREDECIR el hueco de entorno antes de quemar +# la campaña, en vez de descubrirlo con medio Fedora bajado. Y es deliberadamente RUIDOSO ante la +# ausencia (CLAUDE.md regla 3): cada prueba dice qué faltó y cómo se repara, nunca sólo "no". +# +# ── LOS TRES NIVELES ──────────────────────────────────────────────────────────────────────────── +# BLOQUEA — ninguna instancia arranca en esta máquina. +# LIMITA — arranca, pero una CLASE concreta de uso falla (se nombra cuál). +# NOTA — informativo; no afecta a arrancar. +# Salida: 0 todo verde · 1 hay BLOQUEA · 2 sólo LIMITA. El 2 es una respuesta legítima, no un error: +# una instancia de sólo-correr-un-binario no necesita subuid. +# +# Uso: scripts/qorpa/qorpa-preflight.sh [--raiz /var/lib/hammer/qorpa] +set -uo pipefail # -e NO: queremos correr TODAS las pruebas y reportar el cuadro entero. + +RAIZ="${QORPA_RAIZ:-/var/lib/hammer/qorpa}" +[ "${1:-}" = "--raiz" ] && { RAIZ="$2"; shift 2; } + +BLOQUEA=0; LIMITA=0 +c_ok=$'\033[1;32m'; c_mal=$'\033[1;31m'; c_lim=$'\033[1;33m'; c_dim=$'\033[2m'; c_off=$'\033[0m' +titulo() { printf '\n%s── %s %s\n' $'\033[1;34m' "$*" "$c_off"; } +ok() { printf ' %s✓%s %s\n' "$c_ok" "$c_off" "$*"; } +nota() { printf ' %s·%s %s\n' "$c_dim" "$c_off" "$*"; } +mal() { printf ' %s✗ BLOQUEA%s %s\n' "$c_mal" "$c_off" "$*"; BLOQUEA=$((BLOQUEA+1)); } +lim() { printf ' %s! LIMITA%s %s\n' "$c_lim" "$c_off" "$*"; LIMITA=$((LIMITA+1)); } +comofix(){ printf ' %s↳ %s%s\n' "$c_dim" "$*" "$c_off"; } + +echo "qorpa preflight — $(hostname) · kernel $(uname -r) · usuario $(id -un) ($(id -u))" + +# ──────────────────────────────────────────────────────────────────────────────────────────────── +titulo "1. el kernel trae lo necesario" +CONFIG="" +if [ -r /proc/config.gz ]; then CONFIG="zcat /proc/config.gz" +elif [ -r "/boot/config-$(uname -r)" ]; then CONFIG="cat /boot/config-$(uname -r)" +fi +if [ -z "$CONFIG" ]; then + nota "sin .config legible (ni /proc/config.gz ni /boot/config-\$(uname -r)) — se mide por runtime" +else + # Lo que el ADR §Lo que ya está construido enumera de linux-metal.toml:111-114. + for sym in USER_NS OVERLAY_FS SECCOMP SECURITY_LANDLOCK; do + v="$($CONFIG | grep -E "^CONFIG_${sym}=" | head -1)" + case "$v" in + *=y) ok "CONFIG_${sym}=y" ;; + *=m) nota "CONFIG_${sym}=m — módulo. NO es limitación por sí sola: quien manda es la prueba + funcional de más abajo, que lo monta de verdad o no." ;; + *) if [ "$sym" = SECURITY_LANDLOCK ]; then + lim "CONFIG_${sym} apagado — la jaula cae a seccomp+namespaces, sin política de grano fino" + else + mal "CONFIG_${sym} apagado — sin esto no hay instancia" + fi ;; + esac + done +fi + +max_ns="$(cat /proc/sys/user/max_user_namespaces 2>/dev/null || echo '?')" +if [ "$max_ns" = 0 ]; then + mal "user.max_user_namespaces = 0 — los userns están apagados por sysctl" + comofix "sysctl -w user.max_user_namespaces=15000" +else + ok "user.max_user_namespaces = $max_ns" +fi +if [ -r /proc/sys/kernel/unprivileged_userns_clone ]; then + if [ "$(cat /proc/sys/kernel/unprivileged_userns_clone)" = 1 ]; then + ok "kernel.unprivileged_userns_clone = 1" + else + mal "kernel.unprivileged_userns_clone = 0 — userns sin privilegios prohibidos" + comofix "sysctl -w kernel.unprivileged_userns_clone=1" + fi +fi + +# ──────────────────────────────────────────────────────────────────────────────────────────────── +titulo "2. user namespaces sin privilegios" +if timeout 10 unshare -Ur true 2>/dev/null; then + ok "unshare -Ur — userns con mapeo de un solo id" +else + mal "unshare -Ur falla — sin esto NADA de qorpa funciona" +fi +# D6: pressure-vessel crea un userns DESDE DENTRO del nuestro. El ADR lo marca como «verificar, no +# asumir», y es barato verificarlo acá en vez de con un juego real. +if timeout 10 unshare -Ur unshare -Ur true 2>/dev/null; then + ok "userns ANIDADO — pressure-vessel de Valve puede crear el suyo adentro (ADR D6)" +else + lim "userns anidado falla — Steam/pressure-vessel (ADR D6) no arrancaría; el resto sí" +fi +if command -v bwrap >/dev/null 2>&1; then + if timeout 10 bwrap --unshare-user --ro-bind / / /bin/true 2>/dev/null; then + ok "bwrap --unshare-user (la pieza que ya es dep del lab de build)" + else + mal "bwrap presente pero no puede crear el userns" + fi +else + lim "no hay bwrap en el PATH del host — presente en el corpus (recipes/bwrap.toml), falta hidratarlo" +fi + +# ──────────────────────────────────────────────────────────────────────────────────────────────── +titulo "3. subuid — el mapeo multi-id (ADR §NO-resuelve 2, «la primera cosa que va a fallar»)" +USUARIO="$(id -un)" +leer_rango() { # $1 = fichero, $2 = usuario → imprime "inicio cantidad" o nada + [ -r "$1" ] || return 1 + awk -F: -v u="$2" '$1==u {print $2, $3; exit}' "$1" +} +RU="$(leer_rango /etc/subuid "$USUARIO")" +RG="$(leer_rango /etc/subgid "$USUARIO")" +if [ -z "$RU" ] || [ -z "$RG" ]; then + lim "sin rango en /etc/subuid|/etc/subgid para '$USUARIO' — dnf/rpm rompen al hacer chown (tipo 1)" + comofix "usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USUARIO" +else + ok "rango declarado: subuid=[$RU] subgid=[$RG]" +fi + +herramienta_ok=1 +for h in newuidmap newgidmap; do + ruta="$(command -v $h 2>/dev/null)" + if [ -z "$ruta" ]; then + lim "$h no está — sin él el rango declarado es papel mojado" + comofix "receta shadow.toml ya lo instala; en el host, paquete 'shadow'" + herramienta_ok=0; continue + fi + caps="$(getcap "$ruta" 2>/dev/null)" + perm="$(stat -c %a "$ruta")" + if printf '%s' "$caps" | grep -q 'cap_set[ug]id'; then + ok "$ruta con $(printf '%s' "$caps" | sed 's/.* //')" + elif [ "${perm:0:1}" = 4 ] || [ "$(( 8#$perm & 04000 ))" -ne 0 ] 2>/dev/null; then + ok "$ruta setuid-root (modo $perm)" + else + cap=cap_setuid; mapa=uid_map + [ "$h" = newgidmap ] && { cap=cap_setgid; mapa=gid_map; } + lim "$ruta SIN setuid y SIN capability (modo $perm) — no puede escribir el $mapa" + comofix "sudo setcap ${cap}+ep $ruta" + herramienta_ok=0 + fi +done + +# La prueba FUNCIONAL. Las tres de arriba son condiciones necesarias declaradas; ésta es la que +# realmente responde «¿puedo mapear el rango?». Se hace con un hijo en un userns SIN mapa que +# espera en un fifo mientras el padre le escribe el mapa desde fuera — que es exactamente la +# coreografía que hace un runtime de contenedores. +if [ -n "$RU" ] && [ -n "$RG" ] && [ "$herramienta_ok" = 1 ]; then + d="$(mktemp -d)"; mkfifo "$d/listo" "$d/adelante" + set -- $RU; ui=$1; uc=$2 + set -- $RG; gi=$1; gc=$2 + timeout 20 unshare -U sh -c "echo r > $d/listo; read _ < $d/adelante; id -u > $d/uid; wc -l < /proc/self/uid_map > $d/lineas" & + hijo=$! + if timeout 10 head -c 1 "$d/listo" >/dev/null 2>&1; then + err="$( { newuidmap "$hijo" 0 "$(id -u)" 1 1 "$ui" "$uc" && \ + newgidmap "$hijo" 0 "$(id -g)" 1 1 "$gi" "$gc"; } 2>&1 )" + rc=$? + timeout 5 sh -c "echo g > $d/adelante" 2>/dev/null + wait "$hijo" 2>/dev/null + if [ "$rc" = 0 ] && [ "$(cat "$d/uid" 2>/dev/null)" = 0 ] && [ "$(cat "$d/lineas" 2>/dev/null)" -ge 2 ] 2>/dev/null; then + ok "PRUEBA FUNCIONAL: el rango se mapeó ($uc uids) — dnf/rpm pueden chownear" + else + lim "PRUEBA FUNCIONAL falla: ${err:-mapa no aplicado} — root-adentro sí, multi-id no (tipo 1 cojo)" + fi + else + kill "$hijo" 2>/dev/null; lim "PRUEBA FUNCIONAL: el hijo no llegó al fifo (userns no se creó)" + fi + rm -rf "$d" +else + nota "prueba funcional omitida — falta una condición previa de arriba" +fi + +# ──────────────────────────────────────────────────────────────────────────────────────────────── +titulo "4. overlayfs sin privilegios (la capa mutable de D3)" +if timeout 15 unshare -Urm sh -c ' + mkdir -p /tmp/.qorpa-ovl 2>/dev/null || exit 90 + mount -t tmpfs none /tmp/.qorpa-ovl || exit 91 + mkdir -p /tmp/.qorpa-ovl/lower /tmp/.qorpa-ovl/upper /tmp/.qorpa-ovl/work /tmp/.qorpa-ovl/merged + mount -t overlay overlay -o lowerdir=/tmp/.qorpa-ovl/lower,upperdir=/tmp/.qorpa-ovl/upper,workdir=/tmp/.qorpa-ovl/work /tmp/.qorpa-ovl/merged +' 2>/dev/null; then + ok "overlay montado dentro del userns — el upper/ de la instancia es viable sin root" +else + lim "overlayfs no monta sin privilegios — la instancia queda de sólo lectura (o pide fuse-overlayfs)" + comofix "kernel ≥5.11 con CONFIG_OVERLAY_FS y userns; si no, fuse-overlayfs como plan B" +fi + +# ──────────────────────────────────────────────────────────────────────────────────────────────── +titulo "5. el borde (D2) — qué hay para conceder" +soc_wl="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/${WAYLAND_DISPLAY:-wayland-0}" +[ -S "$soc_wl" ] && ok "wayland: $soc_wl" || nota "wayland: sin socket (no hay sesión gráfica acá)" +soc_pw="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pipewire-0" +[ -S "$soc_pw" ] && ok "pipewire: $soc_pw" || nota "pipewire: sin socket" +if compgen -G "/dev/dri/*" >/dev/null; then ok "GPU: $(echo /dev/dri/* | tr ' ' ' ')"; else nota "GPU: sin /dev/dri (sin aceleración adentro)"; fi +[ -e /dev/ntsync ] && ok "/dev/ntsync (juegos)" || nota "/dev/ntsync ausente — Proton cae a esync/fsync" +[ -r /etc/resolv.conf ] && ok "/etc/resolv.conf legible (segundo fallo clásico de la 1ª corrida)" \ + || lim "/etc/resolv.conf ilegible — dnf no resuelve nombres adentro" + +# ──────────────────────────────────────────────────────────────────────────────────────────────── +titulo "6. disco (§NO-resuelve 5: un rootfs son 1-2 G y no lo alcanza store-gc)" +destino="$RAIZ"; while [ ! -d "$destino" ] && [ "$destino" != / ]; do destino="$(dirname "$destino")"; done +libre_k="$(df -Pk "$destino" | awk 'NR==2{print $4}')" +libre_g=$(( libre_k / 1024 / 1024 )) +if [ "$libre_g" -ge 8 ]; then ok "$destino: ${libre_g} G libres (raíz qorpa: $RAIZ)" +elif [ "$libre_g" -ge 3 ]; then lim "$destino: sólo ${libre_g} G libres — alcanza para UNA imagen y nada más" +else mal "$destino: ${libre_g} G libres — no entra ni un rootfs base"; fi + +# ──────────────────────────────────────────────────────────────────────────────────────────────── +titulo "veredicto" +if [ "$BLOQUEA" -gt 0 ]; then + printf ' %sBLOQUEA %d · LIMITA %d%s — esta máquina NO puede alojar una instancia todavía.\n' "$c_mal" "$BLOQUEA" "$LIMITA" "$c_off"; exit 1 +elif [ "$LIMITA" -gt 0 ]; then + printf ' %sLIMITA %d%s — arranca, pero con las clases de uso marcadas arriba recortadas.\n' "$c_lim" "$LIMITA" "$c_off"; exit 2 +else + printf ' %stodo verde%s — esta máquina puede alojar una instancia qorpa.\n' "$c_ok" "$c_off"; exit 0 +fi