//! 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) } // ══ kexec: el reboot suave (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. `kexec_file_load` recibe los // **descriptores** del kernel y del initrd y hace el trabajo dentro del kernel. // // Se hace 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 compartido por dos frentes. // // ⚠ **Esto no es «actualizar sin rebootear».** El userspace muere igual: es un reboot que se salta // el firmware. Lo que ahorra son los 20-30 s de POST/UEFI — la mitad del downtime en un servidor // remoto, y nada en un portátil. La ayuda del comando lo dice con esas palabras a propósito. #[cfg(target_arch = "x86_64")] mod sys { /// `kexec_file_load` en x86_64. Contrato: `include/uapi/asm-generic/unistd.h`. pub const NR_KEXEC_FILE_LOAD: u64 = 320; /// `reboot`. pub const NR_REBOOT: u64 = 169; pub const LINUX_REBOOT_MAGIC1: u64 = 0xfee1_dead; pub const LINUX_REBOOT_MAGIC2: u64 = 672_274_793; pub const LINUX_REBOOT_CMD_KEXEC: u64 = 0x4558_4543; /// Cargar sin initramfs (el fd de initrd se ignora). pub const KEXEC_FILE_NO_INITRAMFS: u64 = 0x4; /// # Safety /// Invoca un syscall crudo. Los argumentos tienen que respetar la ABI del syscall llamado. pub unsafe fn syscall5(n: u64, a: u64, b: u64, c: u64, d: u64, e: u64) -> i64 { let ret: i64; unsafe { std::arch::asm!( "syscall", inlateout("rax") n => ret, in("rdi") a, in("rsi") b, in("rdx") c, in("r10") d, in("r8") e, lateout("rcx") _, lateout("r11") _, options(nostack) ); } ret } } /// Carga un kernel para el próximo `reboot -f kexec`. NO salta todavía. #[cfg(target_arch = "x86_64")] pub fn kexec_cargar(kernel: &Path, initrd: Option<&Path>, cmdline: &str) -> Result<()> { use std::os::fd::AsRawFd; let fk = std::fs::File::open(kernel) .with_context(|| format!("abriendo el kernel {}", kernel.display()))?; // 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. let mut cl: Vec = cmdline.as_bytes().to_vec(); cl.push(0); let (fd_i, flags) = match initrd { Some(p) => { let fi = std::fs::File::open(p) .with_context(|| format!("abriendo el initrd {}", p.display()))?; (Some(fi), 0u64) } None => (None, sys::KEXEC_FILE_NO_INITRAMFS), }; let raw_i = fd_i.as_ref().map(|f| f.as_raw_fd()).unwrap_or(-1); let r = unsafe { sys::syscall5( sys::NR_KEXEC_FILE_LOAD, fk.as_raw_fd() as u64, raw_i as u64, cl.len() as u64, cl.as_ptr() as u64, flags, ) }; if r < 0 { let e = -r; // Traducir los tres errores que de verdad pasan, porque «Permission denied» a secas manda a // buscar permisos de fichero cuando el problema es otro. // ⚠ EPERM y ENOSYS se confunden fácil y mandan a lugares OPUESTOS. Medido en este mismo hub: // el kernel de Artix trae `CONFIG_KEXEC_FILE=y` y aun así devolvió EPERM — por no ser root. // El primer texto de este match decía «falta CONFIG_KEXEC_FILE» para EPERM, que es // exactamente el diagnóstico equivocado: manda a recompilar un kernel que estaba bien. let pista = match e { 1 => " ⇒ falta CAP_SYS_BOOT: hay que ser root (y si YA sos root, es lockdown: Secure Boot no deja cargar un kernel sin firmar)", 38 => " ⇒ el kernel que CORRE no implementa kexec_file_load: le falta CONFIG_KEXEC_FILE", 13 => " ⇒ no puedo leer el fichero del kernel o del initrd", 22 => " ⇒ la imagen no le gustó al kernel (¿es un bzImage x86_64 válido?)", _ => "", }; bail!("kexec_file_load falló: errno {e}{pista}"); } Ok(()) } /// Salta al kernel ya cargado. **No vuelve.** #[cfg(target_arch = "x86_64")] pub fn kexec_saltar() -> Result<()> { let r = unsafe { sys::syscall5( sys::NR_REBOOT, sys::LINUX_REBOOT_MAGIC1, sys::LINUX_REBOOT_MAGIC2, sys::LINUX_REBOOT_CMD_KEXEC, 0, 0, ) }; bail!("reboot(KEXEC) volvió con {r}: no había kernel cargado, o no somos root") } #[cfg(not(target_arch = "x86_64"))] pub fn kexec_cargar(_: &Path, _: Option<&Path>, _: &str) -> Result<()> { bail!("kexec: sólo implementado para x86_64") } #[cfg(not(target_arch = "x86_64"))] pub fn kexec_saltar() -> Result<()> { bail!("kexec: sólo implementado para x86_64") } /// ¿El kernel que corre soporta `kexec_file_load`? Se responde **preguntándole al kernel**, no /// leyendo un `.config` que puede no ser el suyo. pub fn kexec_file_disponible() -> bool { // Un `kexec_file_load` con fd inválido devuelve EBADF (9) si el syscall EXISTE, y ENOSYS (38) // si no está compilado. Distinguir los dos es toda la prueba, y no carga nada. #[cfg(target_arch = "x86_64")] { let r = unsafe { sys::syscall5(sys::NR_KEXEC_FILE_LOAD, u64::MAX, u64::MAX, 0, 0, sys::KEXEC_FILE_NO_INITRAMFS) }; -r != 38 } #[cfg(not(target_arch = "x86_64"))] { false } } #[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(); } }