diff --git a/docs/24-arbol-de-fuentes.md b/docs/24-arbol-de-fuentes.md new file mode 100644 index 00000000..d0d229d5 --- /dev/null +++ b/docs/24-arbol-de-fuentes.md @@ -0,0 +1,305 @@ +# SDD 24 — El árbol de fuentes: caché sellada + workspace efímero + +> **Resuelve ADR 0012.** Estado propuesto: ACEPTADO al cerrar F2.5. +> **Fecha:** 2026-08-28. + +--- + +## 0. El problema, medido + +`work/sources/-` cumple dos papeles que se contradicen bajo concurrencia: + +1. **Caché direccionada por contenido**: dos builds de la misma dep resuelven al MISMO directorio. +2. **Workspace mutable de build**: patches, `cargo vendor`, `go mod vendor` (minutos, decenas de GB), + y el wrapper `.zwrap/cc` que la receta deja EN EL ÁRBOL. + +Fallo medido a escala: al invalidar `libdrm`, ~93/205 recetas KDE murieron con +`/src/.zwrap/cc is not a full path…` — el wrapper barrido por el fetch concurrente de otra receta. +Mitigación vigente: `flock` global + `JOBS=1` ⇒ **la granja entera corre serializada**. Un lock sólo +en la extracción NO alcanza: un segundo proceso puede borrar un árbol que un `bwrap` está compilando. + +**La tesis de este SDD:** los dos papeles no se reconcilian — se **separan por capa**, con el corte +puesto para que lo caro (vendor) caiga del lado inmutable. Es la misma jugada que el sandbox ya usa +para las build-deps (`--overlay-src` RO apiladas): extenderla a `/src`. + +--- + +## 1. Diseño + +### 1.1 Las dos capas + +``` +MATERIALIZACIÓN (host, una vez por fuente_id, con lock corto por-hash) + fetch (red) → patch → vendor (cargo/go) + → sellar árbol RO en work/sources-cas/-/ + fuente_id = blake3( source_id ⧺ Σ blake3(patch_i) ⧺ marca_vendor ) + +BUILD (sandbox, N en paralelo, cero locks) + bwrap monta /src = overlay( lower: fuente sellada RO, upper: work/ws// privado ) + configure → compile → install → seal artefacto → destruir upper +``` + +- **El vendoreo se paga una vez** y queda del lado inmutable: `marca_vendor` deriva de los lockfiles + (`Cargo.lock` / `go.sum`) que ya viven en el `source_id`, así que la fuente sellada es + determinista dado el pin. +- **`.zwrap/cc` y todo residuo de build viven en el upper privado.** El fallo medido queda + estructuralmente imposible: no hay inode compartido escribible. +- **El diamante deja de serializar**: N builds montan la misma fuente sellada como lower RO + simultáneamente. Es exactamente el mecanismo ya probado con las build-deps. +- **La fase `patch` migra a la materialización** (fuera del sandbox). Inerte para el hash: el + contenido del patch ya está en `hash_inputs`; sólo cambia dónde se aplica. + +### 1.2 Por qué no las otras opciones del ADR + +- **(A) lock por árbol durante todo el build**: correcto pero serializa el diamante qtbase — el + costo que este SDD elimina. +- **(B) árbol privado por constructor**: paga el vendor (decenas de GB, minutos) en CADA build. +- **(C) caché inmutable + copia**: paga la copia completa por build. Este diseño es (C) con + overlay en vez de copia y con el vendor movido al lado sellado. + +--- + +## 2. Reglas + +- **R1** — La fuente sellada es inmutable y direccionada: `fuente_id = blake3(source_id ⧺ + Σ blake3(patch) ⧺ marca_vendor)`. Vive en `work/sources-cas/`, `chmod a-w` recursivo tras sellar. +- **R2** — Sellado atómico: materializar en `tmp/`, escribir marker, `rename`. Jamás visible un + árbol parcial bajo su nombre final. +- **R3** — El marker `.sellada` contiene `{fuente_id, blake3_arbol, ts}`. Presencia del directorio + SIN marker = no sellado (lección §14.4.1: *un vacío es un cache-hit*; el marker exige contenido). +- **R4** — Nada escribe en una fuente sellada. El build la recibe sólo como lower RO del overlay + de bwrap. +- **R5** — Workspace efímero por constructor: `work/ws//`, destruido tras el seal del + artefacto. `--keep-ws` lo conserva para debug. +- **R6** — Lock **sólo** durante la materialización, por `fuente_id` + (`flock work/locks/`). El segundo llegador espera y encuentra sellado. Los builds no + toman ningún lock. +- **R7** — Fallo o kill durante materialización ⇒ sólo queda `tmp/`; la corrida siguiente lo barre + y re-materializa. Nunca se sella parcial. +- **R8** — La adopción **no mueve ningún `artifact_hash`**: `fuente_id` no entra en `hash_inputs` + (sus componentes ya están). Verificación obligatoria pre-merge: `hammer hash --check` sobre el + corpus completo, cero movidos. +- **R9** — GC de `sources-cas` por alcanzabilidad desde recetas activas; los no alcanzables son + podables gratis (reproducibles desde el mirror ADR 0013). Medir con `st_nlink`/contenido, nunca + con `du`. +- **R10** — Re-materializar un `fuente_id` existente debe reproducir `blake3_arbol` del marker o + fallar ruidoso (detecta vendor no determinista o mirror envenenado). +- **R11** — El `flock` global de CLAUDE.md regla 1 se retira **sólo** tras F2.5 verde, en el mismo + commit que sube `JOBS` del worker. + +## 3. Invariantes (mecánicamente verificables) + +- **IF-1** — N builds concurrentes que comparten fuente jamás mutan un inode de `sources-cas`. + Test: diamante real (dos recetas KDE sobre la misma qt-dep) con `fanotify` de escritura sobre + `sources-cas` — cero eventos. +- **IF-2** — Canario deliberado (estilo D9 de harkaq): un build de test intenta `touch` en `/src` + fuera del upper ⇒ DEBE recibir `EROFS`. Si no lo recibe, el montaje está roto — no se cree, se gana. +- **IF-3** — `hammer hash --check` corpus completo: hash vigente idéntico pre/post migración. +- **IF-4** — `kill -9` en medio de la materialización ⇒ no existe marker; la corrida siguiente + sella limpio y `blake3_arbol` casa con una materialización sin interrupción. +- **IF-5** — Artefacto construido sobre overlay ≡ bit-a-bit al mismo artefacto construido con el + modelo viejo (por receta de cada clase: c, cargo, go, gui). +- **IF-6** — Tras F3, `work/sources/` (viejo) no existe y ningún script lo referencia + (verificado reconstruyendo, no con `grep` — §14.4.4). + +## 4. Fases + +- **F0 — spike** (gate del frente): sellar `zlib`, montar `/src` como overlay en bwrap, construir. + Verde ⇔ IF-5 para zlib + IF-2. Un día. +- **F1 — materialización**: `fetch.rs` produce fuentes selladas (R1–R3, R6–R7, R10). El build + sigue copiando del sellado al modelo viejo (puente temporal, sin overlay aún). +- **F2 — build sobre overlay**: `/src` = lower sellado + upper efímero (R4–R5). `.zwrap` al upper. + Detrás de `SOURCES_CAS=1`, **inerte sin él** (mismo requisito que harkaq: 700+ sellados no + cambian por encender un mecanismo). IF-3 + IF-5 por clase. +- **F2.5 — vertical slice**: un worker real de granja con `JOBS=4` drena una onda KDE que + atraviesa el diamante qtbase. Todo `✓ REPRODUCIBLE`, IF-1 con fanotify activo. **Gate del ADR:** + 0012 pasa a ACEPTADO aquí o el diseño vuelve. +- **F3 — migración**: borrar `work/sources/`, retirar flock global + `JOBS=1` (R11), GC (R9) + cableado al latido. +- **F4 — cosecha**: medir speedup real de la campaña de reconstrucción del corpus (§13.3) con + JOBS=N. El número cierra el informe del ADR. + +## 5. No-objetivos + +- **NG-1** — No cambia el sandbox de build ni sus fases internas (bwrap sigue; sólo cambia cómo + llega `/src`). +- **NG-2** — No toca `hash_inputs` ni re-hashea nada (R8 lo hace verificable, no aspiracional). +- **NG-3** — No resuelve la red del vendor de Go en host (→ D1). +- **NG-4** — `sources-cas` NO es el store ni procedencia: es caché podable. La procedencia de + fuentes es el mirror (ADR 0013). +- **NG-5** — No introduce dependencia de filesystem con reflink: el mecanismo es el overlay de + bwrap que ya se usa para deps. + +## 6. Deudas nombradas + +- **D1** — `go mod vendor` sigue corriendo en host con red. Acotado a materialización; R10 lo hace + detectable. Cierre futuro: materialización dentro de un sandbox con red controlada. +- **D2** — Verificar que los kernels de la granja (los propios, SDD 22) montan overlay dentro del + userns de bwrap con lower en `sources-cas`. Si algún worker no puede: fallback copia-completa + (correcto, lento, ruidoso en el log — nunca silencioso). +- **D3** — `--keep-ws` acumula workspaces; podarlos en el mismo GC de R9. +- **D4** — El snapshot golden de la granja debe hornear `sources-cas` poblado o los primeros + builds pagan materialización fría; decidir en F2.5 con el dato del slice. + +## 7. Preguntas abiertas + +- **Q1** — ¿La fase `patch` fuera del sandbox es aceptable como riesgo? Propuesta: sí (`git apply` + es inerte y el contenido está en el hash). Alternativa si no: materialización dentro de un bwrap + sin red salvo el paso fetch. +- **Q2** — Política de retención de `sources-cas` con vendors grandes: ¿tope en GB con evicción + LRU, o sólo alcanzabilidad? Decide el operador con el dato de F4. +- **Q3** — ¿`fuente_id` se registra en `bootstrap.json` como eslabón del log de transparencia? + Barato ahora, difícil retrofit. Propuesta: sí, campo opcional. + +--- + +*Cruces: ADR 0012 (resuelve) · ADR 0013 (procedencia de fuentes) · SDD 02 §4.2 (overlay-src de +deps: el mecanismo que este SDD reutiliza) · SDD 16 (patrón "inerte sin flag" y canario D9) · +§14.4.1 (marker contra vacíos) · CLAUDE.md regla 1 (retiro en R11).* + +--- + +# 8. Verificación contra el código (2026-08-28) + +> Escrito por el agente con acceso al repo, siguiendo el patrón del [SDD 22](22-configurador-kernel.md): +> un diseño entrante se contesta con evidencia, y **si un dato de hammer lo contradice, manda hammer**. +> El diseño **se sostiene** y su mecanismo central quedó **probado en un comando** (§8.1). Hay +> **cuatro correcciones** que hay que aplicar antes de escribir código: dos invalidan una regla y un +> invariante tal como están escritos, y dos son huecos de alcance. + +## 8.1 ✅ El mecanismo central FUNCIONA — spike F0 ya ejecutado + +`bwrap 0.11.2` (el del `.dev-fs`) soporta `--overlay RWSRC WORKDIR DEST`. Montado dentro de +`--unshare-all`, con lower en disco y upper en disco: + +``` +lower legible: hola +escritura al upper: cc ← /mnt/.zwrap/cc fue al upper privado +lower intacto: hola ← el árbol sellado no se tocó +upper: ovl/upper/.zwrap/cc ← el residuo de build queda aislado +``` + +⇒ **F0 no es «un día»: el gate de mecanismo ya está verde.** Lo que queda de F0 es IF-5 sobre +`zlib` (bit-a-bit contra el modelo viejo), que sí exige construir. + +Dos hechos del código que lo refuerzan y que el SDD no cita: + +- **`--overlay` exige al menos un `--overlay-src` previo**, y el acumulador de `--overlay-src` se + **consume por operación de overlay**. Como `sandbox.rs` ya emite `--overlay-src [deps…] + --tmp-overlay /`, el grupo de `/src` debe ir **después** y no debe filtrar srcs al del root. + Es un gotcha de orden de argumentos, no de diseño. +- **El límite de página del `lowerdir`** que `sandbox.rs:347-357` documenta (el kernel concatena + todas las lowerdir en un string de una página; por eso hay pre-merge con `cp -al` cuando hay + muchas deps) **no aplica aquí**: `/src` es un *mount aparte* con su propia lowerdir. El diseño no + agrava ese límite. + +## 8.2 🔴 R1 está mal: `chmod a-w` recursivo ROMPE el build (medido) + +El sandbox corre como **uid 1001, sin `CAP_DAC_OVERRIDE`** (`id` dentro de bwrap lo confirma), y +**overlayfs preserva el modo del lower al hacer copy-up**. Con el árbol sellado a `a-w` +(dirs 0555, ficheros 0444), medido sobre patrones reales de fase: + +| patrón de build | resultado | +|---|---| +| `echo >> /mnt/configure` (reescribir fichero existente) | **FALLA** — `Permission denied` | +| `echo y > /mnt/sub/nuevo.o` (crear dentro de un subdir del lower) | **FALLA** — `Permission denied` | +| `sed -i` en la **raíz** del overlay | OK | +| `mkdir /mnt/output` (patrón meson) en la **raíz** | OK | + +Y lo peor es **por qué** las dos últimas pasan: el upper root ya existe con modo 0755 y su modo gana +sobre el del lower root. O sea que el fallo es **parcial y por profundidad** — funciona arriba y +falla adentro. + +⇒ **Un spike sobre `zlib` (autotools que configura en la raíz) pasaría F0 en verde y el diseño +reventaría después, en meson/cmake/cargo, que escriben en subdirectorios.** Es exactamente la forma +del fallo *sellado ≠ arranca*: verde en el grafo, roto en la realidad. + +**Corrección propuesta.** La inmutabilidad la da el **mount**, no los bits de modo: a través de un +overlay **es estructuralmente imposible** escribir el lower — R4 ya se cumple sin ayuda de `chmod`. +Si además se quiere blindaje contra un proceso del *host*, protéjase el **directorio contenedor** +(`work/sources-cas/`, para que nada pueda crear/renombrar/borrar dentro) y déjense los modos del +árbol **tal como los trae upstream**. La detección de corrupción ya la cubre `blake3_arbol` (R10). + +## 8.3 🔴 IF-2 es inimplementable tal como está escrito + +«Un `touch` en `/src` **fuera del upper** ⇒ debe recibir `EROFS`» no es una noción coherente bajo +overlayfs: *todo* lo escribible pasa por el upper vía copy-up. Un build que recibiera `EROFS` en +`/src` sería un build roto, no un invariante que pasa. El canario D9 de harkaq es legítimo, pero el +hecho negativo que hay que ganarse aquí es otro: + +> **IF-2 (corregido)** — Tras N builds concurrentes sobre la misma fuente, `blake3_arbol` del árbol +> sellado sigue casando con el del marker. El canario deliberado: un build de test **escribe** en +> `/src` (debe funcionar, vía copy-up) y el árbol sellado **sigue idéntico**. Si el hash se movió, el +> montaje está mal (alguien montó el lower como bind RW). No se cree: se gana. + +Esto además hace IF-1 verificable **sin `fanotify`** (que exige `CAP_SYS_ADMIN` y complica el test en +la granja): comparar el `blake3_arbol` antes y después es más barato y más fuerte. + +## 8.4 🟡 Falta la SEGUNDA carrera — y es el mejor argumento del diseño + +El SDD sólo nombra la carrera de `fetch` (uno borra mientras otro extrae). Hay una segunda, ya +documentada en `scripts/build-farm.sh:82-99`, que el SDD no menciona: + +> *«dos recetas DISTINTAS que comparten una dep transitiva NO sellada (p.ej. gtk4 bajo libadwaita y +> gtksourceview) la construyen en el MISMO `sources/-/output` ⇒ chocan»* — +> `Some other Meson process is already using this build directory`, `Directory not empty`. + +Hoy se concilia con un **reintento serial** (Fase 1b de `build-farm.sh`) que existe *sólo* por esto. +El diseño de este SDD **la mata también** (cada constructor tiene su propio `output/` en su upper), y +conviene decirlo porque: + +1. Es la carrera que un lock por-árbol (opción A del ADR) **no** resuelve sin serializar. +2. Permite **borrar la Fase 1b entera** en F3 — un entregable medible que hoy no está en el plan. + +Además, `.zwrap` no es un caso aislado de `perl`: **131 recetas lo escriben en el árbol de +fuentes** *(medido)*, la mayoría de la cola KDE. El daño potencial es mayor de lo que sugiere el §0. + +## 8.5 🟡 Q1 ya está contestada por el código: la fase `patch` NUNCA estuvo en el sandbox + +`hammer-build/src/lib.rs` llama `fetch::apply_patches`, `ensure_cargo_workspace_isolation` y +`fetch::vendor_cargo_deps` **en el host, antes de entrar a bwrap** (líneas 224, 248, 258). O sea: + +- §1.1 dice «la fase `patch` **migra** a la materialización». No migra: **ya está ahí**. Lo que este + SDD hace es *nombrar* como capa lo que hoy ocurre de facto y sin contrato. +- **Q1 es discutible pero no es una decisión nueva** — es el statu quo desde el día 1, y el riesgo + que plantea ya se está corriendo hoy. +- **Efecto colateral que sí hay que registrar:** la tabla de fases del [SDD 02 §4](02-build-lab.md) + dice que `patch` corre **en sandbox**. Está **desactualizada** y este SDD la propaga. Corregirla es + parte de F1. + +## 8.6 Precisiones menores + +- **§0, el nombre del árbol.** `-` sólo vale para tarballs + (`&sha256[..16]`, `fetch.rs:234`); las fuentes **git** usan el **commit completo** + (`fetch.rs:73`). Sin efecto en el diseño, pero el `fuente_id` debe cubrir los dos modos. +- **R5 está incompleto.** `--overlay` exige `RWSRC` **y** `WORKDIR`, «un directorio vacío en el mismo + filesystem que RWSRC». El workspace es `work/ws//{upper,work}`, no un solo directorio. +- **F3 tiene un consumidor no listado.** El watchdog de disco de `farm-worker-loop.sh:43` protege + árboles haciendo `pgrep -af bwrap | grep -oE 'work/sources/[^ ]+'` — está **acoplado al esquema de + rutas actual**. Con el modelo nuevo hay que reescribirlo (queda más simple y más seguro: lo sellado + es inmutable y cada `ws/` tiene un solo dueño), no re-apuntarlo. Otros consumidores: + `reconstruir-corpus.sh:206` (purga) y `build-farm.sh` (Fase 1b, §8.4). +- **D4 choca con un test existente.** `hammer-bootstrap` afirma explícitamente que `work/sources/` + **no debe viajar** en el builder rootfs («regenerable y pesado», `lib.rs:2659`). Si `sources-cas` se + hornea en la golden, hay que decidir si el builder lo hereda o no — y hay un test que fallará si + alguien lo incluye sin pensarlo. +- **NG-4 y Q3 están en tensión.** Si `fuente_id` entra en `bootstrap.json` (Q3, «propuesta: sí»), + `sources-cas` deja de ser «caché podable, no procedencia» (NG-4) para volverse un eslabón citado del + log de transparencia. No es contradictorio —se puede citar un hash cuya materialización sea + regenerable— pero conviene decirlo, o la primera poda agresiva romperá una cita. + +## 8.7 Veredicto + +**El diseño es correcto y el mecanismo está probado.** Separar caché sellada de workspace efímero es +la opción (C) del ADR 0012 con overlay en vez de copia, y resuelve las **dos** carreras — no sólo la +que el §0 nombra. Antes de abrir F1: + +1. **Reescribir R1** (§8.2) — es la que rompe builds reales, y de forma parcial y silenciosa. +2. **Reescribir IF-2** (§8.3) — hoy no se puede implementar y además hace innecesario el `fanotify` + de IF-1. +3. **Añadir la carrera del directorio de build al §0** y **borrar la Fase 1b** como entregable de F3 + (§8.4). +4. **Corregir la tabla de fases del SDD 02** y cerrar Q1 como statu quo, no como decisión nueva + (§8.5). + +Con eso, el gate de F0 pasa a ser sólo IF-5 sobre `zlib`, y F2.5 sigue siendo el gate real del ADR.