diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs index ad7ca0a7..3836d783 100644 --- a/crates/hammer-cli/src/qorpa.rs +++ b/crates/hammer-cli/src/qorpa.rs @@ -1295,18 +1295,33 @@ fn run_instance( // `exec` si no es CLOEXEC. En vez de arrastrar una dep de C para un `fcntl`, lo abre la shell: // sus redirecciones no son CLOEXEC por definición, y de paso el `--dry-run` puede mostrar // exactamente lo que pasa. - let st = match &mapeado { - Some(m) => Command::new("sh") - .arg("-c") - .arg(r#"exec 3<"$1"; shift; exec bwrap --userns 3 "$@""#) - .arg("qorpa") - .arg(&m.ns_path) - .args(&args) - .status() - .context("no pude ejecutar sh/bwrap")?, - None => Command::new("bwrap").args(&args).status() - .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?, - }; + // ── EL OVERLAY PUEDE SEGUIR TOMADO POR LA CORRIDA ANTERIOR ───────────────────────────────── + // MEDIDO: dos `run` seguidos sobre la MISMA instancia y el segundo muere con + // `bwrap: Can't make overlay mount … Device or resource busy`; el tercero anda. + // + // No es un capricho de overlayfs: el kernel se NIEGA a montar dos overlays vivos que compartan + // `upperdir`/`workdir` porque eso corrompe la capa. O sea que el EBUSY es un GUARDIÁN, no una + // molestia — lo que está mal es el momento: la corrida anterior ya devolvió el prompt y su + // namespace todavía no terminó de reaparse, así que el usuario ve una denegación correcta por + // una razón que ya no existe. Se reintenta acotado; si de verdad hay otra `run` viva, los + // reintentos se agotan y se dice cuál es la causa en vez de dejar el mensaje crudo de bwrap. + const INTENTOS: u32 = 6; + let mut st = None; + for intento in 1..=INTENTOS { + let (status, overlay_tomado) = lanzar_bwrap(&mapeado, &args)?; + if status.success() || !overlay_tomado { + st = Some(status); + break; + } + if intento == INTENTOS { + bail!( + "el overlay de `{id}` sigue tomado tras {INTENTOS} intentos.\n ¿Hay otra `hammer qorpa run {id}` viva? Dos overlays con el mismo `upper` \n corromperían la capa mutable, y por eso el kernel lo niega." + ); + } + eprintln!(" el overlay lo tiene todavía la corrida anterior — reintento {intento}/{INTENTOS}"); + std::thread::sleep(std::time::Duration::from_millis(200 * u64::from(intento))); + } + let st = st.expect("el bucle sale con estado o con bail"); match st.code() { Some(0) => Ok(()), Some(c) => std::process::exit(c), @@ -1314,6 +1329,60 @@ fn run_instance( } } +/// Lanza bwrap y devuelve su estado junto con **si el fallo fue el overlay tomado**. +/// +/// El stderr se PASA A TRAVÉS mientras se mira, byte a byte y sin esperar renglones: hay que leerlo +/// para distinguir ese fallo de cualquier otro, pero tragárselo sería peor que el bug —adentro +/// corre un shell interactivo y su prompt sale por stderr sin salto de línea—. Sólo se guardan los +/// primeros KB para buscar la firma; el resto se reenvía y se olvida. +fn lanzar_bwrap( + mapeado: &Option, args: &[String], +) -> Result<(std::process::ExitStatus, bool)> { + let mut c = match mapeado { + Some(m) => { + let mut c = Command::new("sh"); + c.arg("-c") + .arg(r#"exec 3<"$1"; shift; exec bwrap --userns 3 "$@""#) + .arg("qorpa") + .arg(&m.ns_path) + .args(args); + c + } + None => { + let mut c = Command::new("bwrap"); + c.args(args); + c + } + }; + c.stderr(std::process::Stdio::piped()); + let mut hijo = c + .spawn() + .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?; + let mut err = hijo.stderr.take().expect("stderr pedido arriba"); + let hilo = std::thread::spawn(move || { + use std::io::Write; + let mut principio = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + match err.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if principio.len() < 8192 { + principio.extend_from_slice(&buf[..n]); + } + let _ = std::io::stderr().write_all(&buf[..n]); + let _ = std::io::stderr().flush(); + } + } + } + let t = String::from_utf8_lossy(&principio).into_owned(); + t.contains("Can't make overlay mount") && t.contains("busy") + }); + let st = hijo.wait().context("esperando a bwrap")?; + let overlay_tomado = hilo.join().unwrap_or(false); + Ok((st, overlay_tomado)) +} + // ── prune: la poda (§Orden 7) ─────────────────────────────────────────────────────────────────── // // Nace CON el subsistema y no después, a propósito: un rootfs son cientos de MB o varios GB, y no diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index 8524f397..aa19e3a6 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -505,6 +505,14 @@ Se escriben acá para que no se descubran en producción. `recreate` / `run`. El overlay lo monta bwrap (`--overlay-src` + `--overlay`) dentro de su propio namespace: sin root y sin montar nada en el host. `run` entra con `--clearenv` y con `--unshare-net` salvo que se declare `network` — el entorno del host también es una concesión. + + **Corregido el mismo día, y el bug era una verdad a destiempo:** dos `run` seguidos sobre la + misma instancia y el segundo moría con `Can't make overlay mount … Device or resource busy`; el + tercero andaba. El kernel se **niega a propósito** a montar dos overlays vivos que compartan + `upperdir`/`workdir` —eso corrompe la capa—, así que el EBUSY es un guardián correcto: lo que + estaba mal era el momento, porque la corrida anterior ya devolvió el prompt y su namespace + todavía no terminó de reaparse. Ahora `run` reintenta acotado y, si el overlay sigue tomado, + dice **la causa** («¿hay otra `run` viva?») en vez de dejar pasar el mensaje crudo de bwrap. 4. ✅ **HECHO 2026-09-03.** Concesiones → política de harkaq. `harkaq-exec` entra como último eslabón dentro de bwrap (cruza un binario **estático**, no una librería ⇒ D2 sigue en pie), y aporta lo que bwrap no da: **seccomp** —hoy una instancia podría `io_uring`, `bpf`, `ptrace`, @@ -524,6 +532,34 @@ Se escriben acá para que no se descubran en producción. **Lo que NO se pudo, y por qué:** la máquina no tiene ninguna sesión gráfica (`/dev/dri` sí, socket Wayland no), así que Steam no dibuja; y el runtime *sniper* sólo se baja al instalar un juego, lo que exige credenciales. **pressure-vessel con un juego real sigue sin ejercitarse.** + + **✅ AMPLIADO 2026-09-03: pressure-vessel SÍ se ejercitó — sin cuenta, sin juego y sin pantalla.** + Lo que lo destrabó fue pinear el depot (D8): `SteamLinuxRuntime_sniper.tar.xz` es lo que Steam + despliega, y colocado a mano no hace falta comprar nada. Corrido dentro de una instancia qorpa + *sobre Ubuntu base*, con `nesting` y la jaula puesta: + + | evidencia | fuera de pressure-vessel | dentro | + |---|---|---| + | `/etc/os-release` | `Ubuntu 24.04.3 LTS` | **`Steam Runtime 3 (sniper)`** | + | namespace de montaje | `mnt:[4026532468]` | **`mnt:[4026532526]`** | + | `/usr/lib/x86_64-linux-gnu` | el de Ubuntu | **675 libs de sniper, `libSDL2-2.0.so.0` incluida** | + + O sea: **contenedor de Valve anidado dentro de nuestra jaula, con el runtime real adentro.** Es + la pregunta que ordenaba D6 y ya no está abierta. + + **Y la concesión resultó ser de verdad, no un adorno:** con `nesting = false` el mismo comando + muere en `bwrap: Creating new namespace failed: Operation not permitted`. Una concesión que no + se puede apagar no es una concesión. + + Tres detalles que sólo salen corriéndolo: pressure-vessel avisa `Cannot determine ld.so for + i386-linux-gnu` porque Ubuntu base no trae multilib —para juegos la base es Arch, que es + justamente por lo que Arch está en la terna—; `ldd --version` sigue diciendo la glibc de la + instancia y **está bien**, porque pressure-vessel importa la libc del host cuando es más nueva + que la del runtime, así que la prueba de que el runtime entró es `os-release`, no `ldd`; y no + hay ICD de Vulkan porque no se concedió `/dev/dri` — la jaula no regala dispositivos. + + **Lo que sigue faltando, y ahora es sólo esto:** un juego real dibujando, que pide sesión + gráfica y credenciales. Ninguna de las dos es una pregunta de diseño. 7. ✅ **HECHO 2026-09-03.** `hammer qorpa prune` + el renglón en [SDD 20](../20-catalogo-publicable-y-completa.md#imágenes-ajenas-qorpa-fuera-del-catálogo-y-por-escrito). Poda restos de pulls a medias, imágenes que ninguna instancia usa (se re-traen por digest: es la diff --git a/scripts/qorpa/pressure-vessel-probe.sh b/scripts/qorpa/pressure-vessel-probe.sh new file mode 100755 index 00000000..01051bfa --- /dev/null +++ b/scripts/qorpa/pressure-vessel-probe.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# Prueba que pressure-vessel —el contenedor con el que Valve corre los juegos— arranca ANIDADO +# dentro de una instancia qorpa enjaulada. ADR 0015 §Orden 6 / D6. +# +# ── POR QUÉ ESTE GUARDIÁN ─────────────────────────────────────────────────────────────────────── +# El paso 6 del ADR quedó PARCIAL con esta frase: «pressure-vessel con un juego real sigue sin +# ejercitarse», porque el runtime sniper sólo se baja al instalar un juego y eso pide credenciales. +# Falso techo: el depot está publicado y pineado (`docs/state/qorpa-imagenes.toml`), así que se +# coloca a mano y **no hace falta cuenta, ni juego, ni pantalla** para responder la pregunta que +# ordenaba el diseño — ¿nuestra jaula deja anidar la de Valve? +# +# ── QUÉ MIDE, Y POR QUÉ ESO Y NO OTRA COSA ────────────────────────────────────────────────────── +# El veredicto es `/etc/os-release` dentro del contenedor: si dice «Steam Runtime 3 (sniper)», el +# runtime REAL se montó. NO se mide con `ldd --version`, que es la comprobación que uno escribiría +# primero y sale mal: pressure-vessel **importa la libc del host cuando es más nueva** que la del +# runtime, así que ver la glibc de la instancia adentro es lo correcto y no prueba nada. +# +# Y mide la contraparte: con `nesting = false` el mismo comando tiene que MORIR. Una concesión que +# no se puede apagar no es una concesión — sin este segundo tramo, el guardián diría «anida» aunque +# la jaula no estuviera haciendo nada. +set -eu + +QORPA_ROOT="${HAMMER_QORPA_ROOT:-/var/lib/hammer/qorpa}" +HAMMER="${HAMMER_BIN:-./target/release/hammer}" +ID="${1:-sniper-probe}" + +UBUNTU_URL="https://cdimage.ubuntu.com/ubuntu-base/releases/24.04/release/ubuntu-base-24.04.3-base-amd64.tar.gz" +UBUNTU_SHA="6bc2cde3930ad088b3bb46fa45279e96d25bc3810f209850ecbe4722711874f9" +DEPOT_URL="https://repo.steampowered.com/steamrt-images-sniper/snapshots/3.0.20260805.254768/SteamLinuxRuntime_sniper.tar.xz" +DEPOT_SHA="e264f0639ab775338311036f207b35cebe99bc417b016b53931ebca8b30b3d94" + +export HAMMER_QORPA_ROOT="$QORPA_ROOT" +echo "== qorpa root: $QORPA_ROOT" + +# 1. Las dos piezas, por digest. `pull` verifica ANTES de desempacar y es idempotente. +"$HAMMER" qorpa pull "$UBUNTU_URL" --sha256 "$UBUNTU_SHA" --label ubuntu-base-24.04.3 +# El depot NO es un rootfs (trae `run`, `pressure-vessel/` y la imagen adentro): se trae verificado +# y sin desempacar. Traerlo a secas FALLA el anclaje por estructura, que es lo que tiene que hacer. +"$HAMMER" qorpa pull "$DEPOT_URL" --sha256 "$DEPOT_SHA" --label steamlinuxruntime-sniper --verify-only + +INST="$QORPA_ROOT/instances/$ID" +if [ ! -f "$INST/instance.toml" ]; then + "$HAMMER" qorpa create "$ID" --base "$UBUNTU_SHA" --distro ubuntu-24.04 +fi + +# 2. El depot va al `upper`, que es caché por D3: si esto se pierde, se rehace con este script. +# `--delay-directory-restore` no es un adorno — sin él, tar sin ser root no puede terminar de +# llenar un directorio cuyo modo final es de sólo lectura. +if [ ! -x "$INST/upper/opt/SteamLinuxRuntime_sniper/run" ]; then + mkdir -p "$INST/upper/opt" + tar -xf "$QORPA_ROOT/images/$DEPOT_SHA/archive" --delay-directory-restore -C "$INST/upper/opt" +fi + +DENTRO='cd /opt/SteamLinuxRuntime_sniper && ./run -- /bin/sh -c "grep ^PRETTY_NAME= /etc/os-release; readlink /proc/self/ns/mnt"' +LOG=$(mktemp -d) +trap 'rm -rf "$LOG"' EXIT + +# ── LA SALIDA VA A FICHERO, NO A `$( )` ───────────────────────────────────────────────────────── +# MEDIDO acá, y cuesta una hora si no se sabe: `SALIDA=$(timeout … hammer qorpa run …)` **se cuelga +# para siempre** aunque `timeout` mate al hijo. La sustitución de comandos no espera al PROCESO, +# espera a que se cierre el PIPE, y pressure-vessel deja descendientes (su logger) con el fd +# abierto. Con redirección a fichero el mismo comando termina y devuelve su código. Es la misma +# familia que la cicatriz del preflight: el guardián que se cuelga o miente es peor que no tenerlo. +corre() { # corre + timeout -k 5 "$2" "$HAMMER" qorpa run "$ID" -- /bin/sh -c "$DENTRO" > "$1" 2>&1 < /dev/null || true +} + +# 3. Con la concesión: tiene que aparecer el runtime de Valve. +sed -i 's/^nesting = false/nesting = true/' "$INST/instance.toml" +echo "== con nesting = true" +corre "$LOG/si" 300 +grep -E "PRETTY_NAME|mnt:" "$LOG/si" || true +if ! grep -q "Steam Runtime 3 (sniper)" "$LOG/si"; then + tail -20 "$LOG/si" + echo "FALLA: pressure-vessel no montó el runtime sniper" >&2 + exit 1 +fi + +# 4. Sin la concesión: tiene que MORIR. Si esto pasa, la jaula no está haciendo nada. +sed -i 's/^nesting = true/nesting = false/' "$INST/instance.toml" +echo "== con nesting = false (tiene que fallar)" +corre "$LOG/no" 120 +sed -i 's/^nesting = false/nesting = true/' "$INST/instance.toml" +if grep -q "Steam Runtime 3 (sniper)" "$LOG/no"; then + echo "FALLA: anidó SIN la concesión — \`nesting\` no está conteniendo nada" >&2 + exit 1 +fi +# NO alcanza con «no salió sniper»: un cuelgue, un timeout o una ruta mal escrita también dejan un +# log sin esa línea, y el guardián diría OK sin haber probado nada. Se exige la EVIDENCIA POSITIVA +# de la denegación — el mismo criterio de evidencia negativa de harkaq. +if ! grep -q "Creating new namespace failed" "$LOG/no"; then + tail -20 "$LOG/no" + echo "FALLA: sin \`nesting\` no salió el rechazo esperado del kernel — no se probó nada" >&2 + exit 1 +fi +grep -o "bwrap: Creating new namespace failed.*" "$LOG/no" | head -1 + +echo "OK: pressure-vessel anida bajo la jaula con \`nesting\`, y NO anida sin ella"