kernel contract: el sellado VIGENTE bloquea, el superado se informa
El store guarda todos los sellados, no el último. Como `--sealed` los miraba a todos por igual, al cambiar una receta de kernel el gate quedaba rojo para siempre por artefactos que nadie va a volver a construir — y un portón que no puede ponerse verde deja de leerse. Ahora clasifica cada sellado contra el ArtifactHash de la receta de hoy (misma vigencia que `hammer hash --check`, lab incluido): el vigente bloquea, el superado sale en su propia sección con lo que le falta, porque sigue siendo cierto que una máquina que arranque ese kernel corre sus Cards sin tope. Dos negativas explícitas: si no se puede resolver la vigencia se comprueba TODO y se dice por qué; y cero vigentes con superados a la vista sale != 0 en vez de verde — el vacío leído como presencia es justo el fallo que este guardián vino a arreglar. Recetas derivadas incluidas: --recipes mira `recipes/` y `docs/state/kernel-plans/`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ACUcwo9mZsE5ocYVE9npih
This commit is contained in:
@@ -187,6 +187,10 @@ pub enum KernelCmd {
|
||||
/// 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>,
|
||||
@@ -274,6 +278,7 @@ pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
|
||||
KernelCmd::Contract {
|
||||
configs,
|
||||
sealed,
|
||||
recipes,
|
||||
profile,
|
||||
contract,
|
||||
list,
|
||||
@@ -281,6 +286,7 @@ pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
|
||||
} => contract_cmd(
|
||||
&configs,
|
||||
sealed,
|
||||
&recipes,
|
||||
profile.as_deref(),
|
||||
&contract_path(contract)?,
|
||||
store,
|
||||
@@ -1332,10 +1338,98 @@ fn artifact_of(path: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Dónde viven las recetas de kernel cuando nadie pasa `--recipes`. La segunda es la de las
|
||||
/// recetas DERIVADAS que emite `hammer 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
|
||||
}
|
||||
|
||||
/// 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<hammer_core::Recipe> {
|
||||
for d in dirs {
|
||||
let directo = d.join(format!("{artifact}.toml"));
|
||||
if let Ok(r) = hammer_core::Recipe::load_from_path(&directo) {
|
||||
if r.name == artifact {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
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) = hammer_core::Recipe::load_from_path(&p) {
|
||||
if r.name == artifact {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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: &hammer_core::kernel::Contract,
|
||||
dirs: &[PathBuf],
|
||||
store: &str,
|
||||
) -> Result<std::collections::BTreeMap<String, String>> {
|
||||
let st = hammer_core::Store::open(Path::new(store))?;
|
||||
let cfg = hammer_build::BuildConfig::from_env_or_defaults(st.root());
|
||||
let lab = hammer_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 hammer_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<hammer_core::kernel::ContractReport>,
|
||||
/// Sellados que la receta de HOY ya no produce. Se informan y no bloquean: ver `dirs_vigentes`.
|
||||
#[serde(default)]
|
||||
superados: Vec<hammer_core::kernel::ContractReport>,
|
||||
/// Configs que NO se comprobaron por no tener perfil declarado. No cuentan como aprobados.
|
||||
sin_perfil: Vec<String>,
|
||||
}
|
||||
@@ -1343,6 +1437,7 @@ struct ContractRun {
|
||||
fn contract_cmd(
|
||||
configs: &[PathBuf],
|
||||
sealed: bool,
|
||||
recipes: &[PathBuf],
|
||||
profile: Option<&str>,
|
||||
contract: &Path,
|
||||
store: &str,
|
||||
@@ -1388,6 +1483,37 @@ fn contract_cmd(
|
||||
|
||||
// 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() {
|
||||
@@ -1412,6 +1538,7 @@ fn contract_cmd(
|
||||
let mut run = ContractRun {
|
||||
contract: contract.display().to_string(),
|
||||
reports: Vec::new(),
|
||||
superados: Vec::new(),
|
||||
sin_perfil: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -1431,7 +1558,16 @@ fn contract_cmd(
|
||||
run.sin_perfil.push(label.clone());
|
||||
continue;
|
||||
};
|
||||
run.reports.push(c.check(&cfg, &prof, label)?);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if json {
|
||||
@@ -1448,6 +1584,16 @@ fn contract_cmd(
|
||||
if bloquean > 0 {
|
||||
anyhow::bail!("el contrato NO se cumple en {bloquean} kernel(es)");
|
||||
}
|
||||
// Cero vigentes con superados a la vista NO es aprobado: es que la receta cambió y todavía no
|
||||
// se reconstruyó nada. Dejarlo pasar en verde sería el fallo de CLAUDE.md §3 —el vacío leído
|
||||
// como presencia— en el propio guardián.
|
||||
if sealed && run.reports.is_empty() && !run.superados.is_empty() {
|
||||
anyhow::bail!(
|
||||
"no hay UN SOLO kernel vigente sellado ({} superado(s) en el store): el contrato no se \
|
||||
comprobó contra nada. Construí las recetas de kernel antes de leer esto como aprobado.",
|
||||
run.superados.len()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1501,10 +1647,40 @@ fn imprimir_contrato(run: &ContractRun) {
|
||||
.iter()
|
||||
.filter(|r| r.verdict == hammer_core::kernel::Verdict::Bloquea)
|
||||
.count();
|
||||
if !run.superados.is_empty() {
|
||||
let sup_malos = run
|
||||
.superados
|
||||
.iter()
|
||||
.filter(|r| r.verdict == hammer_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) cumplen su perfil{}",
|
||||
"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 {
|
||||
@@ -1548,6 +1724,16 @@ pub fn read_maybe_gz(p: &Path) -> Result<String> {
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user