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 188Rust 55Shell 30Markdown 281211 Definiciones

⎇ 551a103e2dd119ec47b28d831074a214c8a080d2 crates/takana-cli/src/metal.rs Rust · 394 líneas · 17.4 KB Historia Quién tocó qué crudo

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