hammer-upgrade: journal de intención + recover idempotente (E4 endurecimiento #4a)

El apply escribe el plan completo a pending.json ANTES de proyectar y lo limpia
al commitear; un corte a media proyección deja pending.json + FHS a medias. La
proyección se hizo re-entrante (project_plan, backups idempotentes via
backup_existing_once que nunca pisa el original) ⇒ recover COMPLETA (roll-forward,
re-verifica of_tree) o DESHACE (roll-back: restaura backups, borra la gen a
medias, current->padre). apply se niega con PendingExists si hay intento; status
lo avisa. CLI: hammer upgrade recover [--rollback]. 5 tests (corte a media
proyeccion -> ambos modos) + ejercicio en upgrade-e2e-test.sh. 14 tests verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 04:45:27 -04:00
co-authored by Claude Opus 4.8
parent fd09e842b4
commit 01b3a225b2
4 changed files with 400 additions and 61 deletions
+20
View File
@@ -275,6 +275,12 @@ enum UpgradeCmd {
}, },
/// Revierte la generación viva al árbol anterior (o al estado pre-upgrades). /// Revierte la generación viva al árbol anterior (o al estado pre-upgrades).
Rollback, Rollback,
/// Completa (o deshace con --rollback) un apply interrumpido por un corte/reinicio (intento pendiente).
Recover {
/// Deshace el apply a medias (vuelve al estado pre-apply) en vez de completarlo.
#[arg(long)]
rollback: bool,
},
/// Muestra la generación viva y el historial. /// Muestra la generación viva y el historial.
Status, Status,
/// Borra generaciones que el rollback ya no alcanza (huérfanas tras un rollback). Recupera espacio. /// Borra generaciones que el rollback ya no alcanza (huérfanas tras un rollback). Recupera espacio.
@@ -865,7 +871,21 @@ fn main() -> anyhow::Result<()> {
} }
println!(" {} restaurados, {} borrados", r.restored.len(), r.deleted.len()); println!(" {} restaurados, {} borrados", r.restored.len(), r.deleted.len());
} }
UpgradeCmd::Recover { rollback } => {
let j = hammer_journal::Journal::open(&journal)?;
let r = hammer_upgrade::recover(&store, &root, &state_root, Some(&j), rollback)?;
if !r.recovered {
println!("no hay apply interrumpido — nada que recuperar");
} else if r.rolled_forward {
println!("✓ apply interrumpido COMPLETADO (generación {})", r.generation);
} else {
println!("✓ apply interrumpido DESHECHO (generación {} descartada)", r.generation);
}
}
UpgradeCmd::Status => { UpgradeCmd::Status => {
if let Some(p) = hammer_upgrade::pending(&state_root)? {
println!("⚠ apply interrumpido pendiente (generación {}) — corré `hammer upgrade recover [--rollback]`", p.id);
}
let cur = hammer_upgrade::current(&state_root)?; let cur = hammer_upgrade::current(&state_root)?;
match cur { match cur {
Some(id) => println!("generación viva: {id}"), Some(id) => println!("generación viva: {id}"),
+353 -58
View File
@@ -58,6 +58,11 @@ pub enum Error {
Verify { dir: String, want: String, got: String }, Verify { dir: String, want: String, got: String },
#[error("upgrade: no hay generación viva que revertir (sistema sin upgrades aplicados)")] #[error("upgrade: no hay generación viva que revertir (sistema sin upgrades aplicados)")]
NothingToRollback, NothingToRollback,
#[error(
"upgrade: hay un apply interrumpido pendiente (generación {generation}) — corré \
`hammer upgrade recover` (completa) o `hammer upgrade recover --rollback` (deshace) antes de seguir"
)]
PendingExists { generation: u64 },
#[error("upgrade: {0}")] #[error("upgrade: {0}")]
Other(String), Other(String),
} }
@@ -114,6 +119,17 @@ pub struct ApplyReport {
pub already_current: bool, pub already_current: bool,
} }
/// Reporte de un [`recover`].
#[derive(Debug, Clone, Default)]
pub struct RecoverReport {
/// `false` si no había nada que recuperar (no había intento pendiente).
pub recovered: bool,
/// `true` = se completó el apply (roll-forward); `false` = se deshizo (roll-back).
pub rolled_forward: bool,
/// Generación afectada por el intento pendiente.
pub generation: u64,
}
/// Reporte de un [`rollback`]. /// Reporte de un [`rollback`].
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct RollbackReport { pub struct RollbackReport {
@@ -141,6 +157,33 @@ fn manifest_path(state_root: &Path, id: u64) -> PathBuf {
generations_dir(state_root).join(id.to_string()).join("manifest.json") generations_dir(state_root).join(id.to_string()).join("manifest.json")
} }
/// El **journal de intención**: el plan de un apply en curso. Existe sólo entre el inicio de la
/// proyección y el commit; si sobrevive a un reinicio, un apply quedó a medias y hay que [`recover`].
fn pending_path(state_root: &Path) -> PathBuf {
state_root.join("pending.json")
}
/// Lee el plan pendiente (apply interrumpido), o `None` si no hay ninguno.
pub fn pending(state_root: &Path) -> Result<Option<GenerationManifest>> {
let p = pending_path(state_root);
if !p.is_file() {
return Ok(None);
}
Ok(Some(serde_json::from_slice(&std::fs::read(&p)?)?))
}
fn write_pending(state_root: &Path, manifest: &GenerationManifest) -> Result<()> {
write_atomic(&pending_path(state_root), &serde_json::to_vec_pretty(manifest)?)
}
fn clear_pending(state_root: &Path) -> Result<()> {
match std::fs::remove_file(pending_path(state_root)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Id de la generación viva, o `None` si nunca se aplicó un upgrade. /// Id de la generación viva, o `None` si nunca se aplicó un upgrade.
pub fn current(state_root: &Path) -> Result<Option<u64>> { pub fn current(state_root: &Path) -> Result<Option<u64>> {
let f = current_file(state_root); let f = current_file(state_root);
@@ -329,6 +372,12 @@ pub fn apply(
} }
} }
// Un apply previo interrumpido deja un `pending.json` ⇒ exigimos `recover` explícito antes de tocar
// nada (no pisamos un FHS a medias). El recover lo completa (roll-forward) o lo deshace (roll-back).
if let Some(p) = pending(state_root)? {
return Err(Error::PendingExists { generation: p.id });
}
// Idempotencia: si el árbol vivo ya es éste, no hacemos nada. // Idempotencia: si el árbol vivo ya es éste, no hacemos nada.
if let Some(cur) = current(state_root)? { if let Some(cur) = current(state_root)? {
if let Some(m) = get(state_root, cur)? { if let Some(m) = get(state_root, cur)? {
@@ -338,69 +387,136 @@ pub fn apply(
} }
} }
// --- PLAN completo ANTES de tocar nada: entradas del árbol nuevo + ficheros a retirar + op/path ---
let new_entries = enumerate_tree(&tree_path)?; let new_entries = enumerate_tree(&tree_path)?;
let new_files: Vec<PathBuf> = new_entries.iter().map(|e| e.rel.clone()).collect(); let new_files: Vec<PathBuf> = new_entries.iter().map(|e| e.rel.clone()).collect();
let new_set: std::collections::BTreeSet<&PathBuf> = new_files.iter().collect(); let new_set: std::collections::BTreeSet<&PathBuf> = new_files.iter().collect();
let prev_id = current(state_root)?;
// Ficheros que la generación viva aportaba y el árbol nuevo NO ⇒ se retiran. let prev_files: Vec<PathBuf> = match prev_id {
let prev_files: Vec<PathBuf> = match current(state_root)? {
Some(cur) => get(state_root, cur)?.map(|m| m.files).unwrap_or_default(), Some(cur) => get(state_root, cur)?.map(|m| m.files).unwrap_or_default(),
None => Vec::new(), None => Vec::new(),
}; };
let removed_files: Vec<PathBuf> = let removed_files: Vec<PathBuf> =
prev_files.into_iter().filter(|p| !new_set.contains(p)).collect(); prev_files.into_iter().filter(|p| !new_set.contains(p)).collect();
let id = next_id(state_root)?; let mut changes: Vec<Change> = Vec::with_capacity(new_entries.len() + removed_files.len());
let gen_base = generations_dir(state_root).join(id.to_string());
let backup_root = gen_base.join("backup");
std::fs::create_dir_all(&backup_root)?;
let mut report = ApplyReport { generation: id, ..Default::default() };
let mut changes: Vec<Change> = Vec::new();
// (3)+(4) proyectar el árbol nuevo.
for e in &new_entries { for e in &new_entries {
let dst = target_root.join(&e.rel); let existed = std::fs::symlink_metadata(target_root.join(&e.rel)).is_ok();
let existed = std::fs::symlink_metadata(&dst).is_ok(); changes.push(Change {
if existed { path: e.rel.clone(),
backup_existing(&dst, &backup_root.join(&e.rel))?; op: if existed { ChangeOp::Replaced } else { ChangeOp::Added },
} });
project_entry(&tree_path, e, &dst)?;
let op = if existed { ChangeOp::Replaced } else { ChangeOp::Added };
match op {
ChangeOp::Added => report.added.push(e.rel.clone()),
ChangeOp::Replaced => report.replaced.push(e.rel.clone()),
ChangeOp::Removed => unreachable!(),
}
changes.push(Change { path: e.rel.clone(), op });
journal_change(journal, tree_dir, &dst, op);
} }
// Retirar los sobrantes del árbol previo.
for rel in &removed_files { for rel in &removed_files {
let dst = target_root.join(rel); if std::fs::symlink_metadata(target_root.join(rel)).is_ok() {
if std::fs::symlink_metadata(&dst).is_ok() {
backup_existing(&dst, &backup_root.join(rel))?;
remove_path(&dst)?;
report.removed.push(rel.clone());
changes.push(Change { path: rel.clone(), op: ChangeOp::Removed }); changes.push(Change { path: rel.clone(), op: ChangeOp::Removed });
journal_change(journal, tree_dir, &dst, ChangeOp::Removed);
} }
} }
// (5) manifiesto + avance de `current` (commit-point). let id = next_id(state_root)?;
let manifest = GenerationManifest { let manifest = GenerationManifest {
id, id,
tree_dir: tree_dir.to_string(), tree_dir: tree_dir.to_string(),
tree_content, tree_content,
parent: current(state_root)?, parent: prev_id,
created_at: now_secs(), created_at: now_secs(),
files: new_files, files: new_files,
changes, changes,
}; };
let backup_root = generations_dir(state_root).join(id.to_string()).join("backup");
std::fs::create_dir_all(&backup_root)?;
// (INTENTO) escribir el plan ANTES de proyectar: el commit-point sigue siendo `current`, pero ahora
// un corte a media proyección es recuperable — `recover` re-ejecuta el plan (idempotente) o lo deshace.
write_pending(state_root, &manifest)?;
// (PROYECTAR) ejecutar el plan: backups idempotentes (no pisan el original) + escritura temporal+rename.
project_plan(&tree_path, &manifest, target_root, &backup_root, journal)?;
// (COMMIT) manifiesto definitivo + avance de `current` + limpiar el intento.
write_atomic(&manifest_path(state_root, id), &serde_json::to_vec_pretty(&manifest)?)?; write_atomic(&manifest_path(state_root, id), &serde_json::to_vec_pretty(&manifest)?)?;
set_current(state_root, Some(id))?; set_current(state_root, Some(id))?;
Ok(report) clear_pending(state_root)?;
Ok(report_from_changes(id, &manifest.changes))
}
/// Construye el [`ApplyReport`] a partir de la lista de cambios del plan.
fn report_from_changes(id: u64, changes: &[Change]) -> ApplyReport {
let mut r = ApplyReport { generation: id, ..Default::default() };
for c in changes {
match c.op {
ChangeOp::Added => r.added.push(c.path.clone()),
ChangeOp::Replaced => r.replaced.push(c.path.clone()),
ChangeOp::Removed => r.removed.push(c.path.clone()),
}
}
r
}
/// Ejecuta el plan de una generación sobre `target_root`: proyecta los Added/Replaced del árbol y
/// retira los Removed, respaldando el ORIGINAL de cada path tocado **idempotentemente** (no pisa un
/// backup ya existente — clave para que `recover` pueda re-ejecutar sin perder el estado previo). Es
/// **re-entrante**: correrlo dos veces deja el mismo resultado.
fn project_plan(
tree_path: &Path,
manifest: &GenerationManifest,
target_root: &Path,
backup_root: &Path,
journal: Option<&Journal>,
) -> Result<()> {
let entries = enumerate_tree(tree_path)?;
let by_rel: std::collections::BTreeMap<&PathBuf, &TreeEntry> =
entries.iter().map(|e| (&e.rel, e)).collect();
for ch in &manifest.changes {
let dst = target_root.join(&ch.path);
match ch.op {
ChangeOp::Added | ChangeOp::Replaced => {
if ch.op == ChangeOp::Replaced {
backup_existing_once(&dst, &backup_root.join(&ch.path))?;
}
if let Some(e) = by_rel.get(&ch.path) {
project_entry(tree_path, e, &dst)?;
}
}
ChangeOp::Removed => {
backup_existing_once(&dst, &backup_root.join(&ch.path))?;
remove_path(&dst)?;
}
}
journal_change(journal, &manifest.tree_dir, &dst, ch.op);
}
Ok(())
}
/// Deshace el plan de un manifiesto sobre `target_root` (orden inverso): borra los Added, restaura los
/// Replaced/Removed desde `backup_root`. Idempotente. Compartido por [`rollback`] y `recover --rollback`.
fn undo_changes(
manifest: &GenerationManifest,
target_root: &Path,
backup_root: &Path,
journal: Option<&Journal>,
report_restored: &mut Vec<PathBuf>,
report_deleted: &mut Vec<PathBuf>,
) -> Result<()> {
for ch in manifest.changes.iter().rev() {
let dst = target_root.join(&ch.path);
match ch.op {
ChangeOp::Added => {
if std::fs::symlink_metadata(&dst).is_ok() {
remove_path(&dst)?;
report_deleted.push(ch.path.clone());
journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Removed);
}
}
ChangeOp::Replaced | ChangeOp::Removed => {
restore_from_backup(&backup_root.join(&ch.path), &dst)?;
report_restored.push(ch.path.clone());
journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Replaced);
}
}
}
Ok(())
} }
// --------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------
@@ -420,31 +536,69 @@ pub fn rollback(
let backup_root = generations_dir(state_root).join(cur.to_string()).join("backup"); let backup_root = generations_dir(state_root).join(cur.to_string()).join("backup");
let mut report = RollbackReport { reverted: cur, now_current: m.parent, ..Default::default() }; let mut report = RollbackReport { reverted: cur, now_current: m.parent, ..Default::default() };
undo_changes(&m, target_root, &backup_root, journal, &mut report.restored, &mut report.deleted)?;
// Deshacer en orden inverso al apply (los Added al final, por si un Replaced creó su dir padre).
for ch in m.changes.iter().rev() {
let dst = target_root.join(&ch.path);
match ch.op {
ChangeOp::Added => {
if std::fs::symlink_metadata(&dst).is_ok() {
remove_path(&dst)?;
report.deleted.push(ch.path.clone());
journal_change(journal, &m.tree_dir, &dst, ChangeOp::Removed);
}
}
ChangeOp::Replaced | ChangeOp::Removed => {
let bak = backup_root.join(&ch.path);
restore_from_backup(&bak, &dst)?;
report.restored.push(ch.path.clone());
journal_change(journal, &m.tree_dir, &dst, ChangeOp::Replaced);
}
}
}
set_current(state_root, m.parent)?; set_current(state_root, m.parent)?;
Ok(report) Ok(report)
} }
/// Completa o deshace un apply interrumpido (un `pending.json` que sobrevivió a un corte/reinicio).
///
/// - **roll-forward** (`rollback=false`, default): re-ejecuta el plan pendiente (idempotente) y commitea
/// — el FHS queda como si el apply hubiera terminado. Re-verifica el `of_tree` del árbol contra el que
/// el plan registró (defensa ante un store que cambió bajo los pies).
/// - **roll-back** (`rollback=true`): deshace el plan (restaura backups, borra lo añadido), elimina la
/// generación a medias y vuelve `current` al padre — el FHS queda como ANTES del apply.
///
/// No-op (sin error) si no hay intento pendiente. Idempotente: re-correrlo es seguro.
pub fn recover(
store_root: &Path,
target_root: &Path,
state_root: &Path,
journal: Option<&Journal>,
rollback: bool,
) -> Result<RecoverReport> {
let manifest = match pending(state_root)? {
Some(m) => m,
None => return Ok(RecoverReport::default()),
};
let backup_root = generations_dir(state_root).join(manifest.id.to_string()).join("backup");
if rollback {
let (mut restored, mut deleted) = (Vec::new(), Vec::new());
undo_changes(&manifest, target_root, &backup_root, journal, &mut restored, &mut deleted)?;
// El apply nunca avanzó `current` (eso era el commit-point); por las dudas, asegurarlo en el padre.
if current(state_root)? == Some(manifest.id) {
set_current(state_root, manifest.parent)?;
}
let _ = std::fs::remove_dir_all(generations_dir(state_root).join(manifest.id.to_string()));
clear_pending(state_root)?;
return Ok(RecoverReport { recovered: true, rolled_forward: false, generation: manifest.id });
}
// roll-forward: re-resolver y re-verificar el árbol del store, completar la proyección, commitear.
let store = Store::open(store_root)?;
let tree_path = store.root().join(&manifest.tree_dir);
if !tree_path.is_dir() {
return Err(Error::Other(format!(
"recover: el árbol '{}' del intento ya no existe en el store; usá --rollback",
manifest.tree_dir
)));
}
let got = ArtifactHash::of_tree(&tree_path)?;
if got.as_str() != manifest.tree_content {
return Err(Error::Verify {
dir: manifest.tree_dir.clone(),
want: manifest.tree_content.clone(),
got: got.as_str().to_string(),
});
}
project_plan(&tree_path, &manifest, target_root, &backup_root, journal)?;
write_atomic(&manifest_path(state_root, manifest.id), &serde_json::to_vec_pretty(&manifest)?)?;
set_current(state_root, Some(manifest.id))?;
clear_pending(state_root)?;
Ok(RecoverReport { recovered: true, rolled_forward: true, generation: manifest.id })
}
// --------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------
// Primitivas de filesystem. // Primitivas de filesystem.
// --------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------
@@ -497,6 +651,19 @@ fn backup_existing(src: &Path, bak: &Path) -> Result<()> {
Ok(()) Ok(())
} }
/// Respalda el original de `src` en `bak` **sólo si aún no hay backup** y `src` existe. Idempotente:
/// preserva el primer original capturado (clave para que un `recover` roll-forward re-ejecute la
/// proyección sin sobrescribir el backup con la versión ya proyectada).
fn backup_existing_once(src: &Path, bak: &Path) -> Result<()> {
if std::fs::symlink_metadata(bak).is_ok() {
return Ok(()); // ya respaldado
}
if std::fs::symlink_metadata(src).is_err() {
return Ok(()); // nada que respaldar
}
backup_existing(src, bak)
}
/// Restaura `dst` desde su backup (atómico vía temporal). Si el backup no existe, no hace nada /// Restaura `dst` desde su backup (atómico vía temporal). Si el backup no existe, no hace nada
/// (idempotencia: ya restaurado o nunca existió). /// (idempotencia: ya restaurado o nunca existió).
fn restore_from_backup(bak: &Path, dst: &Path) -> Result<()> { fn restore_from_backup(bak: &Path, dst: &Path) -> Result<()> {
@@ -767,6 +934,134 @@ mod tests {
assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir))); assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir)));
} }
/// Simula un apply INTERRUMPIDO a media proyección: escribe el `pending.json` con el plan y deja el
/// FHS a medias (proyecta sólo el primer cambio), sin commitear. Devuelve el manifiesto del plan.
fn interrupt_apply(
store: &Path,
tree_dir: &str,
target_root: &Path,
state_root: &Path,
partial: usize,
) -> GenerationManifest {
let store_h = Store::open(store).unwrap();
let tree_path = store_h.root().join(tree_dir);
let tree_content = ArtifactHash::of_tree(&tree_path).unwrap().as_str().to_string();
let entries = enumerate_tree(&tree_path).unwrap();
let mut changes = Vec::new();
for e in &entries {
let existed = std::fs::symlink_metadata(target_root.join(&e.rel)).is_ok();
changes.push(Change { path: e.rel.clone(), op: if existed { ChangeOp::Replaced } else { ChangeOp::Added } });
}
let id = next_id(state_root).unwrap();
let manifest = GenerationManifest {
id, tree_dir: tree_dir.to_string(), tree_content,
parent: current(state_root).unwrap(), created_at: 0,
files: entries.iter().map(|e| e.rel.clone()).collect(), changes,
};
let backup_root = generations_dir(state_root).join(id.to_string()).join("backup");
std::fs::create_dir_all(&backup_root).unwrap();
write_pending(state_root, &manifest).unwrap();
// proyectar SÓLO `partial` entradas (corte a media proyección).
for e in entries.iter().take(partial) {
let dst = target_root.join(&e.rel);
if std::fs::symlink_metadata(&dst).is_ok() {
backup_existing_once(&dst, &backup_root.join(&e.rel)).unwrap();
}
project_entry(&tree_path, e, &dst).unwrap();
}
manifest
}
#[test]
fn apply_refuses_when_a_pending_apply_exists() {
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("e1"), "v1", |w| { write(&w.join("a"), b"A"); write(&w.join("b"), b"B"); });
interrupt_apply(&store, &v1, &root, &state, 1);
// con un intento pendiente, apply se NIEGA (exige recover).
let err = apply(&store, &v1, None, &root, &state, None).unwrap_err();
assert!(matches!(err, Error::PendingExists { .. }), "vino {err}");
assert!(pending(&state).unwrap().is_some());
}
#[test]
fn recover_roll_forward_completes_interrupted_apply() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path().join("store");
let root = tmp.path().join("root");
let state = tmp.path().join("state");
std::fs::create_dir_all(&root).unwrap();
write(&root.join("etc/keep"), b"PRE");
let v1 = seal_tree(&store, &h64("e2"), "v1", |w| {
write(&w.join("usr/bin/ls"), b"NEW-ls");
write(&w.join("usr/bin/cat"), b"NEW-cat");
write(&w.join("usr/bin/grep"), b"NEW-grep");
});
// corte tras proyectar 1 de 3 ⇒ FHS a medias, sin generación viva.
interrupt_apply(&store, &v1, &root, &state, 1);
assert_eq!(current(&state).unwrap(), None, "el apply nunca commiteó");
assert!(pending(&state).unwrap().is_some());
let r = recover(&store, &root, &state, None, false).unwrap();
assert!(r.recovered && r.rolled_forward && r.generation == 1);
// el apply quedó COMPLETO: los 3 binarios, generación viva, intento limpio.
assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls");
assert_eq!(std::fs::read(root.join("usr/bin/cat")).unwrap(), b"NEW-cat");
assert_eq!(std::fs::read(root.join("usr/bin/grep")).unwrap(), b"NEW-grep");
assert_eq!(current(&state).unwrap(), Some(1));
assert!(pending(&state).unwrap().is_none());
// y como quedó committeado, un rollback posterior restaura el estado pre-apply EXACTO.
rollback(&root, &state, None).unwrap();
assert!(!root.join("usr/bin/ls").exists(), "lo añadido se revierte");
assert_eq!(std::fs::read(root.join("etc/keep")).unwrap(), b"PRE");
}
#[test]
fn recover_roll_back_undoes_interrupted_apply() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path().join("store");
let root = tmp.path().join("root");
let state = tmp.path().join("state");
std::fs::create_dir_all(&root).unwrap();
write(&root.join("usr/bin/ls"), b"ORIG-ls"); // existirá ⇒ Replaced (con backup)
let v1 = seal_tree(&store, &h64("e3"), "v1", |w| {
write(&w.join("usr/bin/ls"), b"NEW-ls");
write(&w.join("usr/bin/new"), b"NEW-only"); // Added
});
// corte tras proyectar el 1er cambio (orden alfabético: usr/bin/ls Replaced primero).
interrupt_apply(&store, &v1, &root, &state, 1);
assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls", "ls ya pisado a medias");
let r = recover(&store, &root, &state, None, true).unwrap();
assert!(r.recovered && !r.rolled_forward);
// el FHS volvió al estado PRE-apply: ls original restaurado, nada de la generación a medias.
assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"ORIG-ls", "ls restaurado del backup");
assert!(!root.join("usr/bin/new").exists());
assert_eq!(current(&state).unwrap(), None);
assert!(pending(&state).unwrap().is_none());
assert!(!generations_dir(&state).join("1").exists(), "la generación a medias se borró");
}
#[test]
fn recover_is_noop_without_pending() {
let tmp = tempfile::tempdir().unwrap();
let r = recover(&tmp.path().join("store"), &tmp.path().join("root"), &tmp.path().join("state"), None, false).unwrap();
assert!(!r.recovered);
}
#[test]
fn apply_clears_pending_on_success() {
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("e4"), "v1", |w| write(&w.join("a"), b"A"));
apply(&store, &v1, None, &root, &state, None).unwrap();
assert!(pending(&state).unwrap().is_none(), "el apply exitoso limpia el intento");
}
#[test] #[test]
fn prune_removes_orphans_but_keeps_live_chain() { fn prune_removes_orphans_but_keeps_live_chain() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+10 -2
View File
@@ -83,8 +83,16 @@ kernel en `/boot`, los módulos GRUB en `/boot/grub/i386-pc` y los pasos `grub-m
prune [--keep N]` / `hammer_upgrade::prune`): borra las generaciones que el rollback ya no alcanza 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 (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 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 NixOS). **Journal de intención + replay ** (`hammer upgrade recover [--rollback]` /
media escritura; cableado del init de boot para que el rollback sea seleccionable al arranque. `hammer_upgrade::{pending,recover}`): el apply escribe el **plan completo** a `pending.json` ANTES de
proyectar y lo limpia al commitear; si un corte/reinicio lo interrumpe, `pending.json` sobrevive y el
FHS quedó a medias. La proyección es **re-entrante** (`project_plan`, backups *idempotentes*
`backup_existing_once` nunca pisa el original capturado), así `recover` **completa** (roll-forward:
re-ejecuta el plan + commitea, re-verificando el `of_tree`) o **deshace** (roll-back: restaura backups,
borra la generación a medias, `current`→padre). `apply` se **niega** si hay intento pendiente (exige
recover explícito); `status` lo avisa. 5 tests (corte a media proyección → ambos modos) + ejercicio en
`upgrade-e2e-test.sh`. **Endurecimiento pendiente:** cableado del init de boot para que rollback/recover
sea seleccionable al arranque (necesita el binario `hammer` en el producto).
- **E5 — ISO/medio de arranque: ✅ primer corte** (`recipes/xorriso.toml` + `scripts/iso-image.sh` + - **E5 — ISO/medio de arranque: ✅ primer corte** (`recipes/xorriso.toml` + `scripts/iso-image.sh` +
`scripts/iso-boot-test.sh`). Medio **live** que arranca ENTERO desde RAM: GRUB (El Torito) carga `scripts/iso-boot-test.sh`). Medio **live** que arranca ENTERO desde RAM: GRUB (El Torito) carga
kernel + initramfs desde el ISO 9660, el kernel desempaqueta el rootfs del producto a un tmpfs y corre kernel + initramfs desde el ISO 9660, el kernel desempaqueta el rootfs del producto a un tmpfs y corre
+17 -1
View File
@@ -108,5 +108,21 @@ echo " ok: prune $GENS_ANTES→$GENS_DESPUES generación(es)"
# el árbol vivo (gen 3 = v1) sigue intacto tras el prune. # el árbol vivo (gen 3 = v1) sigue intacto tras el prune.
assert "$ROOT/usr/bin/ls" "LS-v1" "post-prune" assert "$ROOT/usr/bin/ls" "LS-v1" "post-prune"
echo "==> recover: simulamos un apply interrumpido plantando un pending.json y lo recuperamos"
# El recover real se prueba a fondo en los tests unitarios (corte a media proyección). Acá ejercitamos
# el CABLEADO del CLI contra artefactos reales: plantamos el manifiesto de la gen viva como intento
# pendiente; status debe avisar y recover (roll-forward, idempotente) completarlo y limpiarlo.
CUR=$(cat "$STATE/current")
cp "$STATE/generations/$CUR/manifest.json" "$STATE/pending.json"
up status | grep -q "apply interrumpido pendiente" || { echo "FALLO: status no avisó del intento" >&2; exit 1; }
echo " ok: status avisó del intento pendiente"
up recover | grep -q "COMPLETADO" || { echo "FALLO: recover no completó" >&2; exit 1; }
[ ! -e "$STATE/pending.json" ] || { echo "FALLO: recover no limpió el intento" >&2; exit 1; }
assert "$ROOT/usr/bin/ls" "LS-v1" "post-recover"
echo " ok: recover completó el intento y lo limpió"
# segundo recover = no-op.
up recover | grep -q "nada que recuperar" || { echo "FALLO: recover no es no-op sin intento" >&2; exit 1; }
echo " ok: recover sin intento es no-op"
echo echo
echo "✓✓ E4 upgrade E2E VERDE: apply/rollback atómicos + restauración exacta + idempotencia + diario + prune" echo "✓✓ E4 upgrade E2E VERDE: apply/rollback atómicos + restauración exacta + idempotencia + diario + prune + recover"