From d23946fa3d45e29f42e0ea707d612d5477ab04f3 Mon Sep 17 00:00:00 2001 From: sergio Date: Sat, 20 Jun 2026 21:46:14 -0400 Subject: [PATCH] =?UTF-8?q?hammer-upgrade:=20upgrades=20at=C3=B3micos=20co?= =?UTF-8?q?n=20generaciones=20y=20rollback=20(Etapa=20E4,=20primer=20corte?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crate hammer-upgrade + CLI 'hammer upgrade apply|rollback|status'. Aplica un árbol Stage1/producto del store sobre el FHS vivo dejando una generación (manifest + backup de bytes previos), atómico fichero-a-fichero con 'current' como commit-point. Rollback restaura EXACTO al árbol anterior (o pre-upgrades). Cada cambio al journal (HammerHydrate, replay-able). Verificación opcional del of_tree esperado (índice de mirror E3 / release firmada). Maneja ficheros + symlinks. 7 tests unitarios + scripts/upgrade-e2e-test.sh (apply v1->v2-> rollback->rollback contra el binario real). SDD 13 actualizado. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 15 + Cargo.toml | 2 + crates/hammer-cli/Cargo.toml | 1 + crates/hammer-cli/src/main.rs | 75 ++++ crates/hammer-upgrade/Cargo.toml | 20 + crates/hammer-upgrade/src/lib.rs | 718 +++++++++++++++++++++++++++++++ docs/13-release-engineering.md | 21 +- scripts/upgrade-e2e-test.sh | 103 +++++ 8 files changed, 953 insertions(+), 2 deletions(-) create mode 100644 crates/hammer-upgrade/Cargo.toml create mode 100644 crates/hammer-upgrade/src/lib.rs create mode 100755 scripts/upgrade-e2e-test.sh diff --git a/Cargo.lock b/Cargo.lock index 754eee14..b2110b4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -545,6 +545,7 @@ dependencies = [ "hammer-journal", "hammer-mirror", "hammer-overlay", + "hammer-upgrade", "hex", "serde_json", "sha2", @@ -613,6 +614,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "hammer-upgrade" +version = "0.0.1" +dependencies = [ + "anyhow", + "hammer-core", + "hammer-journal", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tracing", +] + [[package]] name = "hammerd" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 5f6f8ccb..4b995faf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/hammer-overlay", "crates/hammer-journal", "crates/hammer-mirror", + "crates/hammer-upgrade", "crates/hammer-agent", "crates/hammer-cli", "crates/hammerd", @@ -27,6 +28,7 @@ hammer-bootstrap = { path = "crates/hammer-bootstrap" } hammer-overlay = { path = "crates/hammer-overlay" } hammer-journal = { path = "crates/hammer-journal" } hammer-mirror = { path = "crates/hammer-mirror" } +hammer-upgrade = { path = "crates/hammer-upgrade" } hammer-agent = { path = "crates/hammer-agent" } # shared third-party deps, pinned at the workspace level diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml index ca789a34..4fd8b428 100644 --- a/crates/hammer-cli/Cargo.toml +++ b/crates/hammer-cli/Cargo.toml @@ -22,6 +22,7 @@ hammer-core.workspace = true hammer-build.workspace = true hammer-bootstrap.workspace = true hammer-mirror.workspace = true +hammer-upgrade.workspace = true hammer-overlay.workspace = true hammer-journal.workspace = true hammer-agent.workspace = true diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index ebf14d3a..03f93ddd 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -245,6 +245,38 @@ enum Cmd { #[command(subcommand)] sub: MirrorCmd, }, + + /// [Etapa E4] Upgrades atómicos con rollback: aplica un árbol Stage1/producto del store al root + /// vivo dejando una generación nueva; `rollback` revierte exactamente al árbol anterior. + Upgrade { + /// Root del FHS vivo sobre el que se proyecta el árbol (default `/`). + #[arg(long, default_value = "/", global = true)] + root: String, + /// Directorio de estado de generaciones (default `/var/lib/hammer/upgrades`). + #[arg(long, default_value = hammer_upgrade::DEFAULT_STATE_ROOT, global = true)] + state_root: String, + /// Diario donde registrar cada cambio (default `/var/lib/hammer/journal`). + #[arg(long, default_value = "/var/lib/hammer/journal", global = true)] + journal: String, + #[command(subcommand)] + sub: UpgradeCmd, + }, +} + +#[derive(Subcommand)] +enum UpgradeCmd { + /// Aplica el árbol `` (`-` del store) al root vivo, dejando una generación. + Apply { + /// Directorio del artefacto en el store (p.ej. `b3hex…-product-rootfs`). + tree_dir: String, + /// `of_tree` esperado (`b3:…`) — de un índice de mirror o release firmada; si se da, se exige. + #[arg(long)] + expected: Option, + }, + /// Revierte la generación viva al árbol anterior (o al estado pre-upgrades). + Rollback, + /// Muestra la generación viva y el historial. + Status, } #[derive(Subcommand)] @@ -798,6 +830,49 @@ fn main() -> anyhow::Result<()> { } } } + Cmd::Upgrade { root, state_root, journal, sub } => { + let store = std::path::PathBuf::from(&cli.store); + let root = std::path::PathBuf::from(&root); + let state_root = std::path::PathBuf::from(&state_root); + match sub { + UpgradeCmd::Apply { tree_dir, expected } => { + let j = hammer_journal::Journal::open(&journal)?; + let r = hammer_upgrade::apply( + &store, &tree_dir, expected.as_deref(), &root, &state_root, Some(&j), + )?; + if r.already_current { + println!("generación {} ya viva — árbol idéntico, no-op", r.generation); + } else { + println!("✓ generación {} aplicada", r.generation); + println!(" + {} añadidos, ~ {} pisados, - {} retirados", + r.added.len(), r.replaced.len(), r.removed.len()); + } + } + UpgradeCmd::Rollback => { + let j = hammer_journal::Journal::open(&journal)?; + let r = hammer_upgrade::rollback(&root, &state_root, Some(&j))?; + println!("✓ generación {} revertida", r.reverted); + match r.now_current { + Some(id) => println!(" ahora viva: generación {id}"), + None => println!(" ahora viva: estado pre-upgrades"), + } + println!(" {} restaurados, {} borrados", r.restored.len(), r.deleted.len()); + } + UpgradeCmd::Status => { + let cur = hammer_upgrade::current(&state_root)?; + match cur { + Some(id) => println!("generación viva: {id}"), + None => println!("generación viva: (ninguna — sistema sin upgrades aplicados)"), + } + let gens = hammer_upgrade::list(&state_root)?; + println!("historial: {} generación(es)", gens.len()); + for g in &gens { + let mark = if Some(g.id) == cur { "*" } else { " " }; + println!(" {mark} {} → {} ({} ficheros)", g.id, g.tree_dir, g.files.len()); + } + } + } + } } Ok(()) } diff --git a/crates/hammer-upgrade/Cargo.toml b/crates/hammer-upgrade/Cargo.toml new file mode 100644 index 00000000..4b75227a --- /dev/null +++ b/crates/hammer-upgrade/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "hammer-upgrade" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Upgrades atómicos de hammer (E4): aplica un árbol Stage1/producto del store al root vivo con generaciones y rollback." + +[dependencies] +hammer-core.workspace = true +hammer-journal.workspace = true +anyhow.workspace = true +thiserror.workspace = true +tracing.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/hammer-upgrade/src/lib.rs b/crates/hammer-upgrade/src/lib.rs new file mode 100644 index 00000000..030096a3 --- /dev/null +++ b/crates/hammer-upgrade/src/lib.rs @@ -0,0 +1,718 @@ +//! **E4 — upgrades atómicos con rollback** ([SDD 13 §E4](../../docs/13-release-engineering.md)). +//! +//! Aplicar un árbol Stage1/producto **nuevo** al root vivo sin reinstalar, de forma atómica y con +//! **rollback** al árbol anterior. El árbol nuevo es un artefacto sellado del store (CAS BLAKE3); el +//! "apply" lo proyecta sobre el FHS vivo (`target_root`, normalmente `/`), registra cada cambio en el +//! [diario](hammer_journal) y deja una **generación** nueva. Si algo sale mal, `rollback` revierte +//! exactamente al árbol anterior. +//! +//! ## Modelo de generaciones (inspirado en NixOS, aplicado in-place al FHS) +//! +//! El sistema booteado lee el userland directamente del root (vda2), no de un symlink indirecto. Así +//! que el "switch" no es un único symlink: es la proyección de los ficheros del árbol nuevo sobre el +//! root, fichero a fichero (escritura a temporal + `rename` ⇒ cada fichero conmuta atómicamente), más +//! un **registro de generación** que es la fuente de verdad de "qué árbol está vivo" y guarda lo +//! necesario para deshacer. +//! +//! ```text +//! / (default /var/lib/hammer/upgrades) +//! generations/ +//! /manifest.json id, tree_dir (store), tree_content (of_tree), parent, cambios +//! /backup/ bytes PREVIOS de cada fichero que el apply pisó o borró +//! current fichero con el id de la generación viva (escritura atómica) +//! ``` +//! +//! **Atomicidad y rollback.** El apply respalda el estado previo de todo path que toca *antes* de +//! tocarlo; el commit-point es el avance de `current`. Un corte a mitad deja `current` en la +//! generación vieja y los backups intactos ⇒ `rollback` (o un re-apply) restaura. El rollback deshace +//! en orden inverso: borra lo que el apply creó, restaura desde `backup/` lo que pisó o borró, y +//! retrocede `current` al padre. +//! +//! **Alcance del primer corte.** Maneja ficheros regulares y symlinks (los dos tipos de contenido que +//! `of_tree` hashea); crea los directorios intermedios que haga falta. El cableado del init de boot +//! para que el rollback sobreviva un kernel-panic a media escritura (journal de intención + replay) es +//! endurecimiento posterior, ortogonal a esta lógica. + +use std::path::{Path, PathBuf}; + +use hammer_core::{ArtifactHash, Store}; +use hammer_journal::{Actor, Journal, MutationEvent, MutationOp, Source}; +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_STATE_ROOT: &str = "/var/lib/hammer/upgrades"; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("core: {0}")] + Core(#[from] hammer_core::Error), + #[error("journal: {0}")] + Journal(#[from] hammer_journal::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error( + "integridad: el árbol '{dir}' del store hashea a {got} pero se esperaba {want} \ + — store corrupto o árbol equivocado; NO se aplica" + )] + Verify { dir: String, want: String, got: String }, + #[error("upgrade: no hay generación viva que revertir (sistema sin upgrades aplicados)")] + NothingToRollback, + #[error("upgrade: {0}")] + Other(String), +} + +pub type Result = std::result::Result; + +/// Qué le pasó a un path concreto al aplicar una generación. Determina cómo se deshace. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ChangeOp { + /// El path no existía antes ⇒ rollback lo **borra**. + Added, + /// El path existía y fue pisado ⇒ `backup/` guarda los bytes previos ⇒ rollback los **restaura**. + Replaced, + /// El path existía en el árbol previo y el nuevo no lo tiene ⇒ apply lo borra; `backup/` guarda + /// los bytes previos ⇒ rollback los **restaura**. + Removed, +} + +/// Un cambio atómico sobre un path del FHS, relativo a `target_root`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Change { + pub path: PathBuf, + pub op: ChangeOp, +} + +/// Manifiesto persistente de una generación — la unidad de upgrade/rollback. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerationManifest { + pub id: u64, + /// Directorio del artefacto en el store (`-`) cuyo árbol esta generación proyectó. + pub tree_dir: String, + /// `of_tree` (`b3:…`) del árbol aplicado: ancla de integridad y desempate vs el store. + pub tree_content: String, + /// Generación anterior (a la que el rollback vuelve). `None` en la primera. + pub parent: Option, + /// Segundos desde UNIX epoch al apply. + pub created_at: u64, + /// Lista relativa (ordenada) de los ficheros que el árbol aporta — sirve para que el SIGUIENTE + /// apply sepa qué retirar (paths del árbol previo ausentes en el nuevo). + pub files: Vec, + /// Cada path tocado y cómo, para deshacer. + pub changes: Vec, +} + +/// Reporte de un [`apply`]. +#[derive(Debug, Clone, Default)] +pub struct ApplyReport { + pub generation: u64, + pub added: Vec, + pub replaced: Vec, + pub removed: Vec, + /// `true` si el árbol pedido ya era el vivo (apply idempotente, no se creó generación nueva). + pub already_current: bool, +} + +/// Reporte de un [`rollback`]. +#[derive(Debug, Clone, Default)] +pub struct RollbackReport { + /// Generación que se revirtió (la que estaba viva). + pub reverted: u64, + /// Generación que quedó viva tras revertir (`None` si se volvió al estado pre-upgrades). + pub now_current: Option, + pub restored: Vec, + pub deleted: Vec, +} + +// --------------------------------------------------------------------------------------------------- +// Registro de generaciones (lectura del estado). +// --------------------------------------------------------------------------------------------------- + +fn generations_dir(state_root: &Path) -> PathBuf { + state_root.join("generations") +} + +fn current_file(state_root: &Path) -> PathBuf { + state_root.join("current") +} + +fn manifest_path(state_root: &Path, id: u64) -> PathBuf { + generations_dir(state_root).join(id.to_string()).join("manifest.json") +} + +/// 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); + if !f.is_file() { + return Ok(None); + } + let s = std::fs::read_to_string(&f)?; + let s = s.trim(); + if s.is_empty() { + return Ok(None); + } + s.parse::() + .map(Some) + .map_err(|_| Error::Other(format!("`current` ilegible: {s:?}"))) +} + +/// Lee el manifiesto de una generación concreta. +pub fn get(state_root: &Path, id: u64) -> Result> { + let p = manifest_path(state_root, id); + if !p.is_file() { + return Ok(None); + } + Ok(Some(serde_json::from_slice(&std::fs::read(&p)?)?)) +} + +/// Lista todas las generaciones registradas, ordenadas por id ascendente. +pub fn list(state_root: &Path) -> Result> { + let dir = generations_dir(state_root); + if !dir.is_dir() { + return Ok(Vec::new()); + } + let mut out: Vec = Vec::new(); + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let mp = entry.path().join("manifest.json"); + if mp.is_file() { + out.push(serde_json::from_slice(&std::fs::read(&mp)?)?); + } + } + out.sort_by_key(|m| m.id); + Ok(out) +} + +fn next_id(state_root: &Path) -> Result { + Ok(list(state_root)?.iter().map(|m| m.id).max().map_or(1, |m| m + 1)) +} + +// --------------------------------------------------------------------------------------------------- +// Enumeración de árboles. +// --------------------------------------------------------------------------------------------------- + +/// Un fichero de un árbol: su path relativo y su tipo de contenido (lo que `of_tree` distingue). +#[derive(Debug, Clone)] +struct TreeEntry { + rel: PathBuf, + kind: EntryKind, +} + +#[derive(Debug, Clone)] +enum EntryKind { + /// Fichero regular. `exec` = bit de ejecución (de_tree lo mira). + File { exec: bool }, + /// Symlink → target literal (sin resolver). + Symlink(PathBuf), +} + +/// Camina `root` y devuelve sus ficheros (regulares + symlinks) con path relativo, ordenados. Los +/// directorios no se listan: se recrean al proyectar según haga falta. +fn enumerate_tree(root: &Path) -> Result> { + let mut out = Vec::new(); + fn walk(root: &Path, dir: &Path, out: &mut Vec) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let p = entry.path(); + let meta = std::fs::symlink_metadata(&p)?; + let ft = meta.file_type(); + let rel = p.strip_prefix(root).expect("p ⊂ root").to_path_buf(); + if ft.is_symlink() { + out.push(TreeEntry { rel, kind: EntryKind::Symlink(std::fs::read_link(&p)?) }); + } else if ft.is_dir() { + walk(root, &p, out)?; + } else if ft.is_file() { + let exec = meta.permissions().mode() & 0o111 != 0; + out.push(TreeEntry { rel, kind: EntryKind::File { exec } }); + } + } + Ok(()) + } + walk(root, root, &mut out)?; + out.sort_by(|a, b| a.rel.cmp(&b.rel)); + Ok(out) +} + +// --------------------------------------------------------------------------------------------------- +// Apply. +// --------------------------------------------------------------------------------------------------- + +/// Aplica el árbol `tree_dir` del store sobre `target_root`, dejando una generación nueva. +/// +/// Pasos: (1) resuelve y **verifica** el árbol del store (su `of_tree` debe casar el del índice); +/// (2) diferencia contra el árbol de la generación viva ⇒ qué se añade/pisa/retira; (3) **respalda** +/// el estado previo de cada path tocado; (4) proyecta el árbol nuevo (escritura a temporal + rename) +/// y retira los ficheros sobrantes, registrando cada cambio en el diario; (5) escribe el manifiesto y +/// **avanza `current`** (commit-point). +pub fn apply( + store_root: &Path, + tree_dir: &str, + expected: Option<&str>, + target_root: &Path, + state_root: &Path, + journal: Option<&Journal>, +) -> Result { + let store = Store::open(store_root)?; + let tree_path = store.root().join(tree_dir); + if !tree_path.is_dir() { + return Err(Error::Other(format!( + "el árbol '{tree_dir}' no existe en el store {}", + store.root().display() + ))); + } + // El of_tree real ancla la identidad del árbol. Si el llamador anuncia uno esperado (de un índice + // de mirror o una release firmada), lo exigimos: defiende de un store corrupto/manipulado entre el + // sellado y el apply. Sin `expected`, el store local se confía (sellado read-only) y el of_tree + // sólo se registra en el manifiesto. + let got = ArtifactHash::of_tree(&tree_path)?; + let tree_content = got.as_str().to_string(); + if let Some(want) = expected { + if tree_content != want { + return Err(Error::Verify { + dir: tree_dir.to_string(), + want: want.to_string(), + got: tree_content, + }); + } + } + + // 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)? { + if m.tree_content == tree_content { + return Ok(ApplyReport { generation: cur, already_current: true, ..Default::default() }); + } + } + } + + 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)? { + 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. + 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); + } + + // 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()); + 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 manifest = GenerationManifest { + id, + tree_dir: tree_dir.to_string(), + tree_content, + parent: current(state_root)?, + created_at: now_secs(), + files: new_files, + changes, + }; + write_atomic(&manifest_path(state_root, id), &serde_json::to_vec_pretty(&manifest)?)?; + set_current(state_root, Some(id))?; + Ok(report) +} + +// --------------------------------------------------------------------------------------------------- +// Rollback. +// --------------------------------------------------------------------------------------------------- + +/// Revierte la generación viva: deshace sus cambios (borra lo añadido, restaura desde `backup/` lo +/// pisado/retirado) y retrocede `current` al padre. Idempotente respecto a paths ya restaurados. +pub fn rollback( + target_root: &Path, + state_root: &Path, + journal: Option<&Journal>, +) -> Result { + let cur = current(state_root)?.ok_or(Error::NothingToRollback)?; + let m = get(state_root, cur)? + .ok_or_else(|| Error::Other(format!("manifiesto de la generación {cur} ausente")))?; + 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); + } + } + } + + set_current(state_root, m.parent)?; + Ok(report) +} + +// --------------------------------------------------------------------------------------------------- +// Primitivas de filesystem. +// --------------------------------------------------------------------------------------------------- + +/// Proyecta una entrada del árbol sobre `dst` (escritura a temporal hermano + `rename` atómico). +fn project_entry(tree_root: &Path, e: &TreeEntry, dst: &Path) -> Result<()> { + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = tmp_sibling(dst); + let _ = remove_path(&tmp); + match &e.kind { + EntryKind::File { exec } => { + let src = tree_root.join(&e.rel); + std::fs::copy(&src, &tmp)?; + if *exec { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&src)?.permissions().mode(); + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?; + } + } + EntryKind::Symlink(target) => { + std::os::unix::fs::symlink(target, &tmp)?; + } + } + // rename pisa el destino atómicamente (mismo dir ⇒ mismo FS). + std::fs::rename(&tmp, dst)?; + Ok(()) +} + +/// Respalda el estado PREVIO de `src` (fichero o symlink) en `bak`, preservando el tipo. +fn backup_existing(src: &Path, bak: &Path) -> Result<()> { + if let Some(parent) = bak.parent() { + std::fs::create_dir_all(parent)?; + } + let meta = std::fs::symlink_metadata(src)?; + if meta.file_type().is_symlink() { + let _ = remove_path(bak); + std::os::unix::fs::symlink(std::fs::read_link(src)?, bak)?; + } else if meta.is_file() { + let _ = remove_path(bak); + std::fs::copy(src, bak)?; + std::fs::set_permissions(bak, meta.permissions())?; + } else { + return Err(Error::Other(format!( + "no sé respaldar {} (no es fichero ni symlink)", + src.display() + ))); + } + Ok(()) +} + +/// 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<()> { + let meta = match std::fs::symlink_metadata(bak) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = tmp_sibling(dst); + let _ = remove_path(&tmp); + if meta.file_type().is_symlink() { + std::os::unix::fs::symlink(std::fs::read_link(bak)?, &tmp)?; + } else { + std::fs::copy(bak, &tmp)?; + std::fs::set_permissions(&tmp, meta.permissions())?; + } + std::fs::rename(&tmp, dst)?; + Ok(()) +} + +fn remove_path(p: &Path) -> Result<()> { + match std::fs::symlink_metadata(p) { + Ok(m) if m.is_dir() && !m.file_type().is_symlink() => std::fs::remove_dir_all(p)?, + Ok(_) => std::fs::remove_file(p)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + Ok(()) +} + +fn tmp_sibling(dst: &Path) -> PathBuf { + let name = dst.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + dst.with_file_name(format!(".hammer-upgrade-tmp-{name}")) +} + +/// Escribe `bytes` en `path` de forma atómica (temporal + rename). +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = tmp_sibling(path); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +/// Avanza/retrocede `current` de forma atómica. `None` ⇒ borra el puntero (estado pre-upgrades). +fn set_current(state_root: &Path, id: Option) -> Result<()> { + let f = current_file(state_root); + match id { + Some(id) => write_atomic(&f, id.to_string().as_bytes())?, + None => { + let _ = std::fs::remove_file(&f); + } + } + Ok(()) +} + +fn journal_change(journal: Option<&Journal>, tree_dir: &str, dst: &Path, op: ChangeOp) { + let Some(j) = journal else { return }; + let mop = match op { + ChangeOp::Added => MutationOp::Create, + ChangeOp::Replaced => MutationOp::Replace, + ChangeOp::Removed => MutationOp::Delete, + }; + let ev = MutationEvent { + ts: hammer_journal::now_rfc3339(), + op: mop, + path: dst.to_path_buf(), + by: Actor { + // El upgrade es replay-able: el contenido viene del árbol `tree_dir` del store. + source: Source::HammerHydrate { artifact: tree_dir.to_string() }, + pid: Some(std::process::id()), + uid: None, + }, + content_hash: None, + note: Some("hammer upgrade".to_string()), + }; + if let Err(e) = j.record(&ev) { + tracing::warn!(error = %e, path = %dst.display(), "upgrade: fallo al registrar en el diario"); + } +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Sella un árbol sintético en `store_root` y devuelve su `-`. + fn seal_tree(store_root: &Path, hex: &str, name: &str, build: impl Fn(&Path)) -> String { + let store = Store::open(store_root).unwrap(); + let h = ArtifactHash::from_hex(hex); + let staging = store_root.join(".bootstrap-tmp").join(h.store_dir_name(name)); + let _ = std::fs::remove_dir_all(&staging); + std::fs::create_dir_all(&staging).unwrap(); + build(&staging); + store.seal(&staging, &h, name).unwrap(); + h.store_dir_name(name) + } + + fn h64(prefix: &str) -> String { + format!("{prefix}{}", "0".repeat(64 - prefix.len())) + } + + fn write(p: &Path, bytes: &[u8]) { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, bytes).unwrap(); + } + + #[test] + fn apply_projects_tree_and_records_generation() { + 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(); + // root ya tiene un /usr/bin/ls "viejo" que el upgrade pisará. + write(&root.join("usr/bin/ls"), b"OLD-ls"); + + let dir = seal_tree(&store, &h64("aa"), "v1", |w| { + write(&w.join("usr/bin/ls"), b"NEW-ls"); + write(&w.join("usr/bin/cat"), b"NEW-cat"); + std::fs::create_dir_all(w.join("bin")).unwrap(); + std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap(); + }); + + let r = apply(&store, &dir, None, &root, &state, None).unwrap(); + assert_eq!(r.generation, 1); + assert!(!r.already_current); + assert_eq!(r.replaced, vec![PathBuf::from("usr/bin/ls")]); + assert!(r.added.contains(&PathBuf::from("usr/bin/cat"))); + assert!(r.added.contains(&PathBuf::from("bin/sh"))); + + // los bytes nuevos están en el root vivo. + 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_link(root.join("bin/sh")).unwrap(), PathBuf::from("busybox")); + + assert_eq!(current(&state).unwrap(), Some(1)); + assert_eq!(list(&state).unwrap().len(), 1); + } + + #[test] + fn apply_is_idempotent_for_same_tree() { + let tmp = tempfile::tempdir().unwrap(); + let store = tmp.path().join("store"); + let root = tmp.path().join("root"); + let state = tmp.path().join("state"); + let dir = seal_tree(&store, &h64("bb"), "v1", |w| write(&w.join("a"), b"A")); + let r1 = apply(&store, &dir, None, &root, &state, None).unwrap(); + assert!(!r1.already_current && r1.generation == 1); + let r2 = apply(&store, &dir, None, &root, &state, None).unwrap(); + assert!(r2.already_current, "mismo árbol ⇒ no-op"); + assert_eq!(list(&state).unwrap().len(), 1, "no se creó generación nueva"); + } + + #[test] + fn rollback_restores_previous_tree_exactly() { + 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("c1"), "v1", |w| { + write(&w.join("usr/bin/ls"), b"LS-v1"); + write(&w.join("usr/bin/old"), b"OLD-only-in-v1"); + }); + let v2 = seal_tree(&store, &h64("c2"), "v2", |w| { + write(&w.join("usr/bin/ls"), b"LS-v2"); + write(&w.join("usr/bin/new"), b"NEW-only-in-v2"); + // `old` desaparece ⇒ apply de v2 debe retirarlo. + }); + + apply(&store, &v1, None, &root, &state, None).unwrap(); + let r2 = apply(&store, &v2, None, &root, &state, None).unwrap(); + assert_eq!(r2.generation, 2); + assert_eq!(r2.removed, vec![PathBuf::from("usr/bin/old")]); + // estado tras v2. + assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v2"); + assert!(root.join("usr/bin/new").exists()); + assert!(!root.join("usr/bin/old").exists()); + + // rollback ⇒ vuelve EXACTAMENTE a v1. + let rb = rollback(&root, &state, None).unwrap(); + assert_eq!(rb.reverted, 2); + assert_eq!(rb.now_current, Some(1)); + assert_eq!(current(&state).unwrap(), Some(1)); + assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v1", "ls revertido"); + assert!(!root.join("usr/bin/new").exists(), "lo añadido por v2 se borró"); + assert_eq!(std::fs::read(root.join("usr/bin/old")).unwrap(), b"OLD-only-in-v1", "lo retirado por v2 se restauró"); + } + + #[test] + fn rollback_to_pre_upgrade_state() { + 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/motd"), b"PRISTINE"); + + let v1 = seal_tree(&store, &h64("dd"), "v1", |w| { + write(&w.join("etc/motd"), b"UPGRADED"); + write(&w.join("usr/bin/brand-new"), b"X"); + }); + apply(&store, &v1, None, &root, &state, None).unwrap(); + assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"UPGRADED"); + + let rb = rollback(&root, &state, None).unwrap(); + assert_eq!(rb.now_current, None, "se volvió al estado pre-upgrades"); + assert_eq!(current(&state).unwrap(), None); + assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"PRISTINE", "config original restaurada"); + assert!(!root.join("usr/bin/brand-new").exists()); + } + + #[test] + fn rollback_without_generation_errors() { + let tmp = tempfile::tempdir().unwrap(); + let err = rollback(&tmp.path().join("root"), &tmp.path().join("state"), None).unwrap_err(); + assert!(matches!(err, Error::NothingToRollback), "vino {err}"); + } + + #[test] + fn apply_rejects_tree_that_does_not_match_expected() { + // Si el llamador anuncia un of_tree esperado (de un índice de mirror / release firmada) y el + // árbol del store no casa, apply rechaza ANTES de tocar el root. + let tmp = tempfile::tempdir().unwrap(); + let store_root = tmp.path().join("store"); + let root = tmp.path().join("root"); + let state = tmp.path().join("state"); + let dir = seal_tree(&store_root, &h64("ee"), "v1", |w| write(&w.join("a"), b"A")); + + // expected MENTIROSO ⇒ Verify, y nada se aplicó. + let err = apply(&store_root, &dir, Some("b3:deadbeef"), &root, &state, None).unwrap_err(); + assert!(matches!(err, Error::Verify { .. }), "vino {err}"); + assert_eq!(current(&state).unwrap(), None, "no se creó generación"); + assert!(!root.join("a").exists(), "el root quedó intacto"); + + // expected CORRECTO (el of_tree real) ⇒ aplica. + let want = ArtifactHash::of_tree(&store_root.join(&dir)).unwrap().as_str().to_string(); + let r = apply(&store_root, &dir, Some(&want), &root, &state, None).unwrap(); + assert_eq!(r.generation, 1); + } + + #[test] + fn journal_records_each_change() { + let tmp = tempfile::tempdir().unwrap(); + let store = tmp.path().join("store"); + let root = tmp.path().join("root"); + let state = tmp.path().join("state"); + let j = Journal::open(tmp.path().join("journal")).unwrap(); + let dir = seal_tree(&store, &h64("ff"), "v1", |w| { + write(&w.join("usr/bin/a"), b"A"); + write(&w.join("usr/bin/b"), b"B"); + }); + apply(&store, &dir, None, &root, &state, Some(&j)).unwrap(); + let evs = j.read_all().unwrap(); + assert_eq!(evs.len(), 2); + assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir))); + } +} diff --git a/docs/13-release-engineering.md b/docs/13-release-engineering.md index 14fd397d..c2252e92 100644 --- a/docs/13-release-engineering.md +++ b/docs/13-release-engineering.md @@ -63,7 +63,24 @@ kernel en `/boot`, los módulos GRUB en `/boot/grub/i386-pc` y los pasos `grub-m Endurecimiento pendiente: anclar el `of_tree` a una raíz firmada (log de transparencia `bootstrap.json` / atestación de Etapa D) para defenderse de un origen plenamente malicioso (hoy el invariante forzado es "el contenido recibido hashea a lo que el índice anuncia", que ataja corrupción de transporte). -- **E4 — upgrades:** aplicar un nuevo árbol Stage 1 sin reinstalar — atómico vía el modelo overlay + - el journal (D), con rollback al árbol anterior. +- **E4 — upgrades: ✅ primer corte** (crate `hammer-upgrade` + CLI `hammer upgrade apply|rollback|status`). + Aplica un árbol Stage1/producto **nuevo** (artefacto sellado del store, CAS BLAKE3) sobre el FHS vivo + sin reinstalar, dejando una **generación**, y revierte exactamente al árbol anterior con `rollback`. + **Modelo de generaciones** (inspirado en NixOS, in-place al FHS porque el boot lee el root directo, no + un symlink indirecto): cada generación graba bajo `/generations//` su `manifest.json` + (tree_dir del store, `of_tree`, parent, lista de ficheros, cambios) y un `backup/` con los bytes + **previos** de todo path que pisó o retiró. **Atomicidad:** proyección fichero-a-fichero (escritura a + temporal + `rename` ⇒ cada fichero conmuta atómicamente), con `current` (id de la generación viva) como + commit-point; un corte a mitad deja `current` en la vieja y los backups intactos ⇒ re-apply/rollback + recuperan. **Diff vs el árbol previo:** el apply añade/pisa los ficheros del árbol nuevo y **retira** los + que la generación viva aportaba y el nuevo no tiene. **Rollback:** deshace en orden inverso (borra lo + añadido, restaura desde `backup/` lo pisado/retirado) y retrocede `current` al padre — restauración + *exacta*, incluso al estado pre-upgrades. Cada cambio se registra en el [journal](05-journal.md) como + `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. - **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 new file mode 100755 index 00000000..7512b94a --- /dev/null +++ b/scripts/upgrade-e2e-test.sh @@ -0,0 +1,103 @@ +#!/bin/sh +# E2E de la Etapa E4 (upgrades atómicos con rollback) contra el binario `hammer` REAL. +# +# Monta un store sintético con dos árboles "producto" (v1, v2), un root FHS vivo, y ejercita: +# apply v1 → apply v2 → rollback → rollback, verificando el estado del root en cada paso. +# Todo en host, sin VM: valida el cableado del CLI + la semántica generación/rollback de hammer-upgrade. +# +# Uso: scripts/upgrade-e2e-test.sh +set -eu + +ROOT_DIR="$(CDPATH= cd "$(dirname "$0")/.." && pwd)" +WORK="$(mktemp -d)" +trap 'chmod -R u+w "$WORK" 2>/dev/null || true; rm -rf "$WORK"' EXIT + +STORE="$WORK/store" +ROOT="$WORK/root" +STATE="$WORK/state" +JOURNAL="$WORK/journal" +mkdir -p "$STORE" "$ROOT/usr/bin" "$ROOT/etc" + +echo "==> compilando hammer" +cargo build -q -p hammer-cli --manifest-path "$ROOT_DIR/Cargo.toml" +HAMMER="$ROOT_DIR/target/debug/hammer" + +up() { "$HAMMER" --store "$STORE" upgrade --root "$ROOT" --state-root "$STATE" --journal "$JOURNAL" "$@"; } + +# --- estado pristino del root (lo que el rollback final debe restaurar) --- +printf 'PRISTINE-ls' > "$ROOT/usr/bin/ls" +printf 'motd original\n' > "$ROOT/etc/motd" + +# --- sella dos árboles producto sintéticos en el store (vía `hammer build`? no: usamos un mini-store +# a mano replicando la forma <64hex>-; el of_tree lo computa hammer al aplicar) --- +seal_tree() { + # $1=hex64 $2=name $3=builder-fn + dir="$STORE/$1-$2" + rm -rf "$dir"; mkdir -p "$dir" + "$3" "$dir" + chmod -R a-w "$dir" # el store sella read-only; replicamos para fidelidad +} +hex() { printf '%s' "$1"; i=${#1}; while [ "$i" -lt 64 ]; do printf '0'; i=$((i+1)); done; } + +build_v1() { + mkdir -p "$1/usr/bin" "$1/bin" + printf 'LS-v1' > "$1/usr/bin/ls" + printf 'OLDtool'> "$1/usr/bin/oldtool" + ln -s busybox "$1/bin/sh" +} +build_v2() { + mkdir -p "$1/usr/bin" "$1/bin" + printf 'LS-v2' > "$1/usr/bin/ls" + printf 'NEWtool'> "$1/usr/bin/newtool" # oldtool desaparece + ln -s busybox "$1/bin/sh" +} +seal_tree "$(hex aa11)" product-rootfs build_v1 +seal_tree "$(hex bb22)" product-rootfs build_v2 +V1="$(hex aa11)-product-rootfs" +V2="$(hex bb22)-product-rootfs" + +assert() { # $1=path $2=expected-content $3=label + got="$(cat "$1" 2>/dev/null || echo '')" + if [ "$got" != "$2" ]; then echo "FALLO [$3]: $1 = '$got', esperaba '$2'" >&2; exit 1; fi + echo " ok [$3] $1 = $2" +} +assert_absent() { [ ! -e "$1" ] || { echo "FALLO [$2]: $1 debería estar ausente" >&2; exit 1; }; echo " ok [$2] $1 ausente"; } + +echo "==> apply v1" +up apply "$V1" +assert "$ROOT/usr/bin/ls" "LS-v1" "v1" +assert "$ROOT/usr/bin/oldtool" "OLDtool" "v1" +[ -L "$ROOT/bin/sh" ] || { echo "FALLO: bin/sh no es symlink" >&2; exit 1; } + +echo "==> apply v2 (pisa ls, añade newtool, RETIRA oldtool)" +up apply "$V2" +assert "$ROOT/usr/bin/ls" "LS-v2" "v2" +assert "$ROOT/usr/bin/newtool" "NEWtool" "v2" +assert_absent "$ROOT/usr/bin/oldtool" "v2" + +echo "==> status" +up status + +echo "==> rollback (v2 → v1: ls vuelve, newtool se borra, oldtool se restaura)" +up rollback +assert "$ROOT/usr/bin/ls" "LS-v1" "rb→v1" +assert "$ROOT/usr/bin/oldtool" "OLDtool" "rb→v1" +assert_absent "$ROOT/usr/bin/newtool" "rb→v1" + +echo "==> rollback (v1 → pristino: el root vuelve EXACTO al estado pre-upgrades)" +up rollback +assert "$ROOT/usr/bin/ls" "PRISTINE-ls" "rb→pristino" +assert "$ROOT/etc/motd" "$(printf 'motd original\n')" "rb→pristino" +assert_absent "$ROOT/usr/bin/oldtool" "rb→pristino" + +echo "==> idempotencia: re-apply v1 sobre pristino vuelve a generar, status coherente" +up apply "$V1" >/dev/null +up apply "$V1" # mismo árbol ⇒ no-op + +echo "==> diario: registró cambios" +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 +echo "✓✓ E4 upgrade E2E VERDE: apply/rollback atómicos + restauración exacta + idempotencia + diario"