tawasuyu

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 1182Shell 87Rust 63Markdown 541592 Definiciones

qorpa prune: la poda nace con el subsistema, no después

Paso 7 del ADR 0015, sus dos mitades.

PODA. 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.
`hammer qorpa prune` barre restos de pulls a medias, imágenes que ninguna
instancia usa —se re-traen por digest, que es justo la propiedad que da el pin—
y, con --upper, las capas mutables, que por D3 siempre se pueden tirar.

Con las dos cicatrices del repo cableadas:
- SIN --yes es un simulacro. Y tras borrar COMPRUEBA que el directorio se fue,
  porque store-gc reportaba borrados que no ocurrían y eso se descubrió tarde.
  Si dice que sí y sigue ahí, sale ≠0 diciendo que el número de arriba no es lo
  que se liberó.
- El tamaño de un árbol con directorios ilegibles sale MENOR de lo que es: un
  upper con ficheros de los subuid no se puede recorrer entero. Ahora se cuentan
  los directorios ciegos y la cifra se marca con `≥`. Un número silenciosamente
  bajo es peor que ninguno cuando con él se decide borrar.

Y --upper deja la instancia USABLE: sin upper/ y work/ no vuelve a arrancar.

LICENCIAS (SDD 20). Las imágenes ajenas quedan fuera del catálogo publicable y
del reporte de licencias por escrito, y la razón no es pereza: no podemos
enumerarlas — 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: contarlas aparte en clase `ajeno` (el riesgo real del ADR es que en seis
meses alguien las cuente como corpus), y dejar dicho que si algún día se espejan
hay que mirar licencia Y MARCA antes, igual que con Firefox. Más el corolario
que faltaba escribir donde se lea: el claim «hammer reproduce bit a bit» hay que
acotarlo desde el día que exista una instancia.

2 tests nuevos (la poda no toca una imagen en uso y sí los restos; con --upper
la capa se va pero la instancia queda usable). 50/50.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2

d77ad15d01de2ca938f75a7755d69445858557c9 · por Sergio · (hace 1 mes) · padre da0186d25 · ver el árbol en este commit

Delta semántico · 9

crates/hammer-cli/src/qorpa.rs

3 archivos · +230 −10

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<PathBuf>,
     },
-    /// [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<PathBuf>,
+    },
 }
 
 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<Candidato> = 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<String> = 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<ImageManifest> = 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