SDD 30 §4b: la seed de producto ya sale de la RECETA — y openssh reproduce bit a bit

El emisor lee la receta que viaja DENTRO del artefacto sellado (el sidecar
.hammer/recipe.toml) y no de recipes/, porque seal_product_rootfs sirve a las
DOS vetas —product desde recetas locales y product_from_repo desde el repo
firmado— y sólo una tiene recipes/ a mano. El sidecar es la única fuente que
ambas comparten, que es lo que sostiene que converjan al mismo product-rootfs.

LA TRAMPA QUEDA ARMADA, NO CERRADA: el sidecar no entra al ArtifactHash, así que
un artefacto anterior al bloque [[service]] es un cache-hit válido que no se
reconstruye solo. Si el emisor devolviera lista vacía sin protestar, el producto
saldría booteando, verde y SIN SSHD. Falla ruidosamente, y hay un test que exige
que el mensaje siga explicando la causa Y dando el arreglo (regla 3 del
CLAUDE.md: un ausente falla ruidosamente, un vacío llega hasta el final diciendo
que todo fue bien).

LA MIGRACIÓN, CON CONTROL, y el hecho nuevo que dejó: openssh no estaba
certificado como reproducible ("el criterio es construye y corre", dice su
receta). Manifiesto de 33 sha256+modos ANTES de tocar nada, respaldo por
hardlink, borrado, rebuild bajo flock -o, comparación. Resultado: 33/33
ficheros, modos idénticos, y la ÚNICA diferencia en todo el árbol es
.hammer/recipe.toml. openssh REPRODUCE BIT A BIT.

