> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
servicios de paquete: la receta ya sabe declarar su demonio — y el card sale IGUAL al hardcodeado
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 <target>.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.
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<crate::service::Service>,
/// 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<Recipe> {
- 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 `<target>.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<String>,
+ /// 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<Restart>,
+ /// 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/<label>`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cgroup: Option<String>,
+}
+
+/// `lifecycle` de la Card.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Kind {
+ #[default]
+ Daemon,
+ Oneshot,
+}
+
+impl Kind {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Kind::Daemon => "daemon",
+ Kind::Oneshot => "oneshot",
+ }
+ }
+}
+
+/// `permissions.networking` de la Card.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Networking {
+ #[default]
+ None,
+ Full,
+}
+
+impl Networking {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Networking::None => "none",
+ Networking::Full => "full",
+ }
+ }
+}
+
+/// Backoff exponencial ×2 desde `initial_ms`, saturado en `max_ms`; si el Ente sobrevive `max_ms`
+/// se considera estable y el backoff resetea (`sandokan-lifecycle`, fuente única de la política).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Restart {
+ pub initial_ms: u64,
+ pub max_ms: u64,
+}
+
+impl Service {
+ /// El slice de cgroup efectivo.
+ pub fn cgroup_path(&self) -> String {
+ self.cgroup.clone().unwrap_or_else(|| format!("arje.slice/{}", self.label))
+ }
+
+ /// La Card de arje, como `serde_json::Value`.
+ ///
+ /// La forma es la del esquema validado de `card_core::Card` (`schema_version` 1), verificada
+ /// contra `seeds/arje-qemu.card.json` y contra las dos cards que el producto ya bootea. Los
+ /// campos que este bloque no expone salen con el valor inerte que ya usaban esas cards
+ /// (`lineage: null`, `provides`/`requires` vacíos, namespaces todos `false`, rlimits `null`):
+ /// **no se inventan defaults nuevos**, se reproducen los que están en producción.
+ pub fn card(&self) -> Value {
+ let supervision = match &self.restart {
+ Some(r) => json!({ "Restart": { "initial": r.initial_ms, "max": r.max_ms } }),
+ None => json!("OneShot"),
+ };
+ json!({
+ "schema_version": 1,
+ "id": self.id,
+ "lineage": null,
+ "label": self.label,
+ "provides": [],
+ "requires": [],
+ "permissions": {
+ "networking": self.networking.as_str(),
+ "filesystem": "read-write",
+ "ipc": { "allow": [] },
+ "processes": true
+ },
+ "soma": {
+ "namespaces": {
+ "mount": false, "pid": false, "net": false, "uts": false,
+ "ipc": false, "user": false, "cgroup": false
+ },
+ "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
+ "cgroup": { "path": self.cgroup_path(), "cpu_weight": null, "io_weight": null },
+ "cpu_affinity": null
+ },
+ "payload": { "Native": {
+ "exec": self.exec,
+ "argv": self.argv,
+ "envp": self.envp
+ }},
+ "supervision": supervision,
+ "lifecycle": self.kind.as_str(),
+ "priority": "normal",
+ "flow": { "input": [], "output": [] },
+ "genesis": []
+ })
+ }
+
+ /// Valida la FORMA (no la existencia del binario, que es cosa del artefacto).
+ pub fn validate(&self) -> crate::Result<()> {
+ let bad = |m: String| Err(crate::Error::Recipe(m));
+ if self.label.trim().is_empty() || self.label.contains('/') || self.label.contains("..") {
+ return bad(format!("service.label inválido: {:?} (sin `/`, no vacío)", self.label));
+ }
+ // El id es el ancla de la reproducibilidad de la seed: si falta o es basura, la Card cambia
+ // de identidad entre corridas y el `product_rootfs_hash` deja de ser estable.
+ if self.id.len() != 26 || !self.id.bytes().all(|b| b.is_ascii_alphanumeric()) {
+ return bad(format!(
+ "service[{}].id debe ser un ULID de 26 caracteres alfanuméricos, es {:?}",
+ self.label, self.id
+ ));
+ }
+ if !self.exec.starts_with('/') {
+ return bad(format!(
+ "service[{}].exec debe ser ruta absoluta (PID 1 no tiene PATH), es {:?}",
+ self.label, self.exec
+ ));
+ }
+ if let Some(r) = &self.restart {
+ if r.initial_ms == 0 || r.max_ms < r.initial_ms {
+ return bad(format!(
+ "service[{}].restart: se espera 0 < initial_ms ≤ max_ms, es {}..{}",
+ self.label, r.initial_ms, r.max_ms
+ ));
+ }
+ }
+ Ok(())
+ }
+}
+
+/// Valida una lista de servicios y rechaza labels e ids repetidos: dos Cards con el mismo `id` son
+/// la misma Card para el grafo de arje, y el segundo servicio se perdería en silencio.
+pub fn validate_all(services: &[Service]) -> crate::Result<()> {
+ let mut labels = std::collections::BTreeSet::new();
+ let mut ids = std::collections::BTreeSet::new();
+ for s in services {
+ s.validate()?;
+ if !labels.insert(&s.label) {
+ return Err(crate::Error::Recipe(format!("service.label duplicado: {:?}", s.label)));
+ }
+ if !ids.insert(&s.id) {
+ return Err(crate::Error::Recipe(format!(
+ "service.id duplicado: {:?} (lo usan {:?} y otro)",
+ s.id, s.label
+ )));
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sshd() -> Service {
+ Service {
+ label: "sshd".into(),
+ id: "01HQAR53D4M2NBV8KZTYXFQA12".into(),
+ exec: "/bin/busybox".into(),
+ argv: ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"]
+ .iter()
+ .map(|s| s.to_string())
+ .collect(),
+ envp: vec![("HOME".into(), "/root".into())],
+ kind: Kind::Daemon,
+ restart: Some(Restart { initial_ms: 500, max_ms: 20000 }),
+ networking: Networking::Full,
+ cgroup: Some("arje.slice/sshd".into()),
+ }
+ }
+
+ #[test]
+ fn card_tiene_la_forma_del_esquema() {
+ let c = sshd().card();
+ assert_eq!(c["label"], "sshd");
+ assert_eq!(c["payload"]["Native"]["exec"], "/bin/busybox");
+ assert_eq!(c["payload"]["Native"]["envp"][0][0], "HOME");
+ assert_eq!(c["supervision"]["Restart"]["initial"], 500);
+ assert_eq!(c["permissions"]["networking"], "full");
+ assert_eq!(c["soma"]["cgroup"]["path"], "arje.slice/sshd");
+ assert_eq!(c["lifecycle"], "daemon");
+ }
+
+ #[test]
+ fn sin_restart_es_oneshot() {
+ let mut s = sshd();
+ s.restart = None;
+ assert_eq!(s.card()["supervision"], json!("OneShot"));
+ }
+
+ #[test]
+ fn cgroup_por_defecto_deriva_del_label() {
+ let mut s = sshd();
+ s.cgroup = None;
+ assert_eq!(s.cgroup_path(), "arje.slice/sshd");
+ }
+
+ #[test]
+ fn se_deserializa_de_toml() {
+ let t = r#"
+label = "sshd"
+id = "01HQAR53D4M2NBV8KZTYXFQA12"
+exec = "/bin/busybox"
+argv = ["sh", "-c", "exec /usr/sbin/sshd -D -e"]
+envp = [["HOME", "/root"]]
+networking = "full"
+restart = { initial_ms = 500, max_ms = 20000 }
+"#;
+ let s: Service = toml::from_str(t).unwrap();
+ s.validate().unwrap();
+ assert_eq!(s.kind, Kind::Daemon); // default
+ assert_eq!(s.restart.unwrap().max_ms, 20000);
+ }
+
+ #[test]
+ fn validate_rechaza_lo_que_rompe_la_seed() {
+ let mut s = sshd();
+ s.id = "corto".into();
+ assert!(s.validate().unwrap_err().to_string().contains("ULID"));
+
+ let mut s = sshd();
+ s.exec = "sshd".into();
+ assert!(s.validate().unwrap_err().to_string().contains("absoluta"));
+
+ let mut s = sshd();
+ s.label = "a/b".into();
+ assert!(s.validate().unwrap_err().to_string().contains("label"));
+
+ let mut s = sshd();
+ s.restart = Some(Restart { initial_ms: 0, max_ms: 10 });
+ assert!(s.validate().unwrap_err().to_string().contains("initial_ms"));
+ }
+
+ #[test]
+ fn validate_all_rechaza_duplicados() {
+ let a = sshd();
+ let mut b = sshd();
+ b.label = "otro".into();
+ // mismo id, distinto label ⇒ colisión de identidad en el grafo
+ assert!(validate_all(&[a.clone(), b]).unwrap_err().to_string().contains("id duplicado"));
+ assert!(validate_all(&[a.clone(), a]).unwrap_err().to_string().contains("label duplicado"));
+ }
+}
diff --git a/recipes/openssh.toml b/recipes/openssh.toml
index d0d2dd58..0ac6629a 100644
--- a/recipes/openssh.toml
+++ b/recipes/openssh.toml
@@ -77,3 +77,25 @@ build = ["zlib", "openssl", "perl"]
configure = "./configure --prefix=/usr --sysconfdir=/etc/ssh --with-ssl-dir=/usr --with-zlib=/usr --with-privsep-path=/var/empty --without-pam --without-selinux --without-hardening CC='zig cc -mcpu=baseline -target x86_64-linux-musl' LDFLAGS=-static && sed -i '/^CHANNELLIBS=/{s/-lcrypto/-l:libcrypto.a/g;s/-lz/-l:libz.a/g}' Makefile"
compile = "make -j\"$(nproc)\""
install = "make install-nokeys DESTDIR=/out STRIP_OPT="
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE ───────────────────────────────────────────────────────────
+# Hasta hoy esta Card vivía HARDCODEADA en `takana_bootstrap::SSHD_SERVICE_CARD`, una constante de
+# Rust: el paquete que trae el demonio no tenía dónde decirlo, así que lo decía el binario de takana.
+# El `id` es el ULID que esa constante ya usaba — **cambiarlo movería la seed de producto y con ella
+# el `product_rootfs_hash`**, así que se preserva tal cual, no se regenera.
+#
+# El `argv` hace tres cosas antes del `exec` porque arje NO TIENE readiness por unidad (ver
+# `takana_core::service`): levanta la red, genera las host keys al primer boot, y recién ahí encarna
+# sshd. Eso no es una maña de esta receta — es el patrón obligado mientras el hueco siga abierto, y
+# por eso está a la vista en el argv en vez de escondido en un wrapper.
+#
+# Fuera de `hash_inputs`: declarar esto NO re-hashea openssh.
+[[service]]
+label = "sshd"
+id = "01HQAR53D4M2NBV8KZTYXFQA12"
+exec = "/bin/busybox"
+argv = ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"]
+envp = [["HOME", "/root"]]
+networking = "full"
+cgroup = "arje.slice/sshd"
+restart = { initial_ms = 500, max_ms = 20000 }