tawasuyu

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 1430Shell 145Markdown 87Rust 852973 Sutichaykuna

⎇ 7433bcba8308b39d7c5e2565cf67dcb46d0d3940 crates/hammer-core/src/kernel/contract.rs Rust · 429 siq'ikuna · 15.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

kernel: un contrato de capacidades que hace RUIDOSA la falta de MEMCG 7433bcba8 · Sergio ·

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 hammer 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            assert!(!cap.symbols.is_empty(), "{} no declara símbolos", cap.id);
417            assert!(!cap.consumer.trim().is_empty(), "{} no declara consumidor", cap.id);
418        }
419    }
420
421    #[test]
422    fn sin_perfil_declarado_no_corre() {
423        let c = contrato();
424        let cfg = KernelConfig::parse("CONFIG_MEMCG=y\n");
425        assert!(c.check(&cfg, "no-existe", "test").is_err());
426        assert_eq!(c.profile_for("linux-generic"), Some("anfitrion"));
427        assert_eq!(c.profile_for("linux-metal"), None);
428    }
429}