sergio/takana
> 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…
⎇ 1b56b164028edeb196312e2fe051f7817211a27f
crates/takana-core/src/service.rs
Historia
Quién tocó qué
crudo
1//! `[[service]]` — **el servicio que un paquete TRAE**, traducido a una Card de arje.
2//!
3//! ## Por qué existe (el hueco, medido)
4//!
5//! Hasta hoy una receta no tenía dónde decir «yo traigo un demonio». La consecuencia no era
6//! teórica: los dos únicos servicios del producto (`hammerd` y `sshd`) vivían **hardcodeados en
7//! constantes de Rust** (`takana_bootstrap::SSHD_SERVICE_CARD`), y los ocho demonios de una sesión
8//! GNOME (dbus, logind-compat, accounts-daemon, upowerd, pipewire, pipewire-pulse, wireplumber,
9//! colord) se lanzaban a mano con `&` desde `scripts/gnome/gnome-start-qemu.sh` — **sin supervisión,
10//! sin backoff y sin el `CRASHED` real**, que es justo lo que arje sabe hacer y para lo que se lo
11//! adoptó como PID 1 (SDD 12).
12//!
13//! ## Por qué NO alcanza con `arje-absorb`
14//!
15//! arje ya trae el traductor de inits ajenos (`init/arje-absorb`, con lectores de systemd, OpenRC,
16//! runit, dinit y sysvinit). No sirve acá, y la razón es exacta: **`arje-absorb` sólo absorbe lo
17//! que está HABILITADO** — los symlinks de `<target>.wants/` — porque su trabajo es *retratar* un
18//! sistema vivo, no inventarle uno. Un rootfs de takana no tiene ese estado: medido el 2026-09-12
19//! sobre el store, hay **62 ficheros `.service` sellados (61 de usuario, 1 de sistema) y CERO
20//! directorios `.wants`**. Absorber un rootfs nuestro devuelve la lista vacía, y es la respuesta
21//! correcta a la pregunta que `arje-absorb` contesta.
22//!
23//! O sea que el «enable» de una distro construida desde fuente **no puede leerse del árbol: hay que
24//! declararlo**. Esa declaración se parte en dos, igual que systemd parte `[Service]` de
25//! `[Install]`:
26//!
27//! - **qué es el servicio** (exec, argv, supervisión) — propiedad del PAQUETE ⇒ este bloque;
28//! - **si arranca en esta imagen** — propiedad del PERFIL ⇒ `docs/state/targets.toml` (pendiente).
29//!
30//! ## Fuera de `hash_inputs`
31//!
32//! Igual que `license`, `slots` y `evidence`: describe **cómo se supervisa** el artefacto, no qué
33//! bytes tiene. `Recipe::hash_inputs` es una lista explícita de entradas, así que declarar servicios
34//! en recetas YA SELLADAS no mueve un solo `ArtifactHash` — que es lo único que hace la deuda
35//! pagable sin reconstruir el corpus.
36//!
37//! ## Límite conocido: no hay readiness
38//!
39//! arje ordena el arranque por **capacidades** (`resolve::plan_spawn`, orden topológico), pero eso
40//! ordena el *spawn*, no el *estar listo*: no existe primitiva «esperá a que aparezca este socket»
41//! (`DE-SYSTEMD-A-ARJE.md` §13 lo lista como hueco abierto — el `READY=1` de `Type=notify` se
42//! registra y no ordena). Mientras no exista, un servicio que necesita esperar a otro lo hace
43//! DENTRO de su propio card, con un `sh -c '…; exec …'` — que es exactamente lo que ya hace el card
44//! de `sshd` (levanta la red y genera las host keys antes del `exec`). Este módulo no lo esconde:
45//! lo deja explícito en `argv`.
46
47use serde::{Deserialize, Serialize};
48use serde_json::{json, Value};
49
50/// Un servicio declarado por una receta. Se traduce 1:1 a una Card de arje ([`Service::card`]).
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Service {
53 /// Nombre de la unidad (`sshd`). Es el `label` de la Card y la clave con la que `arjectl` la
54 /// nombra. Sin `/`: también es nombre de fichero en `cards.d/`.
55 pub label: String,
56 /// ULID de la Card. **Se declara, no se genera**: un id aleatorio haría que la seed —y con ella
57 /// el `product_rootfs_hash`— cambiara en cada corrida, rompiendo la reproducibilidad del
58 /// producto. Que sea estable es un requisito, no un detalle.
59 pub id: String,
60 /// El ELF a encarnar (`Payload::Native.exec`). Absoluto: lo ejecuta PID 1, que no tiene `PATH`.
61 pub exec: String,
62 #[serde(default)]
63 pub argv: Vec<String>,
64 /// Entorno explícito. arje inyecta además `ENTE_BUS_SOCK` y `ENTE_ID` por su cuenta.
65 #[serde(default)]
66 pub envp: Vec<(String, String)>,
67 /// `daemon` (default) u `oneshot` — el `lifecycle` de la Card.
68 #[serde(default)]
69 pub kind: Kind,
70 /// **De sistema o de sesión** — y no es cosmético: decide DÓNDE va la Card.
71 ///
72 /// `system` (default) = arranca al boot, va en el `genesis` de la seed. `session` = pertenece a
73 /// una sesión gráfica (pipewire, wireplumber, el bus de sesión): necesita `XDG_RUNTIME_DIR` y un
74 /// usuario logueado, así que meterla en el genesis la arrancaría antes de que exista ninguna de
75 /// las dos cosas.
76 ///
77 /// El campo existe porque el destino de una Card de sesión **hoy no está resuelto fuera de
78 /// mirada**: ahí lo hace el compositor (`mirada-compositor/src/session.rs` arma
79 /// `session.{label}` con `requires = [wayland_floor()]` y la entrega a PID 1 por `RunCard`),
80 /// pero mutter y kwin no tienen esa integración y sus demonios de sesión los sigue lanzando el
81 /// script de arranque. Declararlo igual hace que el hueco se CUENTE en vez de omitirse —
82 /// `scripts/targets.py --servicios` avisa por cada uno.
83 #[serde(default)]
84 pub scope: Scope,
85 /// Política de supervisión. **Ausente ⇒ `OneShot`** (no revive). Presente ⇒ `Restart` con
86 /// backoff exponencial entre `initial_ms` y `max_ms`, y su muerte es el `CRASHED` real.
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub restart: Option<Restart>,
89 /// Permiso de red del Ente. Default `none`: un demonio que no lo declara no toca la red.
90 #[serde(default)]
91 pub networking: Networking,
92 /// Slice de cgroup v2. Default `arje.slice/<label>`.
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub cgroup: Option<String>,
95}
96
97/// `lifecycle` de la Card.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
99#[serde(rename_all = "lowercase")]
100pub enum Kind {
101 #[default]
102 Daemon,
103 Oneshot,
104}
105
106impl Kind {
107 pub fn as_str(self) -> &'static str {
108 match self {
109 Kind::Daemon => "daemon",
110 Kind::Oneshot => "oneshot",
111 }
112 }
113}
114
115/// Dónde vive la Card: arranque del sistema o sesión gráfica. Ver [`Service::scope`].
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
117#[serde(rename_all = "lowercase")]
118pub enum Scope {
119 #[default]
120 System,
121 Session,
122}
123
124impl Scope {
125 pub fn as_str(self) -> &'static str {
126 match self {
127 Scope::System => "system",
128 Scope::Session => "session",
129 }
130 }
131}
132
133/// `permissions.networking` de la Card.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
135#[serde(rename_all = "lowercase")]
136pub enum Networking {
137 #[default]
138 None,
139 Full,
140}
141
142impl Networking {
143 pub fn as_str(self) -> &'static str {
144 match self {
145 Networking::None => "none",
146 Networking::Full => "full",
147 }
148 }
149}
150
151/// Backoff exponencial ×2 desde `initial_ms`, saturado en `max_ms`; si el Ente sobrevive `max_ms`
152/// se considera estable y el backoff resetea (`sandokan-lifecycle`, fuente única de la política).
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154pub struct Restart {
155 pub initial_ms: u64,
156 pub max_ms: u64,
157}
158
159impl Service {
160 /// El slice de cgroup efectivo.
161 pub fn cgroup_path(&self) -> String {
162 self.cgroup.clone().unwrap_or_else(|| format!("arje.slice/{}", self.label))
163 }
164
165 /// La Card de arje, como `serde_json::Value`.
166 ///
167 /// La forma es la del esquema validado de `card_core::Card` (`schema_version` 1), verificada
168 /// contra `seeds/arje-qemu.card.json` y contra las dos cards que el producto ya bootea. Los
169 /// campos que este bloque no expone salen con el valor inerte que ya usaban esas cards
170 /// (`lineage: null`, `provides`/`requires` vacíos, namespaces todos `false`, rlimits `null`):
171 /// **no se inventan defaults nuevos**, se reproducen los que están en producción.
172 pub fn card(&self) -> Value {
173 let supervision = match &self.restart {
174 Some(r) => json!({ "Restart": { "initial": r.initial_ms, "max": r.max_ms } }),
175 None => json!("OneShot"),
176 };
177 json!({
178 "schema_version": 1,
179 "id": self.id,
180 "lineage": null,
181 "label": self.label,
182 "provides": [],
183 "requires": [],
184 "permissions": {
185 "networking": self.networking.as_str(),
186 "filesystem": "read-write",
187 "ipc": { "allow": [] },
188 "processes": true
189 },
190 "soma": {
191 "namespaces": {
192 "mount": false, "pid": false, "net": false, "uts": false,
193 "ipc": false, "user": false, "cgroup": false
194 },
195 "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
196 "cgroup": { "path": self.cgroup_path(), "cpu_weight": null, "io_weight": null },
197 "cpu_affinity": null
198 },
199 "payload": { "Native": {
200 "exec": self.exec,
201 "argv": self.argv,
202 "envp": self.envp
203 }},
204 "supervision": supervision,
205 "lifecycle": self.kind.as_str(),
206 "priority": "normal",
207 "flow": { "input": [], "output": [] },
208 "genesis": []
209 })
210 }
211
212 /// Valida la FORMA (no la existencia del binario, que es cosa del artefacto).
213 pub fn validate(&self) -> crate::Result<()> {
214 let bad = |m: String| Err(crate::Error::Recipe(m));
215 if self.label.trim().is_empty() || self.label.contains('/') || self.label.contains("..") {
216 return bad(format!("service.label inválido: {:?} (sin `/`, no vacío)", self.label));
217 }
218 // El id es el ancla de la reproducibilidad de la seed: si falta o es basura, la Card cambia
219 // de identidad entre corridas y el `product_rootfs_hash` deja de ser estable.
220 if self.id.len() != 26 || !self.id.bytes().all(|b| b.is_ascii_alphanumeric()) {
221 return bad(format!(
222 "service[{}].id debe ser un ULID de 26 caracteres alfanuméricos, es {:?}",
223 self.label, self.id
224 ));
225 }
226 if !self.exec.starts_with('/') {
227 return bad(format!(
228 "service[{}].exec debe ser ruta absoluta (PID 1 no tiene PATH), es {:?}",
229 self.label, self.exec
230 ));
231 }
232 if let Some(r) = &self.restart {
233 if r.initial_ms == 0 || r.max_ms < r.initial_ms {
234 return bad(format!(
235 "service[{}].restart: se espera 0 < initial_ms ≤ max_ms, es {}..{}",
236 self.label, r.initial_ms, r.max_ms
237 ));
238 }
239 }
240 Ok(())
241 }
242}
243
244/// Valida una lista de servicios y rechaza labels e ids repetidos: dos Cards con el mismo `id` son
245/// la misma Card para el grafo de arje, y el segundo servicio se perdería en silencio.
246pub fn validate_all(services: &[Service]) -> crate::Result<()> {
247 let mut labels = std::collections::BTreeSet::new();
248 let mut ids = std::collections::BTreeSet::new();
249 for s in services {
250 s.validate()?;
251 if !labels.insert(&s.label) {
252 return Err(crate::Error::Recipe(format!("service.label duplicado: {:?}", s.label)));
253 }
254 if !ids.insert(&s.id) {
255 return Err(crate::Error::Recipe(format!(
256 "service.id duplicado: {:?} (lo usan {:?} y otro)",
257 s.id, s.label
258 )));
259 }
260 }
261 Ok(())
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn sshd() -> Service {
269 Service {
270 label: "sshd".into(),
271 id: "01HQAR53D4M2NBV8KZTYXFQA12".into(),
272 exec: "/bin/busybox".into(),
273 argv: ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"]
274 .iter()
275 .map(|s| s.to_string())
276 .collect(),
277 envp: vec![("HOME".into(), "/root".into())],
278 kind: Kind::Daemon,
279 scope: Scope::System,
280 restart: Some(Restart { initial_ms: 500, max_ms: 20000 }),
281 networking: Networking::Full,
282 cgroup: Some("arje.slice/sshd".into()),
283 }
284 }
285
286 #[test]
287 fn card_tiene_la_forma_del_esquema() {
288 let c = sshd().card();
289 assert_eq!(c["label"], "sshd");
290 assert_eq!(c["payload"]["Native"]["exec"], "/bin/busybox");
291 assert_eq!(c["payload"]["Native"]["envp"][0][0], "HOME");
292 assert_eq!(c["supervision"]["Restart"]["initial"], 500);
293 assert_eq!(c["permissions"]["networking"], "full");
294 assert_eq!(c["soma"]["cgroup"]["path"], "arje.slice/sshd");
295 assert_eq!(c["lifecycle"], "daemon");
296 }
297
298 #[test]
299 fn sin_restart_es_oneshot() {
300 let mut s = sshd();
301 s.restart = None;
302 assert_eq!(s.card()["supervision"], json!("OneShot"));
303 }
304
305 #[test]
306 fn cgroup_por_defecto_deriva_del_label() {
307 let mut s = sshd();
308 s.cgroup = None;
309 assert_eq!(s.cgroup_path(), "arje.slice/sshd");
310 }
311
312 #[test]
313 fn se_deserializa_de_toml() {
314 let t = r#"
315label = "sshd"
316id = "01HQAR53D4M2NBV8KZTYXFQA12"
317exec = "/bin/busybox"
318argv = ["sh", "-c", "exec /usr/sbin/sshd -D -e"]
319envp = [["HOME", "/root"]]
320networking = "full"
321restart = { initial_ms = 500, max_ms = 20000 }
322"#;
323 let s: Service = toml::from_str(t).unwrap();
324 s.validate().unwrap();
325 assert_eq!(s.kind, Kind::Daemon); // default
326 assert_eq!(s.restart.unwrap().max_ms, 20000);
327 }
328
329 #[test]
330 fn validate_rechaza_lo_que_rompe_la_seed() {
331 let mut s = sshd();
332 s.id = "corto".into();
333 assert!(s.validate().unwrap_err().to_string().contains("ULID"));
334
335 let mut s = sshd();
336 s.exec = "sshd".into();
337 assert!(s.validate().unwrap_err().to_string().contains("absoluta"));
338
339 let mut s = sshd();
340 s.label = "a/b".into();
341 assert!(s.validate().unwrap_err().to_string().contains("label"));
342
343 let mut s = sshd();
344 s.restart = Some(Restart { initial_ms: 0, max_ms: 10 });
345 assert!(s.validate().unwrap_err().to_string().contains("initial_ms"));
346 }
347
348 #[test]
349 fn el_scope_es_system_por_defecto_y_no_entra_en_la_card() {
350 // El scope decide DÓNDE se coloca la Card, no qué dice: la Card de un servicio de sesión
351 // tiene la misma forma. Si entrara en el JSON, cambiaría el esquema de `card_core::Card`,
352 // que no es nuestro.
353 let s: Service = toml::from_str(
354 "label='pipewire'\nid='01HQAR53D4M2NBV8KZTYXFQA20'\nexec='/usr/bin/pipewire'",
355 )
356 .unwrap();
357 assert_eq!(s.scope, Scope::System, "default");
358 let con_sesion: Service = toml::from_str(
359 "label='pipewire'\nid='01HQAR53D4M2NBV8KZTYXFQA20'\nexec='/usr/bin/pipewire'\nscope='session'",
360 )
361 .unwrap();
362 assert_eq!(con_sesion.scope, Scope::Session);
363 assert_eq!(s.card(), con_sesion.card(), "el scope no cambia la Card");
364 }
365
366 #[test]
367 fn validate_all_rechaza_duplicados() {
368 let a = sshd();
369 let mut b = sshd();
370 b.label = "otro".into();
371 // mismo id, distinto label ⇒ colisión de identidad en el grafo
372 assert!(validate_all(&[a.clone(), b]).unwrap_err().to_string().contains("id duplicado"));
373 assert!(validate_all(&[a.clone(), a]).unwrap_err().to_string().contains("label duplicado"));
374 }
375}