kernel A/B: BootNext hace la vuelta atrás, y la hace el firmware (ADR 0017 §2)

`takana kernel {stage,boot-status,confirm,rollback}`.

La idea que lo vuelve barato: hay DOS modos de fallo y sólo uno necesita
código nuestro.

  1. El candidato NO ARRANCA (pánico temprano, EFI-stub rechazado). Lo
     cubre BootNext y sale GRATIS: es una variable de UN SOLO USO que el
     firmware CONSUME al leerla. Si el kernel muere, el siguiente
     arranque ya no la encuentra, cae en BootOrder y vuelve al estable
     sin que corra una línea nuestra. No hay contador que mantener ni
     estado que se pueda corromper: el mecanismo es el firmware.
  2. Arranca pero el sistema no queda sano. Eso el firmware no lo sabe ⇒
     confirmación explícita, y cada arranque sin confirmar gasta un
     intento.

El candidato va a una ranura PROPIA de la ESP, nunca encima del estable:
un fallo a media copia dejaría sin kernel al que volver. La copia es tmp
+ rename.

Y `stage` REPONE el BootOrder después de crear la entrada: reconcile
deja al candidato primero, y un candidato no debe volverse el default —
tiene que arrancar UNA vez. Para eso está BootNext.

Verificado con DOS KERNELES REALES del store, tres arranques en OVMF:
  1º  6.16.12 estable, sin candidato → stage del 7.1.2
  2º  BdsDxe: starting Boot0004 "takana (candidato)" → uname = 7.1.2,
      " EN PRUEBA", confirm → promovido (y la ruta fallback también)
  3º  arranque normal → uname = 7.1.2: el estable cambió

Un bug que cazó un test y no una revisión: `en_esp` normalizaba los
backslashes DESPUÉS de quitar la barra inicial, así que una ruta en
formato EFI salía como "/EFI/..." —absoluta— y Path::join con absoluta
DESCARTA la base: habría escrito en el /EFI de la raíz del sistema en
vez de dentro de la ESP.

Y un hallazgo del banco de pruebas que vale para el diseño: con el
estado A/B en un tmpfs, stage + reboot lo perdía y el sistema decía "sin
candidato". Falló SEGURO (no promovió nada), pero dejaba la entrada
NVRAM huérfana sin que nadie lo supiera. boot-status ahora lo detecta
preguntándole al firmware, que es la fuente de verdad, y lo dice.

