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 1534Shell 199Rust 109Markdown 993713 Definitions

kernel: el gate contra un .config producido — ve los huecos que la receta base ya traía

El gate que había mira lo que apaga el PLAN, así que sólo ve regresiones que introduce el
plan: un hueco que ya venía en la receta base le pasa por debajo. El modo nuevo compara
DOS configs y define regresión como «funcionaba y dejó de funcionar», que es la
formulación literal del #6 del handoff:

  hammer kernel gate --config <producido> [--baseline /proc/config.gz] --objective X

El referente por defecto es /proc/config.gz: el kernel que arrancó esta máquina es la
prueba viva de qué hace falta para arrancarla.

Y comparar DOS configs, en vez de mirar sólo el nuevo, mata de raíz un falso positivo que
tenía: los nombres de módulo cortos colisionan. El driver que /sys llama `usb` mapea a
QE_USB (el USB de las QUICC Engine de Freescale) y `port` a PORT_CHAN. Mirando sólo el
config nuevo aparecen como perdidos y el gate bloquearía un plan sano; exigiendo que
estuvieran encendidos en el referente, el falso positivo se cae solo. Con test.

Probado contra gioser (Hetzner vServer, 38 drivers bindeados) partiendo de linux-metal:
destapó cuatro pérdidas que NINGÚN bundle causaba — aer, iTCO_wdt, lpc_ich y pcspkr están
encendidos en el kernel que corre y linux-metal no los enciende nunca. El gate viejo no
podía verlas por construcción.

De paso, un mensaje que mandaba a buscar donde no está: sin culpable atribuido decía «lo
apaga una perilla», cuando la causa es que la receta base no lo enciende.

Catálogo, tres entradas nuevas nacidas de medir esta máquina:
  · bundle sin-gpu-intel — DRM_I915 es de los drivers más grandes del kernel y no sirve en
    una VM con virtio-gpu. NO apaga DRM: el vídeo sigue por simpledrm/EFI o virtio-gpu.
  · knob invitado-virtio — VIRTIO_BALLOON y HW_RANDOM_VIRTIO no vienen en el defconfig y
    ninguna receta del repo los enciende; en gioser los dos están BINDEADOS. Un kernel sin
    ellos arranca, pero la VM pierde el globo de memoria y la entropía del anfitrión.
  · knob plataforma-pc — PCIEAER, LPC_ICH, INPUT_PCSPKR y el watchdog ITCO_WDT. El
    watchdog necesita además WATCHDOG, que linux-metal apaga a propósito: por eso va en una
    perilla y no en la base. En una máquina sin acceso físico, el watchdog es lo que la
    reinicia cuando se cuelga.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

d797fc472d7c648b433d956ade3fe7713e4e93e4 · by Sergio · (2 months ago) · parent 7279ea878 · browse the tree at this commit

Semantic delta · 13

crates/hammer-cli/src/kernel_cmd.rs

  • signature changed enum KernelCmd pub
  • signature changed fn gate
  • body changed fn run pub

crates/hammer-core/src/kernel/modmap.rs

4 files · +296 −24

