qorpa: nombre adoptado y el preflight que mide si una máquina puede alojar

ADR 0015 pasa de "propuesta de nombre" a `qorpa` adoptado: la frontera es
`hammer qorpa {…}` y el espacio de nombres se unifica en
/var/lib/hammer/qorpa/{imagenes,instancias}/ — un solo árbol, para que la poda
de §NO-resuelve 5 tenga un único sitio que barrer. La clase de nodo del grafo
sigue siendo `ajeno`: describe la procedencia, no el subsistema.

Y arranca el §Orden de trabajo 1 (subuid) como GUARDIÁN en vez de a mano:
scripts/qorpa/qorpa-preflight.sh mide las cinco capacidades de entorno que un
huésped necesita y que no están en ningún grafo — userns sin privilegios (+
anidado), mapeo multi-id, overlayfs sin root, los nodos del borde y disco.
Tres niveles (BLOQUEA/LIMITA/NOTA) y salida 0/1/2, porque "arranca pero sin
dnf" es una respuesta legítima, no un error.

Medido en `momento` (exit 2, 2 limitaciones):
- userns ANIDADO funciona ⇒ el "verificar, no asumir" de D6 (pressure-vessel
  creando su userns dentro del nuestro) queda verificado a nivel de primitiva.
- subuid es papel mojado acá: el rango está declarado en /etc/subuid y las
  herramientas están, pero newuidmap/newgidmap vienen sin setuid y sin
  capability ⇒ no pueden escribir el uid_map. Es exactamente la "primera cosa
  que va a fallar" del ADR, y resulta ser de PROVISIÓN, no de kernel.

El guardián ya se corrigió a sí mismo una vez: marcaba LIMITA por
CONFIG_OVERLAY_FS=m mientras tres secciones más abajo el overlay montaba de
verdad. Manda la prueba funcional, no la declarada.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
This commit is contained in:
Sergio
2026-09-03 03:05:12 +00:00
co-authored by Claude Opus 5
parent 2fc30c4df2
commit aa5bb67fd7
2 changed files with 236 additions and 16 deletions
+215
View File
@@ -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