tawasuyu

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 1569Shell 203Rust 109Markdown 993728 Definiciones

takana metal: la tapa del instalador — tres verbos en vez del motor a la vista

El instalador funcionaba desde ayer y no se podía usar. La forma de invocarlo era

    sudo ./takana kernel kexec ./kernel --initrd ./takana-instalador.cpio.gz \
         --cmdline 'console=ttyS0,115200 console=tty0' --jump

y el usuario lo nombró exacto: «esos son comandos a lo poweruser». Tiene razón — eso no es
un instalador, es el motor con la tapa abierta. Quien lo teclea tiene que saber que existe
kexec, que el payload va por --initrd, que sin --jump sólo se carga y qué consola pedirle
al kernel nuevo. Ninguna de esas cuatro cosas es una decisión del que instala.

    takana metal check     ¿puede esta máquina instalarse sin USB?   (no toca nada)
    takana metal survey    ¿qué hay en los discos?                   (no toca nada)
    takana metal install   el viaje entero: comprueba, avisa y salta

`survey` NO reimplementa el censo: llama al mismo takana-install-partition.sh --survey que
corre dentro del instalador, y con --json emite el contrato que consume una cara gráfica.
Es la parte que no se negocia (ADR 0021).

`install` busca el paquete junto al BINARIO y no en el directorio actual —el paquete se
copia a un pen drive y se corre desde cualquier lado—, corre el diagnóstico ANTES de
prometer nada, dice en voz alta qué va a pasar y pide un «si» escrito: no acepta un enter
pelado, que es lo que se aprieta sin leer, y lo que sigue mata la sesión. Las respuestas ya
dadas viajan por la cmdline del kernel (takana.root=, takana.esp=, takana.free,
takana.disk=, takana.yes), que es el mismo canal que lee el /init del initramfs: no hay
nada horneado por máquina, y hay un test que lo sostiene.

`--yes` cubre sólo el salto; el desatendido de punta a punta es `--unattended` y exige
destino, porque nadie va a estar ahí para elegirlo.

Y se expone `soy_root`: un «te falta ser root» y un «tu kernel no trae el syscall» se
arreglan de formas opuestas —repetir con sudo contra arrancar de un USB— y sin privilegios
el sondeo no distingue uno del otro. Medido en esta caja: como uid 1001 dice que falta
root; como root aparece la causa real.

a1bcfa9b1e7267a7ca8de79544875106c5c1d8f1 · por Sergio · (hace 29 d) · padre 251405e72 · ver el árbol en este commit

Delta semántico · 27

crates/takana-cli/src/kernel_cmd.rs

crates/takana-cli/src/main.rs

  • cambió de firma enum Cmd
  • cambió por dentro fn main
  • nueva mod metal

crates/takana-cli/src/metal.rs

3 archivos · +410 −1

