//! `takana kernel` — el armador de kernel (SDD 22). //! //! Superficie en inglés (Regla 7.bis), mensajes en castellano. El contrato con las UIs es JSON //! estable, mismo patrón que `/run/hammer/boot-graph.json`. use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::Subcommand; use takana_core::kernel::kconfig::KconfigTree; use takana_core::kernel::KernelConfig; use serde::Serialize; /// Dónde busca el árbol de Kconfig si no se pasa `--kconfig`. const KCONFIG_ENV: &str = "TAKANA_KCONFIG_ROOT"; /// Ídem para el catálogo de bundles. const CATALOG_ENV: &str = "TAKANA_KERNEL_BUNDLES"; /// Ídem para el contrato de capacidades (SDD 25 §4). const CONTRACT_ENV: &str = "TAKANA_KERNEL_CONTRACT"; /// Sitios donde buscarlo sin que nadie diga nada: el del repo y el del sistema instalado. const CATALOG_DEFAULTS: &[&str] = &[ "docs/state/kernel-bundles.toml", "/usr/share/hammer/kernel-bundles.toml", ]; const CONTRACT_DEFAULTS: &[&str] = &[ "docs/state/kernel-contract.toml", "/usr/share/hammer/kernel-contract.toml", ]; #[derive(Subcommand)] pub enum KernelCmd { /// Analiza el grafo de Kconfig: qué muere si se apagan estos símbolos, y por dónde se cuela un /// `select` de vuelta. /// /// Es la herramienta con la que se decide si un bundle «por clausura» es viable (SDD 22 §3). /// No construye ni escribe nada. Closure { /// Símbolos raíz a apagar, SIN el prefijo `CONFIG_` (p.ej. `WIRELESS`). #[arg(required = true)] symbols: Vec, /// Raíz del árbol de fuentes del kernel (el directorio con el `Kconfig` de arriba). #[arg(long)] kconfig: Option, /// Contrasta la clausura contra los símbolos apagados en este `.config`. #[arg(long)] against: Option, /// Cierra la clausura contra sus propias fugas: apaga también a cada selector, y repite. /// Muestra el PRECIO (qué hubo que tragarse de más) en vez de aplicarlo a ciegas. #[arg(long)] fixpoint: bool, /// Emite JSON en vez del informe legible. #[arg(long)] json: bool, /// Cuántos símbolos de la clausura listar en el informe legible. #[arg(long, default_value_t = 20)] limit: usize, }, /// **Modo reversa**: lee el kernel que ya corre y lo muestra por el lente de los bundles. /// /// No compila ni escribe nada. Contesta tres cosas: cuánto de cada bundle ya rige, qué /// capacidad carga este kernel que esta máquina no usa, y dónde el hardware CONTRADICE a un /// bundle aplicado. Probe { /// `.config` a mirar. Por defecto `/proc/config.gz` y si no `/boot/config-`. #[arg(long)] config: Option, #[arg(long)] kconfig: Option, #[arg(long)] catalog: Option, /// No leer el hardware (útil para analizar el config de OTRA máquina). #[arg(long)] no_hardware: bool, #[arg(long)] json: bool, }, /// Lista el catálogo con sus clausuras ya resueltas contra un árbol de Kconfig concreto. /// /// Es también el control de frescura: `--check` sale distinto de cero si algún bundle nombra /// símbolos que ya no existen o dejó fugas `select` sin declarar. Bundles { #[arg(long)] kconfig: Option, #[arg(long)] catalog: Option, /// Falla si el catálogo envejeció respecto de este árbol. #[arg(long)] check: bool, #[arg(long)] json: bool, }, /// De una selección de bundles a una **receta derivada**, sin construir nada. /// /// El config del kernel vive en la fase `configure` y las fases entran en `hash_inputs` ⇒ el /// config ES la identidad del artefacto. Por eso esto emite una receta con su propio /// `ArtifactHash`, y no finge que el kernel sea un binario parametrizable. Plan { /// Receta base (p.ej. `recipes/linux.toml`). #[arg(long)] recipe: PathBuf, /// Id de bundle del catálogo. Repetible. #[arg(long = "bundle")] bundles: Vec, /// Id de perilla del catálogo. Repetible. #[arg(long = "knob")] knobs: Vec, #[arg(long)] kconfig: Option, #[arg(long)] catalog: Option, /// Escribe el plan en JSON (el contrato con las UIs). #[arg(long)] out: Option, /// Escribe la receta derivada. Para construirla hay que dejarla donde sus `deps.build` /// resuelvan — es decir, junto a la receta base. #[arg(long)] recipe_out: Option, #[arg(long)] json: bool, }, /// **Diff-back**: contrasta un plan contra el `.config` que produjo el kernel. /// /// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio. DiffBack { /// El JSON que emitió `takana kernel plan --out`. #[arg(long)] plan: PathBuf, /// El `.config` resultante (el que la receta instala en `/out/boot/config-`). #[arg(long)] config: PathBuf, #[arg(long)] json: bool, }, /// **Gate de no-regresión, por objetivo**: ¿el plan deja sin driver algún dispositivo en uso? /// /// 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ó `takana kernel plan --out`. Con `--config` es opcional: sólo sirve /// para atribuir cada pérdida a su bundle. #[arg(long)] 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, /// Hardware de la máquina DESTINO, capturado con `takana kernel hw --out`. Sin esto se /// mira el de esta máquina, que no siempre es la misma. #[arg(long)] devices: Option, #[arg(long)] kconfig: Option, #[arg(long)] catalog: Option, #[arg(long)] json: bool, }, /// Vuelca el hardware de esta máquina (huella + dispositivos + drivers bindeados). /// /// Se corre en la máquina DESTINO y el JSON viaja a la de build: el gate necesita saber qué /// funciona allá, no acá. Hw { #[arg(long)] out: Option, }, /// **Contrato de capacidades**: ¿este kernel trae lo que el userland usa por nombre? /// /// El punto ciego que cierra (SDD 25 §4): sin `CONFIG_MEMCG` el fichero `memory.max` no /// existe, el escritor de arje descarta el error y una Card corre SIN tope de memoria dejando /// sólo un `warn!`. La máquina de desarrollo no lo ve porque su kernel sí lo trae. Se comprueba /// contra un `.config` YA PRODUCIDO, no contra la receta: entre el `scripts/config -e X` y el /// `.config` hay un `olddefconfig` que puede tragarse el símbolo en silencio. /// /// Sale distinto de cero si falta una capacidad EXIGIDA por el perfil. Contract { /// `.config` a comprobar. Repetible. Por defecto, el del kernel vivo. #[arg(long = "config")] configs: Vec, /// Barre todos los kernels sellados del store (`-/boot/config-*`). #[arg(long)] sealed: bool, /// Dónde buscar las recetas de kernel para saber qué artefacto es el VIGENTE. Repetible. /// Por defecto `recipes/` y `docs/state/kernel-plans/`. #[arg(long = "recipes")] recipes: Vec, /// Perfil del contrato. Obligatorio salvo que el nombre del artefacto ya lo declare. #[arg(long)] profile: Option, #[arg(long)] contract: Option, /// Lista el contrato (capacidades, perfiles y objetivos) y no comprueba nada. #[arg(long)] list: bool, #[arg(long)] json: bool, }, /// Stage a kernel artifact as the boot candidate for the NEXT boot only (ADR 0017). /// /// Copia su bzImage a una ranura PROPIA de la ESP —nunca encima del estable— y pone `BootNext`. /// `BootNext` es de un solo uso: si el candidato no arranca, el firmware ya no la encuentra en /// el arranque siguiente y vuelve solo al estable. La vuelta atrás del caso «no arranca» no la /// hace takana: la hace el firmware, y por eso no se puede romper. Stage { /// Artefacto del kernel en el store (nombre de directorio o ruta). artefacto: String, /// ESP ya montada. #[arg(long, default_value = "/boot/efi")] esp: PathBuf, /// Root of efivarfs. #[arg(long, default_value = crate::efi_boot::EFIVARS)] efivars: PathBuf, /// Arranques que tiene el candidato para ser confirmado. #[arg(long, default_value_t = 1)] tries: u32, /// A/B state file. #[arg(long, default_value = crate::kernel_ab::ESTADO)] state: PathBuf, /// Say what would happen and touch nothing. #[arg(long)] dry_run: bool, }, /// Soft reboot into a kernel: skip firmware and bootloader (ADR 0017 §1). /// /// ⚠ **NO es «actualizar sin rebootear».** El userspace muere igual — es un reboot que se salta /// el firmware. Ahorra los 20-30 s de POST/UEFI, que en un servidor remoto es la mitad del /// downtime y en un portátil no es nada. Se dice acá porque media industria vende esto como /// «live kernel update» y no lo es. Kexec { /// Artefacto del kernel en el store (nombre de directorio o ruta). artefacto: String, /// initramfs a cargar con él. #[arg(long)] initrd: Option, /// Kernel command line for the new kernel. #[arg(long, default_value = "console=tty0 console=ttyS0,115200")] cmdline: String, /// Load it and stop there: the jump needs `--jump`. #[arg(long)] load_only: bool, /// Actually jump. Without this it only loads and reports. #[arg(long)] jump: bool, }, /// Say where this boot came from and what is on trial. BootStatus { #[arg(long, default_value = crate::efi_boot::EFIVARS)] efivars: PathBuf, #[arg(long, default_value = crate::kernel_ab::ESTADO)] state: PathBuf, }, /// Promote the candidate that is currently running to stable. Confirm { #[arg(long, default_value = "/boot/efi")] esp: PathBuf, #[arg(long, default_value = crate::efi_boot::EFIVARS)] efivars: PathBuf, #[arg(long, default_value = crate::kernel_ab::ESTADO)] state: PathBuf, }, /// Drop the candidate and keep the stable kernel. Rollback { #[arg(long, default_value = "/boot/efi")] esp: PathBuf, #[arg(long, default_value = crate::efi_boot::EFIVARS)] efivars: PathBuf, #[arg(long, default_value = crate::kernel_ab::ESTADO)] state: PathBuf, }, /// Estadísticas del árbol de Kconfig parseado (control de salud del lector). Stats { #[arg(long)] kconfig: Option, /// Muestra las primeras N advertencias del parseo. #[arg(long, default_value_t = 10)] warnings: usize, }, } pub fn run(cmd: KernelCmd, store: &str) -> Result<()> { match cmd { KernelCmd::Stage { artefacto, esp, efivars, tries, state, dry_run } => ab_stage(&artefacto, store, &esp, &efivars, tries, &state, dry_run), KernelCmd::Kexec { artefacto, initrd, cmdline, load_only, jump } => kexec_cmd(&artefacto, store, initrd.as_deref(), &cmdline, load_only, jump), KernelCmd::BootStatus { efivars, state } => ab_status(&efivars, &state), KernelCmd::Confirm { esp, efivars, state } => ab_confirm(&esp, &efivars, &state), KernelCmd::Rollback { esp, efivars, state } => ab_rollback(&esp, &efivars, &state), KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings), KernelCmd::Probe { config, kconfig, catalog, no_hardware, json, } => probe( config.as_deref(), &kconfig_root(kconfig)?, &catalog_path(catalog)?, !no_hardware, json, ), KernelCmd::Bundles { kconfig, catalog, check, json, } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json), KernelCmd::Plan { recipe, bundles: bs, knobs, kconfig, catalog, out, recipe_out, json, } => plan( &recipe, &bs, &knobs, &kconfig_root(kconfig)?, &catalog_path(catalog)?, out.as_deref(), recipe_out.as_deref(), store, json, ), KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json), KernelCmd::Gate { plan, config, baseline, objective, devices, kconfig, catalog, json, } => gate( plan.as_deref(), config.as_deref(), baseline.as_deref(), &objective, devices.as_deref(), &kconfig_root(kconfig)?, &catalog_path(catalog)?, json, ), KernelCmd::Hw { out } => hw(out.as_deref()), KernelCmd::Contract { configs, sealed, recipes, profile, contract, list, json, } => contract_cmd( &configs, sealed, &recipes, profile.as_deref(), &contract_path(contract)?, store, list, json, ), KernelCmd::Closure { symbols, kconfig, against, fixpoint, json, limit, } => closure( &symbols, &kconfig_root(kconfig)?, against.as_deref(), fixpoint, json, limit, ), } } /// Resuelve la raíz del árbol de Kconfig: `--kconfig` gana, si no la variable de entorno. fn kconfig_root(flag: Option) -> Result { if let Some(p) = flag { return Ok(p); } match takana_core::env::var_os(KCONFIG_ENV) { Some(v) => Ok(PathBuf::from(v)), None => anyhow::bail!( "falta el árbol de fuentes del kernel: pasá --kconfig o exportá {KCONFIG_ENV}" ), } } /// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo. fn catalog_path(flag: Option) -> Result { if let Some(p) = flag { return Ok(p); } if let Some(v) = takana_core::env::var_os(CATALOG_ENV) { return Ok(PathBuf::from(v)); } for c in CATALOG_DEFAULTS { let p = PathBuf::from(c); if p.is_file() { return Ok(p); } } anyhow::bail!( "no encontré el catálogo de bundles: pasá --catalog o exportá {CATALOG_ENV}" ) } /// Resuelve el contrato de capacidades: `--contract`, si no la variable de entorno, si no el repo. fn contract_path(flag: Option) -> Result { if let Some(p) = flag { return Ok(p); } if let Some(v) = takana_core::env::var_os(CONTRACT_ENV) { return Ok(PathBuf::from(v)); } for c in CONTRACT_DEFAULTS { let p = PathBuf::from(c); if p.is_file() { return Ok(p); } } anyhow::bail!("no encontré el contrato de kernel: pasá --contract o exportá {CONTRACT_ENV}") } /// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite /// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`. fn running_config_path() -> Result { let gz = PathBuf::from("/proc/config.gz"); if gz.is_file() { return Ok(gz); } if let Ok(out) = std::process::Command::new("uname").arg("-r").output() { let rel = String::from_utf8_lossy(&out.stdout).trim().to_string(); let p = PathBuf::from(format!("/boot/config-{rel}")); if p.is_file() { return Ok(p); } } anyhow::bail!( "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-).\n\ Pasá --config para mirar el de otra máquina." ) } fn load_tree(root: &Path) -> Result { if !root.join("Kconfig").is_file() { anyhow::bail!( "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\ Pasá --kconfig o exportá {KCONFIG_ENV}.", root.display() ); } KconfigTree::parse(root, &KconfigTree::vars_x86_64()) .with_context(|| format!("parseando {}", root.display())) } fn stats(root: &Path, show_warnings: usize) -> Result<()> { let t = load_tree(root)?; let visible = t.symbols.values().filter(|s| s.is_visible()).count(); let multi = t.symbols.values().filter(|s| s.defs.len() > 1).count(); let with_help = t.symbols.values().filter(|s| s.help().is_some()).count(); let selects: usize = t.symbols.values().map(|s| s.select_targets().len()).sum(); let implies: usize = t.symbols.values().map(|s| s.imply_targets().len()).sum(); println!("árbol {}", root.display()); println!("ficheros {}", t.files.len()); println!("símbolos {}", t.symbols.len()); println!(" visibles {visible} (marcables en menuconfig)"); println!(" con ayuda {with_help}"); println!(" definidos en >1 sitio {multi}"); println!("aristas select {selects} · imply {implies}"); println!("avisos {}", t.warnings.len()); for w in t.warnings.iter().take(show_warnings) { println!(" · {w}"); } if t.warnings.len() > show_warnings { println!(" … y {} más", t.warnings.len() - show_warnings); } Ok(()) } #[derive(Serialize)] struct ClosureReport { roots: Vec, /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces). closure: Vec, /// De la clausura, los que un humano puede marcar en `menuconfig`. visible: usize, /// `select` que entran desde fuera: reencienden algo de la clausura ignorando sus `depends on`. leaks: Vec, /// Símbolos distintos de la clausura alcanzados por esas fugas. leaked_targets: usize, #[serde(skip_serializing_if = "Option::is_none")] fixpoint: Option, #[serde(skip_serializing_if = "Option::is_none")] against: Option, } /// El precio de cerrar el bundle contra sus fugas. #[derive(Serialize)] struct FixpointReport { /// La clausura ya cerrada, entera. closure: Vec, /// Tamaño de la clausura una vez cerrada. closure_len: usize, /// Cuánto creció respecto de la clausura estricta. grew_by: usize, rounds: usize, /// Lo que hubo que apagar de más, con la ronda en que apareció. added: Vec<(usize, String)>, /// Fugas que sobrevivieron al tope de rondas. Vacío = cerró. remaining_leaks: Vec, } /// Contraste contra un `.config` real: la validación §3 del SDD 22. #[derive(Serialize)] struct AgainstReport { config: String, /// De la clausura, los que el `.config` tiene ENCENDIDOS (la clausura predice de más, o el /// config tiene una fuga real). on_in_config: Vec, /// De la clausura, los que el `.config` apaga explícitamente. off_in_config: usize, /// De la clausura, los que el `.config` ni menciona. absent_in_config: usize, } fn closure( roots: &[String], kroot: &Path, against: Option<&Path>, fixpoint: bool, json: bool, limit: usize, ) -> Result<()> { let t = load_tree(kroot)?; // Avisar de raíces que no existen: un símbolo mal escrito daría clausura vacía en silencio, // que es exactamente el modo de fallo que este comando existe para no tener. let desconocidas: Vec<&String> = roots.iter().filter(|r| !t.symbols.contains_key(*r)).collect(); if !desconocidas.is_empty() { eprintln!( "aviso: {} símbolo(s) no existen en este árbol: {}", desconocidas.len(), desconocidas .iter() .map(|s| s.as_str()) .collect::>() .join(", ") ); } let cl = t.closure_off(roots); let leaks = t.select_leaks(&cl); let leaked_targets = leaks .iter() .map(|l| l.target.as_str()) .collect::>() .len(); let visible = cl .iter() .filter(|s| t.symbols.get(*s).is_some_and(|s| s.is_visible())) .count(); let fx = fixpoint.then(|| { let f = t.closure_off_fixpoint(roots, 16); FixpointReport { closure_len: f.closure.len(), closure: f.closure.iter().cloned().collect(), grew_by: f.closure.len().saturating_sub(cl.len()), rounds: f.rounds, added: f.added, remaining_leaks: f.remaining_leaks, } }); let against_report = match against { None => None, Some(p) => { let text = read_maybe_gz(p)?; let cfg = KernelConfig::parse(&text); let on: Vec = cl.iter().filter(|s| cfg.is_on(s)).cloned().collect(); let off = cl .iter() .filter(|s| matches!(cfg.get(s), Some(takana_core::kernel::ConfigValue::No))) .count(); let absent = cl.iter().filter(|s| cfg.get(s).is_none()).count(); Some(AgainstReport { config: p.display().to_string(), on_in_config: on, off_in_config: off, absent_in_config: absent, }) } }; let rep = ClosureReport { roots: roots.to_vec(), closure: cl.iter().cloned().collect(), visible, leaks, leaked_targets, fixpoint: fx, against: against_report, }; if json { println!("{}", serde_json::to_string_pretty(&rep)?); return Ok(()); } println!("raíces apagadas {}", rep.roots.join(" ")); println!( "clausura {} símbolo(s) ({} visibles en menuconfig)", rep.closure.len(), rep.visible ); for s in rep.closure.iter().take(limit) { let prompt = t .symbols .get(s) .and_then(|x| x.prompt()) .unwrap_or("(sin prompt: sólo entra por select)"); println!(" · {s} — {prompt}"); } if rep.closure.len() > limit { println!(" … y {} más", rep.closure.len() - limit); } println!(); if rep.leaks.is_empty() { println!("fugas por `select` ninguna — la clausura es cerrada"); } else { println!( "fugas por `select` {} arista(s) desde fuera reencienden {} símbolo(s) de la clausura", rep.leaks.len(), rep.leaked_targets ); println!(" (`select` FUERZA el destino ignorando sus `depends on`: apagar la raíz no basta)"); let mut por_destino: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); for l in &rep.leaks { por_destino .entry(&l.target) .or_default() .push(&l.selector); } for (target, selectors) in por_destino.iter().take(limit) { println!(" · {target} ← {}", selectors.join(", ")); } if por_destino.len() > limit { println!(" … y {} destino(s) más", por_destino.len() - limit); } } if let Some(f) = &rep.fixpoint { println!(); println!( "punto fijo {} símbolo(s) tras {} ronda(s) — {:+} sobre la clausura estricta", f.closure_len, f.rounds, f.grew_by as i64 ); if f.remaining_leaks.is_empty() { println!(" cerró: ninguna fuga sobrevive"); } else { println!( " ⚠ NO cerró: {} fuga(s) siguen abiertas tras el tope de rondas", f.remaining_leaks.len() ); } if f.added.is_empty() { println!(" no hizo falta apagar nada de más"); } else { println!(" precio: {} símbolo(s) apagados de más", f.added.len()); for (r, s) in f.added.iter().take(limit) { println!(" · ronda {r}: {s}"); } if f.added.len() > limit { println!(" … y {} más", f.added.len() - limit); } } } if let Some(a) = &rep.against { println!(); println!("contraste contra {}", a.config); println!(" apagados explícitos {}", a.off_in_config); println!(" ausentes {}", a.absent_in_config); println!(" ENCENDIDOS {}", a.on_in_config.len()); for s in a.on_in_config.iter().take(limit) { println!(" · {s}"); } if a.on_in_config.len() > limit { println!(" … y {} más", a.on_in_config.len() - limit); } } Ok(()) } // --------------------------------------------------------------------------------------------- // probe — el modo reversa (#9) // --------------------------------------------------------------------------------------------- fn probe( config: Option<&Path>, kroot: &Path, catalog: &Path, with_hw: bool, json: bool, ) -> Result<()> { use takana_core::kernel::reverse::{BundleState, HwSignal}; let cfg_path = match config { Some(p) => p.to_path_buf(), None => running_config_path()?, }; let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?); if cfg.is_empty() { anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display()); } let tree = load_tree(kroot)?; let cat = takana_core::kernel::Catalog::load(catalog)?; let hw = with_hw.then(takana_core::kernel::Hardware::probe_local); let rep = takana_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref()); if json { println!("{}", serde_json::to_string_pretty(&rep)?); return Ok(()); } println!("config {}", cfg_path.display()); if let Some(k) = &rep.kernel { println!("kernel {k}"); } // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo. if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) { if let (Some(a), Some(b)) = (serie(banner), serie(rev)) { if a != b { println!( " ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \ la {b}: las clausuras son aproximadas" ); } } } println!("símbolos {} declarados", rep.config_symbols); if let Some(h) = &hw { println!("huella {}", rep.fingerprint); if let Some(m) = &h.cpu_model { println!(" {m}"); } let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")] .into_iter() .flatten() .cloned() .collect::>() .join(" "); if !maquina.is_empty() { println!(" {maquina}"); } println!( " {} dispositivos PCI · {} drivers bindeados", h.pci.len(), h.bound_drivers.len() ); for g in &h.gaps { println!(" ⚠ {g}"); } } println!(); for b in &rep.bundles { let on = b.on_builtin + b.on_module; let estado = match b.state { BundleState::Aplicado => "APLICADO ", BundleState::Parcial => "parcial ", BundleState::Sin => "sin ", }; let hwtxt = match &b.hardware { HwSignal::SinSeñal => String::new(), HwSignal::Ausente => " · hardware ausente".to_string(), HwSignal::Presente { evidencia } => { format!(" · ⚠ HARDWARE PRESENTE ({})", evidencia.len()) } }; println!( "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}", b.id, b.closure_len ); if let HwSignal::Presente { evidencia } = &b.hardware { for e in evidencia.iter().take(4) { println!(" {e}"); } } if !b.unknown_symbols.is_empty() { println!( " ⚠ símbolos que este kernel no tiene: {}", b.unknown_symbols.join(", ") ); } if !b.undeclared_leaks.is_empty() { println!( " ⚠ {} fuga(s) `select` sin declarar en el catálogo", b.undeclared_leaks.len() ); } } println!(); println!( "resumen {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa", rep.bundles.len(), rep.bundles .iter() .filter(|b| b.state == BundleState::Aplicado) .count(), rep.applicable_savings ); if rep.contradictions > 0 { println!( " ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente", rep.contradictions ); } println!(" (detectar sirve para contradecir, nunca para podar solo)"); Ok(()) } /// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`, /// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna. fn serie(texto: &str) -> Option { for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) { let mut it = tok.split('.'); if let (Some(a), Some(b)) = (it.next(), it.next()) { if !a.is_empty() && !b.is_empty() { return Some(format!("{a}.{b}")); } } } None } // --------------------------------------------------------------------------------------------- // bundles — catálogo + control de frescura // --------------------------------------------------------------------------------------------- #[derive(Serialize)] struct BundleView { id: String, title: String, #[serde(skip_serializing_if = "Option::is_none")] help: Option, disable: Vec, close_leaks: Vec, closure_len: usize, visible: usize, unknown_symbols: Vec, undeclared_leaks: Vec, accepted_leaks: usize, } #[derive(Serialize)] struct BundlesReport { catalog: String, #[serde(skip_serializing_if = "Option::is_none")] reviewed_against: Option, bundles: Vec, knobs: Vec, stale: bool, } #[derive(Serialize)] struct KnobView { id: String, title: String, side: takana_core::kernel::Side, enable: Vec, disable: Vec, #[serde(skip_serializing_if = "Option::is_none")] recipe_field: Option, unknown_symbols: Vec, } fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> { let tree = load_tree(kroot)?; let cat = takana_core::kernel::Catalog::load(catalog)?; let mut views = Vec::new(); for b in &cat.bundles { let mut roots = b.disable.clone(); roots.extend(b.close_leaks.iter().cloned()); let unknown: Vec = roots .iter() .chain(b.enable.iter()) .filter(|s| !tree.symbols.contains_key(*s)) .cloned() .collect(); let cl = tree.closure_off(&roots); let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect(); let undeclared: Vec<_> = tree .select_leaks(&cl) .into_iter() .filter(|l| !aceptadas.contains(l.target.as_str())) .collect(); views.push(BundleView { id: b.id.clone(), title: b.title.clone(), help: b.help.clone(), disable: b.disable.clone(), close_leaks: b.close_leaks.clone(), closure_len: cl.len(), visible: cl .iter() .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible())) .count(), unknown_symbols: unknown, undeclared_leaks: undeclared, accepted_leaks: b.accept_leaks.len(), }); } let knobs: Vec = cat .knobs .iter() .map(|k| KnobView { id: k.id.clone(), title: k.title.clone(), side: k.side, enable: k.enable.clone(), disable: k.disable.clone(), recipe_field: k.recipe_field.clone(), unknown_symbols: k .enable .iter() .chain(k.disable.iter()) .filter(|s| !tree.symbols.contains_key(*s)) .cloned() .collect(), }) .collect(); let stale = views .iter() .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty()) || knobs.iter().any(|k| !k.unknown_symbols.is_empty()); let rep = BundlesReport { catalog: catalog.display().to_string(), reviewed_against: cat.reviewed_against.clone(), bundles: views, knobs, stale, }; if json { println!("{}", serde_json::to_string_pretty(&rep)?); } else { println!("catálogo {}", rep.catalog); if let Some(r) = &rep.reviewed_against { println!("revisado contra {r}"); } println!("árbol {}", kroot.display()); println!(); println!("NIVEL 1 — aserciones del dueño de la máquina"); for v in &rep.bundles { println!( " {:<26} {:>5} símbolos ({} visibles) ← {}", v.id, v.closure_len, v.visible, v.disable.join(" ") ); println!(" {}", v.title); if !v.close_leaks.is_empty() { println!(" fugas cerradas: {}", v.close_leaks.join(", ")); } if v.accepted_leaks > 0 { println!(" fugas aceptadas a sabiendas: {}", v.accepted_leaks); } if !v.unknown_symbols.is_empty() { println!( " ⚠ NO EXISTEN en este árbol: {}", v.unknown_symbols.join(", ") ); } for l in v.undeclared_leaks.iter().take(6) { println!(" ⚠ fuga sin declarar: {} ← {}", l.target, l.selector); } if v.undeclared_leaks.len() > 6 { println!(" ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6); } } println!(); println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)"); for k in &rep.knobs { let lado = match k.side { takana_core::kernel::Side::Kconfig => "kconfig", takana_core::kernel::Side::Recipe => "receta ", }; let que = match k.side { takana_core::kernel::Side::Kconfig => { let mut v: Vec = k.enable.iter().map(|s| format!("+{s}")).collect(); v.extend(k.disable.iter().map(|s| format!("-{s}"))); v.join(" ") } takana_core::kernel::Side::Recipe => { k.recipe_field.clone().unwrap_or_default() } }; println!(" [{lado}] {:<26} {que}", k.id); println!(" {}", k.title); if !k.unknown_symbols.is_empty() { println!( " ⚠ NO EXISTEN en este árbol: {}", k.unknown_symbols.join(", ") ); } } println!(); if rep.stale { println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)"); } else { println!("✓ el catálogo está al día con este árbol"); } } if check && rep.stale { anyhow::bail!("catálogo desactualizado"); } Ok(()) } // --------------------------------------------------------------------------------------------- // plan / diff-back // --------------------------------------------------------------------------------------------- #[allow(clippy::too_many_arguments)] fn plan( recipe_path: &Path, bundle_ids: &[String], knob_ids: &[String], kroot: &Path, catalog: &Path, out: Option<&Path>, recipe_out: Option<&Path>, store: &str, json: bool, ) -> Result<()> { if bundle_ids.is_empty() && knob_ids.is_empty() { anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob"); } let base = takana_core::Recipe::load_from_path(recipe_path)?; let tree = load_tree(kroot)?; let cat = takana_core::kernel::Catalog::load(catalog)?; // Las recetas de kernel del repo NO son todas la misma versión (`linux`/`linux-metal` van por // 6.16.12; `linux-metal-dual`/`linux-generic` por 7.1.2). Planear una contra el árbol de la otra // calcularía clausuras sobre símbolos que ahí no existen, y saldría SIN RUIDO. Como el plan // determina un artefacto, acá se falla en vez de avisar. match tree.version.as_deref() { Some(v) if v != base.version => anyhow::bail!( "la receta «{}» es del kernel {} y el árbol de {} es el {v}.\n\ Las clausuras saldrían calculadas sobre otro juego de símbolos. Pasá el árbol correcto \ con --kconfig.", base.name, base.version, kroot.display() ), None => eprintln!( "aviso: {} no trae el Makefile de arriba, así que no puedo comprobar que sea el kernel \ {} de la receta «{}»", kroot.display(), base.version, base.name ), Some(_) => {} } let mut p = takana_core::kernel::plan::make_plan( &base, &recipe_path.display().to_string(), &cat, &tree, bundle_ids, knob_ids, )?; // El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir // «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se // dice que no se pudo en vez de emitir un hash inventado. let derived = takana_core::kernel::plan::derive_recipe(&base, &p); match takana_core::Store::open(Path::new(store)) .map_err(anyhow::Error::from) .and_then(|s| { let cfg = takana_build::BuildConfig::from_env_or_defaults(s.root()); let lab = takana_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs) .map_err(anyhow::Error::from)?; takana_build::artifact_hash(&derived, &s, &lab).map_err(anyhow::Error::from) }) { Ok(h) => p.artifact_hash = Some(h.to_string()), Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"), } if let Some(o) = out { if let Some(d) = o.parent() { std::fs::create_dir_all(d).ok(); } std::fs::write(o, serde_json::to_string_pretty(&p)?) .with_context(|| format!("escribiendo {}", o.display()))?; } if let Some(ro) = recipe_out { if let Some(d) = ro.parent() { std::fs::create_dir_all(d).ok(); } let cabecera = format!( "# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\ # base {}\n# bundles {}\n# perillas {}\n\ # Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\ # resuelven ({}).\n", p.base_recipe, if p.bundles.is_empty() { "—".into() } else { p.bundles.join(" ") }, if p.knobs.is_empty() { "—".into() } else { p.knobs.join(" ") }, recipe_path .parent() .map(|d| d.display().to_string()) .unwrap_or_default(), ); let cuerpo = takana_core::kernel::plan::render_recipe_toml(&derived)?; std::fs::write(ro, format!("{cabecera}{cuerpo}")) .with_context(|| format!("escribiendo {}", ro.display()))?; } if json { println!("{}", serde_json::to_string_pretty(&p)?); return Ok(()); } println!("base {} ({})", p.base_name, p.base_recipe); println!("derivada {}", p.derived_name); match &p.artifact_hash { Some(h) => println!("hash {h}"), None => println!("hash (no calculado)"), } println!( "selección {} bundle(s), {} perilla(s)", p.bundles.len(), p.knobs.len() ); println!(); println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size); println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)"); for s in &p.symbols { let signo = match s.action { takana_core::kernel::plan::Action::Enable => "+", takana_core::kernel::plan::Action::Disable => "-", }; println!(" {signo}{:<24} ← {} ({})", s.symbol, s.from, s.reason); } if !p.unknown_symbols.is_empty() { println!(); println!("⚠ {} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len()); for s in &p.unknown_symbols { println!(" {} ← {}", s.symbol, s.from); } } if !p.recipe_side.is_empty() { println!(); println!("perillas del lado RECETA (declaradas, este plan no las aplica):"); for k in &p.recipe_side { println!(" {:<24} {} — {}", k.id, k.recipe_field, k.title); } } println!(); if let Some(o) = out { println!("plan → {}", o.display()); } if let Some(ro) = recipe_out { println!("receta → {}", ro.display()); } Ok(()) } fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> { let p: takana_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()))?; let cfg = KernelConfig::parse(&read_maybe_gz(config)?); if cfg.is_empty() { anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display()); } let d = takana_core::kernel::diff_back(&p, &cfg); if json { println!("{}", serde_json::to_string_pretty(&d)?); } else { println!("plan {} ({})", p.derived_name, plan_path.display()); println!("config {}", config.display()); println!("cumplidos {}", d.honored.len()); // Kconfig no emite un símbolo cuyas dependencias no se cumplen: para un `-d`, que no // aparezca ES el resultado buscado. Contarlo como fallo reportaba planes perfectos como // rotos — lo aprendí corriendo un olddefconfig de verdad. println!("apagados por dependencia {} (no aparecen porque cayeron con su raíz)", d.off_by_dependency.len()); if !d.never_existed.is_empty() { println!("no existen en este árbol {}", d.never_existed.len()); for s in &d.never_existed { println!(" {s} — la bandera no hace nada"); } } println!("INCUMPLIDOS {}", d.violated.len()); for v in &d.violated { println!( " {} — se pidió {:?} y quedó {} (lo pidió {})", v.symbol, v.asked, v.got, v.from ); } } if !d.is_clean() { anyhow::bail!("el .config no honra el plan"); } Ok(()) } // --------------------------------------------------------------------------------------------- // gate / hw // --------------------------------------------------------------------------------------------- fn hw(out: Option<&Path>) -> Result<()> { let h = takana_core::kernel::Hardware::probe_local(); let texto = serde_json::to_string_pretty(&h)?; match out { Some(o) => { if let Some(d) = o.parent() { std::fs::create_dir_all(d).ok(); } std::fs::write(o, &texto).with_context(|| format!("escribiendo {}", o.display()))?; println!("huella {}", h.fingerprint()); println!("hardware → {}", o.display()); println!(" {} PCI · {} USB · {} drivers bindeados", h.pci.len(), h.usb.len(), h.bound_drivers.len()); for g in &h.gaps { println!(" ⚠ {g}"); } } None => println!("{texto}"), } Ok(()) } #[allow(clippy::too_many_arguments)] fn gate( plan_path: Option<&Path>, config: Option<&Path>, baseline: Option<&Path>, objective: &str, devices: Option<&Path>, kroot: &Path, catalog: &Path, json: bool, ) -> Result<()> { 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 = takana_core::kernel::Catalog::load(catalog)?; let tree = load_tree(kroot)?; let modmap = takana_core::kernel::ModMap::parse(kroot) .with_context(|| format!("leyendo los Makefiles de {}", kroot.display()))?; if modmap.is_empty() { anyhow::bail!( "no encontré una sola regla `obj-$(CONFIG_…)` en {}: sin los Makefiles del kernel el \ gate no puede mapear driver → símbolo, y un gate que no puede mirar no es un gate.", kroot.display() ); } let hardware = match devices { Some(d) => serde_json::from_str::( &std::fs::read_to_string(d).with_context(|| format!("leyendo {}", d.display()))?, ) .with_context(|| format!("{} no es un volcado de `hammer kernel hw`", d.display()))?, None => takana_core::kernel::Hardware::probe_local(), }; 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()); takana_core::kernel::gate::run_against_config( &base, &nuevo, &cat, &modmap, &hardware.bound_drivers, objective, p.as_ref(), Some(&tree), )? } None => takana_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(""); if !r.plan.is_empty() { println!("plan {}", r.plan); } println!("objetivo {} — {obj}", r.objective); println!( "hardware {} ({} drivers bindeados, {} reglas obj- en {} Makefiles)", devices.map(|d| d.display().to_string()).unwrap_or_else(|| "esta máquina".into()), r.drivers_in_use, modmap.len(), modmap.files ); println!(); if r.regressions.is_empty() { println!("✓ ningún dispositivo en uso se queda sin driver"); } else { println!("✗ {} REGRESIÓN(ES) — dispositivos en uso que se quedarían sin driver:", r.regressions.len()); for l in &r.regressions { // 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() { println!(); println!("{} pérdida(s) AUTORIZADAS por el objetivo:", r.allowed.len()); for l in &r.allowed { println!(" {:<20} ← {}", l.driver, l.blamed_on.join(", ")); } } if !r.unmapped.is_empty() { println!(); println!( "⚠ {} driver(s) SIN COMPROBAR (no se pudieron mapear a ningún CONFIG_*):", r.unmapped.len() ); println!(" {}", r.unmapped.join(" ")); println!(" no cuentan como aprobados: el gate no los miró."); } } if r.verdict == takana_core::kernel::Verdict::Bloquea { anyhow::bail!("el gate bloquea"); } Ok(()) } // --------------------------------------------------------------------------------------------- // contract — el contrato de capacidades (SDD 25 §4) // --------------------------------------------------------------------------------------------- /// Nombre del artefacto si la ruta cae dentro de un directorio `<64 hex>-` del store. /// /// Es lo que permite que `--sealed` sepa qué perfil le toca a cada kernel sin que nadie lo escriba /// dos veces. Fuera del store devuelve `None` y el perfil hay que pasarlo a mano. fn artifact_of(path: &Path) -> Option { for a in path.ancestors() { let name = a.file_name()?.to_string_lossy().to_string(); if let Some((hash, art)) = name.split_once('-') { if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) { return Some(art.to_string()); } } } None } /// Dónde viven las recetas de kernel cuando nadie pasa `--recipes`. La segunda es la de las /// recetas DERIVADAS que emite `takana kernel plan` (p. ej. `linux-gioser`). const RECIPE_DEFAULTS: [&str; 2] = ["recipes", "docs/state/kernel-plans"]; /// Nombre del directorio del store (`<64 hex>-`) que contiene esta ruta. fn store_dir_of(path: &Path) -> Option { for a in path.ancestors() { let name = a.file_name()?.to_string_lossy().to_string(); if let Some((hash, _)) = name.split_once('-') { if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) { return Some(name); } } } None } /// ¿Resuelven las `deps.build` de esta receta desde `dir`? Misma regla que el build: hermana, y /// si no, en el directorio padre (que es como una receta de `recipes/incoming-*/` alcanza el /// catálogo). fn deps_resuelven_desde(dir: &Path, deps: &[String]) -> bool { deps.iter().all(|d| { let f = format!("{d}.toml"); dir.join(&f).exists() || dir.parent().is_some_and(|p| p.join(&f).exists()) }) } /// La receta cuyo campo `name` es `artifact`, buscada en `dirs`. /// /// Primero por convención (`/.toml`) y sólo si eso no da, barriendo el directorio: /// **el artefacto se nombra por el campo `name`, no por el fichero**, y en este corpus hay recetas /// donde los dos difieren. fn receta_de(artifact: &str, dirs: &[PathBuf]) -> Option { for d in dirs { let directo = d.join(format!("{artifact}.toml")); if let Ok(r) = takana_core::Recipe::load_from_path(&directo) { if r.name == artifact { return Some(con_catalogo(r, dirs)); } } let Ok(rd) = std::fs::read_dir(d) else { continue }; for e in rd.flatten() { let p = e.path(); if p.extension().is_some_and(|x| x == "toml") { if let Ok(r) = takana_core::Recipe::load_from_path(&p) { if r.name == artifact { return Some(con_catalogo(r, dirs)); } } } } } None } /// Le presta un catálogo a una receta que vive fuera de uno. /// /// Las recetas DERIVADAS (`takana kernel plan --recipe-out`) se guardan a propósito fuera de /// `recipes/`: dejarlas ahí las mete en el grafo compartido como deuda. Pero sus `deps.build` son /// las del catálogo padre, así que desde su directorio no resuelven y el hash no se puede calcular /// — y sin hash no hay vigencia que comparar. Acá se les apunta el `base_dir` al primer directorio /// de `dirs` donde sí resuelven, que es exactamente lo que hace el runbook cuando la copia a /// `recipes/incoming-kernel/` para construirla. /// /// NO se toca si la receta trae `patches`: `base_dir` también los resuelve, y moverlo los rompería /// en silencio. Ahí es mejor no saber la vigencia (se comprueba todo) que hashear otra cosa. fn con_catalogo(mut r: takana_core::Recipe, dirs: &[PathBuf]) -> takana_core::Recipe { if r.deps.build.is_empty() || deps_resuelven_desde(&r.base_dir, &r.deps.build) { return r; } if !r.source.patches.is_empty() { eprintln!( "aviso: «{}» vive fuera de un catálogo y trae patches ⇒ no le presto base_dir", r.name ); return r; } if let Some(d) = dirs.iter().find(|d| deps_resuelven_desde(d, &r.deps.build)) { r.base_dir = d.clone(); } r } /// Qué directorio del store le toca HOY a cada kernel del contrato: `artefacto → -`. /// /// POR QUÉ. El store guarda TODOS los sellados, no el último. Sin esta pregunta `--sealed` mide /// también los kernels que la receta de hoy ya no produce, y entonces el gate se queda rojo para /// siempre por artefactos que nadie va a volver a construir: **un portón que no puede ponerse /// verde deja de leerse, que es exactamente como se pierde un guardián**. Con ella, el superado se /// informa (sigue siendo cierto que le falta la capacidad: si un USB por ahí arranca ese kernel, /// sus Cards corren sin tope) pero no bloquea. /// /// Es la misma vigencia que `hammer hash --check`: la del ArtifactHash de la receta de hoy con el /// lab de hoy — o sea que un kernel sellado con otro rootfs también sale superado, y es correcto /// (SDD 25: el lab entra en el artefacto). fn dirs_vigentes( c: &takana_core::kernel::Contract, dirs: &[PathBuf], store: &str, ) -> Result> { let st = takana_core::Store::open(Path::new(store))?; let cfg = takana_build::BuildConfig::from_env_or_defaults(st.root()); let lab = takana_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)?; let mut out = std::collections::BTreeMap::new(); for t in &c.targets { let Some(r) = receta_de(&t.artifact, dirs) else { eprintln!( "aviso: no encontré la receta de «{}» en {} ⇒ sus sellados se comprueban todos", t.artifact, dirs.iter().map(|d| d.display().to_string()).collect::>().join(" ") ); continue; }; match takana_build::artifact_hash(&r, &st, &lab) { Ok(h) => { out.insert(t.artifact.clone(), h.store_dir_name(&r.name)); } Err(e) => eprintln!("aviso: no pude calcular el hash vigente de «{}»: {e}", t.artifact), } } Ok(out) } #[derive(Serialize)] struct ContractRun { contract: String, reports: Vec, /// Sellados que la receta de HOY ya no produce. Se informan y no bloquean: ver `dirs_vigentes`. #[serde(default)] superados: Vec, /// Configs que NO se comprobaron por no tener perfil declarado. No cuentan como aprobados. sin_perfil: Vec, /// Objetivos del contrato cuya receta de hoy NO tiene ni un sellado en el store. No se /// comprobó nada de ellos: se nombran para que la ausencia no pase por aprobado. #[serde(default)] sin_construir: Vec, } fn contract_cmd( configs: &[PathBuf], sealed: bool, recipes: &[PathBuf], profile: Option<&str>, contract: &Path, store: &str, list: bool, json: bool, ) -> Result<()> { use takana_core::kernel::contract::sealed_kernel_configs; use takana_core::kernel::Contract; let c = Contract::load(contract)?; if list { if json { println!("{}", serde_json::to_string_pretty(&c)?); return Ok(()); } println!("contrato {} (v{}", contract.display(), c.version); if let Some(r) = &c.reviewed_against { println!("revisado contra kernel {r}"); } println!(); let req = takana_core::kernel::contract::required_by(&c); for cap in &c.capabilities { let quien = req.get(cap.id.as_str()).map(|v| v.join(", ")).unwrap_or_default(); let quien = if quien.is_empty() { "—".to_string() } else { quien }; println!("{:<24} {}", cap.id, cap.title); println!("{:<24} símbolos: {}", "", cap.symbols.join(" ")); println!("{:<24} consumidor: {}", "", cap.consumer.trim().replace('\n', " ")); println!("{:<24} exigida por: {quien}", ""); println!(); } for p in &c.profiles { println!("perfil {:<17} {}", p.id, p.title); println!("{:<24} exige: {}", "", p.requires.join(" ")); println!("{:<24} quiere: {}", "", p.wants.join(" ")); } println!(); for t in &c.targets { println!("{:<24} → perfil {}", t.artifact, t.profile); } return Ok(()); } // Qué configs se miran, en orden: --sealed, --config, y si no hay nada, el kernel vivo. let mut objetivos: Vec<(String, Option, PathBuf)> = Vec::new(); // Vigencia: sólo tiene sentido en el barrido del store. Un `--config` a mano es una pregunta // explícita sobre ESE fichero y se comprueba siempre. let vigentes = if sealed { let dirs: Vec = if recipes.is_empty() { RECIPE_DEFAULTS.iter().map(PathBuf::from).filter(|d| d.is_dir()).collect() } else { recipes.to_vec() }; if dirs.is_empty() { eprintln!( "aviso: no hay dónde mirar las recetas ({}) ⇒ no puedo distinguir vigente de \ superado y comprueba TODO lo sellado", RECIPE_DEFAULTS.join(" ") ); None } else { match dirs_vigentes(&c, &dirs, store) { Ok(v) => Some(v), Err(e) => { // Callar acá sería fingir que todo está al día: si no se puede clasificar, se // comprueba todo (ruidoso) y se dice por qué. eprintln!("aviso: no pude resolver los artefactos vigentes ({e}) ⇒ comprueba TODO lo sellado"); None } } } } else { None }; if sealed { let s = sealed_kernel_configs(Path::new(store)); if s.is_empty() { anyhow::bail!( "no hay ningún kernel sellado con `boot/config-*` en {store}.\n\ Un kernel que no instale su config no se puede comprobar — y fingir que pasó \ sería el mismo fallo que este guardián viene a arreglar." ); } for (art, p) in s { objetivos.push((format!("{art} · {}", p.display()), Some(art), p)); } } for p in configs { objetivos.push((p.display().to_string(), artifact_of(p), p.clone())); } if objetivos.is_empty() { let p = running_config_path()?; objetivos.push((format!("kernel vivo · {}", p.display()), None, p)); } let mut run = ContractRun { contract: contract.display().to_string(), reports: Vec::new(), superados: Vec::new(), sin_perfil: Vec::new(), sin_construir: Vec::new(), }; for (label, artifact, path) in &objetivos { let cfg = takana_core::kernel::KernelConfig::parse(&read_maybe_gz(path)?); if cfg.is_empty() { anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", path.display()); } let prof = match profile { Some(p) => Some(p.to_string()), None => artifact .as_deref() .and_then(|a| c.profile_for(a)) .map(str::to_string), }; let Some(prof) = prof else { run.sin_perfil.push(label.clone()); continue; }; let rep = c.check(&cfg, &prof, label)?; let superado = match (&vigentes, artifact.as_deref(), store_dir_of(path)) { (Some(v), Some(art), Some(dir)) => v.get(art).is_some_and(|d| *d != dir), _ => false, }; if superado { run.superados.push(rep); } else { run.reports.push(rep); } } // Un objetivo del contrato cuya receta de hoy no tiene NINGÚN sellado no aparece en el // barrido — y no aparecer se lee como que no hay nada que objetar. Se nombra. if let Some(v) = &vigentes { let vistos: std::collections::BTreeSet = objetivos.iter().filter_map(|(_, _, p)| store_dir_of(p)).collect(); for (art, dir) in v { if !vistos.contains(dir) { run.sin_construir.push(format!("{art} · {dir}")); } } } if json { println!("{}", serde_json::to_string_pretty(&run)?); } else { imprimir_contrato(&run); } let bloquean = run .reports .iter() .filter(|r| r.verdict == takana_core::kernel::Verdict::Bloquea) .count(); if bloquean > 0 { anyhow::bail!("el contrato NO se cumple en {bloquean} kernel(es)"); } // Un objetivo sin sellado vigente no se comprobó, y no comprobar no es aprobar: es el fallo // de CLAUDE.md §3 —el vacío leído como presencia— en el propio guardián. if !run.sin_construir.is_empty() { anyhow::bail!( "{} objetivo(s) del contrato sin un sellado vigente: no se comprobó nada de ellos. \ Construí sus recetas antes de leer esto como aprobado.", run.sin_construir.len() ); } Ok(()) } fn imprimir_contrato(run: &ContractRun) { println!("contrato {}", run.contract); println!(); for r in &run.reports { let marca = if r.verdict == takana_core::kernel::Verdict::Bloquea { "✗" } else { "✓" }; println!("{marca} {}", r.config); println!(" perfil {} ({} capacidad(es) miradas)", r.profile, r.checked); if let Some(k) = &r.kernel { println!(" kernel {k}"); } for m in &r.missing_required { println!(" ✗ FALTA {} — {}", m.id, m.title); if !m.explicit_off.is_empty() { println!(" apagado a mano: {}", m.explicit_off.join(" ")); } if !m.absent.is_empty() { println!( " ni nombrado en el .config: {} (¿renombrado entre versiones?)", m.absent.join(" ") ); } println!(" lo usa: {}", m.consumer.trim().replace('\n', " ")); if let Some(s) = &m.silent { println!(" cómo falla hoy: {}", s.trim().replace('\n', " ")); } } for m in &r.missing_wanted { println!( " · falta (no bloquea) {} — {} [{}]", m.id, m.title, [m.explicit_off.clone(), m.absent.clone()].concat().join(" ") ); } for u in &r.unknown { println!( " ⚠ el perfil nombra «{}», que el contrato no define: NO se comprobó", u.id ); } if r.missing_required.is_empty() { println!(" ✓ las {} capacidades exigidas están", r.ok.iter().filter(|o| o.required).count()); } println!(); } let malos = run .reports .iter() .filter(|r| r.verdict == takana_core::kernel::Verdict::Bloquea) .count(); if !run.superados.is_empty() { let sup_malos = run .superados .iter() .filter(|r| r.verdict == takana_core::kernel::Verdict::Bloquea) .count(); println!( "· {} sellado(s) SUPERADO(s) por la receta de hoy — no bloquean, {} con faltas:", run.superados.len(), sup_malos ); for r in &run.superados { let faltan: Vec<&str> = r.missing_required.iter().map(|m| m.id.as_str()).collect(); println!( " {} {}", r.config, if faltan.is_empty() { "cumplía".to_string() } else { format!("le falta: {}", faltan.join(" ")) } ); } println!( " Siguen siendo ciertos: si una máquina arranca uno de éstos, sus Cards corren con lo \ que ese kernel traiga. Se reconstruyen o se podan (`scripts/store-gc.sh`)." ); println!(); } println!( "resumen {} de {} config(s) VIGENTES cumplen su perfil{}{}", run.reports.len() - malos, run.reports.len(), if run.superados.is_empty() { String::new() } else { format!(" · {} superado(s)", run.superados.len()) }, if run.sin_perfil.is_empty() { String::new() } else { format!(" · {} sin comprobar", run.sin_perfil.len()) } ); println!(); if !run.sin_construir.is_empty() { println!( "⚠ {} objetivo(s) del contrato SIN UN SELLADO VIGENTE — no se comprobó nada de ellos:", run.sin_construir.len() ); for s in &run.sin_construir { println!(" {s}"); } println!(" no cuentan como aprobados: el barrido no tuvo qué mirar. Construí sus recetas."); println!(); } if !run.sin_perfil.is_empty() { println!("⚠ {} config(s) SIN COMPROBAR (ningún perfil declarado):", run.sin_perfil.len()); for s in &run.sin_perfil { println!(" {s}"); } println!(" no cuentan como aprobados. Declaralos en [[target]] o pasá --profile."); } } /// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`). /// /// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de /// diagnóstico, no del camino de build, y el core queda sin dependencias nuevas. pub fn read_maybe_gz(p: &Path) -> Result { if p.extension().is_some_and(|e| e == "gz") { let out = std::process::Command::new("gzip") .arg("-dc") .arg(p) .output() .with_context(|| format!("descomprimiendo {}", p.display()))?; if !out.status.success() { anyhow::bail!( "gzip -dc {} falló: {}", p.display(), String::from_utf8_lossy(&out.stderr).trim() ); } return Ok(String::from_utf8_lossy(&out.stdout).into_owned()); } std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display())) } #[cfg(test)] mod tests { use super::*; #[test] fn store_dir_of_saca_el_directorio_y_artifact_of_el_nombre() { let hex = "a".repeat(64); let p = PathBuf::from(format!("./store/{hex}-linux-metal/boot/config-6.16.12-metal")); assert_eq!(store_dir_of(&p).as_deref(), Some(format!("{hex}-linux-metal").as_str())); assert_eq!(artifact_of(&p).as_deref(), Some("linux-metal")); // Fuera del store no hay vigencia que comparar: mejor None que una comparación inventada. assert_eq!(store_dir_of(Path::new("/boot/config-7.1.4")), None); } #[test] fn serie_de_las_dos_formas_que_se_cruzan() { assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1")); assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16")); assert_eq!(serie("sin versión"), None); } } // ══ A/B del kernel (ADR 0017 §2) ═══════════════════════════════════════════════════════════════ use crate::efi_boot; use crate::kernel_ab::{self, Veredicto}; use anyhow::bail; /// Localiza el bzImage de un artefacto del store, aceptando nombre de directorio, hash o ruta. fn bzimage_de(artefacto: &str, store: &str) -> Result<(PathBuf, String)> { let directo = PathBuf::from(artefacto); let dir = if directo.is_dir() { directo } else { let s = PathBuf::from(store); let exacto = s.join(artefacto); if exacto.is_dir() { exacto } else { // Por prefijo de hash o por nombre de receta: útil a mano, y si hay varios se dice // en vez de elegir por orden de lectura del directorio. let pat = artefacto.trim_start_matches("b3:"); let mut cand: Vec = std::fs::read_dir(&s) .with_context(|| format!("no puedo leer el store {}", s.display()))? .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| { let n = p.file_name().unwrap_or_default().to_string_lossy().to_string(); p.is_dir() && (n.starts_with(pat) || n.split_once('-').map(|(_, r)| r == pat).unwrap_or(false)) }) .collect(); cand.sort(); match cand.len() { 1 => cand.remove(0), 0 => bail!("no encuentro el artefacto «{artefacto}» en {}", s.display()), n => bail!( "«{artefacto}» casa con {n} artefactos; usá el nombre completo:\n {}", cand.iter() .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string()) .collect::>() .join("\n ") ), } } }; let bz = dir.join("boot/bzImage"); if !bz.is_file() { bail!("{} no trae boot/bzImage: ¿es un artefacto de kernel?", dir.display()); } let nombre = dir.file_name().unwrap_or_default().to_string_lossy().to_string(); Ok((bz, nombre)) } fn ab_stage( artefacto: &str, store: &str, esp: &Path, efivars: &Path, tries: u32, state: &Path, dry_run: bool, ) -> Result<()> { let (bz, nombre) = bzimage_de(artefacto, store)?; let tam = std::fs::metadata(&bz)?.len(); println!("candidato: {nombre}"); println!(" bzImage: {} ({:.1} MiB)", bz.display(), tam as f64 / 1048576.0); if !esp.is_dir() { bail!( "la ESP no está montada en {}: montala primero\n mount -t vfat {}", esp.display(), esp.display() ); } if !efi_boot::hay_firmware_efi(efivars) { bail!( "sin NVRAM en {}: sin firmware UEFI no hay BootNext, y sin BootNext no hay A/B", efivars.display() ); } // Que entre ANTES de copiar: una ESP llena a media copia deja un candidato truncado, y un // candidato truncado no falla — arranca y muere en el firmware. let destino = kernel_ab::en_esp(esp, kernel_ab::RUTA_CANDIDATO); println!(" ranura: {} (propia; el estable no se toca)", destino.display()); if dry_run { println!("--dry-run: no se copió nada ni se tocó la NVRAM"); return Ok(()); } let n = kernel_ab::instalar_candidato(&bz, esp, kernel_ab::RUTA_CANDIDATO)?; println!(" ✓ copiado ({n} bytes) de forma atómica (tmp + rename)"); // Entrada NVRAM propia del candidato, apuntando a SU ranura. let (disco, parte_n) = { let discos = efi_boot::block_disks(Path::new("/sys/block")).unwrap_or_default(); let esps = efi_boot::find_esps(&discos); match esps.len() { 1 => esps[0].clone(), 0 => bail!("no encuentro ninguna ESP para apuntar la entrada de arranque"), n => bail!("hay {n} ESP: no voy a adivinar cuál es la de este arranque"), } }; let parte = efi_boot::read_partition(&disco, parte_n)?; let r = efi_boot::reconcile( efivars, &parte, kernel_ab::RUTA_CANDIDATO, kernel_ab::ETIQUETA_CANDIDATO, false, )?; println!(" ✓ entrada Boot{:04X} «{}»", r.slot, kernel_ab::ETIQUETA_CANDIDATO); // OJO: `reconcile` deja al candidato PRIMERO en BootOrder, y eso no es lo que queremos — el // candidato tiene que arrancar UNA vez, no volverse el default. Se repone el orden y se usa // BootNext, que es de un solo uso. if r.orden_cambiado { let ruta_orden = efivars.join(format!("BootOrder-{}", efi_boot::GLOBAL_GUID)); efi_boot::write_var( &ruta_orden, efi_boot::ATTRS, &efi_boot::serialize_boot_order(&r.orden_antes), )?; println!(" ✓ BootOrder repuesto: el candidato NO se vuelve el default"); } kernel_ab::poner_boot_next(efivars, r.slot)?; println!(" ✓ BootNext = Boot{:04X} (un solo uso)", r.slot); let mut e = kernel_ab::leer_estado(state); e.candidato = Some(kernel_ab::Candidato { hash: nombre.split_once('-').map(|(h, _)| format!("b3:{h}")).unwrap_or(nombre.clone()), ruta: kernel_ab::RUTA_CANDIDATO.into(), slot: r.slot, intentos: tries, }); kernel_ab::escribir_estado(state, &e)?; println!("\nEl PRÓXIMO arranque usa el candidato. Si no arranca, el firmware vuelve solo al"); println!("estable — BootNext es de un solo uso. Si arranca y anda: `takana kernel confirm`."); Ok(()) } fn ab_status(efivars: &Path, state: &Path) -> Result<()> { let e = kernel_ab::leer_estado(state); let actual = kernel_ab::boot_current(efivars); match actual { Some(a) => println!("arrancamos por Boot{a:04X}"), None => println!("el firmware no publica BootCurrent (o no hay NVRAM)"), } if let Some(n) = kernel_ab::boot_next(efivars) { println!("BootNext pendiente: Boot{n:04X} (el próximo arranque irá por ahí)"); } match kernel_ab::evaluar(&e, actual) { Veredicto::SinCandidato => { println!("sin candidato: el kernel que corre es el estable"); // El firmware es la fuente de verdad, no nuestro fichero. Si el estado se perdió —el // caso real es que viviera en algo que no persiste— pero la NVRAM todavía tiene la // entrada del candidato, hay basura que nadie va a limpiar porque nadie sabe que está. // Medido: con el estado en un tmpfs, `stage` + reboot dejaba exactamente esto. let huerfanas: Vec = efi_boot::list_entries(efivars) .unwrap_or_default() .into_iter() .filter(|(_, lo)| lo.description == kernel_ab::ETIQUETA_CANDIDATO) .map(|(n, _)| n) .collect(); if !huerfanas.is_empty() { println!("⚠ pero la NVRAM tiene {} entrada(s) «{}» sin estado que las respalde:", huerfanas.len(), kernel_ab::ETIQUETA_CANDIDATO); for n in &huerfanas { println!(" Boot{n:04X}"); } println!(" El fichero de estado se perdió o nunca persistió. `kernel rollback` no"); println!(" puede limpiarlas sin saber cuál era: borralas a mano o volvé a hacer `stage`."); } } Veredicto::EnPrueba { slot, intentos_restantes } => { println!("⏳ EN PRUEBA: Boot{slot:04X} es el candidato y es el que está corriendo"); println!(" le quedan {intentos_restantes} arranque(s) sin confirmar"); println!(" → `takana kernel confirm` si anda, `takana kernel rollback` si no"); } Veredicto::CandidatoFallo { slot } => { println!("✗ el candidato Boot{slot:04X} NO arrancó: esta sesión vino del estable"); println!(" → `takana kernel rollback` lo descarta (o `stage` otra vez para reintentar)"); } Veredicto::CandidatoAgotado { slot } => { println!("✗ el candidato Boot{slot:04X} agotó sus intentos sin que nadie lo confirmara"); println!(" → `takana kernel rollback`"); } } Ok(()) } fn ab_confirm(esp: &Path, efivars: &Path, state: &Path) -> Result<()> { let mut e = kernel_ab::leer_estado(state); let Some(c) = e.candidato.clone() else { println!("no hay candidato que confirmar"); return Ok(()); }; let actual = kernel_ab::boot_current(efivars); // Confirmar un kernel que NO es el que está corriendo sería promover algo que nadie probó, que // es exactamente lo contrario de para qué existe esto. if let Some(a) = actual { if a != c.slot { bail!( "esta sesión arrancó por Boot{a:04X} y el candidato es Boot{:04X}: no se confirma \ un kernel que no es el que está corriendo", c.slot ); } } let cand = kernel_ab::en_esp(esp, &c.ruta); let estable = kernel_ab::en_esp(esp, kernel_ab::RUTA_ESTABLE); let dir = estable.parent().context("ruta estable sin directorio")?; let tmp = dir.join("takana-prom.tmp"); std::fs::copy(&cand, &tmp) .with_context(|| format!("copiando {} → {}", cand.display(), tmp.display()))?; std::fs::rename(&tmp, &estable)?; println!("✓ promovido: el candidato es ahora el kernel estable ({})", estable.display()); // La ruta fallback también, que es la otra mitad del seguro del ADR 0018 §3. let fallback = kernel_ab::en_esp(esp, "/EFI/BOOT/BOOTX64.EFI"); if fallback.exists() { let t2 = dir.join("takana-fb.tmp"); if std::fs::copy(&cand, &t2).is_ok() && std::fs::rename(&t2, &fallback).is_ok() { println!("✓ y la ruta fallback, que apunta al mismo kernel (ADR 0018 §3)"); } } let _ = std::fs::remove_file(&cand); kernel_ab::borrar_boot_next(efivars); e.estable_hash = Some(c.hash.clone()); e.candidato = None; kernel_ab::escribir_estado(state, &e)?; println!(" estable: {}", c.hash); Ok(()) } fn ab_rollback(esp: &Path, efivars: &Path, state: &Path) -> Result<()> { let mut e = kernel_ab::leer_estado(state); let Some(c) = e.candidato.clone() else { println!("no hay candidato que descartar"); return Ok(()); }; kernel_ab::borrar_boot_next(efivars); let cand = kernel_ab::en_esp(esp, &c.ruta); if cand.exists() { let _ = std::fs::remove_file(&cand); println!("✓ borrada la ranura del candidato ({})", cand.display()); } // La entrada NVRAM del candidato se va también: dejarla sería ofrecer en el menú del firmware // un arranque que apunta a un fichero que ya no está. let var = efi_boot::boot_var_path(efivars, c.slot); if var.exists() { let _ = std::fs::remove_file(&var); println!("✓ borrada la entrada Boot{:04X}", c.slot); } e.candidato = None; kernel_ab::escribir_estado(state, &e)?; println!("el kernel estable sigue siendo el que estaba. No se tocó."); Ok(()) } fn kexec_cmd( artefacto: &str, store: &str, initrd: Option<&Path>, cmdline: &str, load_only: bool, jump: bool, ) -> Result<()> { let (bz, nombre) = bzimage_de(artefacto, store)?; println!("kexec: {nombre}"); println!(" kernel: {}", bz.display()); match initrd { Some(p) => println!(" initrd: {}", p.display()), None => println!(" initrd: (ninguno)"), } println!(" cmdline: {cmdline}"); // Ojo: esto detecta si el SYSCALL existe, no si podemos usarlo. Un kernel con el flag puesto y // un usuario sin privilegios pasa esta puerta y falla después con EPERM, que es lo correcto: // mejor el error real del syscall que una sospecha nuestra. if !kernel_ab::kexec_file_disponible() { bail!( "este kernel no implementa kexec_file_load (falta CONFIG_KEXEC_FILE).\n \ Los kernels del corpus traen CONFIG_KEXEC —el syscall viejo— que exige armar los \ segmentos y el purgatory a mano, o sea kexec-tools entero.\n \ La variante `recipes/linux-metal-kexec.toml` lo trae encendido." ); } kernel_ab::kexec_cargar(&bz, initrd, cmdline)?; println!("✓ kernel cargado en memoria"); if load_only || !jump { println!(" (no se salta: pasá --jump. El kernel queda cargado hasta el próximo reboot.)"); return Ok(()); } println!("⚡ saltando — el userspace muere ACÁ. Esto no vuelve."); kernel_ab::kexec_saltar() }