From d797fc472d7c648b433d956ade3fe7713e4e93e4 Mon Sep 17 00:00:00 2001 From: Sergio Date: Tue, 11 Aug 2026 14:51:23 +0000 Subject: [PATCH] =?UTF-8?q?kernel:=20el=20gate=20contra=20un=20.config=20p?= =?UTF-8?q?roducido=20=E2=80=94=20ve=20los=20huecos=20que=20la=20receta=20?= =?UTF-8?q?base=20ya=20tra=C3=ADa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [--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) --- crates/hammer-cli/src/kernel_cmd.rs | 99 +++++++++---- crates/hammer-core/src/kernel/gate.rs | 179 ++++++++++++++++++++++++ crates/hammer-core/src/kernel/modmap.rs | 8 ++ docs/state/kernel-bundles.toml | 34 +++++ 4 files changed, 296 insertions(+), 24 deletions(-) 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, + /// `.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, + /// 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, /// 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 = 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, + objective_id: &str, + plan: Option<&Plan>, + tree: Option<&KconfigTree>, +) -> crate::Result { + 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> = 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 = 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 { + 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"