From 01b3a225b24754394e809e68042318e67361be29 Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 21 Jun 2026 04:45:27 -0400 Subject: [PATCH] =?UTF-8?q?hammer-upgrade:=20journal=20de=20intenci=C3=B3n?= =?UTF-8?q?=20+=20recover=20idempotente=20(E4=20endurecimiento=20#4a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/hammer-cli/src/main.rs | 20 ++ crates/hammer-upgrade/src/lib.rs | 411 ++++++++++++++++++++++++++----- docs/13-release-engineering.md | 12 +- scripts/upgrade-e2e-test.sh | 18 +- 4 files changed, 400 insertions(+), 61 deletions(-) diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index c6e31cbf..d93032de 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -275,6 +275,12 @@ enum UpgradeCmd { }, /// Revierte la generación viva al árbol anterior (o al estado pre-upgrades). 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. Status, /// 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()); } + 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 => { + 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)?; match cur { Some(id) => println!("generación viva: {id}"), diff --git a/crates/hammer-upgrade/src/lib.rs b/crates/hammer-upgrade/src/lib.rs index 10092880..7a7d13cc 100644 --- a/crates/hammer-upgrade/src/lib.rs +++ b/crates/hammer-upgrade/src/lib.rs @@ -58,6 +58,11 @@ pub enum Error { Verify { dir: String, want: String, got: String }, #[error("upgrade: no hay generación viva que revertir (sistema sin upgrades aplicados)")] 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}")] Other(String), } @@ -114,6 +119,17 @@ pub struct ApplyReport { 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`]. #[derive(Debug, Clone, Default)] 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") } +/// 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> { + 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. pub fn current(state_root: &Path) -> Result> { 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. if let Some(cur) = current(state_root)? { 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_files: Vec = new_entries.iter().map(|e| e.rel.clone()).collect(); let new_set: std::collections::BTreeSet<&PathBuf> = new_files.iter().collect(); - - // Ficheros que la generación viva aportaba y el árbol nuevo NO ⇒ se retiran. - let prev_files: Vec = match current(state_root)? { + let prev_id = current(state_root)?; + let prev_files: Vec = match prev_id { Some(cur) => get(state_root, cur)?.map(|m| m.files).unwrap_or_default(), None => Vec::new(), }; let removed_files: Vec = prev_files.into_iter().filter(|p| !new_set.contains(p)).collect(); - let id = next_id(state_root)?; - 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 = Vec::new(); - - // (3)+(4) proyectar el árbol nuevo. + let mut changes: Vec = Vec::with_capacity(new_entries.len() + removed_files.len()); for e in &new_entries { - let dst = target_root.join(&e.rel); - let existed = std::fs::symlink_metadata(&dst).is_ok(); - if existed { - backup_existing(&dst, &backup_root.join(&e.rel))?; - } - 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); + 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 }, + }); } - - // Retirar los sobrantes del árbol previo. for rel in &removed_files { - let dst = target_root.join(rel); - if std::fs::symlink_metadata(&dst).is_ok() { - backup_existing(&dst, &backup_root.join(rel))?; - remove_path(&dst)?; - report.removed.push(rel.clone()); + if std::fs::symlink_metadata(target_root.join(rel)).is_ok() { 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 { id, tree_dir: tree_dir.to_string(), tree_content, - parent: current(state_root)?, + parent: prev_id, created_at: now_secs(), files: new_files, 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)?)?; 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, + report_deleted: &mut Vec, +) -> 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 mut report = RollbackReport { reverted: cur, now_current: m.parent, ..Default::default() }; - - // 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); - } - } - } - + undo_changes(&m, target_root, &backup_root, journal, &mut report.restored, &mut report.deleted)?; set_current(state_root, m.parent)?; 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 { + 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. // --------------------------------------------------------------------------------------------------- @@ -497,6 +651,19 @@ fn backup_existing(src: &Path, bak: &Path) -> Result<()> { 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 /// (idempotencia: ya restaurado o nunca existió). 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))); } + /// 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] fn prune_removes_orphans_but_keeps_live_chain() { let tmp = tempfile::tempdir().unwrap(); diff --git a/docs/13-release-engineering.md b/docs/13-release-engineering.md index c33d400a..2013d0f4 100644 --- a/docs/13-release-engineering.md +++ b/docs/13-release-engineering.md @@ -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 (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. + NixOS). **Journal de intención + replay ✅** (`hammer upgrade recover [--rollback]` / + `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` + `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 diff --git a/scripts/upgrade-e2e-test.sh b/scripts/upgrade-e2e-test.sh index f556520e..59daad7e 100755 --- a/scripts/upgrade-e2e-test.sh +++ b/scripts/upgrade-e2e-test.sh @@ -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. 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 "✓✓ 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"