gioser

sergio/takana

> 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…

TOML 1430Shell 145Markdown 87Rust 852973 Sutichaykuna

⎇ 551a103e2dd119ec47b28d831074a214c8a080d2 crates/takana-cli/src/kernel_ab.rs Rust · 406 siq'ikuna · 17.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

kernel kexec: el reboot suave por kexec_file_load, sin kexec-tools 0a9622c7b · Sergio ·

1//! A/B del kernel con vuelta atrás (ADR 0017 §2).
2//!
3//! ── LA IDEA, Y POR QUÉ ES BARATA EN TAKANA ──────────────────────────────────────────────────────
4//! El kernel es un artefacto de contenido: el viejo y el nuevo coexisten sin disputarse un nombre,
5//! así que **el rollback ya es gratis**. Lo que falta —y es lo que hace que actualizar el kernel de
6//! una máquina remota deje de dar miedo— es que la vuelta atrás ocurra **sola** cuando el kernel
7//! nuevo no sirve.
8//!
9//! ── DOS MODOS DE FALLO, DOS MECANISMOS ──────────────────────────────────────────────────────────
10//! 1. **El candidato no arranca en absoluto** (pánico temprano, EFI-stub que el firmware rechaza).
11//!    Lo cubre `BootNext`, y sale gratis: es una variable **de un solo uso** que el firmware CONSUME
12//!    al leerla. Si el kernel muere, el siguiente arranque ya no la encuentra, cae en `BootOrder`
13//!    normal y vuelve al estable **sin que corra una sola línea nuestra**. No hay contador que
14//!    mantener ni estado que se pueda corromper: el mecanismo es el propio firmware.
15//! 2. **El candidato arranca pero el sistema no queda sano.** Eso el firmware no lo puede saber, así
16//!    que hace falta confirmación explícita: el candidato se promueve sólo si alguien dice que
17//!    funcionó (`kernel confirm`). Mientras no se confirme, cada arranque gasta un intento.
18//!
19//! ⚠ **El estado en disco NO es la fuente de verdad de por dónde arrancamos**: eso lo dice
20//! `BootCurrent`, que lo escribe el firmware. El fichero sólo recuerda *qué* pusimos a prueba. Si
21//! los dos se contradicen, gana el firmware — el fichero puede quedar viejo si alguien arrancó a
22//! mano desde el menú.
23
24use anyhow::{bail, Context, Result};
25use std::path::{Path, PathBuf};
26
27use crate::efi_boot;
28
29pub const ESTADO: &str = "/var/lib/hammer/boot/ab.json";
30/// Ranura del candidato dentro de la ESP. Deliberadamente **distinta** de la del estable: si se
31/// escribiera encima, un fallo a media copia dejaría sin kernel estable al que volver.
32pub const RUTA_CANDIDATO: &str = "/EFI/takana/takana-cand.efi";
33pub const RUTA_ESTABLE: &str = "/EFI/takana/takanax64.efi";
34pub const ETIQUETA_CANDIDATO: &str = "takana (candidato)";
35
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
37pub struct Candidato {
38    pub hash: String,
39    pub ruta: String,
40    pub slot: u16,
41    /// Arranques que le quedan para ser confirmado antes de descartarlo.
42    pub intentos: u32,
43}
44
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
46pub struct EstadoAb {
47    pub schema: String,
48    pub estable_hash: Option<String>,
49    pub candidato: Option<Candidato>,
50}
51
52impl Default for EstadoAb {
53    fn default() -> Self {
54        EstadoAb {
55            schema: "takana-kernel-ab/1".into(),
56            estable_hash: None,
57            candidato: None,
58        }
59    }
60}
61
62pub fn leer_estado(p: &Path) -> EstadoAb {
63    std::fs::read_to_string(p)
64        .ok()
65        .and_then(|s| serde_json::from_str::<EstadoAb>(&s).ok())
66        .filter(|e| e.schema == "takana-kernel-ab/1")
67        .unwrap_or_default()
68}
69
70pub fn escribir_estado(p: &Path, e: &EstadoAb) -> Result<()> {
71    if let Some(d) = p.parent() {
72        std::fs::create_dir_all(d)?;
73    }
74    std::fs::write(p, serde_json::to_string_pretty(e)?)?;
75    Ok(())
76}
77
78/// `BootCurrent`: por dónde arrancó **esta** sesión. Lo escribe el firmware y es de sólo lectura.
79pub fn boot_current(efivars: &Path) -> Option<u16> {
80    let p = efivars.join(format!("BootCurrent-{}", efi_boot::GLOBAL_GUID));
81    let (_, d) = efi_boot::read_var(&p).ok()?;
82    (d.len() >= 2).then(|| u16::from_le_bytes([d[0], d[1]]))
83}
84
85/// `BootNext`: arrancá **una vez** por aquí. El firmware la borra al usarla, y en eso se apoya toda
86/// la vuelta atrás del modo de fallo 1.
87pub fn poner_boot_next(efivars: &Path, slot: u16) -> Result<()> {
88    let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
89    efi_boot::write_var(&p, efi_boot::ATTRS, &slot.to_le_bytes())
90        .with_context(|| format!("no pude escribir BootNext en {}", p.display()))
91}
92
93pub fn boot_next(efivars: &Path) -> Option<u16> {
94    let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
95    let (_, d) = efi_boot::read_var(&p).ok()?;
96    (d.len() >= 2).then(|| u16::from_le_bytes([d[0], d[1]]))
97}
98
99pub fn borrar_boot_next(efivars: &Path) {
100    let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
101    let _ = std::fs::remove_file(p);
102}
103
104/// Qué hacer al arrancar, decidido **sólo** con datos y sin efectos: así se puede probar entero.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum Veredicto {
107    /// No hay nada en prueba.
108    SinCandidato,
109    /// Arrancamos por el candidato: está a prueba y le quedan `intentos` después de éste.
110    EnPrueba { slot: u16, intentos_restantes: u32 },
111    /// Arrancamos por el estable habiendo un candidato pendiente ⇒ **el candidato no arrancó**.
112    CandidatoFallo { slot: u16 },
113    /// Se acabaron los intentos sin que nadie confirmara.
114    CandidatoAgotado { slot: u16 },
115}
116
117/// El corazón de la decisión. `actual` es `BootCurrent` (puede faltar: hay firmwares que no la
118/// publican, y entonces no se puede afirmar nada — se prefiere no tocar el candidato antes que
119/// descartarlo por una sospecha).
120pub fn evaluar(estado: &EstadoAb, actual: Option<u16>) -> Veredicto {
121    let Some(c) = &estado.candidato else { return Veredicto::SinCandidato };
122    match actual {
123        Some(a) if a == c.slot => {
124            if c.intentos == 0 {
125                Veredicto::CandidatoAgotado { slot: c.slot }
126            } else {
127                Veredicto::EnPrueba { slot: c.slot, intentos_restantes: c.intentos - 1 }
128            }
129        }
130        Some(_) => Veredicto::CandidatoFallo { slot: c.slot },
131        // Sin BootCurrent no hay forma de saber por dónde vinimos. Descartar un candidato bueno es
132        // peor que dejarlo un arranque más: se informa y no se toca.
133        None => Veredicto::EnPrueba { slot: c.slot, intentos_restantes: c.intentos },
134    }
135}
136
137/// Ruta de la ESP montada + ruta interna del `.efi` ⇒ ruta del sistema de ficheros.
138pub fn en_esp(esp: &Path, ruta_efi: &str) -> PathBuf {
139    // ⚠ El orden importa y no es cosmético. Si se quitan las barras ANTES de traducir los
140    // backslashes, una ruta en formato EFI (`\EFI\takana\x.efi`) sale de aquí como `/EFI/...`,
141    // que es ABSOLUTA — y `Path::join` con una ruta absoluta **descarta la base**: se escribiría en
142    // el `/EFI` de la raíz del sistema en vez de dentro de la ESP. Traducir primero y recortar
143    // después. (Lo cazó el test, no una revisión.)
144    let rel = ruta_efi.replace('\\', "/");
145    esp.join(rel.trim_start_matches('/'))
146}
147
148/// Copia el bzImage de un artefacto del store a la ranura candidata de la ESP.
149///
150/// Escribe a un temporal y renombra: un corte de luz a media copia no puede dejar medio kernel en
151/// la ruta que el firmware va a lanzar. (En FAT el rename es atómico dentro del directorio.)
152pub fn instalar_candidato(bzimage: &Path, esp: &Path, ruta_efi: &str) -> Result<u64> {
153    let destino = en_esp(esp, ruta_efi);
154    let dir = destino.parent().context("ruta de candidato sin directorio")?;
155    std::fs::create_dir_all(dir)?;
156    let tmp = dir.join("takana-cand.tmp");
157    std::fs::copy(bzimage, &tmp)
158        .with_context(|| format!("copiando {} → {}", bzimage.display(), tmp.display()))?;
159    let n = std::fs::metadata(&tmp)?.len();
160    std::fs::rename(&tmp, &destino)
161        .with_context(|| format!("renombrando a {}", destino.display()))?;
162    Ok(n)
163}
164
165// ══ kexec: el reboot suave (ADR 0017 §1) ═══════════════════════════════════════════════════════
166//
167// Se usa **`kexec_file_load`**, no el `kexec_load` viejo, y la diferencia es de orden de magnitud:
168// `kexec_load` recibe los segmentos YA armados y el *purgatory* —el código que corre entre los dos
169// kernels—, o sea que usarlo obliga a reimplementar kexec-tools. `kexec_file_load` recibe los
170// **descriptores** del kernel y del initrd y hace el trabajo dentro del kernel.
171//
172// Se hace con `asm!` en vez de agregar la crate `libc` al workspace: es **un** syscall, su número y
173// su ABI son contrato estable de Linux, y la alternativa era arrastrar una dependencia a un
174// workspace compartido por dos frentes.
175//
176// ⚠ **Esto no es «actualizar sin rebootear».** El userspace muere igual: es un reboot que se salta
177// el firmware. Lo que ahorra son los 20-30 s de POST/UEFI — la mitad del downtime en un servidor
178// remoto, y nada en un portátil. La ayuda del comando lo dice con esas palabras a propósito.
179#[cfg(target_arch = "x86_64")]
180mod sys {
181    /// `kexec_file_load` en x86_64. Contrato: `include/uapi/asm-generic/unistd.h`.
182    pub const NR_KEXEC_FILE_LOAD: u64 = 320;
183    /// `reboot`.
184    pub const NR_REBOOT: u64 = 169;
185
186    pub const LINUX_REBOOT_MAGIC1: u64 = 0xfee1_dead;
187    pub const LINUX_REBOOT_MAGIC2: u64 = 672_274_793;
188    pub const LINUX_REBOOT_CMD_KEXEC: u64 = 0x4558_4543;
189    /// Cargar sin initramfs (el fd de initrd se ignora).
190    pub const KEXEC_FILE_NO_INITRAMFS: u64 = 0x4;
191
192    /// # Safety
193    /// Invoca un syscall crudo. Los argumentos tienen que respetar la ABI del syscall llamado.
194    pub unsafe fn syscall5(n: u64, a: u64, b: u64, c: u64, d: u64, e: u64) -> i64 {
195        let ret: i64;
196        unsafe {
197            std::arch::asm!(
198                "syscall",
199                inlateout("rax") n => ret,
200                in("rdi") a,
201                in("rsi") b,
202                in("rdx") c,
203                in("r10") d,
204                in("r8") e,
205                lateout("rcx") _,
206                lateout("r11") _,
207                options(nostack)
208            );
209        }
210        ret
211    }
212}
213
214/// Carga un kernel para el próximo `reboot -f kexec`. NO salta todavía.
215#[cfg(target_arch = "x86_64")]
216pub fn kexec_cargar(kernel: &Path, initrd: Option<&Path>, cmdline: &str) -> Result<()> {
217    use std::os::fd::AsRawFd;
218    let fk = std::fs::File::open(kernel)
219        .with_context(|| format!("abriendo el kernel {}", kernel.display()))?;
220    // El cmdline viaja con su NUL: el kernel cuenta `cmdline_len` INCLUYENDO el terminador, y sin él
221    // lee un byte de más. Es el error clásico de esta llamada.
222    let mut cl: Vec<u8> = cmdline.as_bytes().to_vec();
223    cl.push(0);
224
225    let (fd_i, flags) = match initrd {
226        Some(p) => {
227            let fi = std::fs::File::open(p)
228                .with_context(|| format!("abriendo el initrd {}", p.display()))?;
229            (Some(fi), 0u64)
230        }
231        None => (None, sys::KEXEC_FILE_NO_INITRAMFS),
232    };
233    let raw_i = fd_i.as_ref().map(|f| f.as_raw_fd()).unwrap_or(-1);
234
235    let r = unsafe {
236        sys::syscall5(
237            sys::NR_KEXEC_FILE_LOAD,
238            fk.as_raw_fd() as u64,
239            raw_i as u64,
240            cl.len() as u64,
241            cl.as_ptr() as u64,
242            flags,
243        )
244    };
245    if r < 0 {
246        let e = -r;
247        // Traducir los tres errores que de verdad pasan, porque «Permission denied» a secas manda a
248        // buscar permisos de fichero cuando el problema es otro.
249        // ⚠ EPERM y ENOSYS se confunden fácil y mandan a lugares OPUESTOS. Medido en este mismo hub:
250        // el kernel de Artix trae `CONFIG_KEXEC_FILE=y` y aun así devolvió EPERM — por no ser root.
251        // El primer texto de este match decía «falta CONFIG_KEXEC_FILE» para EPERM, que es
252        // exactamente el diagnóstico equivocado: manda a recompilar un kernel que estaba bien.
253        let pista = match e {
254            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)",
255            38 => " ⇒ el kernel que CORRE no implementa kexec_file_load: le falta CONFIG_KEXEC_FILE",
256            13 => " ⇒ no puedo leer el fichero del kernel o del initrd",
257            22 => " ⇒ la imagen no le gustó al kernel (¿es un bzImage x86_64 válido?)",
258            _ => "",
259        };
260        bail!("kexec_file_load falló: errno {e}{pista}");
261    }
262    Ok(())
263}
264
265/// Salta al kernel ya cargado. **No vuelve.**
266#[cfg(target_arch = "x86_64")]
267pub fn kexec_saltar() -> Result<()> {
268    let r = unsafe {
269        sys::syscall5(
270            sys::NR_REBOOT,
271            sys::LINUX_REBOOT_MAGIC1,
272            sys::LINUX_REBOOT_MAGIC2,
273            sys::LINUX_REBOOT_CMD_KEXEC,
274            0,
275            0,
276        )
277    };
278    bail!("reboot(KEXEC) volvió con {r}: no había kernel cargado, o no somos root")
279}
280
281#[cfg(not(target_arch = "x86_64"))]
282pub fn kexec_cargar(_: &Path, _: Option<&Path>, _: &str) -> Result<()> {
283    bail!("kexec: sólo implementado para x86_64")
284}
285#[cfg(not(target_arch = "x86_64"))]
286pub fn kexec_saltar() -> Result<()> {
287    bail!("kexec: sólo implementado para x86_64")
288}
289
290/// ¿El kernel que corre soporta `kexec_file_load`? Se responde **preguntándole al kernel**, no
291/// leyendo un `.config` que puede no ser el suyo.
292pub fn kexec_file_disponible() -> bool {
293    // Un `kexec_file_load` con fd inválido devuelve EBADF (9) si el syscall EXISTE, y ENOSYS (38)
294    // si no está compilado. Distinguir los dos es toda la prueba, y no carga nada.
295    #[cfg(target_arch = "x86_64")]
296    {
297        let r = unsafe { sys::syscall5(sys::NR_KEXEC_FILE_LOAD, u64::MAX, u64::MAX, 0, 0, sys::KEXEC_FILE_NO_INITRAMFS) };
298        -r != 38
299    }
300    #[cfg(not(target_arch = "x86_64"))]
301    {
302        false
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn con_candidato(slot: u16, intentos: u32) -> EstadoAb {
311        EstadoAb {
312            candidato: Some(Candidato {
313                hash: "b3:abc".into(),
314                ruta: RUTA_CANDIDATO.into(),
315                slot,
316                intentos,
317            }),
318            ..Default::default()
319        }
320    }
321
322    #[test]
323    fn sin_candidato_no_hay_nada_que_decidir() {
324        assert_eq!(evaluar(&EstadoAb::default(), Some(4)), Veredicto::SinCandidato);
325        assert_eq!(evaluar(&EstadoAb::default(), None), Veredicto::SinCandidato);
326    }
327
328    #[test]
329    fn arrancar_por_el_candidato_lo_pone_a_prueba_y_gasta_un_intento() {
330        assert_eq!(
331            evaluar(&con_candidato(7, 1), Some(7)),
332            Veredicto::EnPrueba { slot: 7, intentos_restantes: 0 }
333        );
334        assert_eq!(
335            evaluar(&con_candidato(7, 3), Some(7)),
336            Veredicto::EnPrueba { slot: 7, intentos_restantes: 2 }
337        );
338    }
339
340    #[test]
341    fn arrancar_por_otro_lado_con_candidato_pendiente_significa_que_no_arranco() {
342        // Es EL caso que justifica todo: pusimos BootNext al candidato y el firmware terminó
343        // arrancando otra cosa ⇒ el candidato no llegó a ejecutarse.
344        assert_eq!(evaluar(&con_candidato(7, 1), Some(4)), Veredicto::CandidatoFallo { slot: 7 });
345    }
346
347    #[test]
348    fn sin_intentos_se_agota_en_vez_de_reintentar_para_siempre() {
349        assert_eq!(evaluar(&con_candidato(7, 0), Some(7)), Veredicto::CandidatoAgotado { slot: 7 });
350    }
351
352    #[test]
353    fn sin_boot_current_no_se_descarta_por_sospecha() {
354        // Hay firmwares que no publican BootCurrent. Descartar un candidato bueno es peor que
355        // dejarlo un arranque más, así que NO se consume intento.
356        assert_eq!(
357            evaluar(&con_candidato(7, 2), None),
358            Veredicto::EnPrueba { slot: 7, intentos_restantes: 2 },
359            "sin dato del firmware no se gasta intento"
360        );
361    }
362
363    #[test]
364    fn el_estado_ida_y_vuelta_y_un_esquema_ajeno_no_se_come() {
365        let tmp = std::env::temp_dir().join(format!("takana-ab-{}", std::process::id()));
366        std::fs::create_dir_all(&tmp).unwrap();
367        let f = tmp.join("ab.json");
368        let e = con_candidato(9, 2);
369        escribir_estado(&f, &e).unwrap();
370        assert_eq!(leer_estado(&f), e);
371        // Un fichero de otro esquema (o basura) devuelve el default, no un estado a medias.
372        std::fs::write(&f, r#"{"schema":"otra-cosa/9","candidato":null}"#).unwrap();
373        assert_eq!(leer_estado(&f), EstadoAb::default());
374        std::fs::write(&f, "no soy json").unwrap();
375        assert_eq!(leer_estado(&f), EstadoAb::default());
376        std::fs::remove_dir_all(&tmp).ok();
377    }
378
379    #[test]
380    fn la_ranura_del_candidato_no_pisa_la_del_estable() {
381        // Si compartieran ruta, un fallo a media copia se llevaría puesto el kernel al que hay que
382        // volver — el rollback dejaría de existir justo cuando hace falta.
383        assert_ne!(RUTA_CANDIDATO, RUTA_ESTABLE);
384        let esp = Path::new("/boot/efi");
385        assert_eq!(en_esp(esp, RUTA_CANDIDATO), esp.join("EFI/takana/takana-cand.efi"));
386        assert_eq!(en_esp(esp, "\\EFI\\takana\\x.efi"), esp.join("EFI/takana/x.efi"));
387    }
388
389    #[test]
390    fn instalar_candidato_es_atomico_y_no_deja_el_temporal() {
391        let tmp = std::env::temp_dir().join(format!("takana-ab-inst-{}", std::process::id()));
392        std::fs::create_dir_all(&tmp).unwrap();
393        let src = tmp.join("bzImage");
394        std::fs::write(&src, vec![0xAAu8; 4096]).unwrap();
395        let esp = tmp.join("esp");
396        let n = instalar_candidato(&src, &esp, RUTA_CANDIDATO).unwrap();
397        assert_eq!(n, 4096);
398        let dst = en_esp(&esp, RUTA_CANDIDATO);
399        assert_eq!(std::fs::read(&dst).unwrap().len(), 4096);
400        assert!(
401            !dst.parent().unwrap().join("takana-cand.tmp").exists(),
402            "el temporal se renombró, no quedó suelto"
403        );
404        std::fs::remove_dir_all(&tmp).ok();
405    }
406}