88 tests del CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
This commit is contained in:
Sergio
2026-09-12 00:49:50 +00:00
co-authored by Claude Opus 5
parent 13ce9b16f1
commit 6642b93858
3 changed files with 580 additions and 0 deletions
+265
View File
@@ -0,0 +1,265 @@
//! A/B del kernel con vuelta atrás (ADR 0017 §2).
//!
//! ── LA IDEA, Y POR QUÉ ES BARATA EN TAKANA ──────────────────────────────────────────────────────
//! El kernel es un artefacto de contenido: el viejo y el nuevo coexisten sin disputarse un nombre,
//! así que **el rollback ya es gratis**. Lo que falta —y es lo que hace que actualizar el kernel de
//! una máquina remota deje de dar miedo— es que la vuelta atrás ocurra **sola** cuando el kernel
//! nuevo no sirve.
//!
//! ── DOS MODOS DE FALLO, DOS MECANISMOS ──────────────────────────────────────────────────────────
//! 1. **El candidato no arranca en absoluto** (pánico temprano, EFI-stub que el firmware rechaza).
//! Lo cubre `BootNext`, y sale gratis: es una variable **de un solo uso** que el firmware CONSUME
//! al leerla. Si el kernel muere, el siguiente arranque ya no la encuentra, cae en `BootOrder`
//! normal y vuelve al estable **sin que corra una sola línea nuestra**. No hay contador que
//! mantener ni estado que se pueda corromper: el mecanismo es el propio firmware.
//! 2. **El candidato arranca pero el sistema no queda sano.** Eso el firmware no lo puede saber, así
//! que hace falta confirmación explícita: el candidato se promueve sólo si alguien dice que
//! funcionó (`kernel confirm`). Mientras no se confirme, cada arranque gasta un intento.
//!
//! ⚠ **El estado en disco NO es la fuente de verdad de por dónde arrancamos**: eso lo dice
//! `BootCurrent`, que lo escribe el firmware. El fichero sólo recuerda *qué* pusimos a prueba. Si
//! los dos se contradicen, gana el firmware — el fichero puede quedar viejo si alguien arrancó a
//! mano desde el menú.
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use crate::efi_boot;
pub const ESTADO: &str = "/var/lib/hammer/boot/ab.json";
/// Ranura del candidato dentro de la ESP. Deliberadamente **distinta** de la del estable: si se
/// escribiera encima, un fallo a media copia dejaría sin kernel estable al que volver.
pub const RUTA_CANDIDATO: &str = "/EFI/takana/takana-cand.efi";
pub const RUTA_ESTABLE: &str = "/EFI/takana/takanax64.efi";
pub const ETIQUETA_CANDIDATO: &str = "takana (candidato)";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct Candidato {
pub hash: String,
pub ruta: String,
pub slot: u16,
/// Arranques que le quedan para ser confirmado antes de descartarlo.
pub intentos: u32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct EstadoAb {
pub schema: String,
pub estable_hash: Option<String>,
pub candidato: Option<Candidato>,
}
impl Default for EstadoAb {
fn default() -> Self {
EstadoAb {
schema: "takana-kernel-ab/1".into(),
estable_hash: None,
candidato: None,
}
}
}
pub fn leer_estado(p: &Path) -> EstadoAb {
std::fs::read_to_string(p)
.ok()
.and_then(|s| serde_json::from_str::<EstadoAb>(&s).ok())
.filter(|e| e.schema == "takana-kernel-ab/1")
.unwrap_or_default()
}
pub fn escribir_estado(p: &Path, e: &EstadoAb) -> Result<()> {
if let Some(d) = p.parent() {
std::fs::create_dir_all(d)?;
}
std::fs::write(p, serde_json::to_string_pretty(e)?)?;
Ok(())
}
/// `BootCurrent`: por dónde arrancó **esta** sesión. Lo escribe el firmware y es de sólo lectura.
pub fn boot_current(efivars: &Path) -> Option<u16> {
let p = efivars.join(format!("BootCurrent-{}", efi_boot::GLOBAL_GUID));
let (_, d) = efi_boot::read_var(&p).ok()?;
(d.len() >= 2).then(|| u16::from_le_bytes([d[0], d[1]]))
}
/// `BootNext`: arrancá **una vez** por aquí. El firmware la borra al usarla, y en eso se apoya toda
/// la vuelta atrás del modo de fallo 1.
pub fn poner_boot_next(efivars: &Path, slot: u16) -> Result<()> {
let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
efi_boot::write_var(&p, efi_boot::ATTRS, &slot.to_le_bytes())
.with_context(|| format!("no pude escribir BootNext en {}", p.display()))
}
pub fn boot_next(efivars: &Path) -> Option<u16> {
let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
let (_, d) = efi_boot::read_var(&p).ok()?;
(d.len() >= 2).then(|| u16::from_le_bytes([d[0], d[1]]))
}
pub fn borrar_boot_next(efivars: &Path) {
let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
let _ = std::fs::remove_file(p);
}
/// Qué hacer al arrancar, decidido **sólo** con datos y sin efectos: así se puede probar entero.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Veredicto {
/// No hay nada en prueba.
SinCandidato,
/// Arrancamos por el candidato: está a prueba y le quedan `intentos` después de éste.
EnPrueba { slot: u16, intentos_restantes: u32 },
/// Arrancamos por el estable habiendo un candidato pendiente ⇒ **el candidato no arrancó**.
CandidatoFallo { slot: u16 },
/// Se acabaron los intentos sin que nadie confirmara.
CandidatoAgotado { slot: u16 },
}
/// El corazón de la decisión. `actual` es `BootCurrent` (puede faltar: hay firmwares que no la
/// publican, y entonces no se puede afirmar nada — se prefiere no tocar el candidato antes que
/// descartarlo por una sospecha).
pub fn evaluar(estado: &EstadoAb, actual: Option<u16>) -> Veredicto {
let Some(c) = &estado.candidato else { return Veredicto::SinCandidato };
match actual {
Some(a) if a == c.slot => {
if c.intentos == 0 {
Veredicto::CandidatoAgotado { slot: c.slot }
} else {
Veredicto::EnPrueba { slot: c.slot, intentos_restantes: c.intentos - 1 }
}
}
Some(_) => Veredicto::CandidatoFallo { slot: c.slot },
// Sin BootCurrent no hay forma de saber por dónde vinimos. Descartar un candidato bueno es
// peor que dejarlo un arranque más: se informa y no se toca.
None => Veredicto::EnPrueba { slot: c.slot, intentos_restantes: c.intentos },
}
}
/// Ruta de la ESP montada + ruta interna del `.efi` ⇒ ruta del sistema de ficheros.
pub fn en_esp(esp: &Path, ruta_efi: &str) -> PathBuf {
// ⚠ El orden importa y no es cosmético. Si se quitan las barras ANTES de traducir los
// backslashes, una ruta en formato EFI (`\EFI\takana\x.efi`) sale de aquí como `/EFI/...`,
// que es ABSOLUTA — y `Path::join` con una ruta absoluta **descarta la base**: se escribiría en
// el `/EFI` de la raíz del sistema en vez de dentro de la ESP. Traducir primero y recortar
// después. (Lo cazó el test, no una revisión.)
let rel = ruta_efi.replace('\\', "/");
esp.join(rel.trim_start_matches('/'))
}
/// Copia el bzImage de un artefacto del store a la ranura candidata de la ESP.
///
/// Escribe a un temporal y renombra: un corte de luz a media copia no puede dejar medio kernel en
/// la ruta que el firmware va a lanzar. (En FAT el rename es atómico dentro del directorio.)
pub fn instalar_candidato(bzimage: &Path, esp: &Path, ruta_efi: &str) -> Result<u64> {
let destino = en_esp(esp, ruta_efi);
let dir = destino.parent().context("ruta de candidato sin directorio")?;
std::fs::create_dir_all(dir)?;
let tmp = dir.join("takana-cand.tmp");
std::fs::copy(bzimage, &tmp)
.with_context(|| format!("copiando {}{}", bzimage.display(), tmp.display()))?;
let n = std::fs::metadata(&tmp)?.len();
std::fs::rename(&tmp, &destino)
.with_context(|| format!("renombrando a {}", destino.display()))?;
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
fn con_candidato(slot: u16, intentos: u32) -> EstadoAb {
EstadoAb {
candidato: Some(Candidato {
hash: "b3:abc".into(),
ruta: RUTA_CANDIDATO.into(),
slot,
intentos,
}),
..Default::default()
}
}
#[test]
fn sin_candidato_no_hay_nada_que_decidir() {
assert_eq!(evaluar(&EstadoAb::default(), Some(4)), Veredicto::SinCandidato);
assert_eq!(evaluar(&EstadoAb::default(), None), Veredicto::SinCandidato);
}
#[test]
fn arrancar_por_el_candidato_lo_pone_a_prueba_y_gasta_un_intento() {
assert_eq!(
evaluar(&con_candidato(7, 1), Some(7)),
Veredicto::EnPrueba { slot: 7, intentos_restantes: 0 }
);
assert_eq!(
evaluar(&con_candidato(7, 3), Some(7)),
Veredicto::EnPrueba { slot: 7, intentos_restantes: 2 }
);
}
#[test]
fn arrancar_por_otro_lado_con_candidato_pendiente_significa_que_no_arranco() {
// Es EL caso que justifica todo: pusimos BootNext al candidato y el firmware terminó
// arrancando otra cosa ⇒ el candidato no llegó a ejecutarse.
assert_eq!(evaluar(&con_candidato(7, 1), Some(4)), Veredicto::CandidatoFallo { slot: 7 });
}
#[test]
fn sin_intentos_se_agota_en_vez_de_reintentar_para_siempre() {
assert_eq!(evaluar(&con_candidato(7, 0), Some(7)), Veredicto::CandidatoAgotado { slot: 7 });
}
#[test]
fn sin_boot_current_no_se_descarta_por_sospecha() {
// Hay firmwares que no publican BootCurrent. Descartar un candidato bueno es peor que
// dejarlo un arranque más, así que NO se consume intento.
assert_eq!(
evaluar(&con_candidato(7, 2), None),
Veredicto::EnPrueba { slot: 7, intentos_restantes: 2 },
"sin dato del firmware no se gasta intento"
);
}
#[test]
fn el_estado_ida_y_vuelta_y_un_esquema_ajeno_no_se_come() {
let tmp = std::env::temp_dir().join(format!("takana-ab-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let f = tmp.join("ab.json");
let e = con_candidato(9, 2);
escribir_estado(&f, &e).unwrap();
assert_eq!(leer_estado(&f), e);
// Un fichero de otro esquema (o basura) devuelve el default, no un estado a medias.
std::fs::write(&f, r#"{"schema":"otra-cosa/9","candidato":null}"#).unwrap();
assert_eq!(leer_estado(&f), EstadoAb::default());
std::fs::write(&f, "no soy json").unwrap();
assert_eq!(leer_estado(&f), EstadoAb::default());
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn la_ranura_del_candidato_no_pisa_la_del_estable() {
// Si compartieran ruta, un fallo a media copia se llevaría puesto el kernel al que hay que
// volver — el rollback dejaría de existir justo cuando hace falta.
assert_ne!(RUTA_CANDIDATO, RUTA_ESTABLE);
let esp = Path::new("/boot/efi");
assert_eq!(en_esp(esp, RUTA_CANDIDATO), esp.join("EFI/takana/takana-cand.efi"));
assert_eq!(en_esp(esp, "\\EFI\\takana\\x.efi"), esp.join("EFI/takana/x.efi"));
}
#[test]
fn instalar_candidato_es_atomico_y_no_deja_el_temporal() {
let tmp = std::env::temp_dir().join(format!("takana-ab-inst-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let src = tmp.join("bzImage");
std::fs::write(&src, vec![0xAAu8; 4096]).unwrap();
let esp = tmp.join("esp");
let n = instalar_candidato(&src, &esp, RUTA_CANDIDATO).unwrap();
assert_eq!(n, 4096);
let dst = en_esp(&esp, RUTA_CANDIDATO);
assert_eq!(std::fs::read(&dst).unwrap().len(), 4096);
assert!(
!dst.parent().unwrap().join("takana-cand.tmp").exists(),
"el temporal se renombró, no quedó suelto"
);
std::fs::remove_dir_all(&tmp).ok();
}
}
+314
View File
@@ -202,6 +202,56 @@ pub enum KernelCmd {
#[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,
},
/// 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)]
@@ -214,6 +264,11 @@ pub enum KernelCmd {
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::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,
@@ -1806,3 +1861,262 @@ mod tests {
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(())
}
+1
View File
@@ -11,6 +11,7 @@ use clap::{Parser, Subcommand};
mod alpine_import;
mod efi_boot;
mod fat_ro;
mod kernel_ab;
mod kernel_cmd;
mod qorpa;
mod qorpa_wl;