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>
477 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|