diff --git a/crates/takana-cli/src/kernel_cmd.rs b/crates/takana-cli/src/kernel_cmd.rs
index 1c7d13df..3ec9087e 100644
--- a/crates/takana-cli/src/kernel_cmd.rs
+++ b/crates/takana-cli/src/kernel_cmd.rs
@@ -2157,7 +2157,7 @@ fn ab_rollback(esp: &Path, efivars: &Path, state: &Path) -> Result<()> {
 /// Se responde **preguntándole al kernel** (un `kexec_file_load` con fd inválido distingue ENOSYS de
 /// EBADF sin cargar nada) y leyendo `/sys/kernel/security/lockdown`, no adivinando por el `.config`:
 /// el `.config` que hay en `/boot` puede no ser el del kernel que corre.
-fn kexec_probe() -> (bool, Vec<String>) {
+pub(crate) fn kexec_probe() -> (bool, Vec<String>) {
     let mut motivos = Vec::new();
     #[cfg(target_family = "unix")]
     let root = unsafe { libc_getuid() } == 0;
@@ -2204,6 +2204,18 @@ fn kexec_probe() -> (bool, Vec<String>) {
     (motivos.is_empty(), motivos)
 }
 
+/// Si somos root. Se expone porque un «te falta ser root» y un «tu kernel no trae el syscall» se
+/// arreglan de formas opuestas —repetir con `sudo` contra arrancar de un USB— y quien pone la cara
+/// amable necesita distinguirlos sin leer las cadenas del diagnóstico.
+#[cfg(target_family = "unix")]
+pub(crate) fn soy_root() -> bool {
+    unsafe { libc_getuid() == 0 }
+}
+#[cfg(not(target_family = "unix"))]
+pub(crate) fn soy_root() -> bool {
+    true
+}
+
 /// `getuid()` sin arrastrar la crate `libc` al workspace, igual que el syscall de kexec.
 #[cfg(target_arch = "x86_64")]
 unsafe fn libc_getuid() -> u32 {
diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs
index 05521ee7..b3b70617 100644
--- a/crates/takana-cli/src/main.rs
+++ b/crates/takana-cli/src/main.rs
@@ -13,6 +13,7 @@ mod efi_boot;
 mod fat_ro;
 mod kernel_ab;
 mod kernel_cmd;
+mod metal;
 mod qorpa;
 mod qorpa_wl;
 mod nix_import;
@@ -547,6 +548,15 @@ enum Cmd {
         #[command(subcommand)]
         sub: kernel_cmd::KernelCmd,
     },
+    /// Instalar takana EN ESTA MÁQUINA, con un verbo en vez de con el motor a la vista.
+    ///
+    /// `check` dice si se puede sin USB · `survey` muestra el terreno sin tocarlo ·
+    /// `install` comprueba, avisa y salta al instalador. Elegir partición, formatear y
+    /// escribir la NVRAM pasa DESPUÉS del salto, dentro del instalador.
+    Metal {
+        #[command(subcommand)]
+        sub: metal::MetalCmd,
+    },
     /// [ADR 0015] Imágenes ajenas: rootfs de otras distros, pineados por digest, que corren
     /// enjaulados sobre el mismo kernel. **No entran al store** — no reproducen desde fuente, y
     /// meterlos ahí haría que el store mienta.
@@ -2297,6 +2307,7 @@ del ensamblado mapea al que CORRE ⇒ este bit no saldría root en la imagen");
             }
         }
         Cmd::Kernel { sub } => kernel_cmd::run(sub, &cli.store)?,
+        Cmd::Metal { sub } => metal::run(sub)?,
         Cmd::Qorpa { sub } => qorpa::run(sub, std::path::Path::new(&cli.store))?,
     }
     Ok(())
diff --git a/crates/takana-cli/src/metal.rs b/crates/takana-cli/src/metal.rs
new file mode 100644
index 00000000..4e1e42d3
--- /dev/null
+++ b/crates/takana-cli/src/metal.rs
@@ -0,0 +1,386 @@
+//! `takana metal` — instalar takana EN ESTA MÁQUINA, con UN verbo.
+//!
+//! ══ POR QUÉ EXISTE ════════════════════════════════════════════════════════════════════════════
+//! El instalador funcionaba y no se podía usar. La forma de invocarlo era ésta:
+//!
+//! ```text
+//! sudo ./takana kernel kexec ./kernel --initrd ./takana-instalador.cpio.gz \
+//!      --cmdline 'console=ttyS0,115200 console=tty0' --jump
+//! ```
+//!
+//! Eso no es un instalador: es el motor con la tapa abierta. Quien lo teclea tiene que saber que
+//! existe `kexec`, que el payload va por `--initrd`, que sin `--jump` sólo se carga, y qué consola
+//! pedirle al kernel nuevo. Ninguna de esas cuatro cosas es una decisión del que instala.
+//!
+//! `takana metal` es la tapa. Tres verbos, y el orden en que se usan es el orden en que se leen:
+//!
+//! ```text
+//! takana metal check     ¿puede esta máquina instalarse sin USB?   (no toca nada)
+//! takana metal survey    ¿qué hay en los discos y qué se puede usar? (no toca nada)
+//! takana metal install   el viaje entero: comprueba, avisa y salta
+//! ```
+//!
+//! ── UN MOTOR, VARIAS CARAS (SDD 28 §8) ────────────────────────────────────────────────────────
+//! `survey` **no reimplementa el censo**: llama al mismo `takana-install-partition.sh --survey` que
+//! corre dentro del instalador. Con `--json` emite el contrato que consume una cara gráfica
+//! (churay). El riesgo que esto evita está medido en otro frente: dos censos divergen, y el que se
+//! equivoca no muestra de menos — formatea de más.
+//!
+//! ── LO QUE ESTE VERBO NO HACE ─────────────────────────────────────────────────────────────────
+//! No elige partición, no formatea y no escribe la NVRAM. Todo eso pasa DESPUÉS del salto, dentro
+//! del instalador, que es donde el disco destino puede estar desmontado. Acá sólo se comprueba, se
+//! avisa y se salta.
+
+use std::path::{Path, PathBuf};
+
+use anyhow::{bail, Context, Result};
+use clap::Subcommand;
+
+use crate::kernel_ab;
+
+/// Nombres con los que viaja el payload. Se buscan en ese orden dentro del directorio del paquete.
+const INITRD: &[&str] = &["takana-instalador.cpio.gz", "instalador.cpio.gz", "initramfs.cpio.gz"];
+const KERNEL: &[&str] = &["kernel/boot/bzImage", "bzImage", "boot/bzImage", "kernel"];
+/// La consola por defecto pide las DOS: `tty0` es la pantalla del que instala y `ttyS0` es la
+/// única forma de ver por qué murió cuando no llega a pintar nada.
+const CONSOLA: &str = "console=tty0 console=ttyS0,115200";
+
+#[derive(Subcommand)]
+pub enum MetalCmd {
+    /// ¿Puede esta máquina instalarse sin USB? Diagnóstico, no toca nada.
+    Check,
+    /// El terreno de esta máquina: qué hay en cada disco y qué se puede usar. No toca nada.
+    Survey {
+        /// Emitir el censo en JSON (el contrato que consume una cara gráfica).
+        #[arg(long)]
+        json: bool,
+        /// Directorio del paquete (por defecto, el del propio binario).
+        #[arg(long)]
+        payload: Option<PathBuf>,
+    },
+    /// Instalar takana en esta máquina: comprueba, avisa y salta al instalador.
+    Install {
+        /// Directorio del paquete (por defecto, el del propio binario).
+        #[arg(long)]
+        payload: Option<PathBuf>,
+        /// Partición destino, ya elegida. Sin esto, el instalador muestra el censo y pregunta.
+        /// **Usá `PARTUUID=…`**: los nombres `/dev/nvme…` no son estables entre arranques.
+        #[arg(long)]
+        root: Option<String>,
+        /// ESP a poblar. Sin esto se deduce del disco del destino.
+        #[arg(long)]
+        esp: Option<String>,
+        /// No formatear nada: instalar en el espacio SIN ASIGNAR más grande.
+        #[arg(long)]
+        free: bool,
+        /// Disco sobre el que buscar el hueco de `--free`.
+        #[arg(long)]
+        disk: Option<String>,
+        /// No preguntar antes de saltar. **No** hace desatendido al instalador: eso es `--unattended`.
+        #[arg(long, short = 'y')]
+        yes: bool,
+        /// Desatendido de punta a punta: el instalador tampoco pregunta. Exige `--root` o `--free`.
+        #[arg(long)]
+        unattended: bool,
+        /// Cargar el instalador en memoria y NO saltar. Ensayo: no se pierde la sesión.
+        #[arg(long)]
+        load_only: bool,
+        /// Consolas para el kernel del instalador.
+        #[arg(long, default_value = CONSOLA)]
+        console: String,
+    },
+}
+
+pub fn run(cmd: MetalCmd) -> Result<()> {
+    match cmd {
+        MetalCmd::Check => check(),
+        MetalCmd::Survey { json, payload } => survey(json, payload.as_deref()),
+        MetalCmd::Install {
+            payload, root, esp, free, disk, yes, unattended, load_only, console,
+        } => install(Instalacion {
+            payload, root, esp, free, disk, yes, unattended, load_only, console,
+        }),
+    }
+}
+
+struct Instalacion {
+    payload: Option<PathBuf>,
+    root: Option<String>,
+    esp: Option<String>,
+    free: bool,
+    disk: Option<String>,
+    yes: bool,
+    unattended: bool,
+    load_only: bool,
+    console: String,
+}
+
+// ── dónde está el paquete ─────────────────────────────────────────────────────────────────────
+// Por defecto, JUNTO AL BINARIO y no en el directorio actual: el paquete se copia entero a un pen
+// drive o a `~/Descargas` y se corre con `sudo ./takana …`, muchas veces desde otro sitio. Buscar
+// en el cwd manda a «no encuentro el instalador» a alguien que lo tiene al lado.
+fn dir_del_binario() -> Option<PathBuf> {
+    std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf())
+}
+
+fn buscar(dirs: &[PathBuf], nombres: &[&str]) -> Option<PathBuf> {
+    for d in dirs {
+        for n in nombres {
+            let p = d.join(n);
+            if p.is_file() {
+                return Some(p);
+            }
+        }
+    }
+    None
+}
+
+fn candidatos(payload: Option<&Path>) -> Vec<PathBuf> {
+    let mut v = Vec::new();
+    if let Some(p) = payload {
+        v.push(p.to_path_buf());
+    } else {
+        if let Some(d) = dir_del_binario() {
+            v.push(d);
+        }
+        v.push(PathBuf::from("."));
+        // El árbol del repo, para el que trabaja desde adentro.
+        v.push(PathBuf::from("work/instalador-salida"));
+    }
+    v
+}
+
+// ── check ─────────────────────────────────────────────────────────────────────────────────────
+fn check() -> Result<()> {
+    let (puede, motivos) = crate::kernel_cmd::kexec_probe();
+    if puede {
+        println!("✓ esta máquina puede instalarse SIN USB: el salto al instalador está disponible.");
+        println!("  Seguí con:  takana metal survey      (mirar el terreno, no toca nada)");
+        println!("              sudo takana metal install");
+        return Ok(());
+    }
+    println!("✗ esta máquina NO puede saltar al instalador desde el sistema que corre:");
+    for m in &motivos {
+        println!("  · {m}");
+    }
+    println!();
+    // ⚠ Sin root el diagnóstico NO es concluyente —el kernel contesta EPERM antes de decir si
+    // implementa el syscall— así que mandar al USB acá sería mandar a la solución cara por un
+    // problema que se arregla con `sudo`. Medido en esta misma caja: como uid 1001 el motivo es
+    // «falta root»; como root aparece el de verdad, que es otro.
+    if !crate::kernel_cmd::soy_root() {
+        println!("Antes de nada: repetilo con `sudo`. Sin privilegios este diagnóstico no distingue");
+        println!("«te falta ser root» de «tu kernel no lo trae», y son cosas distintas.");
+        std::process::exit(1);
+    }
+    println!("Eso NO quiere decir que no se pueda instalar: quiere decir que hay que arrancar el");
+    println!("instalador desde otro medio. La imagen USB hace exactamente lo mismo que el salto —el");
+    println!("instalador es el mismo— y no depende de lo que traiga el kernel de esta máquina.");
+    std::process::exit(1);
+}
+
+// ── survey ────────────────────────────────────────────────────────────────────────────────────
+fn survey(json: bool, payload: Option<&Path>) -> Result<()> {
+    let guion = buscar(
+        &candidatos(payload),
+        &["scripts/takana-install-partition.sh", "takana-install-partition.sh"],
+    )
+    .ok_or_else(|| {
+        anyhow::anyhow!(
+            "no encuentro `takana-install-partition.sh` junto al binario ni en el directorio actual.\n  \
+             Pasá --payload <directorio del paquete>."
+        )
+    })?;
+    let mut c = std::process::Command::new("sh");
+    c.arg(&guion).arg("--survey");
+    if json {
+        c.arg("--json");
+    }
+    let st = c
+        .status()
+        .with_context(|| format!("no puedo ejecutar {}", guion.display()))?;
+    if !st.success() {
+        bail!("el censo falló ({st})");
+    }
+    Ok(())
+}
+
+// ── install ───────────────────────────────────────────────────────────────────────────────────
+fn install(i: Instalacion) -> Result<()> {
+    if i.unattended && i.root.is_none() && !i.free {
+        bail!(
+            "--unattended sin destino: nadie va a estar ahí para elegirlo.\n  \
+             Dale --root PARTUUID=… (mirá `takana metal survey`) o --free."
+        );
+    }
+    let dirs = candidatos(i.payload.as_deref());
+    let initrd = buscar(&dirs, INITRD).ok_or_else(|| {
+        anyhow::anyhow!(
+            "no encuentro el instalador ({}) en:\n  {}\n  Pasá --payload <directorio del paquete>.",
+            INITRD.join(" / "),
+            dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join("\n  ")
+        )
+    })?;
+    let bz = buscar(&dirs, KERNEL).ok_or_else(|| {
+        anyhow::anyhow!(
+            "encontré el instalador ({}) pero no su kernel ({}).\n  \
+             El paquete va entero: el instalador corre sobre SU kernel, no sobre el de esta máquina.",
+            initrd.display(),
+            KERNEL.join(" / ")
+        )
+    })?;
+
+    // El diagnóstico va ANTES de decir lo que va a pasar: si la máquina no puede saltar, prometer
+    // el viaje y fallar al final es peor que no prometerlo.
+    let (puede, motivos) = crate::kernel_cmd::kexec_probe();
+    if !puede {
+        println!("✗ no se puede saltar al instalador desde esta máquina:");
+        for m in &motivos {
+            println!("  · {m}");
+        }
+        println!();
+        if !crate::kernel_cmd::soy_root() {
+            println!("Repetilo con `sudo`: sin privilegios esto no es concluyente.");
+        } else {
+            println!("Con la imagen USB se instala igual: el instalador es el mismo.");
+        }
+        std::process::exit(1);
+    }
+
+    let cmdline = cmdline_de(&i);
+    println!("── instalar takana en esta máquina ────────────────────────────────────────────");
+    println!("  instalador: {}  ({})", initrd.display(), tam(&initrd));
+    println!("  su kernel:  {}  ({})", bz.display(), tam(&bz));
+    match (&i.root, i.free) {
+        (Some(r), _) => println!("  destino:    {r}"),
+        (None, true) => println!("  destino:    el espacio SIN ASIGNAR más grande (no se borra nada)"),
+        (None, false) => println!("  destino:    lo vas a elegir en la pantalla siguiente"),
+    }
+    println!();
+    println!("  Lo que pasa al confirmar: esta sesión MUERE y arranca el instalador en RAM. Todavía");
+    println!("  no se escribe en ningún disco — el instalador muestra el terreno y pregunta antes.");
+    if i.unattended {
+        println!("  ⚠ --unattended: el instalador NO va a preguntar. Formatea el destino de arriba.");
+    }
+    println!("  Lo que NO vuelve: los programas abiertos de esta sesión. Guardá antes.");
+    println!("──────────────────────────────────────────────────────────────────────────────");
+
+    if !i.yes && !i.load_only && !confirmar()? {
+        println!("nada hecho.");
+        return Ok(());
+    }
+
+    kernel_ab::kexec_cargar(&bz, Some(&initrd), &cmdline)?;
+    println!("✓ instalador cargado en memoria");
+    if i.load_only {
+        println!("  (--load-only: no se salta. Queda cargado hasta el próximo reinicio.)");
+        return Ok(());
+    }
+    println!("⚡ saltando al instalador — esto no vuelve.");
+    kernel_ab::kexec_saltar()
+}
+
+/// La cmdline del instalador: las respuestas que ya se dieron viajan por acá, y las que no, no.
+/// Es el mismo canal que lee el `/init` del initramfs, así que **no hay nada horneado por máquina**.
+fn cmdline_de(i: &Instalacion) -> String {
+    let mut s = i.console.clone();
+    if let Some(r) = &i.root {
+        s.push_str(&format!(" takana.root={r}"));
+    }
+    if let Some(e) = &i.esp {
+        s.push_str(&format!(" takana.esp={e}"));
+    }
+    if let Some(d) = &i.disk {
+        s.push_str(&format!(" takana.disk={d}"));
+    }
+    if i.free {
+        s.push_str(" takana.free");
+    }
+    if i.unattended {
+        s.push_str(" takana.yes");
+    }
+    s
+}
+
+fn tam(p: &Path) -> String {
+    match std::fs::metadata(p).map(|m| m.len()) {
+        Ok(b) if b >= 1 << 20 => format!("{} M", b >> 20),
+        Ok(b) => format!("{} K", b >> 10),
+        Err(_) => "?".to_string(),
+    }
+}
+
+/// Confirmación explícita. **No** acepta un `enter` pelado: el enter es lo que se aprieta sin leer,
+/// y lo que sigue mata la sesión.
+fn confirmar() -> Result<bool> {
+    use std::io::{BufRead, Write};
+    print!("¿Saltar al instalador? Escribí «si» para seguir: ");
+    std::io::stdout().flush().ok();
+    let mut l = String::new();
+    if std::io::stdin().lock().read_line(&mut l).is_err() {
+        return Ok(false);
+    }
+    let l = l.trim().to_lowercase();
+    Ok(l == "si" || l == "sí")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn base() -> Instalacion {
+        Instalacion {
+            payload: None, root: None, esp: None, free: false, disk: None,
+            yes: false, unattended: false, load_only: false, console: CONSOLA.to_string(),
+        }
+    }
+
+    #[test]
+    fn sin_respuestas_la_cmdline_no_lleva_ninguna_maquina_adentro() {
+        // El punto entero de la generalización: un paquete sirve en cualquier máquina porque no
+        // trae respuestas horneadas. Si esto empieza a fallar, alguien volvió a hornear.
+        assert_eq!(cmdline_de(&base()), CONSOLA);
+    }
+
+    #[test]
+    fn las_respuestas_dadas_viajan_por_la_cmdline() {
+        let i = Instalacion {
+            root: Some("PARTUUID=55b5-0de7".into()),
+            esp: Some("PARTUUID=aaaa-bbbb".into()),
+            unattended: true,
+            ..base()
+        };
+        let c = cmdline_de(&i);
+        assert!(c.contains("takana.root=PARTUUID=55b5-0de7"), "{c}");
+        assert!(c.contains("takana.esp=PARTUUID=aaaa-bbbb"), "{c}");
+        assert!(c.contains("takana.yes"), "{c}");
+        assert!(!c.contains("takana.free"), "{c}");
+    }
+
+    #[test]
+    fn free_y_disk_viajan_juntos_y_sin_destino() {
+        let i = Instalacion { free: true, disk: Some("/dev/nvme0n1".into()), ..base() };
+        let c = cmdline_de(&i);
+        assert!(c.contains("takana.free"), "{c}");
+        assert!(c.contains("takana.disk=/dev/nvme0n1"), "{c}");
+        assert!(!c.contains("takana.root="), "{c}");
+    }
+
+    #[test]
+    fn buscar_respeta_el_orden_de_los_nombres_y_de_los_directorios() {
+        let d = std::env::temp_dir().join(format!("metal-test-{}", std::process::id()));
+        let a = d.join("a");
+        let b = d.join("b");
+        std::fs::create_dir_all(&a).unwrap();
+        std::fs::create_dir_all(&b).unwrap();
+        std::fs::write(b.join("initramfs.cpio.gz"), b"x").unwrap();
+        std::fs::write(b.join("takana-instalador.cpio.gz"), b"x").unwrap();
+        // El directorio gana sobre el nombre: `a` no lo tiene, así que se cae a `b`.
+        let v = vec![a.clone(), b.clone()];
+        assert_eq!(buscar(&v, INITRD).unwrap(), b.join("takana-instalador.cpio.gz"));
+        std::fs::write(a.join("initramfs.cpio.gz"), b"x").unwrap();
+        // Y ahora `a` gana aunque su nombre sea el último de la lista: primero el directorio.
+        assert_eq!(buscar(&v, INITRD).unwrap(), a.join("initramfs.cpio.gz"));
+        std::fs::remove_dir_all(&d).ok();
+    }
+}