hammer-upgrade: GC de generaciones huérfanas — 'hammer upgrade prune' (E4 hardening)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 21:49:18 -04:00
co-authored by Claude Opus 4.8
parent d23946fa3d
commit a22552e28f
4 changed files with 125 additions and 4 deletions
+12
View File
@@ -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<usize>,
},
}
#[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}"); }
}
}
}
}
+97
View File
@@ -188,6 +188,57 @@ fn next_id(state_root: &Path) -> Result<u64> {
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<Vec<u64>> {
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<u64>,
pub kept: Vec<u64>,
}
/// 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<usize>) -> Result<PruneReport> {
let chain = live_chain(state_root)?;
let keep_set: std::collections::BTreeSet<u64> = 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<_>>(), 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<_>>(), vec![2, 3]);
}
}
+6 -3
View File
@@ -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).
+10 -1
View File
@@ -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"