//! `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, }, /// 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, /// 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, /// ESP a poblar. Sin esto se deduce del disco del destino. #[arg(long)] esp: Option, /// 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, /// 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, root: Option, esp: Option, free: bool, disk: Option, 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 { std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf()) } fn buscar(dirs: &[PathBuf], nombres: &[&str]) -> Option { 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 { 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")); // ⚠ Y las rutas de un takana INSTALADO. Sin esto, `takana metal survey` sólo funcionaba // con el paquete recién copiado: llamado desde una cara gráfica que encontró `takana` en // el `PATH` —que es exactamente como lo va a encontrar churay— el binario está en // `/usr/bin` y el censo no está al lado, así que el censo moría con «no encuentro // takana-install-partition.sh». Medido el 2026-09-23 desde `churay-cli metal`. for d in ["/usr/libexec/takana", "/usr/lib/takana", "/usr/share/takana"] { v.push(PathBuf::from(d)); } } 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 ." ) })?; 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 .", INITRD.join(" / "), dirs.iter().map(|d| d.display().to_string()).collect::>().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 { 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(); } }