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>
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user