gioser

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…

TOML 815Rust 56Shell 37Markdown 291229 Definiciones

⎇ 1b56b164028edeb196312e2fe051f7817211a27f crates/takana-core/src/service.rs Rust · 375 líneas · 15.3 KB Historia Quién tocó qué crudo

SDD 30 §4a+§4c: los 9 demonios de GNOME declarados — y aparecieron dos que no estaban en NINGÚN perfil 1b56b1640 · Sergio ·

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}