diff --git a/crates/hammer-cli/src/kernel_cmd.rs b/crates/hammer-cli/src/kernel_cmd.rs
index 99b89513..304f905e 100644
--- a/crates/hammer-cli/src/kernel_cmd.rs
+++ b/crates/hammer-cli/src/kernel_cmd.rs
@@ -131,9 +131,18 @@ pub enum KernelCmd {
     /// No corre sin `--objective`: la regla aplicada global rechazaría recetas sanas (el kernel de
     /// QEMU apaga USB, HID e INPUT a propósito). Sale distinto de cero si bloquea.
     Gate {
-        /// El JSON que emitió `hammer kernel plan --out`.
+        /// El JSON que emitió `hammer kernel plan --out`. Con `--config` es opcional: sólo sirve
+        /// para atribuir cada pérdida a su bundle.
         #[arg(long)]
-        plan: PathBuf,
+        plan: Option<PathBuf>,
+        /// `.config` YA PRODUCIDO. Cambia la pregunta: en vez de «¿qué apaga el plan?» mide
+        /// «¿qué funcionaba y deja de funcionar?», que además ve los huecos de la receta base.
+        #[arg(long)]
+        config: Option<PathBuf>,
+        /// Referente contra el que medir la regresión (default `/proc/config.gz`): el kernel que
+        /// arrancó esta máquina es la prueba viva de qué hace falta para arrancarla.
+        #[arg(long)]
+        baseline: Option<PathBuf>,
         /// Id de objetivo del catálogo (`qemu-serial`, `servidor`, `metal-escritorio`, `portatil`).
         #[arg(long)]
         objective: String,
@@ -211,13 +220,17 @@ pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
         KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
         KernelCmd::Gate {
             plan,
+            config,
+            baseline,
             objective,
             devices,
             kconfig,
             catalog,
             json,
         } => gate(
-            &plan,
+            plan.as_deref(),
+            config.as_deref(),
+            baseline.as_deref(),
             &objective,
             devices.as_deref(),
             &kconfig_root(kconfig)?,
@@ -1102,19 +1115,29 @@ fn hw(out: Option<&Path>) -> Result<()> {
     Ok(())
 }
 
+#[allow(clippy::too_many_arguments)]
 fn gate(
-    plan_path: &Path,
+    plan_path: Option<&Path>,
+    config: Option<&Path>,
+    baseline: Option<&Path>,
     objective: &str,
     devices: Option<&Path>,
     kroot: &Path,
     catalog: &Path,
     json: bool,
 ) -> Result<()> {
-    let p: hammer_core::kernel::Plan = serde_json::from_str(
-        &std::fs::read_to_string(plan_path)
-            .with_context(|| format!("leyendo {}", plan_path.display()))?,
-    )
-    .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
+    if plan_path.is_none() && config.is_none() {
+        anyhow::bail!("pasá --plan (qué apagaría) o --config (qué quedó): sin uno de los dos no hay nada que comparar");
+    }
+    let p: Option<hammer_core::kernel::Plan> = match plan_path {
+        None => None,
+        Some(pp) => Some(
+            serde_json::from_str(
+                &std::fs::read_to_string(pp).with_context(|| format!("leyendo {}", pp.display()))?,
+            )
+            .with_context(|| format!("{} no es un plan de `hammer kernel plan`", pp.display()))?,
+        ),
+    };
     let cat = hammer_core::kernel::Catalog::load(catalog)?;
     let tree = load_tree(kroot)?;
     let modmap = hammer_core::kernel::ModMap::parse(kroot)
@@ -1135,20 +1158,46 @@ fn gate(
         None => hammer_core::kernel::Hardware::probe_local(),
     };
 
-    let r = hammer_core::kernel::gate::run(
-        &p,
-        &cat,
-        &tree,
-        &modmap,
-        &hardware.bound_drivers,
-        objective,
-    )?;
+    let r = match config {
+        // Modo fuerte: comparar dos configs reales. Ve también los huecos que ya venían en la base.
+        Some(c) => {
+            let base_path = baseline
+                .map(|b| b.to_path_buf())
+                .map_or_else(running_config_path, Ok)?;
+            let base = KernelConfig::parse(&read_maybe_gz(&base_path)?);
+            let nuevo = KernelConfig::parse(&read_maybe_gz(c)?);
+            if nuevo.is_empty() {
+                anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", c.display());
+            }
+            println!("referente   {} ({} símbolos)", base_path.display(), base.len());
+            hammer_core::kernel::gate::run_against_config(
+                &base,
+                &nuevo,
+                &cat,
+                &modmap,
+                &hardware.bound_drivers,
+                objective,
+                p.as_ref(),
+                Some(&tree),
+            )?
+        }
+        None => hammer_core::kernel::gate::run(
+            p.as_ref().expect("validado arriba"),
+            &cat,
+            &tree,
+            &modmap,
+            &hardware.bound_drivers,
+            objective,
+        )?,
+    };
 
     if json {
         println!("{}", serde_json::to_string_pretty(&r)?);
     } else {
         let obj = cat.objective(objective).map(|o| o.title.as_str()).unwrap_or("");
-        println!("plan        {}", r.plan);
+        if !r.plan.is_empty() {
+            println!("plan        {}", r.plan);
+        }
         println!("objetivo    {} — {obj}", r.objective);
         println!(
             "hardware    {} ({} drivers bindeados, {} reglas obj- en {} Makefiles)",
@@ -1163,12 +1212,14 @@ fn gate(
         } else {
             println!("✗ {} REGRESIÓN(ES) — dispositivos en uso que se quedarían sin driver:", r.regressions.len());
             for l in &r.regressions {
-                println!(
-                    "  {:<20} lo apaga {}  (símbolos: {})",
-                    l.driver,
-                    if l.blamed_on.is_empty() { "una perilla".to_string() } else { l.blamed_on.join(", ") },
-                    l.symbols.join(", ")
-                );
+                // Sin culpable atribuido la causa NO es el plan: es que la receta base nunca
+                // enciende ese símbolo. Decir «lo apaga una perilla» mandaría a buscar donde no está.
+                let causa = if l.blamed_on.is_empty() {
+                    "la receta base no lo enciende".to_string()
+                } else {
+                    format!("lo apaga {}", l.blamed_on.join(", "))
+                };
+                println!("  {:<20} {causa}  (símbolos: {})", l.driver, l.symbols.join(", "));
             }
         }
         if !r.allowed.is_empty() {
diff --git a/crates/hammer-core/src/kernel/gate.rs b/crates/hammer-core/src/kernel/gate.rs
index b85051e4..cc370d5c 100644
--- a/crates/hammer-core/src/kernel/gate.rs
+++ b/crates/hammer-core/src/kernel/gate.rs
@@ -152,6 +152,107 @@ pub fn run(
     Ok(rep)
 }
 
+/// El gate contra un `.config` **ya producido**, midiendo contra el que corre hoy.
+///
+/// Es una pregunta distinta —y más fuerte— que [`run`]. Aquélla mira lo que el **plan** apaga, así
+/// que sólo ve regresiones que introduce el plan: un hueco que ya venía en la receta base le pasa
+/// por debajo. Ésta compara dos configs y define regresión como **«funcionaba y dejó de
+/// funcionar»**, que es la formulación literal del #6 del handoff.
+///
+/// El referente natural es `/proc/config.gz`: el kernel que arrancó esta máquina es la prueba viva
+/// de qué hace falta para arrancarla.
+///
+/// # Por qué comparar dos configs y no mirar sólo el nuevo
+/// Los nombres de módulo cortos colisionan: el driver que `/sys` llama `usb` mapea a `QE_USB` (el
+/// USB de las QUICC Engine de Freescale) y `port` a `PORT_CHAN`. Mirando sólo el config nuevo, esos
+/// aparecen como «perdidos» y el gate bloquearía un plan sano. Exigiendo que **estuvieran
+/// encendidos en el referente** el falso positivo desaparece solo: lo que nunca estuvo no puede
+/// regresar.
+pub fn run_against_config(
+    baseline: &super::KernelConfig,
+    produced: &super::KernelConfig,
+    cat: &Catalog,
+    modmap: &ModMap,
+    drivers_in_use: &BTreeSet<String>,
+    objective_id: &str,
+    plan: Option<&Plan>,
+    tree: Option<&KconfigTree>,
+) -> crate::Result<GateReport> {
+    let obj = cat.objective(objective_id).ok_or_else(|| {
+        crate::Error::Recipe(format!(
+            "no existe el objetivo «{objective_id}» en el catálogo. \
+             El gate NO corre sin objetivo declarado: uno global rechazaría recetas sanas."
+        ))
+    })?;
+    let permitidos: BTreeSet<&str> = obj.allow_bundles.iter().map(|s| s.as_str()).collect();
+
+    // Atribución opcional: con el plan y el árbol se puede decir QUÉ bundle se lo llevó.
+    let mut culpa: BTreeMap<String, Vec<String>> = BTreeMap::new();
+    if let (Some(plan), Some(tree)) = (plan, tree) {
+        for id in &plan.bundles {
+            let Some(b) = cat.bundle(id) else { continue };
+            let mut r = b.disable.clone();
+            r.extend(b.close_leaks.iter().cloned());
+            for s in tree.closure_off(&r) {
+                culpa.entry(s).or_default().push(id.clone());
+            }
+        }
+    }
+
+    let mut rep = GateReport {
+        objective: objective_id.to_string(),
+        plan: plan.map(|p| p.derived_name.clone()).unwrap_or_default(),
+        drivers_in_use: drivers_in_use.len(),
+        regressions: Vec::new(),
+        allowed: Vec::new(),
+        unmapped: Vec::new(),
+        verdict: Verdict::Pasa,
+    };
+
+    for drv in drivers_in_use {
+        let Some(syms) = modmap.symbols_for(drv) else {
+            rep.unmapped.push(drv.clone());
+            continue;
+        };
+        // Sólo cuentan los símbolos que HOY lo sostienen. Si ninguno estaba encendido en el
+        // referente, este driver no viene de ahí (o el mapa colisionó) y no hay nada que proteger.
+        let sostenes: Vec<&String> = syms.iter().filter(|s| baseline.is_on(s)).collect();
+        if sostenes.is_empty() {
+            rep.unmapped.push(drv.clone());
+            continue;
+        }
+        if sostenes.iter().any(|s| produced.is_on(s)) {
+            continue;
+        }
+        let mut culpables: Vec<String> = sostenes
+            .iter()
+            .filter_map(|s| culpa.get(s.as_str()))
+            .flatten()
+            .cloned()
+            .collect();
+        culpables.sort();
+        culpables.dedup();
+
+        let loss = DriverLoss {
+            driver: drv.clone(),
+            symbols: sostenes.iter().map(|s| (*s).clone()).collect(),
+            blamed_on: culpables.clone(),
+        };
+        let autorizado =
+            !culpables.is_empty() && culpables.iter().all(|c| permitidos.contains(c.as_str()));
+        if autorizado {
+            rep.allowed.push(loss);
+        } else {
+            rep.regressions.push(loss);
+        }
+    }
+
+    if !rep.regressions.is_empty() {
+        rep.verdict = Verdict::Bloquea;
+    }
+    Ok(rep)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -295,3 +396,81 @@ configure = "make ARCH=x86_64 defconfig && make ARCH=x86_64 olddefconfig"
         assert!(r.regressions.is_empty() && r.allowed.is_empty() && r.unmapped.is_empty());
     }
 }
+
+#[cfg(test)]
+mod tests_contra_config {
+    use super::*;
+    use crate::kernel::KernelConfig;
+
+    fn catalogo() -> Catalog {
+        toml::from_str(
+            r#"
+version = 1
+[[bundle]]
+id = "sin-wifi"
+title = "Sin wifi"
+disable = ["WIRELESS"]
+[[objective]]
+id = "servidor"
+title = "Servidor"
+allow_bundles = []
+"#,
+        )
+        .unwrap()
+    }
+
+    fn mm() -> ModMap {
+        let mut m = ModMap::default();
+        super::super::modmap::tests_support::feed(
+            &mut m,
+            "obj-$(CONFIG_IWLWIFI) += iwlwifi.o\n\
+             obj-$(CONFIG_VIRTIO_BALLOON) += virtio_balloon.o\n\
+             obj-$(CONFIG_QE_USB) += usb.o\n",
+        );
+        m
+    }
+
+    fn drv(v: &[&str]) -> BTreeSet<String> {
+        v.iter().map(|s| s.to_string()).collect()
+    }
+
+    #[test]
+    fn regresion_es_estaba_encendido_y_dejo_de_estarlo() {
+        let base = KernelConfig::parse("CONFIG_VIRTIO_BALLOON=y\n");
+        let nuevo = KernelConfig::parse("# CONFIG_VIRTIO_BALLOON is not set\n");
+        let r = run_against_config(
+            &base, &nuevo, &catalogo(), &mm(), &drv(&["virtio_balloon"]), "servidor", None, None,
+        )
+        .unwrap();
+        assert_eq!(r.verdict, Verdict::Bloquea);
+        assert_eq!(r.regressions[0].driver, "virtio_balloon");
+    }
+
+    /// El driver que `/sys` llama `usb` mapea a `QE_USB` (Freescale). Mirando sólo el config nuevo
+    /// aparecería como perdido y bloquearía un plan sano; exigiendo que estuviera encendido en el
+    /// referente, el falso positivo se cae solo.
+    #[test]
+    fn la_colision_de_nombres_no_produce_falso_positivo() {
+        let base = KernelConfig::parse("# CONFIG_QE_USB is not set\n");
+        let nuevo = KernelConfig::parse("# CONFIG_QE_USB is not set\n");
+        let r = run_against_config(
+            &base, &nuevo, &catalogo(), &mm(), &drv(&["usb"]), "servidor", None, None,
+        )
+        .unwrap();
+        assert_eq!(r.verdict, Verdict::Pasa);
+        assert!(r.regressions.is_empty());
+        // Queda declarado como NO comprobado, que es la verdad: no sabemos de dónde sale.
+        assert_eq!(r.unmapped, vec!["usb"]);
+    }
+
+    #[test]
+    fn lo_que_sigue_encendido_no_molesta() {
+        let base = KernelConfig::parse("CONFIG_IWLWIFI=m\n");
+        let nuevo = KernelConfig::parse("CONFIG_IWLWIFI=y\n");
+        let r = run_against_config(
+            &base, &nuevo, &catalogo(), &mm(), &drv(&["iwlwifi"]), "servidor", None, None,
+        )
+        .unwrap();
+        assert_eq!(r.verdict, Verdict::Pasa);
+    }
+}
diff --git a/crates/hammer-core/src/kernel/modmap.rs b/crates/hammer-core/src/kernel/modmap.rs
index 851c8e88..40f857e7 100644
--- a/crates/hammer-core/src/kernel/modmap.rs
+++ b/crates/hammer-core/src/kernel/modmap.rs
@@ -137,6 +137,14 @@ fn normalize(s: &str) -> String {
     s.replace('-', "_")
 }
 
+/// Alimentar un `ModMap` desde texto, para los tests de otros módulos.
+#[cfg(test)]
+pub(crate) mod tests_support {
+    pub fn feed(m: &mut super::ModMap, texto: &str) {
+        super::parse_makefile(texto, m);
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/docs/state/kernel-bundles.toml b/docs/state/kernel-bundles.toml
index 0432c7ce..a8b9805e 100644
--- a/docs/state/kernel-bundles.toml
+++ b/docs/state/kernel-bundles.toml
@@ -179,6 +179,17 @@ disable = ["MMC", "MEMSTICK"]
 [bundle.contradicted_by]
 driver = ["sdhci_pci", "rtsx_pci_sdmmc"]
 
+[[bundle]]
+id = "sin-gpu-intel"
+title = "No tengo gráficos Intel"
+help = """
+`DRM_I915` es de los drivers más grandes del kernel y sólo sirve en hardware Intel. Una VM con
+virtio-gpu, una máquina AMD o un servidor sin pantalla no lo usan nunca. NO apaga DRM: la salida de
+vídeo sigue por simpledrm/EFI o por virtio-gpu."""
+disable = ["DRM_I915"]
+[bundle.contradicted_by]
+driver = ["i915"]
+
 [[bundle]]
 id = "sin-virtualizacion"
 title = "No voy a virtualizar en esta máquina"
@@ -246,6 +257,29 @@ side = "recipe"
 recipe_field = "build.compiler + build.phases.compile (make LLVM=1)"
 notes = "Ver la cabecera de recipes/linux.toml: el kernel se queda en el gueto gcc hasta que se exponga el clang que zig empaqueta."
 
+[[knob]]
+id = "invitado-virtio"
+title = "Invitado de VM: los virtio que el defconfig deja fuera"
+help = """
+`VIRTIO_BALLOON` (que el anfitrión pueda recuperar RAM) y `HW_RANDOM_VIRTIO` (entropía del
+anfitrión) no vienen en el defconfig y ninguna receta de kernel del repo los enciende. En gioser los
+dos están BINDEADOS ahora mismo: un kernel sin ellos arranca, pero la VM pierde el globo de memoria
+y la fuente de entropía. Es justo la clase de pérdida silenciosa que el gate existe para ver."""
+side = "kconfig"
+enable = ["VIRTIO_BALLOON", "HW_RANDOM_VIRTIO", "VIRTIO_CONSOLE", "SCSI_VIRTIO"]
+
+[[knob]]
+id = "plataforma-pc"
+title = "Chipset de PC: errores PCIe, puente LPC, watchdog y bocina"
+help = """
+Cosas que cualquier distro trae encendidas y que el defconfig y `linux-metal` dejan fuera:
+`PCIEAER` (reporte avanzado de errores PCIe), `LPC_ICH` (puente LPC de Intel), `INPUT_PCSPKR` y el
+watchdog `ITCO_WDT`. En gioser los cuatro están BINDEADOS ahora mismo. El watchdog necesita además
+`WATCHDOG`, que `linux-metal` apaga a propósito — por eso va acá y no en la receta base: en una
+máquina sin acceso físico, un watchdog es lo que la reinicia cuando se cuelga."""
+side = "kconfig"
+enable = ["PCIEAER", "LPC_ICH", "INPUT_PCSPKR", "WATCHDOG", "ITCO_WDT"]
+
 [[knob]]
 id = "march-nativo"
 title = "Compilar para esta CPU exacta"