ADR 0014: distribución multi-origen — el origen no necesita confianza
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CK6HpSoHcN9M4GBpqRSusR
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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 <URL>`) |
|
||||
|
||||
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 ⇒ **<US$1/mes por 51 G** | **0** |
|
||||
| Backblaze B2 + Cloudflare | ~US$6/TB-mes | 0 vía Bandwidth Alliance |
|
||||
| Hetzner Object Storage | ~€6/mes (1 TB + 1 TB) | incluido hasta el TB |
|
||||
|
||||
**Lo que decide este ADR no es «usamos R2»: es que R2 sea una entrada más de una lista.** Si mañana
|
||||
cierra la cuenta, se añade otra base a la lista y no cambia una línea de código ni se re-hashea un
|
||||
artefacto. La condición de no depender de un servidorcito no se cumple contratando un proveedor
|
||||
grande — se cumple pudiendo perder cualquiera de ellos sin enterarse.
|
||||
|
||||
Regla que se deriva: **nunca menos de dos orígenes en dos proveedores distintos**. Hoy el repo, el
|
||||
respaldo y el mirror de fuentes están los tres en Hetzner y en la misma cuenta — riesgo que el ADR
|
||||
0013 ya dejó anotado sin resolver, y que R2 resuelve por ser de otra casa.
|
||||
|
||||
### 7. ⛔ Lo que NUNCA sale hacia el CDN
|
||||
|
||||
La **clave raíz de firma**, que es offline y es lo único no delegable de todo el lanzamiento
|
||||
([SDD 19 §3.2](../19-lanzamiento-publico.md), [SDD 20](../20-catalogo-publicable-y-completa.md)). Un
|
||||
CDN sirve bytes públicos; firmar es lo contrario de publicar. Si la clave estuviera en el sitio desde
|
||||
donde se sirve, los N espejos dejarían de ser «orígenes sin confianza» y pasarían a ser N copias de
|
||||
la autoridad.
|
||||
|
||||
## Verificado de punta a punta, no por inspección
|
||||
|
||||
Dos servidores HTTP locales con el **mismo `index.json`** (mismo nombre de paquete, mismo `file`,
|
||||
mismo `digest`) y `.swm` distintos: el bueno y una copia con **un solo byte cambiado** — el mismo
|
||||
tamaño, el mismo nombre de fichero. Es exactamente lo que puede hacer un espejo que no controlamos.
|
||||
|
||||
| escenario | resultado | qué prueba |
|
||||
|---|---|---|
|
||||
| 1. sólo el origen bueno | pasa el digest, baja, carga el `.swm`, y muere después en el `config_edit` (el paquete sintético toca un `/etc/network.conf` que no existe) | la descarga y la verificación se cruzan de verdad |
|
||||
| 2. sólo el origen manipulado | `Error: digest de 'demo' no coincide … el origen entregó un .swm distinto del que se firmó`, exit 1 | el byte cambiado se detecta |
|
||||
| 3. origen **muerto** primero (:9999), bueno detrás | llega al mismo punto que (1), y reporta `bajados 1 .swm de http://127.0.0.1:8801` | **la lista hace failover ante ausencia** |
|
||||
| 4. origen **manipulado** primero, bueno detrás | aborta con el mismo error que (2), exit 1 | **NO cae al siguiente ante contenido distinto** — §4 |
|
||||
|
||||
(3) y (4) son el par que importa: sin el segundo, la lista sería un mecanismo para tapar espejos
|
||||
mentirosos en vez de para sobrevivir a espejos caídos.
|
||||
|
||||
Además, el mensaje de (3) nombra **el origen que sirvió de verdad**, no la lista entera. Decir
|
||||
«bajado de \<lista\>» 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.
|
||||
Reference in New Issue
Block a user