From a51d49a342ccbae8d8f4464bce62a000e0e829e8 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 12 Sep 2026 10:33:40 +0000 Subject: [PATCH] =?UTF-8?q?servicios=20de=20paquete:=20la=20receta=20ya=20?= =?UTF-8?q?sabe=20declarar=20su=20demonio=20=E2=80=94=20y=20el=20card=20sa?= =?UTF-8?q?le=20IGUAL=20al=20hardcodeado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un paquete con servicio no tenía dónde decirlo: los dos del producto (hammerd, sshd) vivían en constantes de Rust y los ocho de una sesión GNOME se lanzaban con `&` desde un script, sin supervisión ni backoff ni el CRASHED real — o sea sin nada de lo que arje es PID 1 para dar. `[[service]]` en la receta, fuera de `hash_inputs` como license/slots/evidence: declarar el servicio de openssh NO movió su hash, medido con el binario viejo (que ignora el bloque) contra el nuevo, b3:938835e6… en los dos. La prueba que autoriza el cambio no es que "parezca bien": el card generado desde recipes/openssh.toml se compara ENTERO contra SSHD_SERVICE_CARD, que es el que ya bootea en QEMU. Empatan ⇒ mover el servicio a la receta no cambiaría un byte de la seed ni del product_rootfs_hash. Por qué no alcanzaba `arje-absorb` (que existe y traduce systemd/openrc/runit/ dinit/sysvinit): sólo absorbe lo HABILITADO, los symlinks de .wants/. Un rootfs nuestro no tiene ese estado — medido: 62 .service sellados en el store y CERO directorios .wants. Absorber devuelve vacío, y es la respuesta correcta a la pregunta que absorb contesta. El enable de una distro construida desde fuente no se lee del árbol: se declara. --- crates/takana-bootstrap/src/lib.rs | 25 +++ crates/takana-core/src/lib.rs | 1 + crates/takana-core/src/recipe.rs | 23 +- crates/takana-core/src/service.rs | 323 +++++++++++++++++++++++++++++ recipes/openssh.toml | 22 ++ 5 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 crates/takana-core/src/service.rs diff --git a/crates/takana-bootstrap/src/lib.rs b/crates/takana-bootstrap/src/lib.rs index b3ee5e2d..98f6dca6 100644 --- a/crates/takana-bootstrap/src/lib.rs +++ b/crates/takana-bootstrap/src/lib.rs @@ -2083,6 +2083,31 @@ mod tests { assert!(sshd["supervision"]["Restart"]["initial"].is_number(), "sshd es Restart (lifecycle)"); } + /// **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 + /// `recipes/openssh.toml`: no compara «una card parecida», compara el `serde_json::Value` + /// entero contra [`SSHD_SERVICE_CARD`], que es **la card que el producto ya bootea en QEMU** + /// (verificada e2e por `scripts/ssh-e2e-test.sh`). Si empatan, declarar el servicio en la + /// receta no cambia ni un byte de la seed — y por lo tanto tampoco el `product_rootfs_hash`. + /// + /// Lee la receta REAL del repo a propósito: un fixture inline probaría el emisor contra sí + /// mismo y no detectaría que alguien editó la receta y la sacó de sincronía con el producto. + #[test] + fn la_receta_de_openssh_reproduce_la_card_hardcodeada() { + let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let r = takana_core::Recipe::load_from_path(repo.join("recipes/openssh.toml")).unwrap(); + assert_eq!(r.services.len(), 1, "openssh declara exactamente un servicio"); + + let generada = r.services[0].card(); + let hardcodeada: serde_json::Value = serde_json::from_str(SSHD_SERVICE_CARD).unwrap(); + assert_eq!( + generada, hardcodeada, + "la card generada desde recipes/openssh.toml difiere de SSHD_SERVICE_CARD: \ + mover el servicio a la receta CAMBIARÍA la seed de producto" + ); + } + #[test] fn product_rootfs_hash_is_deterministic_and_sensitive() { let base = ArtifactHash::from_hex("ab"); diff --git a/crates/takana-core/src/lib.rs b/crates/takana-core/src/lib.rs index 8372a9bf..9fcfed0d 100644 --- a/crates/takana-core/src/lib.rs +++ b/crates/takana-core/src/lib.rs @@ -17,6 +17,7 @@ pub mod proto; pub mod query; pub mod recipe; pub mod repo; +pub mod service; pub mod sign; pub mod store; pub mod swm; diff --git a/crates/takana-core/src/recipe.rs b/crates/takana-core/src/recipe.rs index 83117bf7..507807f1 100644 --- a/crates/takana-core/src/recipe.rs +++ b/crates/takana-core/src/recipe.rs @@ -61,6 +61,21 @@ pub struct Recipe { /// build. Añadirla no mueve un solo `ArtifactHash`. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub foreign: bool, + /// **Los servicios que este paquete TRAE** (`[[service]]`), como Cards de arje. + /// + /// Es la respuesta al hueco que la distro tenía abierto: un paquete con demonio no tenía dónde + /// declararlo, así que los dos servicios del producto vivían hardcodeados en constantes de Rust + /// y los ocho de una sesión GNOME se lanzaban con `&` desde un script, sin supervisión. Ver + /// [`crate::service`] para el porqué completo (incluido por qué `arje-absorb` no lo cubre). + /// + /// Declara **qué es** el servicio, no **si arranca**: lo segundo es del PERFIL, igual que + /// systemd separa `[Service]` de `[Install]`. + /// + /// **NO entra en `hash_inputs`**, como `license`, `slots` y `evidence`: describe cómo se + /// supervisa el artefacto, no qué bytes tiene ⇒ se puebla en recetas ya selladas sin mover un + /// solo `ArtifactHash`. + #[serde(default, rename = "service", skip_serializing_if = "Vec::is_empty")] + pub services: Vec, /// Directorio base contra el que se resuelven rutas relativas de la receta /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío. #[serde(skip, default)] @@ -502,7 +517,13 @@ impl Recipe { /// Parsea una receta desde su forma TOML. `base_dir` queda vacío: si la receta declara /// `patches`, el caller debe fijar `base_dir` antes de hashear, o usar `load_from_path`. pub fn from_toml(s: &str) -> crate::Result { - toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string())) + let r: Recipe = toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string()))?; + // Los servicios se validan AL CARGAR, no al emitir la seed: un `id` mal puesto rompe la + // reproducibilidad del rootfs de producto, y eso hay que verlo en la receta —no tres pasos + // después, en un rootfs que ya no reproduce y no dice por qué. Recetas sin `[[service]]` + // pasan por una lista vacía, así que esto no toca a las 979 existentes. + crate::service::validate_all(&r.services)?; + Ok(r) } /// Serializa la receta a TOML. Útil para el sidecar de provenance que `takana-build` diff --git a/crates/takana-core/src/service.rs b/crates/takana-core/src/service.rs new file mode 100644 index 00000000..42252c6a --- /dev/null +++ b/crates/takana-core/src/service.rs @@ -0,0 +1,323 @@ +//! `[[service]]` — **el servicio que un paquete TRAE**, traducido a una Card de arje. +//! +//! ## Por qué existe (el hueco, medido) +//! +//! Hasta hoy una receta no tenía dónde decir «yo traigo un demonio». La consecuencia no era +//! teórica: los dos únicos servicios del producto (`hammerd` y `sshd`) vivían **hardcodeados en +//! constantes de Rust** (`takana_bootstrap::SSHD_SERVICE_CARD`), y los ocho demonios de una sesión +//! GNOME (dbus, logind-compat, accounts-daemon, upowerd, pipewire, pipewire-pulse, wireplumber, +//! colord) se lanzaban a mano con `&` desde `scripts/gnome/gnome-start-qemu.sh` — **sin supervisión, +//! sin backoff y sin el `CRASHED` real**, que es justo lo que arje sabe hacer y para lo que se lo +//! adoptó como PID 1 (SDD 12). +//! +//! ## Por qué NO alcanza con `arje-absorb` +//! +//! arje ya trae el traductor de inits ajenos (`init/arje-absorb`, con lectores de systemd, OpenRC, +//! runit, dinit y sysvinit). No sirve acá, y la razón es exacta: **`arje-absorb` sólo absorbe lo +//! que está HABILITADO** — los symlinks de `.wants/` — porque su trabajo es *retratar* un +//! sistema vivo, no inventarle uno. Un rootfs de takana no tiene ese estado: medido el 2026-09-12 +//! sobre el store, hay **62 ficheros `.service` sellados (61 de usuario, 1 de sistema) y CERO +//! directorios `.wants`**. Absorber un rootfs nuestro devuelve la lista vacía, y es la respuesta +//! correcta a la pregunta que `arje-absorb` contesta. +//! +//! O sea que el «enable» de una distro construida desde fuente **no puede leerse del árbol: hay que +//! declararlo**. Esa declaración se parte en dos, igual que systemd parte `[Service]` de +//! `[Install]`: +//! +//! - **qué es el servicio** (exec, argv, supervisión) — propiedad del PAQUETE ⇒ este bloque; +//! - **si arranca en esta imagen** — propiedad del PERFIL ⇒ `docs/state/targets.toml` (pendiente). +//! +//! ## Fuera de `hash_inputs` +//! +//! Igual que `license`, `slots` y `evidence`: describe **cómo se supervisa** el artefacto, no qué +//! bytes tiene. `Recipe::hash_inputs` es una lista explícita de entradas, así que declarar servicios +//! en recetas YA SELLADAS no mueve un solo `ArtifactHash` — que es lo único que hace la deuda +//! pagable sin reconstruir el corpus. +//! +//! ## Límite conocido: no hay readiness +//! +//! arje ordena el arranque por **capacidades** (`resolve::plan_spawn`, orden topológico), pero eso +//! ordena el *spawn*, no el *estar listo*: no existe primitiva «esperá a que aparezca este socket» +//! (`DE-SYSTEMD-A-ARJE.md` §13 lo lista como hueco abierto — el `READY=1` de `Type=notify` se +//! registra y no ordena). Mientras no exista, un servicio que necesita esperar a otro lo hace +//! DENTRO de su propio card, con un `sh -c '…; exec …'` — que es exactamente lo que ya hace el card +//! de `sshd` (levanta la red y genera las host keys antes del `exec`). Este módulo no lo esconde: +//! lo deja explícito en `argv`. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// Un servicio declarado por una receta. Se traduce 1:1 a una Card de arje ([`Service::card`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Service { + /// Nombre de la unidad (`sshd`). Es el `label` de la Card y la clave con la que `arjectl` la + /// nombra. Sin `/`: también es nombre de fichero en `cards.d/`. + pub label: String, + /// ULID de la Card. **Se declara, no se genera**: un id aleatorio haría que la seed —y con ella + /// el `product_rootfs_hash`— cambiara en cada corrida, rompiendo la reproducibilidad del + /// producto. Que sea estable es un requisito, no un detalle. + pub id: String, + /// El ELF a encarnar (`Payload::Native.exec`). Absoluto: lo ejecuta PID 1, que no tiene `PATH`. + pub exec: String, + #[serde(default)] + pub argv: Vec, + /// Entorno explícito. arje inyecta además `ENTE_BUS_SOCK` y `ENTE_ID` por su cuenta. + #[serde(default)] + pub envp: Vec<(String, String)>, + /// `daemon` (default) u `oneshot` — el `lifecycle` de la Card. + #[serde(default)] + pub kind: Kind, + /// Política de supervisión. **Ausente ⇒ `OneShot`** (no revive). Presente ⇒ `Restart` con + /// backoff exponencial entre `initial_ms` y `max_ms`, y su muerte es el `CRASHED` real. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub restart: Option, + /// Permiso de red del Ente. Default `none`: un demonio que no lo declara no toca la red. + #[serde(default)] + pub networking: Networking, + /// Slice de cgroup v2. Default `arje.slice/