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
This commit is contained in:
Sergio
2026-09-03 12:18:10 +00:00
co-authored by Claude Opus 5
parent da0186d250
commit d77ad15d01
3 changed files with 230 additions and 10 deletions
+201 -9
View File
@@ -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());
+22
View File
@@ -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
+7 -1
View File
@@ -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