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 Definitions

⎇ a51d49a342ccbae8d8f4464bce62a000e0e829e8 crates/takana-core/src/service.rs Rust · 323 lines · 13.0 KB History Who touched what raw

servicios de paquete: la receta ya sabe declarar su demonio — y el card sale IGUAL al hardcodeado a51d49a34 · 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    /// Política de supervisión. **Ausente ⇒ `OneShot`** (no revive). Presente ⇒ `Restart` con
71    /// backoff exponencial entre `initial_ms` y `max_ms`, y su muerte es el `CRASHED` real.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub restart: Option<Restart>,
74    /// Permiso de red del Ente. Default `none`: un demonio que no lo declara no toca la red.
75    #[serde(default)]
76    pub networking: Networking,
77    /// Slice de cgroup v2. Default `arje.slice/<label>`.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub cgroup: Option<String>,
80}
81
82/// `lifecycle` de la Card.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum Kind {
86    #[default]
87    Daemon,
88    Oneshot,
89}
90
91impl Kind {
92    pub fn as_str(self) -> &'static str {
93        match self {
94            Kind::Daemon => "daemon",
95            Kind::Oneshot => "oneshot",
96        }
97    }
98}
99
100/// `permissions.networking` de la Card.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum Networking {
104    #[default]
105    None,
106    Full,
107}
108
109impl Networking {
110    pub fn as_str(self) -> &'static str {
111        match self {
112            Networking::None => "none",
113            Networking::Full => "full",
114        }
115    }
116}
117
118/// Backoff exponencial ×2 desde `initial_ms`, saturado en `max_ms`; si el Ente sobrevive `max_ms`
119/// se considera estable y el backoff resetea (`sandokan-lifecycle`, fuente única de la política).
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121pub struct Restart {
122    pub initial_ms: u64,
123    pub max_ms: u64,
124}
125
126impl Service {
127    /// El slice de cgroup efectivo.
128    pub fn cgroup_path(&self) -> String {
129        self.cgroup.clone().unwrap_or_else(|| format!("arje.slice/{}", self.label))
130    }
131
132    /// La Card de arje, como `serde_json::Value`.
133    ///
134    /// La forma es la del esquema validado de `card_core::Card` (`schema_version` 1), verificada
135    /// contra `seeds/arje-qemu.card.json` y contra las dos cards que el producto ya bootea. Los
136    /// campos que este bloque no expone salen con el valor inerte que ya usaban esas cards
137    /// (`lineage: null`, `provides`/`requires` vacíos, namespaces todos `false`, rlimits `null`):
138    /// **no se inventan defaults nuevos**, se reproducen los que están en producción.
139    pub fn card(&self) -> Value {
140        let supervision = match &self.restart {
141            Some(r) => json!({ "Restart": { "initial": r.initial_ms, "max": r.max_ms } }),
142            None => json!("OneShot"),
143        };
144        json!({
145            "schema_version": 1,
146            "id": self.id,
147            "lineage": null,
148            "label": self.label,
149            "provides": [],
150            "requires": [],
151            "permissions": {
152                "networking": self.networking.as_str(),
153                "filesystem": "read-write",
154                "ipc": { "allow": [] },
155                "processes": true
156            },
157            "soma": {
158                "namespaces": {
159                    "mount": false, "pid": false, "net": false, "uts": false,
160                    "ipc": false, "user": false, "cgroup": false
161                },
162                "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
163                "cgroup": { "path": self.cgroup_path(), "cpu_weight": null, "io_weight": null },
164                "cpu_affinity": null
165            },
166            "payload": { "Native": {
167                "exec": self.exec,
168                "argv": self.argv,
169                "envp": self.envp
170            }},
171            "supervision": supervision,
172            "lifecycle": self.kind.as_str(),
173            "priority": "normal",
174            "flow": { "input": [], "output": [] },
175            "genesis": []
176        })
177    }
178
179    /// Valida la FORMA (no la existencia del binario, que es cosa del artefacto).
180    pub fn validate(&self) -> crate::Result<()> {
181        let bad = |m: String| Err(crate::Error::Recipe(m));
182        if self.label.trim().is_empty() || self.label.contains('/') || self.label.contains("..") {
183            return bad(format!("service.label inválido: {:?} (sin `/`, no vacío)", self.label));
184        }
185        // El id es el ancla de la reproducibilidad de la seed: si falta o es basura, la Card cambia
186        // de identidad entre corridas y el `product_rootfs_hash` deja de ser estable.
187        if self.id.len() != 26 || !self.id.bytes().all(|b| b.is_ascii_alphanumeric()) {
188            return bad(format!(
189                "service[{}].id debe ser un ULID de 26 caracteres alfanuméricos, es {:?}",
190                self.label, self.id
191            ));
192        }
193        if !self.exec.starts_with('/') {
194            return bad(format!(
195                "service[{}].exec debe ser ruta absoluta (PID 1 no tiene PATH), es {:?}",
196                self.label, self.exec
197            ));
198        }
199        if let Some(r) = &self.restart {
200            if r.initial_ms == 0 || r.max_ms < r.initial_ms {
201                return bad(format!(
202                    "service[{}].restart: se espera 0 < initial_ms ≤ max_ms, es {}..{}",
203                    self.label, r.initial_ms, r.max_ms
204                ));
205            }
206        }
207        Ok(())
208    }
209}
210
211/// Valida una lista de servicios y rechaza labels e ids repetidos: dos Cards con el mismo `id` son
212/// la misma Card para el grafo de arje, y el segundo servicio se perdería en silencio.
213pub fn validate_all(services: &[Service]) -> crate::Result<()> {
214    let mut labels = std::collections::BTreeSet::new();
215    let mut ids = std::collections::BTreeSet::new();
216    for s in services {
217        s.validate()?;
218        if !labels.insert(&s.label) {
219            return Err(crate::Error::Recipe(format!("service.label duplicado: {:?}", s.label)));
220        }
221        if !ids.insert(&s.id) {
222            return Err(crate::Error::Recipe(format!(
223                "service.id duplicado: {:?} (lo usan {:?} y otro)",
224                s.id, s.label
225            )));
226        }
227    }
228    Ok(())
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn sshd() -> Service {
236        Service {
237            label: "sshd".into(),
238            id: "01HQAR53D4M2NBV8KZTYXFQA12".into(),
239            exec: "/bin/busybox".into(),
240            argv: ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"]
241                .iter()
242                .map(|s| s.to_string())
243                .collect(),
244            envp: vec![("HOME".into(), "/root".into())],
245            kind: Kind::Daemon,
246            restart: Some(Restart { initial_ms: 500, max_ms: 20000 }),
247            networking: Networking::Full,
248            cgroup: Some("arje.slice/sshd".into()),
249        }
250    }
251
252    #[test]
253    fn card_tiene_la_forma_del_esquema() {
254        let c = sshd().card();
255        assert_eq!(c["label"], "sshd");
256        assert_eq!(c["payload"]["Native"]["exec"], "/bin/busybox");
257        assert_eq!(c["payload"]["Native"]["envp"][0][0], "HOME");
258        assert_eq!(c["supervision"]["Restart"]["initial"], 500);
259        assert_eq!(c["permissions"]["networking"], "full");
260        assert_eq!(c["soma"]["cgroup"]["path"], "arje.slice/sshd");
261        assert_eq!(c["lifecycle"], "daemon");
262    }
263
264    #[test]
265    fn sin_restart_es_oneshot() {
266        let mut s = sshd();
267        s.restart = None;
268        assert_eq!(s.card()["supervision"], json!("OneShot"));
269    }
270
271    #[test]
272    fn cgroup_por_defecto_deriva_del_label() {
273        let mut s = sshd();
274        s.cgroup = None;
275        assert_eq!(s.cgroup_path(), "arje.slice/sshd");
276    }
277
278    #[test]
279    fn se_deserializa_de_toml() {
280        let t = r#"
281label = "sshd"
282id    = "01HQAR53D4M2NBV8KZTYXFQA12"
283exec  = "/bin/busybox"
284argv  = ["sh", "-c", "exec /usr/sbin/sshd -D -e"]
285envp  = [["HOME", "/root"]]
286networking = "full"
287restart = { initial_ms = 500, max_ms = 20000 }
288"#;
289        let s: Service = toml::from_str(t).unwrap();
290        s.validate().unwrap();
291        assert_eq!(s.kind, Kind::Daemon); // default
292        assert_eq!(s.restart.unwrap().max_ms, 20000);
293    }
294
295    #[test]
296    fn validate_rechaza_lo_que_rompe_la_seed() {
297        let mut s = sshd();
298        s.id = "corto".into();
299        assert!(s.validate().unwrap_err().to_string().contains("ULID"));
300
301        let mut s = sshd();
302        s.exec = "sshd".into();
303        assert!(s.validate().unwrap_err().to_string().contains("absoluta"));
304
305        let mut s = sshd();
306        s.label = "a/b".into();
307        assert!(s.validate().unwrap_err().to_string().contains("label"));
308
309        let mut s = sshd();
310        s.restart = Some(Restart { initial_ms: 0, max_ms: 10 });
311        assert!(s.validate().unwrap_err().to_string().contains("initial_ms"));
312    }
313
314    #[test]
315    fn validate_all_rechaza_duplicados() {
316        let a = sshd();
317        let mut b = sshd();
318        b.label = "otro".into();
319        // mismo id, distinto label ⇒ colisión de identidad en el grafo
320        assert!(validate_all(&[a.clone(), b]).unwrap_err().to_string().contains("id duplicado"));
321        assert!(validate_all(&[a.clone(), a]).unwrap_err().to_string().contains("label duplicado"));
322    }
323}