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 815Rust 56Shell 37Markdown 291229 Definitions

⎇ 4148ba4e02310090bb3c844c5942fe7e0251f106 crates/takana-cli/src/metal.rs Rust · 399 lines · 17.8 KB History Who touched what raw

la imagen USB, para cuando el salto no se puede — y tres bugs que sólo se ven por ese camino 4148ba4e0 · Sergio ·

1//! `takana metal` — instalar takana EN ESTA MÁQUINA, con UN verbo.
2//!
3//! ══ POR QUÉ EXISTE ════════════════════════════════════════════════════════════════════════════
4//! El instalador funcionaba y no se podía usar. La forma de invocarlo era ésta:
5//!
6//! ```text
7//! sudo ./takana kernel kexec ./kernel --initrd ./takana-instalador.cpio.gz \
8//!      --cmdline 'console=ttyS0,115200 console=tty0' --jump
9//! ```
10//!
11//! Eso no es un instalador: es el motor con la tapa abierta. Quien lo teclea tiene que saber que
12//! existe `kexec`, que el payload va por `--initrd`, que sin `--jump` sólo se carga, y qué consola
13//! pedirle al kernel nuevo. Ninguna de esas cuatro cosas es una decisión del que instala.
14//!
15//! `takana metal` es la tapa. Tres verbos, y el orden en que se usan es el orden en que se leen:
16//!
17//! ```text
18//! takana metal check     ¿puede esta máquina instalarse sin USB?   (no toca nada)
19//! takana metal survey    ¿qué hay en los discos y qué se puede usar? (no toca nada)
20//! takana metal install   el viaje entero: comprueba, avisa y salta
21//! ```
22//!
23//! ── UN MOTOR, VARIAS CARAS (SDD 28 §8) ────────────────────────────────────────────────────────
24//! `survey` **no reimplementa el censo**: llama al mismo `takana-install-partition.sh --survey` que
25//! corre dentro del instalador. Con `--json` emite el contrato que consume una cara gráfica
26//! (churay). El riesgo que esto evita está medido en otro frente: dos censos divergen, y el que se
27//! equivoca no muestra de menos — formatea de más.
28//!
29//! ── LO QUE ESTE VERBO NO HACE ─────────────────────────────────────────────────────────────────
30//! No elige partición, no formatea y no escribe la NVRAM. Todo eso pasa DESPUÉS del salto, dentro
31//! del instalador, que es donde el disco destino puede estar desmontado. Acá sólo se comprueba, se
32//! avisa y se salta.
33
34use std::path::{Path, PathBuf};
35
36use anyhow::{bail, Context, Result};
37use clap::Subcommand;
38
39use crate::kernel_ab;
40
41/// Nombres con los que viaja el payload. Se buscan en ese orden dentro del directorio del paquete.
42const INITRD: &[&str] = &["takana-instalador.cpio.gz", "instalador.cpio.gz", "initramfs.cpio.gz"];
43const KERNEL: &[&str] = &["kernel/boot/bzImage", "bzImage", "boot/bzImage", "kernel"];
44/// La consola por defecto pide las DOS: `ttyS0` es la única forma de ver por qué murió cuando no
45/// llega a pintar nada, y `tty0` es la pantalla del que instala.
46///
47/// ⚠ **El ORDEN no es cosmético: el kernel hace `/dev/console` = la ÚLTIMA `console=`.** Con `tty0`
48/// primero, `/dev/console` queda en el SERIAL — y el instalador interactivo, que lee y escribe por
49/// ahí, sale por un puerto que un portátil no tiene. La pantalla se queda en el último printk y el
50/// arranque parece colgado. `tty0` va ÚLTIMO.
51const CONSOLA: &str = "console=ttyS0,115200 console=tty0";
52
53#[derive(Subcommand)]
54pub enum MetalCmd {
55    /// ¿Puede esta máquina instalarse sin USB? Diagnóstico, no toca nada.
56    Check,
57    /// El terreno de esta máquina: qué hay en cada disco y qué se puede usar. No toca nada.
58    Survey {
59        /// Emitir el censo en JSON (el contrato que consume una cara gráfica).
60        #[arg(long)]
61        json: bool,
62        /// Directorio del paquete (por defecto, el del propio binario).
63        #[arg(long)]
64        payload: Option<PathBuf>,
65    },
66    /// Instalar takana en esta máquina: comprueba, avisa y salta al instalador.
67    Install {
68        /// Directorio del paquete (por defecto, el del propio binario).
69        #[arg(long)]
70        payload: Option<PathBuf>,
71        /// Partición destino, ya elegida. Sin esto, el instalador muestra el censo y pregunta.
72        /// **Usá `PARTUUID=…`**: los nombres `/dev/nvme…` no son estables entre arranques.
73        #[arg(long)]
74        root: Option<String>,
75        /// ESP a poblar. Sin esto se deduce del disco del destino.
76        #[arg(long)]
77        esp: Option<String>,
78        /// No formatear nada: instalar en el espacio SIN ASIGNAR más grande.
79        #[arg(long)]
80        free: bool,
81        /// Disco sobre el que buscar el hueco de `--free`.
82        #[arg(long)]
83        disk: Option<String>,
84        /// No preguntar antes de saltar. **No** hace desatendido al instalador: eso es `--unattended`.
85        #[arg(long, short = 'y')]
86        yes: bool,
87        /// Desatendido de punta a punta: el instalador tampoco pregunta. Exige `--root` o `--free`.
88        #[arg(long)]
89        unattended: bool,
90        /// Cargar el instalador en memoria y NO saltar. Ensayo: no se pierde la sesión.
91        #[arg(long)]
92        load_only: bool,
93        /// Consolas para el kernel del instalador.
94        #[arg(long, default_value = CONSOLA)]
95        console: String,
96    },
97}
98
99pub fn run(cmd: MetalCmd) -> Result<()> {
100    match cmd {
101        MetalCmd::Check => check(),
102        MetalCmd::Survey { json, payload } => survey(json, payload.as_deref()),
103        MetalCmd::Install {
104            payload, root, esp, free, disk, yes, unattended, load_only, console,
105        } => install(Instalacion {
106            payload, root, esp, free, disk, yes, unattended, load_only, console,
107        }),
108    }
109}
110
111struct Instalacion {
112    payload: Option<PathBuf>,
113    root: Option<String>,
114    esp: Option<String>,
115    free: bool,
116    disk: Option<String>,
117    yes: bool,
118    unattended: bool,
119    load_only: bool,
120    console: String,
121}
122
123// ── dónde está el paquete ─────────────────────────────────────────────────────────────────────
124// Por defecto, JUNTO AL BINARIO y no en el directorio actual: el paquete se copia entero a un pen
125// drive o a `~/Descargas` y se corre con `sudo ./takana …`, muchas veces desde otro sitio. Buscar
126// en el cwd manda a «no encuentro el instalador» a alguien que lo tiene al lado.
127fn dir_del_binario() -> Option<PathBuf> {
128    std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf())
129}
130
131fn buscar(dirs: &[PathBuf], nombres: &[&str]) -> Option<PathBuf> {
132    for d in dirs {
133        for n in nombres {
134            let p = d.join(n);
135            if p.is_file() {
136                return Some(p);
137            }
138        }
139    }
140    None
141}
142
143fn candidatos(payload: Option<&Path>) -> Vec<PathBuf> {
144    let mut v = Vec::new();
145    if let Some(p) = payload {
146        v.push(p.to_path_buf());
147    } else {
148        if let Some(d) = dir_del_binario() {
149            v.push(d);
150        }
151        v.push(PathBuf::from("."));
152        // El árbol del repo, para el que trabaja desde adentro.
153        v.push(PathBuf::from("work/instalador-salida"));
154        // ⚠ Y las rutas de un takana INSTALADO. Sin esto, `takana metal survey` sólo funcionaba
155        // con el paquete recién copiado: llamado desde una cara gráfica que encontró `takana` en
156        // el `PATH` —que es exactamente como lo va a encontrar churay— el binario está en
157        // `/usr/bin` y el censo no está al lado, así que el censo moría con «no encuentro
158        // takana-install-partition.sh». Medido el 2026-09-23 desde `churay-cli metal`.
159        for d in ["/usr/libexec/takana", "/usr/lib/takana", "/usr/share/takana"] {
160            v.push(PathBuf::from(d));
161        }
162    }
163    v
164}
165
166// ── check ─────────────────────────────────────────────────────────────────────────────────────
167fn check() -> Result<()> {
168    let (puede, motivos) = crate::kernel_cmd::kexec_probe();
169    if puede {
170        println!("✓ esta máquina puede instalarse SIN USB: el salto al instalador está disponible.");
171        println!("  Seguí con:  takana metal survey      (mirar el terreno, no toca nada)");
172        println!("              sudo takana metal install");
173        return Ok(());
174    }
175    println!("✗ esta máquina NO puede saltar al instalador desde el sistema que corre:");
176    for m in &motivos {
177        println!("  · {m}");
178    }
179    println!();
180    // ⚠ Sin root el diagnóstico NO es concluyente —el kernel contesta EPERM antes de decir si
181    // implementa el syscall— así que mandar al USB acá sería mandar a la solución cara por un
182    // problema que se arregla con `sudo`. Medido en esta misma caja: como uid 1001 el motivo es
183    // «falta root»; como root aparece el de verdad, que es otro.
184    if !crate::kernel_cmd::soy_root() {
185        println!("Antes de nada: repetilo con `sudo`. Sin privilegios este diagnóstico no distingue");
186        println!("«te falta ser root» de «tu kernel no lo trae», y son cosas distintas.");
187        std::process::exit(1);
188    }
189    println!("Eso NO quiere decir que no se pueda instalar: quiere decir que hay que arrancar el");
190    println!("instalador desde otro medio. La imagen USB hace exactamente lo mismo que el salto —el");
191    println!("instalador es el mismo— y no depende de lo que traiga el kernel de esta máquina.");
192    std::process::exit(1);
193}
194
195// ── survey ────────────────────────────────────────────────────────────────────────────────────
196fn survey(json: bool, payload: Option<&Path>) -> Result<()> {
197    let guion = buscar(
198        &candidatos(payload),
199        &["scripts/takana-install-partition.sh", "takana-install-partition.sh"],
200    )
201    .ok_or_else(|| {
202        anyhow::anyhow!(
203            "no encuentro `takana-install-partition.sh` junto al binario ni en el directorio actual.\n  \
204             Pasá --payload <directorio del paquete>."
205        )
206    })?;
207    let mut c = std::process::Command::new("sh");
208    c.arg(&guion).arg("--survey");
209    if json {
210        c.arg("--json");
211    }
212    let st = c
213        .status()
214        .with_context(|| format!("no puedo ejecutar {}", guion.display()))?;
215    if !st.success() {
216        bail!("el censo falló ({st})");
217    }
218    Ok(())
219}
220
221// ── install ───────────────────────────────────────────────────────────────────────────────────
222fn install(i: Instalacion) -> Result<()> {
223    if i.unattended && i.root.is_none() && !i.free {
224        bail!(
225            "--unattended sin destino: nadie va a estar ahí para elegirlo.\n  \
226             Dale --root PARTUUID=… (mirá `takana metal survey`) o --free."
227        );
228    }
229    let dirs = candidatos(i.payload.as_deref());
230    let initrd = buscar(&dirs, INITRD).ok_or_else(|| {
231        anyhow::anyhow!(
232            "no encuentro el instalador ({}) en:\n  {}\n  Pasá --payload <directorio del paquete>.",
233            INITRD.join(" / "),
234            dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join("\n  ")
235        )
236    })?;
237    let bz = buscar(&dirs, KERNEL).ok_or_else(|| {
238        anyhow::anyhow!(
239            "encontré el instalador ({}) pero no su kernel ({}).\n  \
240             El paquete va entero: el instalador corre sobre SU kernel, no sobre el de esta máquina.",
241            initrd.display(),
242            KERNEL.join(" / ")
243        )
244    })?;
245
246    // El diagnóstico va ANTES de decir lo que va a pasar: si la máquina no puede saltar, prometer
247    // el viaje y fallar al final es peor que no prometerlo.
248    let (puede, motivos) = crate::kernel_cmd::kexec_probe();
249    if !puede {
250        println!("✗ no se puede saltar al instalador desde esta máquina:");
251        for m in &motivos {
252            println!("  · {m}");
253        }
254        println!();
255        if !crate::kernel_cmd::soy_root() {
256            println!("Repetilo con `sudo`: sin privilegios esto no es concluyente.");
257        } else {
258            println!("Con la imagen USB se instala igual: el instalador es el mismo.");
259        }
260        std::process::exit(1);
261    }
262
263    let cmdline = cmdline_de(&i);
264    println!("── instalar takana en esta máquina ────────────────────────────────────────────");
265    println!("  instalador: {}  ({})", initrd.display(), tam(&initrd));
266    println!("  su kernel:  {}  ({})", bz.display(), tam(&bz));
267    match (&i.root, i.free) {
268        (Some(r), _) => println!("  destino:    {r}"),
269        (None, true) => println!("  destino:    el espacio SIN ASIGNAR más grande (no se borra nada)"),
270        (None, false) => println!("  destino:    lo vas a elegir en la pantalla siguiente"),
271    }
272    println!();
273    println!("  Lo que pasa al confirmar: esta sesión MUERE y arranca el instalador en RAM. Todavía");
274    println!("  no se escribe en ningún disco — el instalador muestra el terreno y pregunta antes.");
275    if i.unattended {
276        println!("  ⚠ --unattended: el instalador NO va a preguntar. Formatea el destino de arriba.");
277    }
278    println!("  Lo que NO vuelve: los programas abiertos de esta sesión. Guardá antes.");
279    println!("──────────────────────────────────────────────────────────────────────────────");
280
281    if !i.yes && !i.load_only && !confirmar()? {
282        println!("nada hecho.");
283        return Ok(());
284    }
285
286    kernel_ab::kexec_cargar(&bz, Some(&initrd), &cmdline)?;
287    println!("✓ instalador cargado en memoria");
288    if i.load_only {
289        println!("  (--load-only: no se salta. Queda cargado hasta el próximo reinicio.)");
290        return Ok(());
291    }
292    println!("⚡ saltando al instalador — esto no vuelve.");
293    kernel_ab::kexec_saltar()
294}
295
296/// La cmdline del instalador: las respuestas que ya se dieron viajan por acá, y las que no, no.
297/// Es el mismo canal que lee el `/init` del initramfs, así que **no hay nada horneado por máquina**.
298fn cmdline_de(i: &Instalacion) -> String {
299    let mut s = i.console.clone();
300    if let Some(r) = &i.root {
301        s.push_str(&format!(" takana.root={r}"));
302    }
303    if let Some(e) = &i.esp {
304        s.push_str(&format!(" takana.esp={e}"));
305    }
306    if let Some(d) = &i.disk {
307        s.push_str(&format!(" takana.disk={d}"));
308    }
309    if i.free {
310        s.push_str(" takana.free");
311    }
312    if i.unattended {
313        s.push_str(" takana.yes");
314    }
315    s
316}
317
318fn tam(p: &Path) -> String {
319    match std::fs::metadata(p).map(|m| m.len()) {
320        Ok(b) if b >= 1 << 20 => format!("{} M", b >> 20),
321        Ok(b) => format!("{} K", b >> 10),
322        Err(_) => "?".to_string(),
323    }
324}
325
326/// Confirmación explícita. **No** acepta un `enter` pelado: el enter es lo que se aprieta sin leer,
327/// y lo que sigue mata la sesión.
328fn confirmar() -> Result<bool> {
329    use std::io::{BufRead, Write};
330    print!("¿Saltar al instalador? Escribí «si» para seguir: ");
331    std::io::stdout().flush().ok();
332    let mut l = String::new();
333    if std::io::stdin().lock().read_line(&mut l).is_err() {
334        return Ok(false);
335    }
336    let l = l.trim().to_lowercase();
337    Ok(l == "si" || l == "sí")
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    fn base() -> Instalacion {
345        Instalacion {
346            payload: None, root: None, esp: None, free: false, disk: None,
347            yes: false, unattended: false, load_only: false, console: CONSOLA.to_string(),
348        }
349    }
350
351    #[test]
352    fn sin_respuestas_la_cmdline_no_lleva_ninguna_maquina_adentro() {
353        // El punto entero de la generalización: un paquete sirve en cualquier máquina porque no
354        // trae respuestas horneadas. Si esto empieza a fallar, alguien volvió a hornear.
355        assert_eq!(cmdline_de(&base()), CONSOLA);
356    }
357
358    #[test]
359    fn las_respuestas_dadas_viajan_por_la_cmdline() {
360        let i = Instalacion {
361            root: Some("PARTUUID=55b5-0de7".into()),
362            esp: Some("PARTUUID=aaaa-bbbb".into()),
363            unattended: true,
364            ..base()
365        };
366        let c = cmdline_de(&i);
367        assert!(c.contains("takana.root=PARTUUID=55b5-0de7"), "{c}");
368        assert!(c.contains("takana.esp=PARTUUID=aaaa-bbbb"), "{c}");
369        assert!(c.contains("takana.yes"), "{c}");
370        assert!(!c.contains("takana.free"), "{c}");
371    }
372
373    #[test]
374    fn free_y_disk_viajan_juntos_y_sin_destino() {
375        let i = Instalacion { free: true, disk: Some("/dev/nvme0n1".into()), ..base() };
376        let c = cmdline_de(&i);
377        assert!(c.contains("takana.free"), "{c}");
378        assert!(c.contains("takana.disk=/dev/nvme0n1"), "{c}");
379        assert!(!c.contains("takana.root="), "{c}");
380    }
381
382    #[test]
383    fn buscar_respeta_el_orden_de_los_nombres_y_de_los_directorios() {
384        let d = std::env::temp_dir().join(format!("metal-test-{}", std::process::id()));
385        let a = d.join("a");
386        let b = d.join("b");
387        std::fs::create_dir_all(&a).unwrap();
388        std::fs::create_dir_all(&b).unwrap();
389        std::fs::write(b.join("initramfs.cpio.gz"), b"x").unwrap();
390        std::fs::write(b.join("takana-instalador.cpio.gz"), b"x").unwrap();
391        // El directorio gana sobre el nombre: `a` no lo tiene, así que se cae a `b`.
392        let v = vec![a.clone(), b.clone()];
393        assert_eq!(buscar(&v, INITRD).unwrap(), b.join("takana-instalador.cpio.gz"));
394        std::fs::write(a.join("initramfs.cpio.gz"), b"x").unwrap();
395        // Y ahora `a` gana aunque su nombre sea el último de la lista: primero el directorio.
396        assert_eq!(buscar(&v, INITRD).unwrap(), a.join("initramfs.cpio.gz"));
397        std::fs::remove_dir_all(&d).ok();
398    }
399}