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
+97 -12
View File
@@ -540,19 +540,75 @@ pub fn verify_attestation(rootfs_dir: &Path) -> Result<AttestReport> {
Ok(report)
}
/// Las Cards de servicio que aportan los componentes de servicio, **leídas de la receta que viaja
/// DENTRO de cada artefacto sellado** (el sidecar de provenance `.hammer/recipe.toml`).
///
/// Por qué del sidecar y no del directorio de recetas: [`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, y así las dos
/// convergen al mismo `product-rootfs`, que es la propiedad que esa función existe para sostener.
///
/// Sólo entran las de `scope = "system"`: una Card de sesión en el `genesis` arrancaría antes de que
/// exista `XDG_RUNTIME_DIR` o un usuario logueado (ver `takana_core::service::Scope`).
///
/// ⚠ **Falla RUIDOSAMENTE si no sale ninguna**, y la razón es una trampa concreta: el sidecar **no
/// entra en el `ArtifactHash`**, así que un artefacto sellado antes de que la receta declarara sus
/// servicios sigue siendo un cache-hit válido y **no se reconstruye solo**. Si esto devolviera una
/// lista vacía sin protestar, el producto saldría sin sshd —booteando, verde y sin acceso— que es
/// exactamente la clase de fallo que este proyecto ya pagó con los artefactos vacíos.
fn service_cards(
store: &Store,
services: &[(String, ArtifactHash)],
) -> Result<Vec<serde_json::Value>> {
let mut cards = Vec::new();
let mut sin_sidecar = Vec::new();
for (nombre, hash) in services {
let recipe = match store.recipe_for_hash(hash.as_str()) {
Ok(Some(r)) => r,
Ok(None) => {
sin_sidecar.push(nombre.clone());
continue;
}
Err(e) => return Err(Error::Other(format!("sidecar de `{nombre}` ilegible: {e}"))),
};
for s in &recipe.services {
if s.scope == takana_core::service::Scope::System {
cards.push(s.card());
}
}
}
if cards.is_empty() {
return Err(Error::Other(format!(
"ningún componente de servicio ({}) declara un servicio de sistema.\n\
Casi seguro los artefactos son ANTERIORES al bloque `[[service]]`: el sidecar no entra \
en el ArtifactHash, así que siguen siendo cache-hit válidos y no se reconstruyen solos.\n\
Arreglo: borrar el artefacto del store y reconstruirlo (`flock -o work/.farm-build.lock \
takana build recipes/<receta>.toml`), comparando el árbol nuevo con el viejo.{}",
services.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(", "),
if sin_sidecar.is_empty() {
String::new()
} else {
format!("\nSin sidecar siquiera: {}", sin_sidecar.join(", "))
}
)));
}
Ok(cards)
}
/// Seed de producto = la seed base con los cards de servicio APENDADOS al `genesis`. Composición vía
/// `serde_json` (takana sigue autocontenido: no depende de `card-core`). Determinista ⇒ el
/// `product-rootfs` es reproducible. El núcleo (hammerd + getty) se preserva tal cual.
fn product_seed_card() -> Result<String> {
fn product_seed_card(cards: &[serde_json::Value]) -> Result<String> {
let mut seed: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD)
.map_err(|e| Error::Other(format!("seed base inválida: {e}")))?;
seed["label"] = serde_json::Value::String("hammer-product".into());
let card: serde_json::Value = serde_json::from_str(SSHD_SERVICE_CARD)
.map_err(|e| Error::Other(format!("card sshd inválida: {e}")))?;
seed.get_mut("genesis")
let genesis = seed
.get_mut("genesis")
.and_then(|g| g.as_array_mut())
.ok_or_else(|| Error::Other("seed base sin array `genesis`".into()))?
.push(card);
.ok_or_else(|| Error::Other("seed base sin array `genesis`".into()))?;
for c in cards {
genesis.push(c.clone());
}
serde_json::to_string_pretty(&seed).map_err(|e| Error::Other(format!("serializar seed: {e}")))
}
@@ -745,7 +801,7 @@ fn seal_product_rootfs(
services: &[(String, ArtifactHash)],
store: &Store,
) -> Result<RootfsHash> {
let seed = product_seed_card()?;
let seed = product_seed_card(&service_cards(store, services)?)?;
let phash = product_rootfs_hash(base, userland, services, &seed);
let store_name = "product-rootfs";
@@ -2070,7 +2126,7 @@ mod tests {
fn product_seed_appends_sshd_to_base_genesis() {
// La seed de producto = base + sshd; el núcleo (hammerd, getty) se preserva intacto y el
// resultado es JSON válido (lo que arje-zero validará al boot).
let seed = product_seed_card().unwrap();
let seed = product_seed_card(&[card_sshd_esperada()]).unwrap();
let v: serde_json::Value = serde_json::from_str(&seed).unwrap();
assert_eq!(v["label"], "hammer-product");
assert_eq!(v["payload"], "Virtual");
@@ -2083,6 +2139,35 @@ mod tests {
assert!(sshd["supervision"]["Restart"]["initial"].is_number(), "sshd es Restart (lifecycle)");
}
/// **El sidecar rancio NO puede pasar en silencio.**
///
/// Es la trampa que este mecanismo tiene incorporada y que hay que dejar armada: el sidecar de
/// provenance **no entra en el `ArtifactHash`**, así que un artefacto sellado antes de que su
/// receta declarara servicios sigue siendo un cache-hit perfectamente válido y no se reconstruye
/// nunca solo. Si `service_cards` devolviera una lista vacía sin protestar, el producto saldría
/// **booteando, verde y sin sshd**. Un ausente falla ruidosamente; un vacío llega hasta el final
/// diciendo que todo fue bien — regla 3 del CLAUDE.md, aplicada acá antes de pagarla.
#[test]
fn service_cards_falla_ruidosamente_con_sidecar_rancio() {
let tmp = tempfile::tempdir().unwrap();
let store = Store::open(tmp.path().join("store")).unwrap();
// Un store sin ese artefacto ⇒ sin sidecar ⇒ sin servicios: exactamente lo que se ve cuando
// el artefacto es anterior al bloque `[[service]]`.
let servicios = [("openssh".to_string(), ArtifactHash::from_hex("ab"))];
let e = service_cards(&store, &servicios).unwrap_err().to_string();
assert!(e.contains("ningún componente de servicio"), "{e}");
assert!(e.contains("ArtifactHash"), "el error tiene que EXPLICAR la trampa: {e}");
assert!(e.contains("borrar el artefacto del store"), "y decir el arreglo: {e}");
}
/// La Card de `sshd` de referencia: la constante que el producto **ya bootea en QEMU**. Los
/// tests componen la seed con ella (no con la derivada del sidecar) a propósito: así el
/// esqueleto de la seed se prueba sin necesitar un store, y la equivalencia
/// «receta ⇒ esta misma card» la prueba `la_receta_de_openssh_reproduce_la_card_hardcodeada`.
fn card_sshd_esperada() -> serde_json::Value {
serde_json::from_str(SSHD_SERVICE_CARD).unwrap()
}
/// **La receta produce la MISMA Card que la constante hardcodeada.**
///
/// Esta es la prueba que autoriza a mover el servicio de un literal de Rust a `[[service]]` en
@@ -2113,7 +2198,7 @@ mod tests {
let base = ArtifactHash::from_hex("ab");
let userland = [("uutils".to_string(), ArtifactHash::from_hex("11"))];
let svcs = [("openssh".to_string(), ArtifactHash::from_hex("cd"))];
let seed = product_seed_card().unwrap();
let seed = product_seed_card(&[card_sshd_esperada()]).unwrap();
let a = product_rootfs_hash(&base, &userland, &svcs, &seed);
assert_eq!(a, product_rootfs_hash(&base, &userland, &svcs, &seed), "determinista");
let other_base = ArtifactHash::from_hex("ef");
@@ -2165,7 +2250,7 @@ mod tests {
std::fs::write(w.join("usr/bin/netup"), b"\x7fELFfake").unwrap();
});
let seed = product_seed_card().unwrap();
let seed = product_seed_card(&[card_sshd_esperada()]).unwrap();
let userland = [("uutils".to_string(), huu)];
let services = [("openssh".to_string(), hossh), ("netup".to_string(), hnet)];
let staging = store.root().join("prod-staging");
@@ -2266,7 +2351,7 @@ mod tests {
std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELF-arje-zero-CORE").unwrap();
std::fs::write(w.join("usr/bin/hammerd"), b"\x7fELF-hammerd").unwrap();
std::fs::write(w.join("bin/busybox"), b"\x7fELF-busybox").unwrap();
std::fs::write(w.join("ente/seed.card.json"), product_seed_card().unwrap()).unwrap();
std::fs::write(w.join("ente/seed.card.json"), product_seed_card(&[card_sshd_esperada()]).unwrap()).unwrap();
// attest.json del producto base (con el arje-zero del NÚCLEO) — debe regenerarse.
let a = build_attestation(w).unwrap();
std::fs::write(w.join("ente/attest.json"), serde_json::to_string(&a).unwrap()).unwrap();
@@ -2363,7 +2448,7 @@ open(os.path.join(os.path.dirname(out),"..","..","bins-seen.txt"),"w").write("\n
// (lo que el gate busca en las concesiones firmadas) y las rutas resolver dentro del staging.
let staging = Path::new("/tmp/fake-staging");
let seed: serde_json::Value =
serde_json::from_str(&product_seed_card().unwrap()).unwrap();
serde_json::from_str(&product_seed_card(&[card_sshd_esperada()]).unwrap()).unwrap();
let bins = critical_bins_from_seed(&seed, staging);
let labels: Vec<&str> = bins.iter().map(|(l, _)| l.as_str()).collect();
assert_eq!(
+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 |