From 48b3c5be3fc73d0fb1ebbd9cfca4451da762a1af Mon Sep 17 00:00:00 2001 From: Sergio Date: Tue, 1 Sep 2026 18:45:10 +0000 Subject: [PATCH] =?UTF-8?q?ADR=200014:=20distribuci=C3=B3n=20multi-origen?= =?UTF-8?q?=20=E2=80=94=20el=20origen=20no=20necesita=20confianza?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Se decide ahora, antes de tener usuarios, porque toca el formato del índice y el comportamiento de clientes instalados en máquinas ajenas: después de la primera descarga pública el cambio rompe a alguien o se arrastran dos formatos para siempre. La decisión entera cuelga de una asimetría medida: ~51 G de cuerpo inmutable y verificable por contenido, contra KB de raíz mutable que es lo único que necesita ser fresco y auténtico. El cuerpo puede servirse desde cualquier host, incluso hostil ⇒ conseguir espejos deja de ser un problema de infraestructura. Cloudflare R2 como primer origen público por el egress a 0, pero es INSTANCIA, no arquitectura: lo que se decide es que R2 sea una entrada más de una lista. No depender de un servidorcito no se logra contratando un proveedor grande, sino pudiendo perder cualquiera sin enterarse. Regla derivada: nunca menos de dos orígenes en dos proveedores. Verificado de punta a punta con dos orígenes HTTP y un .swm con UN byte cambiado bajo el mismo nombre: origen muerto ⇒ failover; origen manipulado ⇒ aborta y NO cae al bueno de detrás. Sin ese segundo caso la lista sería un mecanismo para tapar espejos mentirosos. Queda pendiente y anotado: el vigía sólo mira upstream, no nuestros orígenes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CK6HpSoHcN9M4GBpqRSusR --- docs/adr/0013-mirror-de-fuentes.md | 3 + docs/adr/0014-distribucion-multiorigen.md | 188 ++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 docs/adr/0014-distribucion-multiorigen.md diff --git a/docs/adr/0013-mirror-de-fuentes.md b/docs/adr/0013-mirror-de-fuentes.md index a7ab1e7a..cbc9b4fe 100644 --- a/docs/adr/0013-mirror-de-fuentes.md +++ b/docs/adr/0013-mirror-de-fuentes.md @@ -1,6 +1,9 @@ # ADR 0013 — Mirror de fuentes: la URL es transporte, el `sha256` es la identidad - **Estado:** ACEPTADO — implementado en `crates/hammer-build/src/fetch.rs` y `scripts/fuentes/`. + **Ampliado por [ADR 0014](0014-distribucion-multiorigen.md)**: `HAMMER_MIRROR` y + `HAMMER_MIRROR_GIT` son LISTAS de orígenes, no una URL — un solo espejo propio reproducía + el punto único de fallo que este documento quería quitar. - **Fecha:** 2026-08-26 - **Frontera:** `fetch_tarball` / `fetch_git`, `scripts/fuentes/mirror-poblar.sh`, `scripts/fuentes/fuentes-vigia.sh`. diff --git a/docs/adr/0014-distribucion-multiorigen.md b/docs/adr/0014-distribucion-multiorigen.md new file mode 100644 index 00000000..f6c49281 --- /dev/null +++ b/docs/adr/0014-distribucion-multiorigen.md @@ -0,0 +1,188 @@ +# ADR 0014 — Distribución multi-origen: el origen no necesita confianza + +- **Estado:** ACEPTADO — implementado en `crates/hammer-build/src/fetch.rs`, + `crates/hammer-core/src/repo.rs` y `crates/hammer-cli/src/main.rs` (`RepoSource`). +- **Fecha:** 2026-09-01 +- **Frontera:** `bases_de_mirror` / `rotar_bases`, `PackageEntry::digest`, `repo::verify_digest`, + `RepoSource::{parse,read_index,materialize}`. +- **Continúa:** [ADR 0013](0013-mirror-de-fuentes.md) (mirror de fuentes) y + [SDD 19 §3](../19-lanzamiento-publico.md) (infraestructura pública). + +## Contexto + +**Todavía no hay usuarios.** Ése es exactamente el momento de decidir esto: el protocolo de +distribución toca el formato del índice y el comportamiento de clientes ya instalados en máquinas +ajenas. Cambiarlo después de la primera descarga pública significa romper a alguien o arrastrar dos +formatos para siempre; cambiarlo hoy no cuesta nada. + +La pregunta que lo dispara es la correcta: *no quiero apoyarme en un solo servidorcito.* Y hoy nos +apoyamos exactamente en eso, en tres capas a la vez: + +| capa | qué sirve | orígenes soportados **antes** de este ADR | +|---|---|---| +| fuentes tarball | `{sha256}.tar` | **1** (`HAMMER_MIRROR`) | +| fuentes git | `{commit}.bundle` | **1** (`HAMMER_MIRROR_GIT`) | +| repo de paquetes | `index.json` + `.swm` | **1** (`--repo `) | + +El ADR 0013 existe precisamente para no depender de 79 servidores ajenos — y lo resolvió creando una +dependencia de **uno nuestro**. Es el mismo punto único de fallo con nuestro nombre encima. + +### La medida: qué habría que servir, y cuánto pesa + +| qué | tamaño | naturaleza | +|---|---:|---| +| tarballs de fuentes (`work/tarballs`, 393 objetos) | 2,9 G | inmutable, direccionado por `sha256` | +| bundles git de fuentes (571 commits) | ~2,3 G | inmutable, direccionado por `commit` | +| artefactos / paquetes | 36 G en el hub; **~51 G** el espejo publicable tras el split de debug ([SDD 23](../23-plan-rehasheo.md)) | inmutable, direccionado por BLAKE3 | +| imágenes de instalación | unos pocos G por release | inmutable | +| **índice firmado del repo** | **KB** | **mutable, y lo único que debe ser fresco y auténtico** | + +Es decir: **el 99,99% de los bytes es inmutable y verificable por contenido, y el 0,01% restante es +lo único que necesita confianza.** Esa asimetría es la decisión entera. + +## Decisión + +### 1. La partición: raíz chica y firmada, cuerpo grande y sin confianza + +- **Raíz** — el `index.json` firmado. Debe ser fresca y auténtica. Es de kilobytes, así que puede + vivir en sitios que no escalan pero sí se controlan: el propio git (que ya se espeja a gitea **y** + a GitHub, ver `scripts/espejo-setup.sh`), o cualquier host. +- **Cuerpo** — tarballs, bundles, `.swm`, artefactos, imágenes. Decenas de gigas, y **cero + necesidad de confianza**: el nombre del objeto ES su verificación, o su hash está anclado en la + raíz firmada. + +⇒ El cuerpo puede servirse desde **cualquier host, incluso uno hostil**. Eso convierte «conseguir +espejos» de un problema de infraestructura y de contratos a un problema de a quién pedírselo. + +### 2. Todo camino de descarga acepta una LISTA de orígenes, no uno + +`HAMMER_MIRROR` y `HAMMER_MIRROR_GIT` pasan a ser listas separadas por comas. `--repo` acepta +`https://a,https://b`. Añadir orígenes **no re-hashea nada**: la URL nunca estuvo en `hash_inputs` +(ADR 0013 §1), y para el repo el `digest` no es un input de build sino un campo del índice. + +### 3. Las fuentes ROTAN el orden; el repo lo respeta + +Distinto a propósito, y la razón importa: + +- **Fuentes** — es la granja moliendo el corpus sin nadie mirando, cientos de objetos por tanda. Si + se probara siempre en orden, el segundo origen no se tocaría jamás hasta la emergencia: sería *un + espejo que nadie prueba*, la lección literal del ADR 0013 §2 un nivel más arriba. Se rota con una + semilla **determinista tomada del propio hash del objeto**: el mismo objeto sale siempre del mismo + origen (un fallo se reproduce y se depura), pero objetos distintos se reparten entre todos, así + que **una tanda del corpus ejercita la lista entera**. Tras el elegido se recorren los demás. +- **Repo** — es una persona instalando un paquete. El orden es una preferencia suya (el espejo más + cercano primero) y la previsibilidad vale más que el reparto; además son un puñado de ficheros, no + una tanda. Se prueban en el orden que escribió. + +### 4. Ausencia ⇒ seguir. Contenido distinto ⇒ ABORTAR + +La distinción que hace que una lista de orígenes sea una mejora y no un agujero: + +- Un origen que **no tiene** el objeto (404, host caído, DNS) es normal: se pasa al siguiente en + silencio. Un espejo puede ir por detrás. +- Un origen que devuelve **otro contenido** del que la raíz firmada declara es manipulación o + corrupción. **Se aborta ahí mismo**, no se cae al siguiente origen. + +Caer al siguiente sería la tentación natural («total, del bueno lo bajo igual») y es justo el patrón +que este repo ya pagó caro: *un ausente falla ruidosamente; un vacío llega hasta el final diciendo +que todo fue bien* (CLAUDE.md §3). El paquete acabaría instalado desde el espejo bueno y **nadie se +enteraría nunca de que uno de los espejos miente**. + +### 5. El índice firmado ancla el `digest` de cada `.swm` — el hueco que había + +Antes de este ADR la cadena estaba **rota en el último eslabón**, y no era evidente. La firma del +índice cubre la lista de entradas: nombre, versión, `file`, deps. Pero `file` es una **ruta, no un +contenido**: quien sirviera los bytes podía devolver otro `.swm` bajo el mismo nombre y la firma del +índice seguía casando perfectamente. + +Había red aguas abajo — `apply` reconstruye desde fuente y compara contra `expected_hash` — pero no +alcanza: `expected_hash` es **opcional**, y el `.swm` se lee mucho antes de llegar ahí (el gate de +colisiones de ficheros de `install` ya decide con su contenido). El comentario de `download.rs` +decía que el `patch_url` «se cubre indirectamente»; *indirectamente* no es una cadena de confianza. + +Se cierra con `PackageEntry::digest` (BLAKE3, `b3:…`, la misma función que sella artefactos — no una +segunda convención de hash conviviendo con la primera), sellado al publicar y verificado antes de +escribir un byte. La cadena queda: **clave raíz → firma del índice → `digest` → bytes**. + +El campo es `Option` con `skip_serializing_if`, así que **un índice firmado antes de este cambio +serializa idéntico y su firma sigue siendo válida** (hay test). Un índice sin digests no bloquea, +pero **dice cuántos paquetes no verificó**: un índice viejo servido desde un espejo ajeno no es +seguro, y el silencio lo haría parecer verificado. + +### 6. Cloudflare R2 como primer origen público — instancia, no arquitectura + +El criterio no es el precio del almacenamiento (51 G no cuestan nada en ningún sitio): es el +**egress**, que es lo que quiebra a los proyectos que reparten binarios. R2 no cobra salida. + +Órdenes de magnitud, **a verificar el día de contratar** — no son cotizaciones: + +| origen | almacenamiento | egress | +|---|---|---| +| Storage Box BX11 (ya pagado) | €3,20/mes por 1 TiB | sin cargo, pero no es un CDN | +| Cloudflare R2 | ~US$0,015/GB-mes ⇒ **» cuando sólo uno respondió es una media verdad que esconde justo el dato con el +que se depura un espejo caído — el mismo error de diagnóstico que el ADR 0013 documenta en el +poblador de bundles. + +## Consecuencias + +- **Cero re-hasheos.** La URL no está en `hash_inputs` y `digest` no es un input de build. +- **Cero roturas de firmas existentes.** Verificado por test: sin `digest`, `PackageEntry` serializa + idéntico a antes. +- Un espejo nuevo pasa a ser un `rsync` plano sin índice, sin coordinación y sin confianza. La + barrera de entrada para que un tercero nos espeje es más baja que la de una distro clásica, y eso + es una consecuencia directa del direccionamiento por contenido, no un favor que pedimos. +- Aparece deuda de vigilancia (ver abajo): más orígenes = más sitios que pueden pudrirse en silencio. + +## Lo que este ADR NO decide + +- **El vigía de los espejos.** `scripts/fuentes/fuentes-vigia.sh` vigila **upstream**, no nuestros + orígenes. Con una lista de N, hace falta comprobar que **cada uno** tiene lo que dice tener — si no, + volvemos a la métrica que envejece hacia el optimismo. Es el trabajo pendiente más claro que deja + este documento. +- **Qué origen es la verdad al publicar** (cómo se propaga un release de uno a los demás, y qué pasa + si la propagación se corta a media tanda: un espejo con índice nuevo y `.swm` viejo). +- **Las imágenes de instalación**: son ficheros grandes de un solo tiro y el reparto natural es + torrent con webseed, que no comparte mecánica con esto. +- **La rotación de claves de firma** y el procedimiento ante filtración — sigue siendo de + [SDD 19 §3.2](../19-lanzamiento-publico.md), y sin él la firma sigue siendo teatro por muchos + orígenes que haya. +- **La concentración en `github.com`** (64% del corpus como fuente). Este ADR no la toca; sigue + pendiente desde el 0013.