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…
⎇ a51d49a342ccbae8d8f4464bce62a000e0e829e8
crates/takana-core/src/service.rs
History
Who touched what
raw
servicios de paquete: la receta ya sabe declarar su demonio — y el card sale IGUAL al hardcodeado
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}