diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs index a5546fcc..740c8747 100644 --- a/crates/hammer-cli/src/qorpa.rs +++ b/crates/hammer-cli/src/qorpa.rs @@ -130,8 +130,22 @@ pub enum QorpaCmd { #[arg(long)] root: Option, }, - /// [ADR 0015 §Orden 7 — SIN IMPLEMENTAR] Poda imágenes sin instancia y `upper` descartables. - Prune, + /// [ADR 0015 §Orden 7] Poda: restos de descargas a medias, imágenes que ninguna instancia usa + /// y —si se pide— las capas mutables, que por D3 siempre se pueden tirar. + /// + /// **Sin `--yes` no borra nada**: dice qué haría y cuánto liberaría. Este repo ya tuvo tres + /// emergencias de disco y un `store-gc` que REPORTABA borrados que no ocurrían. + Prune { + /// Además, tira la capa mutable de cada instancia. El manifiesto es la verdad y el `upper` + /// es caché (D3) — pero lo que instalaste a mano y no declaraste en `packages` se va. + #[arg(long)] + upper: bool, + /// Ejecuta de verdad. Sin esto es un simulacro. + #[arg(long)] + yes: bool, + #[arg(long)] + root: Option, + }, } pub fn run(cmd: QorpaCmd) -> Result<()> { @@ -150,10 +164,7 @@ pub fn run(cmd: QorpaCmd) -> Result<()> { QorpaCmd::Export { id, into, remove, root } => { export(&resolve_root(root), &id, into.as_deref(), remove) } - QorpaCmd::Prune => bail!( - "sin implementar: la poda es el paso 7 del ADR 0015. Nace con el subsistema y no \ - después — este repo ya tuvo tres emergencias de disco." - ), + QorpaCmd::Prune { upper, yes, root } => prune(&resolve_root(root), upper, yes), } } @@ -491,7 +502,15 @@ fn tree_state(tree: &Path) -> TreeState { } fn walk_tree(root: &Path) -> Result<(u64, u64)> { - let (mut files, mut bytes) = (0u64, 0u64); + let (f, b, _) = walk_tree_ciegos(root)?; + Ok((f, b)) +} + +/// Igual, pero además cuenta los directorios que NO se pudieron leer. Sin ese tercer número, el +/// tamaño de un `upper` con ficheros de los subuid sale MENOR de lo que es y nadie lo nota: un +/// número silenciosamente bajo es peor que ninguno cuando lo que se decide con él es borrar. +fn walk_tree_ciegos(root: &Path) -> Result<(u64, u64, u64)> { + let (mut files, mut bytes, mut ciegos) = (0u64, 0u64, 0u64); let mut stack = vec![root.to_path_buf()]; while let Some(d) = stack.pop() { // Un rootfs ajeno trae directorios que NI SU DUEÑO puede leer (`/etc/sudoers.d` es 0750, @@ -499,7 +518,7 @@ fn walk_tree(root: &Path) -> Result<(u64, u64)> { // Se cuentan como 0 y se sigue — abortar el pull por no poder contar sería absurdo. let rd = match std::fs::read_dir(&d) { Ok(rd) => rd, - Err(_) => continue, + Err(_) => { ciegos += 1; continue } }; for e in rd { let Ok(e) = e else { continue }; @@ -512,7 +531,7 @@ fn walk_tree(root: &Path) -> Result<(u64, u64)> { } } } - Ok((files, bytes)) + Ok((files, bytes, ciegos)) } fn write_manifest(dir: &Path, m: &ImageManifest) -> Result<()> { @@ -1231,6 +1250,139 @@ fn run_instance( } } +// ── prune: la poda (§Orden 7) ─────────────────────────────────────────────────────────────────── +// +// Nace CON el subsistema y no después, a propósito: un rootfs son cientos de MB o varios GB, y no +// lo alcanzan ni `store-gc` ni la caché `.dmerge` — es un tercer montón sin dueño, como ya lo fue +// `work/sources`. La regla que la hace trivial es D3: **un `upper` siempre se puede borrar**. +// +// Dos cicatrices del repo, cableadas acá: +// · `store-gc` REPORTABA borrados que no ocurrían (corriendo en segundo plano). Por eso esto +// comprueba DESPUÉS de borrar que el directorio se fue, y si sigue ahí lo dice. +// · Un tamaño calculado sobre un árbol que no se puede leer entero sale MENOR de lo que es. Por +// eso se cuentan los directorios ciegos y la cifra se marca como cota inferior. + +struct Candidato { que: String, ruta: PathBuf, bytes: u64, cota: bool } + +fn prune(root: &Path, con_upper: bool, de_verdad: bool) -> Result<()> { + let images = root.join("images"); + let mut cands: Vec = Vec::new(); + + // 1. Restos de descargas y desempacados a medias. Basura por definición: llevan el prefijo con + // punto justo para que `list` no los confunda con una imagen. + if let Ok(rd) = std::fs::read_dir(&images) { + for e in rd.flatten() { + let n = e.file_name().to_string_lossy().into_owned(); + if !n.starts_with('.') { + continue; + } + let (bytes, cota) = medir(&e.path()); + cands.push(Candidato { que: format!("resto de un pull a medias ({n})"), + ruta: e.path(), bytes, cota }); + } + } + + // 2. Imágenes que ninguna instancia usa. Se pueden volver a traer por digest, así que borrarlas + // no pierde nada que no se pueda recuperar: es exactamente la propiedad que da el pin. + let usadas: std::collections::BTreeSet = std::fs::read_dir(instances_dir(root)) + .into_iter().flatten().flatten() + .filter_map(|e| { + let body = std::fs::read_to_string(e.path().join(INSTANCE_MANIFEST)).ok()?; + let i: Instance = toml::from_str(&body).ok()?; + Some(i.base.strip_prefix("sha256:").unwrap_or(&i.base).to_string()) + }) + .collect(); + if let Ok(rd) = std::fs::read_dir(&images) { + for e in rd.flatten() { + let n = e.file_name().to_string_lossy().into_owned(); + if n.starts_with('.') || usadas.contains(&n) { + continue; + } + let m: Option = std::fs::read_to_string(e.path().join(IMAGE_MANIFEST)) + .ok().and_then(|s| toml::from_str(&s).ok()); + // El tamaño ya está en el manifiesto (lo midió `pull` sobre el árbol recién + // desempacado, cuando todavía era legible entero): mejor dato y gratis. + let (bytes, cota) = match &m { + Some(m) if m.tree_bytes > 0 => (m.tree_bytes, false), + _ => medir(&e.path()), + }; + let etiqueta = m.and_then(|m| m.label).unwrap_or_else(|| n[..12].to_string()); + cands.push(Candidato { que: format!("imagen sin instancia ({etiqueta})"), + ruta: e.path(), bytes, cota }); + } + } + + // 3. Capas mutables. Sólo si se piden: son caché (D3), pero lo que instalaste a mano y no + // declaraste en `packages` no vuelve solo. + if con_upper { + for e in std::fs::read_dir(instances_dir(root)).into_iter().flatten().flatten() { + let id = e.file_name().to_string_lossy().into_owned(); + let up = e.path().join("upper"); + if !up.exists() { + continue; + } + let (bytes, cota) = medir(&up); + cands.push(Candidato { que: format!("capa mutable de {id}"), ruta: up, bytes, cota }); + } + } + + if cands.is_empty() { + println!("nada que podar en {}", root.display()); + return Ok(()); + } + let total: u64 = cands.iter().map(|c| c.bytes).sum(); + let alguna_cota = cands.iter().any(|c| c.cota); + for c in &cands { + println!(" {}{:<9} {}", if c.cota { "≥" } else { " " }, human(c.bytes), c.que); + } + println!("{}{} en {} elemento(s)", if alguna_cota { "≥ " } else { "" }, human(total), cands.len()); + + if !de_verdad { + println!("\nsimulacro: no se borró nada. Repetí con --yes."); + if !con_upper { + println!("(`--upper` añade las capas mutables, que por D3 siempre se pueden tirar)"); + } + return Ok(()); + } + + let (mut ok, mut fallos) = (0u64, 0u64); + for c in &cands { + // `force_remove_dir_all` sabe abrir los directorios de sólo-lectura de un rootfs y, si el + // árbol tiene ficheros de los subuid, entrar al userns mapeado para borrarlos. + let r = if c.ruta.is_dir() { force_remove_dir_all(&c.ruta) } else { std::fs::remove_file(&c.ruta) }; + // La cicatriz de store-gc: no basta con que la llamada devuelva Ok — se COMPRUEBA que se fue. + match (r, c.ruta.exists()) { + (Ok(()), false) => { ok += 1; } + (Ok(()), true) => { fallos += 1; eprintln!(" !! {} dijo que sí y SIGUE AHÍ", c.ruta.display()); } + (Err(e), _) => { fallos += 1; eprintln!(" !! {}: {e}", c.ruta.display()); } + } + } + // La capa mutable es parte de la instancia: si se tiró, hay que dejarla utilizable. + if con_upper { + for e in std::fs::read_dir(instances_dir(root)).into_iter().flatten().flatten() { + let _ = std::fs::create_dir_all(e.path().join("upper")); + let _ = force_remove_dir_all(&e.path().join("work")); + let _ = std::fs::create_dir_all(e.path().join("work")); + } + } + println!("podado: {ok} de {} elemento(s)", cands.len()); + if fallos > 0 { + bail!("{fallos} no se pudieron borrar — el número de arriba NO es lo que se liberó"); + } + Ok(()) +} + +/// Tamaño de un árbol y si la cifra es cota inferior (hubo directorios ilegibles). +fn medir(p: &Path) -> (u64, bool) { + if p.is_file() { + return (std::fs::metadata(p).map(|m| m.len()).unwrap_or(0), false); + } + match walk_tree_ciegos(p) { + Ok((_, bytes, ciegos)) => (bytes, ciegos > 0), + Err(_) => (0, true), + } +} + // ── export: la capa de transparencia (D5) ─────────────────────────────────────────────────────── // // El objetivo es el poder de Bedrock Linux —apps de cualquier «stratum» disponibles en todos @@ -1910,6 +2062,46 @@ mod tests { assert_eq!(home_de(&dir, &tree, 0), "/root"); } + #[test] + fn la_poda_no_toca_una_imagen_con_instancia_y_si_los_restos() { + let d = tempfile::tempdir().unwrap(); + let root = d.path(); + let usada = "1".repeat(64); + let huerfana = "2".repeat(64); + imagen_falsa(root, &usada); + imagen_falsa(root, &huerfana); + create(root, "viva", &usada, None).unwrap(); + // Un resto de pull a medias: lleva punto justo para no confundirse con una imagen. + std::fs::create_dir_all(root.join("images/.3333.tmp")).unwrap(); + + // Simulacro: NO borra nada. Es la diferencia entre una poda y un accidente. + prune(root, false, false).unwrap(); + assert!(root.join("images").join(&huerfana).exists(), "el simulacro borró"); + + prune(root, false, true).unwrap(); + assert!(root.join("images").join(&usada).exists(), "borró una imagen EN USO"); + assert!(!root.join("images").join(&huerfana).exists(), "no borró la huérfana"); + assert!(!root.join("images/.3333.tmp").exists(), "no borró el resto"); + // La instancia sigue entera: la poda no la toca sin --upper. + assert!(instance_dir(root, "viva").join(INSTANCE_MANIFEST).exists()); + assert!(instance_dir(root, "viva").join("upper").exists()); + } + + #[test] + fn con_upper_la_capa_se_va_pero_la_instancia_queda_usable() { + let d = tempfile::tempdir().unwrap(); + let root = d.path(); + let sha = "4".repeat(64); + imagen_falsa(root, &sha); + create(root, "i", &sha, None).unwrap(); + std::fs::write(instance_dir(root, "i").join("upper/estado"), "x").unwrap(); + prune(root, true, true).unwrap(); + assert!(!instance_dir(root, "i").join("upper/estado").exists(), "el upper no es caché"); + // Y queda utilizable: sin upper/ y work/ la instancia no vuelve a arrancar. + assert!(instance_dir(root, "i").join("upper").is_dir()); + assert!(instance_dir(root, "i").join("work").is_dir()); + } + #[test] fn sha_mal_formado_falla_antes_de_tocar_la_red() { assert!(normalize_sha256("abc").is_err()); diff --git a/docs/20-catalogo-publicable-y-completa.md b/docs/20-catalogo-publicable-y-completa.md index 51fcb06b..4bb5646d 100644 --- a/docs/20-catalogo-publicable-y-completa.md +++ b/docs/20-catalogo-publicable-y-completa.md @@ -150,6 +150,28 @@ de nosotros la fuente correspondiente**. Hoy las recetas apuntan a URLs de terce Acá estamos inusualmente bien parados: cada receta pinea `tarball` + `sha256`, así que el espejo es mecánico **y encima verificable**. Pero tiene que existir **antes de la primera descarga**. +### Imágenes ajenas (qorpa): fuera del catálogo, y por escrito +Desde el [ADR 0015](adr/0015-imagenes-ajenas.md) hay una segunda cadena de suministro: rootfs de +otras distros que corren enjaulados. **No entran en el catálogo publicable ni en el reporte de +licencias, y la razón no es pereza: no podemos enumerarlas.** Un `dnf install` o un `pacman -S` +dentro de una instancia trae paquetes que nadie declaró acá, y afirmar una licencia sobre eso sería +inventarla. + +Lo que sí se hace, y alcanza: +- **Se cuentan aparte, en clase `ajeno`** (`docs/state/qorpa-ajenos.toml` → `build-state.json` → + «187/188 listo … + 1 ajenas»). El riesgo real de todo el ADR es que en seis meses alguien las + cuente como corpus; en otra clase no se pueden contar mal. +- **La licencia la pone quien las publica**, que no somos nosotros: el usuario trae la imagen por + URL + sha256 y la relación es con esa distro. +- **Si algún día se espejan** —el mecanismo lo permite gratis, porque son cuerpo inmutable + direccionado por contenido (ADR 0014)— ahí sí hay que mirar licencia **y marca** antes, igual que + con Firefox: redistribuir un rootfs de Ubuntu sin modificar suele estar permitido, pero la + política de marca es cosa aparte y **no es una pregunta técnica**. + +⚠️ Y el corolario que hay que escribir donde se lea: **el claim «hammer reproduce bit a bit» hay que +acotarlo** desde el día que exista una instancia — *el sistema base reproduce; las instancias ajenas +no, y se declaran como tales*. Sin eso, la cultura de números honestos se erosiona sola. + ### Marcas Redistribuir Firefox con su nombre y logo exige cumplir la política de marca de Mozilla (por eso Debian tuvo Iceweasel). Decidir por adelantado: cumplir o rebrandear. **No bloquea el primer diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index 939e0e12..b6e6be43 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -481,7 +481,13 @@ Se escriben acá para que no se descubran en producción. **Lo que NO se pudo, y por qué:** la máquina no tiene ninguna sesión gráfica (`/dev/dri` sí, socket Wayland no), así que Steam no dibuja; y el runtime *sniper* sólo se baja al instalar un juego, lo que exige credenciales. **pressure-vessel con un juego real sigue sin ejercitarse.** -7. Poda (`hammer qorpa prune`) y renglón en SDD 20 sobre licencias. +7. ✅ **HECHO 2026-09-03.** `hammer qorpa prune` + el renglón en + [SDD 20](../20-catalogo-publicable-y-completa.md#imágenes-ajenas-qorpa-fuera-del-catálogo-y-por-escrito). + Poda restos de pulls a medias, imágenes que ninguna instancia usa (se re-traen por digest: es la + propiedad que da el pin) y, con `--upper`, las capas mutables. **Sin `--yes` es un simulacro**, y + tras borrar **comprueba que el directorio se fue**: la cicatriz de `store-gc`, que reportaba + borrados que no ocurrían. Los tamaños de un árbol con directorios ilegibles se marcan con `≥`, + porque un número silenciosamente bajo es peor que ninguno cuando con él se decide borrar. 8. Proxy filtrante de Wayland — ticket propio, el más valioso de la lista. ## Nombre — ADOPTADO 2026-09-03