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.
This commit is contained in:
Sergio
2026-09-12 10:33:40 +00:00
parent a8876c6d19
commit a51d49a342
5 changed files with 393 additions and 1 deletions
+25
View File
@@ -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");
+1
View File
@@ -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;
+22 -1
View File
@@ -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`
+323
View File
@@ -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"));
}
}
+22
View File
@@ -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 }