Gotcha que costó el primer intento: store/ es bind-mount de /dev/sdb y work/
vive en /dev/sdc ⇒ `cp -al` cruza mounts y muere con EXDEV. El respaldo va
DENTRO del mount del store; un dot-dir ahí es invisible para store-gc.sh, que
sólo mira dirs ^[0-9a-f]{64}-.
This commit is contained in:
Sergio
2026-09-13 21:00:57 +00:00
parent 0295fd6dbe
commit 68dc9cc2de
2 changed files with 137 additions and 30 deletions
+40 -18
View File
@@ -1,6 +1,6 @@
# SDD 30 — Los servicios que trae un paquete
> **Estado:** §3 y §4a IMPLEMENTADOS (2026-09-12), §4b/§4c y §5 abiertos. Nace de una pregunta directa:
> **Estado:** §3, §4a y §4b IMPLEMENTADOS (2026-09-12/13), §4c y §5 abiertos. Nace de una pregunta directa:
> *«los paquetes que tienen sus servicios para systemd, openrc u otros, ¿ya saben empaquetarse en
> takana con arje?»*. La respuesta medida era **no**, y este documento dice exactamente qué faltaba,
> qué se cerró y qué queda.
@@ -142,24 +142,45 @@ CONTROL, que tiene que pasar en verde**— incluidos «dos recetas del mismo per
label» (ERROR) y «el mismo label en dos colas distintas» (NO es colisión: `upower` existe
legítimamente en incoming-gnome y en incoming-kde, y son la misma unidad en dos imágenes).
**(b) El emisor, y la trampa del sidecar rancio.** `seal_product_rootfs()` sirve a las dos vetas
(`product` desde recetas locales, `product_from_repo` desde el repo firmado) y **sólo una tiene el
directorio de recetas**. La fuente uniforme que ambas comparten es el **sidecar de provenance**
`.hammer/recipe.toml`, que `takana-build` congela DENTRO del artefacto y `Store::recipe_for_hash`
sabe leer.
**(b) El emisor — HECHO, y la trampa quedó armada en un test.** `service_cards()` lee la receta que
viaja DENTRO de cada artefacto sellado (el sidecar de provenance `.hammer/recipe.toml`, vía
`Store::recipe_for_hash`) y filtra `scope = "system"`. Se lee de ahí y no de `recipes/` porque
`seal_product_rootfs()` sirve a las DOS vetas —`product` desde recetas locales y `product_from_repo`
desde el repo firmado— y **sólo una tiene `recipes/` a mano**: el sidecar es la única fuente que
ambas comparten, que es lo que sostiene que las dos converjan al mismo `product-rootfs`.
Pero ahí está la trampa, y es de la familia de «el cache-hit congela regresiones»: el sidecar
**no entra en el `ArtifactHash`**, así que el openssh ya sellado en
`store/938835e6…-openssh/.hammer/recipe.toml` **no tiene** el bloque `[[service]]` y, como el hash
no se movió, `store.has()` da verdadero y **nunca se va a reconstruir solo**. Derivar la seed del
sidecar hoy produciría un producto **sin sshd**, en silencio.
La trampa era real y sigue ahí: **el sidecar no entra en el `ArtifactHash`**, así que un artefacto
sellado antes de que su receta declarara servicios es un cache-hit perfectamente válido y no se
reconstruye nunca solo. Si el emisor devolviera lista vacía sin protestar, el producto saldría
**booteando, verde y sin sshd**. Por eso `service_cards()` **falla ruidosamente** con un mensaje que
explica la causa y da el arreglo, y hay un test
(`service_cards_falla_ruidosamente_con_sidecar_rancio`) que exige que el mensaje siga diciendo las
dos cosas. Es la regla 3 del CLAUDE.md aplicada antes de pagarla: un ausente falla ruidosamente; un
vacío llega hasta el final diciendo que todo fue bien.
La migración (re-sellar openssh para que su sidecar lleve el bloque) es una unidad de trabajo
aparte y **con control**: hay que borrar el artefacto del store para forzar el rebuild, y openssh no
está certificado como reproducible (su propia receta dice «el criterio es construye y corre»), así
que el árbol nuevo hay que compararlo contra el viejo antes de darlo por bueno. Cambiar un artefacto
que bootea en QEMU sin ese control es exactamente cómo se cuela una regresión que nadie ve.
Hasta entonces `product_seed_card()` **sigue usando la constante**, a propósito y dicho acá.
### 4b.1 La migración de openssh, medida — y un hecho nuevo
Re-sellar openssh era el paso que faltaba, y se hizo **con control** porque openssh no estaba
certificado como reproducible (su propia receta dice «el criterio es construye y corre»). El
procedimiento, por si hay que repetirlo con otro componente de servicio:
1. **Manifiesto de control ANTES de tocar nada**: `sha256` + modo de los 33 ficheros.
2. **Respaldo por hardlink** — y acá saltó el gotcha conocido: `store/` es un bind-mount de
`/dev/sdb` y `work/` vive en `/dev/sdc`, así que `cp -al` a `work/` muere con `Invalid
cross-device link`. El respaldo tiene que ir **dentro del mount del store**; `store/.respaldo-…`
sirve y es invisible para `store-gc.sh`, que sólo considera dirs `^[0-9a-f]{64}-`.
3. Borrar el artefacto (los bytes sobreviven por el respaldo: 11 enlaces → 10) y reconstruir bajo
`flock -o`.
4. Comparar árbol nuevo contra el manifiesto.
**Resultado: openssh REPRODUCE BIT A BIT.** 33/33 ficheros, modos idénticos, y la única diferencia
en todo el árbol es `.hammer/recipe.toml` — exactamente el sidecar que se quería migrar y nada más.
Eso es un hecho nuevo sobre el corpus, no sólo un paso de esta campaña: openssh pasa de «no
certificado» a «reproduce», comprobado contra un control tomado antes del borrado.
Coste que conviene saber: los árboles ya hidratados compartían inodes con el artefacto viejo, y el
nuevo tiene inodes propios (`%h = 1`). El contenido es el mismo, pero hasta que esos árboles se
refresquen el disco lleva dos copias.
**(c) Los de GNOME: DECLARADOS, no todavía arrancados por arje.** Las nueve unidades que
`gnome-start-qemu.sh` levanta con `&` están declaradas en sus recetas y habilitadas en el perfil
@@ -210,7 +231,8 @@ arriba en arje.
| El vigía CORRE SOLO (cosecha-cron) → `docs/state/servicios.txt` | ✅ 0 errores, 18 avisos |
| Los dos compat que no estaban en ningún perfil | ✅ corregido §4a |
| Los 9 de GNOME declarados y habilitados (hash sin mover, 9/9) | ✅ §4c |
| Emisor desde el sidecar + re-sellado de openssh | §4b (con control de reproducibilidad) |
| Emisor desde el sidecar (`service_cards`, falla ruidosa + test) | §4b |
| Re-sellado de openssh con control → **reproduce bit a bit** | ✅ §4b.1 |
| Que la imagen los arranque por arje y no por el script `&` | ◻ §4c, depende de §4b |
| `escritorio-cosmic`: auditar su `cosmic-start.sh` y habilitar | ◻ hoy sale con 5 AVISO |