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…
⎇ 4148ba4e02310090bb3c844c5942fe7e0251f106
crates/takana-cli/src/metal.rs
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
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}