> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
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
diff --git a/crates/takana-cli/src/kernel_ab.rs b/crates/takana-cli/src/kernel_ab.rs
new file mode 100644
index 00000000..646903d1
--- /dev/null
+++ b/crates/takana-cli/src/kernel_ab.rs
@@ -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();
+ }
+}
diff --git a/crates/takana-cli/src/kernel_cmd.rs b/crates/takana-cli/src/kernel_cmd.rs
index d1b211f5..c1d3a74c 100644
--- a/crates/takana-cli/src/kernel_cmd.rs
+++ b/crates/takana-cli/src/kernel_cmd.rs
@@ -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(())
+}
diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs
index 00f8ed04..ea27e1e3 100644
--- a/crates/takana-cli/src/main.rs
+++ b/crates/takana-cli/src/main.rs
@@ -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;