ADR 0017 §1. Se usa kexec_file_load, no el kexec_load viejo, y la diferencia es de orden de magnitud: kexec_load recibe los segmentos YA armados y el purgatory —el código que corre entre los dos kernels—, o sea que usarlo obliga a reimplementar kexec-tools entero. kexec_file_load recibe los DESCRIPTORES del kernel y del initrd y hace el trabajo adentro. Son ~50 líneas. El syscall va con asm! en vez de agregar la crate libc al workspace: es UN syscall, su número y su ABI son contrato estable de Linux, y la alternativa era arrastrar una dependencia a un workspace que comparten dos frentes. El cmdline viaja CON su NUL: el kernel cuenta cmdline_len incluyendo el terminador, y sin él lee un byte de más. Es el error clásico de esta llamada. recipes/linux-metal-kexec.toml — variante cuya ÚNICA diferencia es CONFIG_KEXEC_FILE=y. Variante y no flag en la canónica porque el .config ES la identidad del artefacto (SDD 22 §1): tocar linux-metal re-hashea el kernel que arranca las imágenes y el que reproduce bit a bit, y el ADR deja esa decisión al usuario. Comprobado que la canónica no se mueve: sigue en b3:2ed8f54a…, el que ya está sellado. Y un diagnóstico que corregí a los dos minutos de escribirlo, porque mandaba al lugar OPUESTO: decía "falta CONFIG_KEXEC_FILE" para EPERM. Medido en este hub — el kernel de Artix trae CONFIG_KEXEC_FILE=y y aun así devolvió EPERM, por no ser root. EPERM es falta de CAP_SYS_BOOT (o lockdown si ya sos root); ENOSYS es el flag que falta. Confundirlos manda a recompilar un kernel que estaba bien. La ayuda del comando dice con todas las letras que esto NO es "actualizar sin rebootear": el userspace muere igual. Ahorra los 20-30 s de POST/UEFI, que es la mitad del downtime en un servidor remoto y nada en un portátil. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2186 lines
82 KiB
Rust
2186 lines
82 KiB
Rust
//! `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<String>,
|
|
/// Raíz del árbol de fuentes del kernel (el directorio con el `Kconfig` de arriba).
|
|
#[arg(long)]
|
|
kconfig: Option<PathBuf>,
|
|
/// Contrasta la clausura contra los símbolos apagados en este `.config`.
|
|
#[arg(long)]
|
|
against: Option<PathBuf>,
|
|
/// 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-<uname -r>`.
|
|
#[arg(long)]
|
|
config: Option<PathBuf>,
|
|
#[arg(long)]
|
|
kconfig: Option<PathBuf>,
|
|
#[arg(long)]
|
|
catalog: Option<PathBuf>,
|
|
/// 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<PathBuf>,
|
|
#[arg(long)]
|
|
catalog: Option<PathBuf>,
|
|
/// 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<String>,
|
|
/// Id de perilla del catálogo. Repetible.
|
|
#[arg(long = "knob")]
|
|
knobs: Vec<String>,
|
|
#[arg(long)]
|
|
kconfig: Option<PathBuf>,
|
|
#[arg(long)]
|
|
catalog: Option<PathBuf>,
|
|
/// Escribe el plan en JSON (el contrato con las UIs).
|
|
#[arg(long)]
|
|
out: Option<PathBuf>,
|
|
/// 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<PathBuf>,
|
|
#[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-<versión>`).
|
|
#[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<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,
|
|
/// 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<PathBuf>,
|
|
#[arg(long)]
|
|
kconfig: Option<PathBuf>,
|
|
#[arg(long)]
|
|
catalog: Option<PathBuf>,
|
|
#[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<PathBuf>,
|
|
},
|
|
/// **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<PathBuf>,
|
|
/// Barre todos los kernels sellados del store (`<hash>-<nombre>/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<PathBuf>,
|
|
/// Perfil del contrato. Obligatorio salvo que el nombre del artefacto ya lo declare.
|
|
#[arg(long)]
|
|
profile: Option<String>,
|
|
#[arg(long)]
|
|
contract: Option<PathBuf>,
|
|
/// 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<PathBuf>,
|
|
/// 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<PathBuf>,
|
|
/// 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<PathBuf>) -> Result<PathBuf> {
|
|
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 <dir> 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<PathBuf>) -> Result<PathBuf> {
|
|
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 <path> 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<PathBuf>) -> Result<PathBuf> {
|
|
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 <path> 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<PathBuf> {
|
|
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-<release>).\n\
|
|
Pasá --config <path> para mirar el de otra máquina."
|
|
)
|
|
}
|
|
|
|
fn load_tree(root: &Path) -> Result<KconfigTree> {
|
|
if !root.join("Kconfig").is_file() {
|
|
anyhow::bail!(
|
|
"{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\
|
|
Pasá --kconfig <dir> 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<String>,
|
|
/// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces).
|
|
closure: Vec<String>,
|
|
/// 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<takana_core::kernel::SelectLeak>,
|
|
/// Símbolos distintos de la clausura alcanzados por esas fugas.
|
|
leaked_targets: usize,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
fixpoint: Option<FixpointReport>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
against: Option<AgainstReport>,
|
|
}
|
|
|
|
/// El precio de cerrar el bundle contra sus fugas.
|
|
#[derive(Serialize)]
|
|
struct FixpointReport {
|
|
/// La clausura ya cerrada, entera.
|
|
closure: Vec<String>,
|
|
/// 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<takana_core::kernel::SelectLeak>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// 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::<Vec<_>>()
|
|
.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::<BTreeSet<_>>()
|
|
.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<String> = 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::<Vec<_>>()
|
|
.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<String> {
|
|
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<String>,
|
|
disable: Vec<String>,
|
|
close_leaks: Vec<String>,
|
|
closure_len: usize,
|
|
visible: usize,
|
|
unknown_symbols: Vec<String>,
|
|
undeclared_leaks: Vec<takana_core::kernel::SelectLeak>,
|
|
accepted_leaks: usize,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct BundlesReport {
|
|
catalog: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
reviewed_against: Option<String>,
|
|
bundles: Vec<BundleView>,
|
|
knobs: Vec<KnobView>,
|
|
stale: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct KnobView {
|
|
id: String,
|
|
title: String,
|
|
side: takana_core::kernel::Side,
|
|
enable: Vec<String>,
|
|
disable: Vec<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
recipe_field: Option<String>,
|
|
unknown_symbols: Vec<String>,
|
|
}
|
|
|
|
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<String> = 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<KnobView> = 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<String> =
|
|
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<takana_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 = 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::<takana_core::kernel::Hardware>(
|
|
&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>-<nombre>` 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<String> {
|
|
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>-<nombre>`) que contiene esta ruta.
|
|
fn store_dir_of(path: &Path) -> Option<String> {
|
|
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 (`<dir>/<artifact>.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<takana_core::Recipe> {
|
|
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 → <hash>-<nombre>`.
|
|
///
|
|
/// 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<std::collections::BTreeMap<String, String>> {
|
|
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::<Vec<_>>().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<takana_core::kernel::ContractReport>,
|
|
/// Sellados que la receta de HOY ya no produce. Se informan y no bloquean: ver `dirs_vigentes`.
|
|
#[serde(default)]
|
|
superados: Vec<takana_core::kernel::ContractReport>,
|
|
/// Configs que NO se comprobaron por no tener perfil declarado. No cuentan como aprobados.
|
|
sin_perfil: Vec<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
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<String>, 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<PathBuf> = 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<String> =
|
|
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<String> {
|
|
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<PathBuf> = 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::<Vec<_>>()
|
|
.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 <partición-ESP> {}",
|
|
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<u16> = 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()
|
|
}
|