From a22552e28f0c53c3d5f3c240e6b99d9de1171205 Mon Sep 17 00:00:00 2001 From: sergio Date: Sat, 20 Jun 2026 21:49:18 -0400 Subject: [PATCH] =?UTF-8?q?hammer-upgrade:=20GC=20de=20generaciones=20hu?= =?UTF-8?q?=C3=A9rfanas=20=E2=80=94=20'hammer=20upgrade=20prune'=20(E4=20h?= =?UTF-8?q?ardening)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tras un rollback las generaciones más nuevas quedan inalcanzables (no hay redo). prune(state, keep) borra esas huérfanas (la cadena viva = current+ancestros vía parent siempre se conserva); --keep N recorta además la profundidad de rollback. live_chain() expuesto. 2 tests nuevos + paso prune en upgrade-e2e-test.sh (3->1 gens, árbol vivo intacto). Co-Authored-By: Claude Opus 4.8 --- crates/hammer-cli/src/main.rs | 12 ++++ crates/hammer-upgrade/src/lib.rs | 97 ++++++++++++++++++++++++++++++++ docs/13-release-engineering.md | 9 ++- scripts/upgrade-e2e-test.sh | 11 +++- 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 03f93ddd..c6e31cbf 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -277,6 +277,13 @@ enum UpgradeCmd { Rollback, /// Muestra la generación viva y el historial. Status, + /// Borra generaciones que el rollback ya no alcanza (huérfanas tras un rollback). Recupera espacio. + Prune { + /// Recorta también la cadena viva a sus N generaciones más nuevas (limita la profundidad de + /// rollback). Sin flag, conserva toda la cadena y borra sólo las huérfanas. + #[arg(long)] + keep: Option, + }, } #[derive(Subcommand)] @@ -871,6 +878,11 @@ fn main() -> anyhow::Result<()> { println!(" {mark} {} → {} ({} ficheros)", g.id, g.tree_dir, g.files.len()); } } + UpgradeCmd::Prune { keep } => { + let r = hammer_upgrade::prune(&state_root, keep)?; + println!("✓ prune: {} borradas, {} conservadas", r.removed.len(), r.kept.len()); + for id in &r.removed { println!(" - {id}"); } + } } } } diff --git a/crates/hammer-upgrade/src/lib.rs b/crates/hammer-upgrade/src/lib.rs index 030096a3..10092880 100644 --- a/crates/hammer-upgrade/src/lib.rs +++ b/crates/hammer-upgrade/src/lib.rs @@ -188,6 +188,57 @@ fn next_id(state_root: &Path) -> Result { Ok(list(state_root)?.iter().map(|m| m.id).max().map_or(1, |m| m + 1)) } +/// La **cadena viva**: la generación actual seguida de sus ancestros vía `parent`, de la más nueva a +/// la más vieja. Son las únicas generaciones que el `rollback` puede necesitar (no hay "redo": tras un +/// rollback las generaciones más nuevas quedan **huérfanas**). Vacía si no hay `current`. +pub fn live_chain(state_root: &Path) -> Result> { + let mut chain = Vec::new(); + let mut cur = current(state_root)?; + let mut guard = 0usize; // anti-ciclo defensivo (un manifiesto manipulado no debe colgar el proceso) + while let Some(id) = cur { + if chain.contains(&id) || guard > 100_000 { + break; + } + chain.push(id); + guard += 1; + cur = get(state_root, id)?.and_then(|m| m.parent); + } + Ok(chain) +} + +/// Reporte de un [`prune`]. +#[derive(Debug, Clone, Default)] +pub struct PruneReport { + pub removed: Vec, + pub kept: Vec, +} + +/// Recupera espacio borrando generaciones (manifiesto + `backup/`) que el rollback ya no puede +/// alcanzar. **Siempre** se conservan las de la [cadena viva](live_chain); el resto (huérfanas tras un +/// rollback) se borran. Si `keep` es `Some(n)`, además se **recorta la cadena** a sus `n` generaciones +/// más nuevas — esto limita la profundidad de rollback disponible (como `delete-generations` en NixOS), +/// así que `None` (conservar toda la cadena) es lo seguro por defecto. Idempotente. +pub fn prune(state_root: &Path, keep: Option) -> Result { + let chain = live_chain(state_root)?; + let keep_set: std::collections::BTreeSet = match keep { + Some(n) => chain.iter().take(n).copied().collect(), + None => chain.iter().copied().collect(), + }; + let mut report = PruneReport::default(); + for m in list(state_root)? { + if keep_set.contains(&m.id) { + report.kept.push(m.id); + continue; + } + let gen_base = generations_dir(state_root).join(m.id.to_string()); + std::fs::remove_dir_all(&gen_base)?; + report.removed.push(m.id); + } + report.removed.sort_unstable(); + report.kept.sort_unstable(); + Ok(report) +} + // --------------------------------------------------------------------------------------------------- // Enumeración de árboles. // --------------------------------------------------------------------------------------------------- @@ -715,4 +766,50 @@ mod tests { assert_eq!(evs.len(), 2); assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir))); } + + #[test] + fn prune_removes_orphans_but_keeps_live_chain() { + let tmp = tempfile::tempdir().unwrap(); + let store = tmp.path().join("store"); + let root = tmp.path().join("root"); + let state = tmp.path().join("state"); + let v1 = seal_tree(&store, &h64("a1"), "v1", |w| write(&w.join("f"), b"1")); + let v2 = seal_tree(&store, &h64("a2"), "v2", |w| write(&w.join("f"), b"2")); + let v3 = seal_tree(&store, &h64("a3"), "v3", |w| write(&w.join("f"), b"3")); + + apply(&store, &v1, None, &root, &state, None).unwrap(); // gen 1 + apply(&store, &v2, None, &root, &state, None).unwrap(); // gen 2 (parent 1) + apply(&store, &v3, None, &root, &state, None).unwrap(); // gen 3 (parent 2) + // rollback dos veces ⇒ current=1; las gens 2 y 3 quedan HUÉRFANAS (rollback no las alcanza). + rollback(&root, &state, None).unwrap(); + rollback(&root, &state, None).unwrap(); + assert_eq!(current(&state).unwrap(), Some(1)); + assert_eq!(live_chain(&state).unwrap(), vec![1]); + + let r = prune(&state, None).unwrap(); + assert_eq!(r.removed, vec![2, 3], "huérfanas borradas"); + assert_eq!(r.kept, vec![1], "cadena viva conservada"); + assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::>(), vec![1]); + // idempotente. + assert!(prune(&state, None).unwrap().removed.is_empty()); + } + + #[test] + fn prune_with_keep_trims_chain_depth() { + let tmp = tempfile::tempdir().unwrap(); + let store = tmp.path().join("store"); + let root = tmp.path().join("root"); + let state = tmp.path().join("state"); + for (hx, nm, by) in [("b1", "v1", b"1"), ("b2", "v2", b"2"), ("b3", "v3", b"3")] { + let d = seal_tree(&store, &h64(hx), nm, |w| write(&w.join("f"), by)); + apply(&store, &d, None, &root, &state, None).unwrap(); + } + // current=3, cadena=[3,2,1]. keep=Some(2) ⇒ conserva 3 y 2, borra 1. + assert_eq!(live_chain(&state).unwrap(), vec![3, 2, 1]); + let r = prune(&state, Some(2)).unwrap(); + assert_eq!(r.kept, vec![2, 3]); + assert_eq!(r.removed, vec![1]); + // gen 1 borrada del disco; 2 y 3 siguen. + assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::>(), vec![2, 3]); + } } diff --git a/docs/13-release-engineering.md b/docs/13-release-engineering.md index c2252e92..d2c2aae8 100644 --- a/docs/13-release-engineering.md +++ b/docs/13-release-engineering.md @@ -79,8 +79,11 @@ kernel en `/boot`, los módulos GRUB en `/boot/grub/i386-pc` y los pasos `grub-m `HammerHydrate{artifact=tree_dir}` (replay-able). Verificación opcional de integridad: si el llamador anuncia el `of_tree` esperado (de un índice de **mirror E3** o release firmada), el apply lo exige antes de tocar el root. Maneja ficheros regulares + symlinks; valida E2E en host con `scripts/upgrade-e2e-test.sh` - (apply v1→v2→rollback→rollback contra el binario real). **Endurecimiento pendiente:** journal de - *intención* + replay para sobrevivir un kernel-panic a media escritura; GC de generaciones viejas - (prune); cableado del init de boot para que el rollback sea seleccionable al arranque. + (apply v1→v2→rollback→rollback contra el binario real). **GC de generaciones ✅** (`hammer upgrade + prune [--keep N]` / `hammer_upgrade::prune`): borra las generaciones que el rollback ya no alcanza + (huérfanas tras un rollback — las de la [cadena viva](#) siempre se conservan); `--keep N` además + recorta la cadena a sus N más nuevas (limita la profundidad de rollback, como `delete-generations` en + NixOS). **Endurecimiento pendiente:** journal de *intención* + replay para sobrevivir un kernel-panic a + media escritura; cableado del init de boot para que el rollback sea seleccionable al arranque. - **E5 — ISO/medio de arranque:** medio live para correr el instalador (xorriso/grub-mkrescue; herramental a construir). diff --git a/scripts/upgrade-e2e-test.sh b/scripts/upgrade-e2e-test.sh index 7512b94a..f556520e 100755 --- a/scripts/upgrade-e2e-test.sh +++ b/scripts/upgrade-e2e-test.sh @@ -99,5 +99,14 @@ LINES="$(wc -l < "$JOURNAL/mutations.jsonl")" [ "$LINES" -gt 0 ] || { echo "FALLO: diario vacío" >&2; exit 1; } echo " ok: $LINES eventos en el diario" +echo "==> prune: tras los dos rollback completos las gens 1 y 2 quedaron huérfanas (current=3)" +GENS_ANTES="$(ls "$STATE/generations" | wc -l)" +up prune +GENS_DESPUES="$(ls "$STATE/generations" | wc -l)" +[ "$GENS_ANTES" -gt "$GENS_DESPUES" ] || { echo "FALLO: prune no borró huérfanas ($GENS_ANTES→$GENS_DESPUES)" >&2; exit 1; } +echo " ok: prune $GENS_ANTES→$GENS_DESPUES generación(es)" +# el árbol vivo (gen 3 = v1) sigue intacto tras el prune. +assert "$ROOT/usr/bin/ls" "LS-v1" "post-prune" + echo -echo "✓✓ E4 upgrade E2E VERDE: apply/rollback atómicos + restauración exacta + idempotencia + diario" +echo "✓✓ E4 upgrade E2E VERDE: apply/rollback atómicos + restauración exacta + idempotencia + diario + prune"