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
+21 -16
View File
@@ -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 - **Fecha:** 2026-09-03
- **Frontera (a crear):** `hammer ajenas {traer,crear,correr,exportar,listar,podar}`, - **Frontera (a crear):** `hammer qorpa {traer,crear,correr,exportar,listar,podar}`,
`/var/lib/hammer/ajenas/<sha256>/`, `/var/lib/hammer/instancias/<id>/`, `/var/lib/hammer/qorpa/imagenes/<sha256>/`, `/var/lib/hammer/qorpa/instancias/<id>/`,
`instancia.toml` (manifiesto), clase de nodo `ajeno` en `build-state.py`. `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), - **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) [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**: Espacio de nombres paralelo, direccionado por digest, **fuera de `hash_inputs` de todo**:
``` ```
/var/lib/hammer/ajenas/<sha256>/ imagen — INMUTABLE, verificada por digest /var/lib/hammer/qorpa/imagenes/<sha256>/ imagen — INMUTABLE, verificada por digest
/var/lib/hammer/instancias/<id>/ /var/lib/hammer/qorpa/instancias/<id>/
├── instancia.toml manifiesto — LA VERDAD ├── instancia.toml manifiesto — LA VERDAD
├── upper/ capa mutable — CACHÉ ├── upper/ capa mutable — CACHÉ
└── work/ overlayfs └── 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: justo lo que hammer existe para no tener. La relación correcta es la misma que receta↔artefacto:
```toml ```toml
# /var/lib/hammer/instancias/juegos/instancia.toml # /var/lib/hammer/qorpa/instancias/juegos/instancia.toml
base = "sha256:…" # digest de la imagen, inmutable base = "sha256:…" # digest de la imagen, inmutable
distro = "fedora-43" # informativo distro = "fedora-43" # informativo
paquetes = ["steam", "mesa-dri-drivers", "mesa-vulkan-drivers"] paquetes = ["steam", "mesa-dri-drivers", "mesa-vulkan-drivers"]
@@ -144,11 +145,11 @@ apps = ["steam.desktop"]
``` ```
Consecuencias que se caen solas: Consecuencias que se caen solas:
- **`hammer ajenas recrear <id>`** reconstruye la instancia desde el manifiesto. El `upper` es - **`hammer qorpa recrear <id>`** reconstruye la instancia desde el manifiesto. El `upper` es
descartable. descartable.
- **Actualizar la base** (Fedora 43 → 44) no es un rebase riesgoso: se cambia el digest y se recrea. - **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 - **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.** - La poda tiene una regla trivial: **un `upper` siempre se puede borrar.**
### D4 — Granularidad: cuatro tipos, no uno ### 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 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. (§NO-resuelve 2) y define si el resto es fácil o difícil.
2. `hammer ajenas traer <url> --sha256` + verificación de digest. Reusa el patrón de `lab-image.sh`. 2. `hammer qorpa traer <url> --sha256` + verificación de digest. Reusa el patrón de `lab-image.sh`.
3. Instancia = overlay sobre la imagen + `instancia.toml`; `crear` / `recrear` / `correr`. 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. 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`. 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. 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. 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, El subsistema se llama **`qorpa`** — *huésped alojado*: alguien que entra a la casa, se le dan
mirada, minga, khipu). Propuesta: **`qorpa`** — *huésped alojado*: alguien que entra a la casa, se habitaciones concretas y no las demás. Elegido por el autor del repo; entra en la familia (harkaq,
le dan habitaciones concretas y no las demás. Encaja con lo que hace y con cómo se comporta. Queda a yupana, arje, wawafs, kikin, churay, mirada, minga).
criterio del autor del repo; el resto del ADR no depende del nombre.
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.
+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