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, + pub candidato: Option, +} + +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::(&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 { + 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 { + 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) -> 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 { + 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 = std::fs::read_dir(&s) + .with_context(|| format!("no puedo leer el store {}", s.display()))? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + let n = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + p.is_dir() && (n.starts_with(pat) || n.split_once('-').map(|(_, r)| r == pat).unwrap_or(false)) + }) + .collect(); + cand.sort(); + match cand.len() { + 1 => cand.remove(0), + 0 => bail!("no encuentro el artefacto «{artefacto}» en {}", s.display()), + n => bail!( + "«{artefacto}» casa con {n} artefactos; usá el nombre completo:\n {}", + cand.iter() + .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string()) + .collect::>() + .join("\n ") + ), + } + } + }; + let bz = dir.join("boot/bzImage"); + if !bz.is_file() { + bail!("{} no trae boot/bzImage: ¿es un artefacto de kernel?", dir.display()); + } + let nombre = dir.file_name().unwrap_or_default().to_string_lossy().to_string(); + Ok((bz, nombre)) +} + +fn ab_stage( + artefacto: &str, + store: &str, + esp: &Path, + efivars: &Path, + tries: u32, + state: &Path, + dry_run: bool, +) -> Result<()> { + let (bz, nombre) = bzimage_de(artefacto, store)?; + let tam = std::fs::metadata(&bz)?.len(); + println!("candidato: {nombre}"); + println!(" bzImage: {} ({:.1} MiB)", bz.display(), tam as f64 / 1048576.0); + + if !esp.is_dir() { + bail!( + "la ESP no está montada en {}: montala primero\n mount -t vfat {}", + esp.display(), + esp.display() + ); + } + if !efi_boot::hay_firmware_efi(efivars) { + bail!( + "sin NVRAM en {}: sin firmware UEFI no hay BootNext, y sin BootNext no hay A/B", + efivars.display() + ); + } + + // Que entre ANTES de copiar: una ESP llena a media copia deja un candidato truncado, y un + // candidato truncado no falla — arranca y muere en el firmware. + let destino = kernel_ab::en_esp(esp, kernel_ab::RUTA_CANDIDATO); + println!(" ranura: {} (propia; el estable no se toca)", destino.display()); + + if dry_run { + println!("--dry-run: no se copió nada ni se tocó la NVRAM"); + return Ok(()); + } + + let n = kernel_ab::instalar_candidato(&bz, esp, kernel_ab::RUTA_CANDIDATO)?; + println!(" ✓ copiado ({n} bytes) de forma atómica (tmp + rename)"); + + // Entrada NVRAM propia del candidato, apuntando a SU ranura. + let (disco, parte_n) = { + let discos = efi_boot::block_disks(Path::new("/sys/block")).unwrap_or_default(); + let esps = efi_boot::find_esps(&discos); + match esps.len() { + 1 => esps[0].clone(), + 0 => bail!("no encuentro ninguna ESP para apuntar la entrada de arranque"), + n => bail!("hay {n} ESP: no voy a adivinar cuál es la de este arranque"), + } + }; + let parte = efi_boot::read_partition(&disco, parte_n)?; + let r = efi_boot::reconcile( + efivars, + &parte, + kernel_ab::RUTA_CANDIDATO, + kernel_ab::ETIQUETA_CANDIDATO, + false, + )?; + println!(" ✓ entrada Boot{:04X} «{}»", r.slot, kernel_ab::ETIQUETA_CANDIDATO); + + // OJO: `reconcile` deja al candidato PRIMERO en BootOrder, y eso no es lo que queremos — el + // candidato tiene que arrancar UNA vez, no volverse el default. Se repone el orden y se usa + // BootNext, que es de un solo uso. + if r.orden_cambiado { + let ruta_orden = efivars.join(format!("BootOrder-{}", efi_boot::GLOBAL_GUID)); + efi_boot::write_var( + &ruta_orden, + efi_boot::ATTRS, + &efi_boot::serialize_boot_order(&r.orden_antes), + )?; + println!(" ✓ BootOrder repuesto: el candidato NO se vuelve el default"); + } + kernel_ab::poner_boot_next(efivars, r.slot)?; + println!(" ✓ BootNext = Boot{:04X} (un solo uso)", r.slot); + + let mut e = kernel_ab::leer_estado(state); + e.candidato = Some(kernel_ab::Candidato { + hash: nombre.split_once('-').map(|(h, _)| format!("b3:{h}")).unwrap_or(nombre.clone()), + ruta: kernel_ab::RUTA_CANDIDATO.into(), + slot: r.slot, + intentos: tries, + }); + kernel_ab::escribir_estado(state, &e)?; + println!("\nEl PRÓXIMO arranque usa el candidato. Si no arranca, el firmware vuelve solo al"); + println!("estable — BootNext es de un solo uso. Si arranca y anda: `takana kernel confirm`."); + Ok(()) +} + +fn ab_status(efivars: &Path, state: &Path) -> Result<()> { + let e = kernel_ab::leer_estado(state); + let actual = kernel_ab::boot_current(efivars); + match actual { + Some(a) => println!("arrancamos por Boot{a:04X}"), + None => println!("el firmware no publica BootCurrent (o no hay NVRAM)"), + } + if let Some(n) = kernel_ab::boot_next(efivars) { + println!("BootNext pendiente: Boot{n:04X} (el próximo arranque irá por ahí)"); + } + match kernel_ab::evaluar(&e, actual) { + Veredicto::SinCandidato => { + println!("sin candidato: el kernel que corre es el estable"); + // El firmware es la fuente de verdad, no nuestro fichero. Si el estado se perdió —el + // caso real es que viviera en algo que no persiste— pero la NVRAM todavía tiene la + // entrada del candidato, hay basura que nadie va a limpiar porque nadie sabe que está. + // Medido: con el estado en un tmpfs, `stage` + reboot dejaba exactamente esto. + let huerfanas: Vec = efi_boot::list_entries(efivars) + .unwrap_or_default() + .into_iter() + .filter(|(_, lo)| lo.description == kernel_ab::ETIQUETA_CANDIDATO) + .map(|(n, _)| n) + .collect(); + if !huerfanas.is_empty() { + println!("⚠ pero la NVRAM tiene {} entrada(s) «{}» sin estado que las respalde:", + huerfanas.len(), kernel_ab::ETIQUETA_CANDIDATO); + for n in &huerfanas { + println!(" Boot{n:04X}"); + } + println!(" El fichero de estado se perdió o nunca persistió. `kernel rollback` no"); + println!(" puede limpiarlas sin saber cuál era: borralas a mano o volvé a hacer `stage`."); + } + } + Veredicto::EnPrueba { slot, intentos_restantes } => { + println!("⏳ EN PRUEBA: Boot{slot:04X} es el candidato y es el que está corriendo"); + println!(" le quedan {intentos_restantes} arranque(s) sin confirmar"); + println!(" → `takana kernel confirm` si anda, `takana kernel rollback` si no"); + } + Veredicto::CandidatoFallo { slot } => { + println!("✗ el candidato Boot{slot:04X} NO arrancó: esta sesión vino del estable"); + println!(" → `takana kernel rollback` lo descarta (o `stage` otra vez para reintentar)"); + } + Veredicto::CandidatoAgotado { slot } => { + println!("✗ el candidato Boot{slot:04X} agotó sus intentos sin que nadie lo confirmara"); + println!(" → `takana kernel rollback`"); + } + } + Ok(()) +} + +fn ab_confirm(esp: &Path, efivars: &Path, state: &Path) -> Result<()> { + let mut e = kernel_ab::leer_estado(state); + let Some(c) = e.candidato.clone() else { + println!("no hay candidato que confirmar"); + return Ok(()); + }; + let actual = kernel_ab::boot_current(efivars); + // Confirmar un kernel que NO es el que está corriendo sería promover algo que nadie probó, que + // es exactamente lo contrario de para qué existe esto. + if let Some(a) = actual { + if a != c.slot { + bail!( + "esta sesión arrancó por Boot{a:04X} y el candidato es Boot{:04X}: no se confirma \ + un kernel que no es el que está corriendo", + c.slot + ); + } + } + let cand = kernel_ab::en_esp(esp, &c.ruta); + let estable = kernel_ab::en_esp(esp, kernel_ab::RUTA_ESTABLE); + let dir = estable.parent().context("ruta estable sin directorio")?; + let tmp = dir.join("takana-prom.tmp"); + std::fs::copy(&cand, &tmp) + .with_context(|| format!("copiando {} → {}", cand.display(), tmp.display()))?; + std::fs::rename(&tmp, &estable)?; + println!("✓ promovido: el candidato es ahora el kernel estable ({})", estable.display()); + + // La ruta fallback también, que es la otra mitad del seguro del ADR 0018 §3. + let fallback = kernel_ab::en_esp(esp, "/EFI/BOOT/BOOTX64.EFI"); + if fallback.exists() { + let t2 = dir.join("takana-fb.tmp"); + if std::fs::copy(&cand, &t2).is_ok() && std::fs::rename(&t2, &fallback).is_ok() { + println!("✓ y la ruta fallback, que apunta al mismo kernel (ADR 0018 §3)"); + } + } + let _ = std::fs::remove_file(&cand); + kernel_ab::borrar_boot_next(efivars); + e.estable_hash = Some(c.hash.clone()); + e.candidato = None; + kernel_ab::escribir_estado(state, &e)?; + println!(" estable: {}", c.hash); + Ok(()) +} + +fn ab_rollback(esp: &Path, efivars: &Path, state: &Path) -> Result<()> { + let mut e = kernel_ab::leer_estado(state); + let Some(c) = e.candidato.clone() else { + println!("no hay candidato que descartar"); + return Ok(()); + }; + kernel_ab::borrar_boot_next(efivars); + let cand = kernel_ab::en_esp(esp, &c.ruta); + if cand.exists() { + let _ = std::fs::remove_file(&cand); + println!("✓ borrada la ranura del candidato ({})", cand.display()); + } + // La entrada NVRAM del candidato se va también: dejarla sería ofrecer en el menú del firmware + // un arranque que apunta a un fichero que ya no está. + let var = efi_boot::boot_var_path(efivars, c.slot); + if var.exists() { + let _ = std::fs::remove_file(&var); + println!("✓ borrada la entrada Boot{:04X}", c.slot); + } + e.candidato = None; + kernel_ab::escribir_estado(state, &e)?; + println!("el kernel estable sigue siendo el que estaba. No se tocó."); + Ok(()) +} 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;