Files
takana/crates/hammer-core/src/kernel/gate.rs
T
SergioandClaude Opus 5 d797fc472d 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>
2026-08-11 14:51:23 +00:00

477 lines
16 KiB
Rust

//! **Gate de no-regresión de hardware, por objetivo** (#6 del handoff, §5 del SDD 22).
//!
//! La regla, en una línea: *todo dispositivo en uso ahora mismo debe seguir teniendo driver en el
//! config nuevo*. No es un consejo, es un portón. Corre en un segundo, sin IA y sin VM, y cubre la
//! mayoría de los desastres de «me quedé sin red / sin teclado».
//!
//! ## Por qué POR OBJETIVO y no global
//! `recipes/linux.toml` apaga `USB_SUPPORT`, `HID`, `INPUT_MOUSE` **a propósito**: es el kernel de
//! QEMU con consola serie, donde nadie va a tocar la máquina. Un gate global «todo dispositivo en
//! uso debe seguir teniendo driver» **rechazaría una receta sana**. El objetivo declara qué
//! pérdidas son legítimas; el portón se queda, sólo que en el sitio correcto.
//!
//! ## Y la máquina destino no es la de build
//! Los drivers en uso se pasan como dato ([`Hardware::bound_drivers`]), así que pueden venir de la
//! máquina donde el kernel va a correr y no de la que lo compila.
//!
//! ## Lo que el gate NO puede comprobar, lo dice
//! Un driver que no se pueda mapear a ningún símbolo queda en [`GateReport::unmapped`] y **no**
//! cuenta como aprobado. Un portón que calla lo que no pudo mirar no es un portón.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::catalog::Catalog;
use super::kconfig::KconfigTree;
use super::modmap::ModMap;
use super::plan::{Action, Plan};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Verdict {
Pasa,
Bloquea,
}
/// Un driver en uso que el plan dejaría sin símbolo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriverLoss {
pub driver: String,
/// Los símbolos que lo construyen. Se pierde sólo si caen TODOS.
pub symbols: Vec<String>,
/// Qué bundle(s) del plan se lo llevan por delante.
pub blamed_on: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateReport {
pub objective: String,
pub plan: String,
/// Cuántos drivers en uso se miraron.
pub drivers_in_use: usize,
/// Pérdidas que el objetivo **no** autoriza. Éstas bloquean.
pub regressions: Vec<DriverLoss>,
/// Pérdidas autorizadas por el objetivo: se informan y se dejan pasar.
pub allowed: Vec<DriverLoss>,
/// Drivers que no se pudieron mapear a ningún `CONFIG_*`: el gate NO los comprobó.
pub unmapped: Vec<String>,
pub verdict: Verdict,
}
/// Corre el gate.
///
/// `drivers_in_use` son los nombres tal como salen de `/sys` (con `_`).
pub fn run(
plan: &Plan,
cat: &Catalog,
tree: &KconfigTree,
modmap: &ModMap,
drivers_in_use: &BTreeSet<String>,
objective_id: &str,
) -> 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."
))
})?;
// Lo que el plan apaga, con su clausura. Se recalcula desde las raíces del plan: es la misma
// cuenta que hará `olddefconfig`, no una copia que pueda desincronizarse.
let roots: Vec<String> = plan
.symbols
.iter()
.filter(|s| s.action == Action::Disable)
.map(|s| s.symbol.clone())
.collect();
let apagados = tree.closure_off(&roots);
// Atribución: qué bundle se lleva cada símbolo. Un símbolo puede caer por varios.
let mut culpa: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
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) {
if let Some(k) = apagados.get(&s) {
culpa.entry(k.as_str()).or_default().push(id.as_str());
}
}
}
let permitidos: BTreeSet<&str> = obj.allow_bundles.iter().map(|s| s.as_str()).collect();
let mut rep = GateReport {
objective: objective_id.to_string(),
plan: plan.derived_name.clone(),
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;
};
// Sobrevive si le queda AL MENOS UN símbolo en pie: un módulo puede construirse desde
// varias opciones y basta una.
if syms.iter().any(|s| !apagados.contains(s)) {
continue;
}
let mut culpables: Vec<String> = syms
.iter()
.filter_map(|s| culpa.get(s.as_str()))
.flatten()
.map(|s| s.to_string())
.collect();
culpables.sort();
culpables.dedup();
let loss = DriverLoss {
driver: drv.clone(),
symbols: syms.iter().cloned().collect(),
blamed_on: culpables.clone(),
};
// Autorizado sólo si TODO lo que se lo lleva está permitido por el objetivo. Si algún
// culpable no lo está —o si no se pudo atribuir a ninguno— es regresión.
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)
}
/// 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::*;
use crate::kernel::plan::make_plan;
use crate::recipe::Recipe;
fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog, Recipe, ModMap) {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("Kconfig"),
r#"
config WIRELESS
bool "Wireless"
config IWLWIFI
tristate "iwlwifi"
depends on WIRELESS
config USB_SUPPORT
bool "USB"
config USB_HID
tristate "usbhid"
depends on USB_SUPPORT
config E1000
tristate "e1000"
"#,
)
.unwrap();
let tree = KconfigTree::parse(d.path(), &std::collections::BTreeMap::new()).unwrap();
let mut modmap = ModMap::default();
std::fs::write(
d.path().join("Makefile"),
"obj-$(CONFIG_IWLWIFI) += iwlwifi.o\n\
obj-$(CONFIG_USB_HID) += usbhid.o\n\
obj-$(CONFIG_E1000) += e1000.o\n",
)
.unwrap();
modmap = ModMap::parse(d.path()).unwrap_or(modmap);
let cat: Catalog = toml::from_str(
r#"
version = 1
[[bundle]]
id = "sin-wifi"
title = "Sin wifi"
disable = ["WIRELESS"]
[[bundle]]
id = "sin-usb"
title = "Sin USB"
disable = ["USB_SUPPORT"]
[[objective]]
id = "qemu-serial"
title = "VM con consola serie"
allow_bundles = ["sin-usb"]
[[objective]]
id = "metal"
title = "Metal"
allow_bundles = []
"#,
)
.unwrap();
let recipe: Recipe = toml::from_str(
r#"
name = "linux"
version = "6.16.12"
[source]
tarball = "https://x/l.tar.gz"
sha256 = "aa"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "dynamic"
[build.phases]
configure = "make ARCH=x86_64 defconfig && make ARCH=x86_64 olddefconfig"
"#,
)
.unwrap();
(d, tree, cat, recipe, modmap)
}
fn drivers(v: &[&str]) -> BTreeSet<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn perder_un_driver_en_uso_bloquea() {
let (_d, tree, cat, base, mm) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
let r = run(&p, &cat, &tree, &mm, &drivers(&["iwlwifi", "e1000"]), "metal").unwrap();
assert_eq!(r.verdict, Verdict::Bloquea);
assert_eq!(r.regressions.len(), 1);
assert_eq!(r.regressions[0].driver, "iwlwifi");
assert_eq!(r.regressions[0].blamed_on, vec!["sin-wifi"]);
}
#[test]
fn el_objetivo_autoriza_la_perdida_que_declara() {
let (_d, tree, cat, base, mm) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-usb".into()], &[]).unwrap();
// El MISMO plan: bloquea en metal y pasa en qemu-serial. Ése es todo el punto del §5.
let metal = run(&p, &cat, &tree, &mm, &drivers(&["usbhid"]), "metal").unwrap();
assert_eq!(metal.verdict, Verdict::Bloquea);
let qemu = run(&p, &cat, &tree, &mm, &drivers(&["usbhid"]), "qemu-serial").unwrap();
assert_eq!(qemu.verdict, Verdict::Pasa);
assert_eq!(qemu.allowed.len(), 1);
}
#[test]
fn el_driver_que_no_se_pudo_mapear_no_cuenta_como_aprobado() {
let (_d, tree, cat, base, mm) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
let r = run(&p, &cat, &tree, &mm, &drivers(&["misterio"]), "metal").unwrap();
assert_eq!(r.unmapped, vec!["misterio"]);
// No está ni en pérdidas ni en aprobados: está declarado como no comprobado.
assert!(r.regressions.is_empty() && r.allowed.is_empty());
}
#[test]
fn sin_objetivo_declarado_el_gate_no_corre() {
let (_d, tree, cat, base, mm) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
let e = run(&p, &cat, &tree, &mm, &drivers(&["iwlwifi"]), "inventado")
.unwrap_err()
.to_string();
assert!(e.contains("inventado"), "{e}");
}
#[test]
fn un_driver_intacto_no_molesta() {
let (_d, tree, cat, base, mm) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
let r = run(&p, &cat, &tree, &mm, &drivers(&["e1000"]), "metal").unwrap();
assert_eq!(r.verdict, Verdict::Pasa);
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);
}
}