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…
takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
1//! **El contrato de capacidades**: qué símbolos del kernel exige el userland, y quién se rompe
2//! callado si faltan (SDD 25 §4 y §8-W6).
3//!
4//! ## Por qué existe este módulo
5//! SDD 25 encontró, midiendo otra cosa, que los kernels de takana se construyen **sin
6//! `CONFIG_MEMCG`**: `memory.max` no existe, y el escritor del otro lado (`arje-incarnate::cgroup`)
7//! descarta el error. Una Card que pide un tope de memoria arranca **sin tope** y la única huella
8//! es una línea de log. Es la forma de fallo que `CLAUDE.md` §3 nombra: *un ausente falla
9//! ruidosamente; un vacío llega hasta el final diciendo que todo fue bien*.
10//!
11//! El arreglo de fondo es de receta (`-e MEMCG`), y cuesta un re-hasheo del kernel. Lo que este
12//! módulo arregla es lo otro, que es peor: **que nadie se entere**. El contrato se declara una vez,
13//! se comprueba contra un `.config` YA PRODUCIDO —el que la receta instala en
14//! `/out/boot/config-<versión>`— y sale distinto de cero cuando una capacidad prometida no está.
15//!
16//! ## Por qué POR PERFIL y no global
17//! Misma lección que el gate de hardware ([`super::gate`]): `recipes/linux.toml` es el kernel de
18//! QEMU con consola serie del selfhost-verify, y no hospeda Cards. Exigirle contabilidad de memoria
19//! sería rechazar una receta sana. El perfil declara **para qué es este kernel**; el contrato sólo
20//! obliga donde el consumidor existe.
21//!
22//! ## Lo que este contrato NO comprueba, lo dice
23//! Un símbolo **ausente** del `.config` no es lo mismo que uno apagado a mano: puede no existir en
24//! esa versión del kernel. Los dos cuentan como capacidad ausente —porque el fichero de cgroup no
25//! va a aparecer por buena voluntad— pero se informan **por separado**, y una capacidad sin perfil
26//! declarado queda en [`ContractReport::unprofiled`] sin contar como aprobada.
27
28use std::collections::BTreeMap;
29use std::path::Path;
30
31use serde::{Deserialize, Serialize};
32
33use super::config::{ConfigValue, KernelConfig};
34use super::gate::Verdict;
35
36/// Formato del fichero de contrato. Se versiona para poder migrarlo sin adivinar.
37pub const CONTRACT_VERSION: u32 = 1;
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Contract {
41 pub version: u32,
42 /// Versión de kernel contra la que se revisaron los nombres de símbolo. No invalida nada:
43 /// avisa de que un `ausente` puede ser un renombrado.
44 #[serde(default)]
45 pub reviewed_against: Option<String>,
46 #[serde(default, rename = "capability")]
47 pub capabilities: Vec<Capability>,
48 #[serde(default, rename = "profile")]
49 pub profiles: Vec<Profile>,
50 #[serde(default, rename = "target")]
51 pub targets: Vec<Target>,
52}
53
54/// Una capacidad: un pedazo de interfaz de kernel que alguien del userland USA por nombre.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Capability {
57 /// Identificador estable, en kebab-case.
58 pub id: String,
59 pub title: String,
60 /// Los símbolos que la construyen. **Todos** deben estar en `y`/`m`: son conjunción, no lista
61 /// de candidatos — si con uno bastara, sería otra capacidad.
62 pub symbols: Vec<String>,
63 /// Qué se puede tocar cuando está: ficheros de cgroup, rutas de `/proc`, syscalls.
64 #[serde(default)]
65 pub interface: Vec<String>,
66 /// Quién la usa, con ruta y símbolo. Sin consumidor una capacidad no entra al contrato.
67 pub consumer: String,
68 /// **Cómo falla HOY si no está.** Es el campo que justifica el guardián: si el fallo fuera
69 /// ruidoso, bastaría con correr el programa.
70 #[serde(default)]
71 pub silent: Option<String>,
72 #[serde(default)]
73 pub notes: Option<String>,
74}
75
76/// Para qué es este kernel. El contrato no corre sin uno.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Profile {
79 pub id: String,
80 pub title: String,
81 #[serde(default)]
82 pub help: Option<String>,
83 /// Capacidades cuya ausencia **bloquea**.
84 #[serde(default)]
85 pub requires: Vec<String>,
86 /// Capacidades que se quieren pero cuya ausencia sólo se informa.
87 #[serde(default)]
88 pub wants: Vec<String>,
89}
90
91/// Qué perfil le toca a cada artefacto de kernel del store.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Target {
94 /// Nombre del artefacto tal como lo nombra la receta (campo `name`), p.ej. `linux-generic`.
95 pub artifact: String,
96 pub profile: String,
97 #[serde(default)]
98 pub notes: Option<String>,
99}
100
101/// Estado de UNA capacidad contra UN config.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct CapabilityState {
104 pub id: String,
105 pub title: String,
106 /// `true` si el perfil la exige; `false` si sólo la quiere.
107 pub required: bool,
108 pub present: bool,
109 /// Símbolos apagados a mano (`# CONFIG_X is not set`): prueba dura de que la decisión se tomó.
110 #[serde(default)]
111 pub explicit_off: Vec<String>,
112 /// Símbolos que el `.config` ni nombra. Puede ser un renombrado entre versiones ⇒ se informa
113 /// aparte, aunque para la capacidad cuente igual de ausente.
114 #[serde(default)]
115 pub absent: Vec<String>,
116 pub consumer: String,
117 #[serde(default)]
118 pub silent: Option<String>,
119}
120
121/// Una capacidad nombrada por un perfil que no está declarada: el contrato envejeció.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct UnknownCapability {
124 pub id: String,
125 pub profile: String,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ContractReport {
130 /// Etiqueta del config mirado (ruta, o nombre del artefacto).
131 pub config: String,
132 pub profile: String,
133 #[serde(default)]
134 pub kernel: Option<String>,
135 pub checked: usize,
136 /// Exigidas y ausentes. Éstas bloquean.
137 pub missing_required: Vec<CapabilityState>,
138 /// Queridas y ausentes: se informan y se dejan pasar.
139 pub missing_wanted: Vec<CapabilityState>,
140 pub ok: Vec<CapabilityState>,
141 /// Ids que el perfil nombra y el contrato no define: NO se comprobaron.
142 #[serde(default)]
143 pub unknown: Vec<UnknownCapability>,
144 pub verdict: Verdict,
145}
146
147impl Contract {
148 pub fn load(path: &Path) -> crate::Result<Contract> {
149 let text = std::fs::read_to_string(path)
150 .map_err(|e| crate::Error::Recipe(format!("leyendo {}: {e}", path.display())))?;
151 Contract::parse(&text)
152 }
153
154 pub fn parse(text: &str) -> crate::Result<Contract> {
155 let c: Contract = toml::from_str(text)
156 .map_err(|e| crate::Error::Recipe(format!("parseando el contrato de kernel: {e}")))?;
157 if c.version != CONTRACT_VERSION {
158 return Err(crate::Error::Recipe(format!(
159 "contrato de kernel versión {} (esperaba {CONTRACT_VERSION})",
160 c.version
161 )));
162 }
163 Ok(c)
164 }
165
166 pub fn capability(&self, id: &str) -> Option<&Capability> {
167 self.capabilities.iter().find(|c| c.id == id)
168 }
169
170 pub fn profile(&self, id: &str) -> Option<&Profile> {
171 self.profiles.iter().find(|p| p.id == id)
172 }
173
174 /// Qué perfil le toca a un artefacto. `None` = no declarado ⇒ **no se comprueba**, se informa.
175 pub fn profile_for(&self, artifact: &str) -> Option<&str> {
176 self.targets
177 .iter()
178 .find(|t| t.artifact == artifact)
179 .map(|t| t.profile.as_str())
180 }
181
182 /// Corre el contrato de un perfil contra un `.config` ya producido.
183 pub fn check(&self, cfg: &KernelConfig, profile_id: &str, label: &str) -> crate::Result<ContractReport> {
184 let prof = self.profile(profile_id).ok_or_else(|| {
185 crate::Error::Recipe(format!(
186 "no existe el perfil «{profile_id}» en el contrato. El contrato NO corre sin \
187 perfil declarado: uno global rechazaría kernels sanos (el de QEMU no hospeda Cards)."
188 ))
189 })?;
190
191 let mut rep = ContractReport {
192 config: label.to_string(),
193 profile: profile_id.to_string(),
194 kernel: cfg.banner.clone(),
195 checked: 0,
196 missing_required: Vec::new(),
197 missing_wanted: Vec::new(),
198 ok: Vec::new(),
199 unknown: Vec::new(),
200 verdict: Verdict::Pasa,
201 };
202
203 for (ids, required) in [(&prof.requires, true), (&prof.wants, false)] {
204 for id in ids {
205 let Some(cap) = self.capability(id) else {
206 rep.unknown.push(UnknownCapability {
207 id: id.clone(),
208 profile: profile_id.to_string(),
209 });
210 continue;
211 };
212 rep.checked += 1;
213 let st = evaluate(cap, cfg, required);
214 match (st.present, required) {
215 (true, _) => rep.ok.push(st),
216 (false, true) => rep.missing_required.push(st),
217 (false, false) => rep.missing_wanted.push(st),
218 }
219 }
220 }
221
222 if !rep.missing_required.is_empty() {
223 rep.verdict = Verdict::Bloquea;
224 }
225 Ok(rep)
226 }
227}
228
229fn evaluate(cap: &Capability, cfg: &KernelConfig, required: bool) -> CapabilityState {
230 let mut explicit_off = Vec::new();
231 let mut absent = Vec::new();
232 for s in &cap.symbols {
233 match cfg.get(s) {
234 Some(v) if v.is_on() => {}
235 Some(ConfigValue::No) => explicit_off.push(s.clone()),
236 // Un `=n` literal es rarísimo en un `.config` generado, pero se lee igual que el
237 // apagado explícito: la decisión está escrita.
238 Some(_) => explicit_off.push(s.clone()),
239 None => absent.push(s.clone()),
240 }
241 }
242 CapabilityState {
243 id: cap.id.clone(),
244 title: cap.title.clone(),
245 required,
246 present: explicit_off.is_empty() && absent.is_empty(),
247 explicit_off,
248 absent,
249 consumer: cap.consumer.clone(),
250 silent: cap.silent.clone(),
251 }
252}
253
254/// Barrido de un store: los `.config` que instaló cada kernel sellado.
255///
256/// Devuelve `(nombre del artefacto, ruta del config)`. Un kernel que no instale su config **no
257/// aparece**: sin config no hay nada que comprobar, y fingir que pasó sería el mismo pecado que
258/// el guardián viene a arreglar.
259pub fn sealed_kernel_configs(store: &Path) -> Vec<(String, std::path::PathBuf)> {
260 let mut out: Vec<(String, std::path::PathBuf)> = Vec::new();
261 let Ok(rd) = std::fs::read_dir(store) else {
262 return out;
263 };
264 for e in rd.flatten() {
265 let name = e.file_name().to_string_lossy().to_string();
266 // `<64 hex>-<nombre>`: el nombre del artefacto es lo que va detrás del primer guion.
267 let Some((hash, artifact)) = name.split_once('-') else {
268 continue;
269 };
270 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
271 continue;
272 }
273 let boot = e.path().join("boot");
274 let Ok(bd) = std::fs::read_dir(&boot) else {
275 continue;
276 };
277 for b in bd.flatten() {
278 let f = b.file_name().to_string_lossy().to_string();
279 if f.starts_with("config-") {
280 out.push((artifact.to_string(), b.path()));
281 }
282 }
283 }
284 out.sort();
285 out
286}
287
288/// Índice `capacidad → perfiles que la exigen`, para el informe legible.
289pub fn required_by(contract: &Contract) -> BTreeMap<&str, Vec<&str>> {
290 let mut m: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
291 for p in &contract.profiles {
292 for id in &p.requires {
293 m.entry(id.as_str()).or_default().push(p.id.as_str());
294 }
295 }
296 m
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 const CONTRATO: &str = r#"
304version = 1
305reviewed_against = "7.1.2"
306
307[[capability]]
308id = "limite-memoria"
309title = "Tope de memoria por unidad"
310symbols = ["MEMCG"]
311interface = ["memory.max", "memory.current"]
312consumer = "arje-incarnate::cgroup"
313silent = "el escritor descarta el error"
314
315[[capability]]
316id = "peso-cpu"
317title = "Reparto de CPU por unidad"
318symbols = ["CGROUPS", "CGROUP_SCHED"]
319consumer = "arje-incarnate::cgroup"
320
321[profile.anfitrion]
322id = "anfitrion"
323title = "Hospeda Cards"
324requires = ["limite-memoria", "peso-cpu"]
325wants = ["no-declarada"]
326
327[[target]]
328artifact = "linux-generic"
329profile = "anfitrion"
330"#;
331
332 fn contrato() -> Contract {
333 // `[profile.x]` con `id` dentro no es lo que usa el fichero real (usa `[[profile]]`);
334 // se arma a mano para no depender del formato en el test de lógica.
335 let mut c = Contract::parse(&CONTRATO.replace("[profile.anfitrion]", "[[profile]]")).unwrap();
336 c.targets.push(Target {
337 artifact: "linux".into(),
338 profile: "sin-perfil".into(),
339 notes: None,
340 });
341 c
342 }
343
344 #[test]
345 fn el_apagado_explicito_bloquea_y_se_distingue_del_ausente() {
346 let c = contrato();
347 let cfg = KernelConfig::parse(
348 "# CONFIG_MEMCG is not set\nCONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n",
349 );
350 let r = c.check(&cfg, "anfitrion", "test").unwrap();
351 assert_eq!(r.verdict, Verdict::Bloquea);
352 assert_eq!(r.missing_required.len(), 1);
353 assert_eq!(r.missing_required[0].explicit_off, vec!["MEMCG"]);
354 assert!(r.missing_required[0].absent.is_empty());
355 assert_eq!(r.ok.len(), 1);
356 }
357
358 #[test]
359 fn el_ausente_cuenta_pero_se_informa_aparte() {
360 let c = contrato();
361 let cfg = KernelConfig::parse("CONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n");
362 let r = c.check(&cfg, "anfitrion", "test").unwrap();
363 assert_eq!(r.verdict, Verdict::Bloquea);
364 assert_eq!(r.missing_required[0].absent, vec!["MEMCG"]);
365 assert!(r.missing_required[0].explicit_off.is_empty());
366 }
367
368 #[test]
369 fn cumplir_todo_pasa() {
370 let c = contrato();
371 let cfg = KernelConfig::parse("CONFIG_MEMCG=y\nCONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n");
372 let r = c.check(&cfg, "anfitrion", "test").unwrap();
373 assert_eq!(r.verdict, Verdict::Pasa);
374 assert_eq!(r.ok.len(), 2);
375 }
376
377 #[test]
378 fn una_capacidad_no_declarada_no_cuenta_como_aprobada() {
379 let c = contrato();
380 let cfg = KernelConfig::parse("CONFIG_MEMCG=y\nCONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n");
381 let r = c.check(&cfg, "anfitrion", "test").unwrap();
382 assert_eq!(r.unknown.len(), 1);
383 assert_eq!(r.unknown[0].id, "no-declarada");
384 assert_eq!(r.checked, 2);
385 }
386
387 /// Control de frescura del fichero real: los perfiles no pueden nombrar capacidades que no
388 /// existen, ni los objetivos perfiles que no existen. Un contrato que se desincroniza de sí
389 /// mismo miente igual que el kernel al que vigila.
390 #[test]
391 fn el_contrato_del_repo_cierra_sobre_si_mismo() {
392 let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
393 .join("../../docs/state/kernel-contract.toml");
394 if !p.is_file() {
395 return; // instalado fuera del repo: no hay nada que comprobar
396 }
397 let c = Contract::load(&p).expect("el contrato del repo debe parsear");
398 for prof in &c.profiles {
399 for id in prof.requires.iter().chain(prof.wants.iter()) {
400 assert!(
401 c.capability(id).is_some(),
402 "el perfil {} nombra la capacidad inexistente {id}",
403 prof.id
404 );
405 }
406 }
407 for t in &c.targets {
408 assert!(
409 c.profile(&t.profile).is_some(),
410 "el artefacto {} apunta al perfil inexistente {}",
411 t.artifact,
412 t.profile
413 );
414 }
415 for cap in &c.capabilities {
416 // Una capacidad SIN símbolos es legítima sólo si es de INTERFAZ: algo que el kernel da
417 // siempre a partir de cierta versión y que ningún `CONFIG_*` enciende ni apaga (pidfd
418 // desde 5.3). Entra al contrato igual, porque el contrato es la lista de lo que se USA
419 // —no la de lo que puede faltar— y es la línea que lo dice si mañana baja el mínimo de
420 // kernel. Pero entonces TIENE que nombrar la interfaz: sin símbolos ni interfaz, la
421 // capacidad no afirma nada comprobable y lo más probable es que sea un campo a medias.
422 assert!(
423 !cap.symbols.is_empty() || !cap.interface.is_empty(),
424 "{} no declara símbolos NI interfaz: no afirma nada comprobable",
425 cap.id
426 );
427 assert!(!cap.consumer.trim().is_empty(), "{} no declara consumidor", cap.id);
428 }
429 }
430
431 #[test]
432 fn sin_perfil_declarado_no_corre() {
433 let c = contrato();
434 let cfg = KernelConfig::parse("CONFIG_MEMCG=y\n");
435 assert!(c.check(&cfg, "no-existe", "test").is_err());
436 assert_eq!(c.profile_for("linux-generic"), Some("anfitrion"));
437 assert_eq!(c.profile_for("linux-metal"), None);
438 }
439}