diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs index f044657e..ad7ca0a7 100644 --- a/crates/hammer-cli/src/qorpa.rs +++ b/crates/hammer-cli/src/qorpa.rs @@ -309,19 +309,40 @@ fn pull( /// Descarga a fichero. NO se reusa `download::fetch_url_to_file` a propósito: ése trae el cuerpo /// entero a memoria (le sirve para un patch de KB) y acá hablamos de cientos de MB, con `--max-time` /// de 300 s que un rootfs no respeta. +/// +/// **`--retry` a secas no cubre el fallo que de verdad pasa.** Medido trayendo el runtime *sniper* +/// (302 MB): la CDN de Valve cortó el stream al 73% con `HTTP/2 INTERNAL_ERROR` (exit 92) y curl +/// **no reintentó**, porque sin `--retry-all-errors` sólo considera transitorios los timeouts y los +/// 5xx — un stream roto a media transferencia no entra en esa lista. Y aun reintentando, sin +/// `-C -` cada intento vuelve a empezar de cero: en un fichero de cientos de MB sobre una conexión +/// que se corta, eso no converge nunca. +/// +/// Por eso el parcial **NO se borra al fallar**: es lo que hace que el siguiente intento reanude en +/// vez de rebajar todo. Es seguro porque quien decide no es el fichero sino el `sha256` que se +/// verifica después —un parcial corrupto no puede pasar por bueno— y porque el nombre lleva punto +/// delante, así que `list` no lo ve como imagen y `prune` ya lo barre como «resto de un pull a +/// medias». fn download_to_file(url: &str, dst: &Path) -> Result<()> { if let Some(p) = dst.parent() { std::fs::create_dir_all(p)?; } let st = Command::new("curl") - .args(["-fL", "--retry", "3", "--retry-delay", "2", "--connect-timeout", "30", - "--progress-bar", "-o"]) + .args(["-fL", "--retry", "5", "--retry-delay", "2", "--retry-all-errors", + "--connect-timeout", "30", "-C", "-", "--progress-bar", "-o"]) .arg(dst) .arg(url) .status() .context("no pude ejecutar curl")?; if !st.success() { - std::fs::remove_file(dst).ok(); + let reanudable = std::fs::metadata(dst).map(|m| m.len()).unwrap_or(0); + if reanudable > 0 { + eprintln!( + " (quedaron {} bajados en {}: el siguiente intento reanuda desde ahí; \ + `qorpa prune` los barre)", + human(reanudable), + dst.display() + ); + } bail!("curl falló bajando {url} (exit {:?})", st.code()); } Ok(()) diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index 275e62a7..8524f397 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -275,11 +275,27 @@ no es lo que *se puede* traer, sino lo que **nosotros probamos, espejamos y publ imagen curada es una segunda cadena de suministro que hay que sostener (§NO-resuelve 3). Se curan **tres**, y cada una entra por un trabajo distinto — no por sabor: -| imagen | tipo | qué trabajo hace | pin (verificado 2026-09-03) | +| imagen | tipo | qué trabajo hace | pin | |---|---|---|---| -| **Arch bootstrap** | 1 | juegos: `multilib` de 32 bits de primera clase, y SteamOS *es* Arch ⇒ extiende el argumento de D6 («la configuración exacta que Valve prueba») | `archlinux-bootstrap-2026.09.01-x86_64.tar.zst`, sha256 `895661bd…`, en `archive.archlinux.org` (archivado para siempre) | -| **Ubuntu base LTS** | 1 | binarios comerciales: Zoom, Slack, Discord, Spotify, Chrome, JetBrains se compilan y prueban contra esto | `ubuntu-base-24.04.3-base-amd64.tar.gz`, sha256 `6bc2cde3…`, `cdimage.ubuntu.com` + `old-releases` | -| **Steam Runtime «sniper»** | 2 | la única **sellable**: al no mutar entra al store por `file_drop` y su digest va al índice firmado — cadena de custodia nuestra | ⚠ pin sin verificar todavía | +| **Arch bootstrap** | 1 | juegos: `multilib` de 32 bits de primera clase, y SteamOS *es* Arch ⇒ extiende el argumento de D6 («la configuración exacta que Valve prueba») | `archlinux-bootstrap-2026.09.01-x86_64.tar.zst`, en `archive.archlinux.org` (archivado para siempre) | +| **Ubuntu base LTS** | 1 | binarios comerciales: Zoom, Slack, Discord, Spotify, Chrome, JetBrains se compilan y prueban contra esto | `ubuntu-base-24.04.3-base-amd64.tar.gz`, `cdimage.ubuntu.com` + `old-releases` | +| **Steam Runtime «sniper»** | 2 | la única **sellable**: al no mutar entra al store por `file_drop` y su digest va al índice firmado — cadena de custodia nuestra | ✅ **verificado 2026-09-03**: snapshot `3.0.20260805.254768` (el que despliega el cliente, no «el último»), imagen y depot | + +**Los digests enteros están en [`docs/state/qorpa-imagenes.toml`](../state/qorpa-imagenes.toml), no +acá.** Estaban acá, abreviados —`895661bd…`— y cuando la poda se llevó las imágenes del disco no +alcanzaron para volver a traerlas: hubo que ir a buscarlos otra vez a upstream. **Un pin que no se +puede pegar en un comando no es un pin, es una cita.** El fichero además dice de qué lista de sumas +salió cada uno y **si esa lista viene firmada**, que es la única forma honesta de hablar de una +cadena de suministro ajena: Ubuntu firma su `SHA256SUMS`, Arch no firma la lista pero sí el tarball, +y **Valve no firma nada** — su garantía entera es TLS más el pin. + +**Del sniper se pinean dos cosas, y la segunda es la que destraba el paso 6.** La imagen +(`Platform-…-runtime.tar.gz`, un rootfs) y el **depot** `SteamLinuxRuntime_sniper.tar.xz`, que trae +pressure-vessel más la imagen y es lo que Steam despliega en `steamapps/common/`. Ese runtime +**sólo se baja al instalar un juego**, y eso pedía credenciales: pineado se coloca a mano, y +pressure-vessel se puede ejercitar **sin cuenta, sin juego y sin pantalla**. El depot **no es un +rootfs**: se trae con `--verify-only`, y traerlo a secas falla el anclaje en vez de adivinar +(comprobado: «no encuentro un rootfs en el archivo»). **Fedora queda BYO, no curada.** No por calidad —publica `Fedora-Container-Base-Generic-43-1.6` con CHECKSUM firmado y se pinea igual de bien— sino porque hace el **mismo trabajo que Arch** en el diff --git a/docs/state/qorpa-imagenes.toml b/docs/state/qorpa-imagenes.toml new file mode 100644 index 00000000..732f979a --- /dev/null +++ b/docs/state/qorpa-imagenes.toml @@ -0,0 +1,98 @@ +# Las imágenes ajenas CURADAS (qorpa, ADR 0015 D8). Un pin por imagen, entero y verificado. +# +# ── POR QUÉ ESTE FICHERO EXISTE ───────────────────────────────────────────────────────────────── +# Los pines vivían en la tabla de D8, en prosa y ABREVIADOS (`895661bd…`). Cuando la poda se llevó +# las imágenes del disco, esos pines no alcanzaban para volver a traerlas: un sha256 truncado no es +# un pin, es una cita. Hubo que ir a buscar los digests otra vez a upstream — que esta vez estaban, +# pero es exactamente el trance del que el pin debía protegernos. +# +# Regla que sale de ahí: **un pin que no se puede pegar en un comando no es un pin.** +# +# ── QUÉ ES CADA CAMPO ─────────────────────────────────────────────────────────────────────────── +# kind 1 = rootfs mutable (se le instala adentro con su gestor) · 2 = runtime inmutable +# sha256 LA IDENTIDAD. Es el sha del ARCHIVO, no del árbol (ver ADR 0015 §Orden 2), así que +# dos máquinas que lo traigan de orígenes distintos coinciden ⇒ ADR 0013 y 0014 se +# aplican sin cambiar nada: la URL no entra en la identidad y espejar es gratis. +# checksums de dónde salió el digest, para poder re-verificarlo contra upstream sin adivinar. +# signed si esa lista viene firmada. Se escribe porque §NO-resuelve 3 dice que ésta es una +# SEGUNDA cadena de suministro sin garantías nuestras: lo honesto es decir cuánta +# garantía trae cada una, y no traen la misma. +# verified fecha en que ALGUIEN lo bajó y el sha dio. No es la fecha de publicación. +# +# Los campos van en inglés como el resto de los manifiestos de qorpa (`image.toml`, +# `instance.toml`); los comentarios, en castellano — `CLAUDE.md` regla 4. +# +# ── LO QUE ESTE FICHERO NO ES ─────────────────────────────────────────────────────────────────── +# No es una lista cerrada: `hammer qorpa pull --sha256 ` acepta cualquier rootfs (D8 b). +# Curada es la lista de lo que NOSOTROS probamos, espejamos y sostenemos, porque cada imagen es una +# cadena de suministro más que mantener. Fedora queda BYO a propósito: hace el mismo trabajo que +# Arch y una tercera cadena mutable es justo la normalización que el ADR quiere evitar. + +[arch-bootstrap] +kind = 1 +label = "archlinux-bootstrap-2026.09.01" +work = """ +Juegos. Trae `multilib` de 32 bits de primera clase —las libs que la F2 del plan de juegos daba por +«una campaña entera»— y SteamOS *es* Arch, así que extiende el argumento de D6: adentro corre la +configuración que Valve prueba. +""" +url = "https://archive.archlinux.org/iso/2026.09.01/archlinux-bootstrap-2026.09.01-x86_64.tar.zst" +sha256 = "895661bdf6c64e91b7725874165fd05dd30c438d3ffec661671ab5cfb261ca58" +checksums = "https://archive.archlinux.org/iso/2026.09.01/sha256sums.txt" +signed = "el tarball tiene .sig PGP propio; la lista de sumas NO está firmada" +verified = "2026-09-03" +# El bootstrap trae TRES entradas arriba (`root.x86_64`, `version`, `pkglist.x86_64.txt`): el +# anclaje por estructura lo eleva solo. La regla vieja —«si hay un solo directorio arriba, ése es +# el rootfs»— no disparaba y dejaba el rootfs un nivel abajo con todo en verde. + +[ubuntu-base] +kind = 1 +label = "ubuntu-base-24.04.3" +work = """ +Binarios comerciales. Zoom, Slack, Discord, Spotify, Chrome y JetBrains se compilan y se prueban +contra Ubuntu LTS: adentro corren sin que nadie tenga que adivinar contra qué glibc los ligaron. +""" +url = "https://cdimage.ubuntu.com/ubuntu-base/releases/24.04/release/ubuntu-base-24.04.3-base-amd64.tar.gz" +sha256 = "6bc2cde3930ad088b3bb46fa45279e96d25bc3810f209850ecbe4722711874f9" +checksums = "https://cdimage.ubuntu.com/ubuntu-base/releases/24.04/release/SHA256SUMS" +signed = "sí — SHA256SUMS.gpg junto a la lista" +verified = "2026-09-03" +# Cuando salga 24.04.5 el punto-release viejo se muda a old-releases.ubuntu.com. No importa: la URL +# no es la identidad (ADR 0013), el sha256 sí. + +[steam-runtime-sniper] +kind = 2 +label = "sniper-platform-3.0.20260805.254768" +work = """ +El runtime en el que Valve corre los juegos. Es la única imagen INMUTABLE de la terna: no se le +instala nada adentro, así que es la única sellable —entra al store por `file_drop` y su digest +puede ir al índice firmado, cadena de custodia nuestra— y la única que no abre una segunda cadena +mutable. +""" +url = "https://repo.steampowered.com/steamrt-images-sniper/snapshots/3.0.20260805.254768/com.valvesoftware.SteamRuntime.Platform-amd64%2Ci386-sniper-runtime.tar.gz" +sha256 = "1a293c312215c59e4e5348ce210594cd7967d1a1fcaec6248a024e0878776f2b" +checksums = "https://repo.steampowered.com/steamrt-images-sniper/snapshots/3.0.20260805.254768/SHA256SUMS" +signed = "NO — ni .asc ni .gpg ni .sig; toda la garantía es TLS + el pin" +verified = "2026-09-03" +# El rootfs viene en `files/` con un hermano `metadata` al lado: tercera forma real de empaquetado, +# después de la plana (Ubuntu) y la de Arch. El anclaje por estructura la resolvió sin ayuda. +# La versión no se eligió por «la última»: `latest-container-runtime-depot.txt` dice cuál despliega +# de verdad el cliente de Steam, y ES ésta. Pinear otra sería pinear algo que nadie corre. + +[steam-linux-runtime-sniper-depot] +kind = 2 +label = "steamlinuxruntime-sniper-3.0.20260805.254768" +work = """ +El bundle que el cliente de Steam despliega en `steamapps/common/SteamLinuxRuntime_sniper`: trae +pressure-vessel MÁS la imagen de arriba. Se pinea porque es lo que destraba el paso 6 del ADR — el +runtime sólo se baja al instalar un juego, y eso pedía credenciales; pineado se coloca a mano y +pressure-vessel se puede ejercitar sin cuenta, sin juego y sin pantalla. +""" +url = "https://repo.steampowered.com/steamrt-images-sniper/snapshots/3.0.20260805.254768/SteamLinuxRuntime_sniper.tar.xz" +sha256 = "e264f0639ab775338311036f207b35cebe99bc417b016b53931ebca8b30b3d94" +checksums = "https://repo.steampowered.com/steamrt-images-sniper/snapshots/3.0.20260805.254768/SHA256SUMS" +signed = "NO — igual que arriba" +verified = "2026-09-03" +# NO es un rootfs: es un depot con `run`, `pressure-vessel/` y la imagen adentro. Traerlo con +# `pull` a secas tiene que FALLAR el anclaje en vez de adivinar; se trae con `--verify-only`, que +# guarda el archivo verificado sin desempacar.