hammer-upgrade: upgrades atómicos con generaciones y rollback (Etapa E4, primer corte)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 21:46:14 -04:00
co-authored by Claude Opus 4.8
parent 30daab35e6
commit d23946fa3d
8 changed files with 953 additions and 2 deletions
Generated
+15
View File
@@ -545,6 +545,7 @@ dependencies = [
"hammer-journal", "hammer-journal",
"hammer-mirror", "hammer-mirror",
"hammer-overlay", "hammer-overlay",
"hammer-upgrade",
"hex", "hex",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -613,6 +614,20 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "hammer-upgrade"
version = "0.0.1"
dependencies = [
"anyhow",
"hammer-core",
"hammer-journal",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tracing",
]
[[package]] [[package]]
name = "hammerd" name = "hammerd"
version = "0.0.1" version = "0.0.1"
+2
View File
@@ -7,6 +7,7 @@ members = [
"crates/hammer-overlay", "crates/hammer-overlay",
"crates/hammer-journal", "crates/hammer-journal",
"crates/hammer-mirror", "crates/hammer-mirror",
"crates/hammer-upgrade",
"crates/hammer-agent", "crates/hammer-agent",
"crates/hammer-cli", "crates/hammer-cli",
"crates/hammerd", "crates/hammerd",
@@ -27,6 +28,7 @@ hammer-bootstrap = { path = "crates/hammer-bootstrap" }
hammer-overlay = { path = "crates/hammer-overlay" } hammer-overlay = { path = "crates/hammer-overlay" }
hammer-journal = { path = "crates/hammer-journal" } hammer-journal = { path = "crates/hammer-journal" }
hammer-mirror = { path = "crates/hammer-mirror" } hammer-mirror = { path = "crates/hammer-mirror" }
hammer-upgrade = { path = "crates/hammer-upgrade" }
hammer-agent = { path = "crates/hammer-agent" } hammer-agent = { path = "crates/hammer-agent" }
# shared third-party deps, pinned at the workspace level # shared third-party deps, pinned at the workspace level
+1
View File
@@ -22,6 +22,7 @@ hammer-core.workspace = true
hammer-build.workspace = true hammer-build.workspace = true
hammer-bootstrap.workspace = true hammer-bootstrap.workspace = true
hammer-mirror.workspace = true hammer-mirror.workspace = true
hammer-upgrade.workspace = true
hammer-overlay.workspace = true hammer-overlay.workspace = true
hammer-journal.workspace = true hammer-journal.workspace = true
hammer-agent.workspace = true hammer-agent.workspace = true
+75
View File
@@ -245,6 +245,38 @@ enum Cmd {
#[command(subcommand)] #[command(subcommand)]
sub: MirrorCmd, 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 `<tree-dir>` (`<hash>-<name>` 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<String>,
},
/// 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)] #[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(()) Ok(())
} }
+20
View File
@@ -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
+718
View File
@@ -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
//! <state_root>/ (default /var/lib/hammer/upgrades)
//! generations/
//! <N>/manifest.json id, tree_dir (store), tree_content (of_tree), parent, cambios
//! <N>/backup/<rel...> 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<T> = std::result::Result<T, Error>;
/// 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 (`<hash>-<name>`) 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<u64>,
/// 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<PathBuf>,
/// Cada path tocado y cómo, para deshacer.
pub changes: Vec<Change>,
}
/// Reporte de un [`apply`].
#[derive(Debug, Clone, Default)]
pub struct ApplyReport {
pub generation: u64,
pub added: Vec<PathBuf>,
pub replaced: Vec<PathBuf>,
pub removed: Vec<PathBuf>,
/// `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<u64>,
pub restored: Vec<PathBuf>,
pub deleted: Vec<PathBuf>,
}
// ---------------------------------------------------------------------------------------------------
// 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<Option<u64>> {
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::<u64>()
.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<Option<GenerationManifest>> {
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<Vec<GenerationManifest>> {
let dir = generations_dir(state_root);
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut out: Vec<GenerationManifest> = 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<u64> {
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<Vec<TreeEntry>> {
let mut out = Vec::new();
fn walk(root: &Path, dir: &Path, out: &mut Vec<TreeEntry>) -> 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<ApplyReport> {
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<PathBuf> = 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<PathBuf> = match current(state_root)? {
Some(cur) => get(state_root, cur)?.map(|m| m.files).unwrap_or_default(),
None => Vec::new(),
};
let removed_files: Vec<PathBuf> =
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<Change> = 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<RollbackReport> {
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<u64>) -> 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 `<hash>-<name>`.
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)));
}
}
+19 -2
View File
@@ -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` / 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 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). "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 + - **E4 — upgrades: ✅ primer corte** (crate `hammer-upgrade` + CLI `hammer upgrade apply|rollback|status`).
el journal (D), con rollback al árbol anterior. 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 `<state>/generations/<N>/` 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; - **E5 — ISO/medio de arranque:** medio live para correr el instalador (xorriso/grub-mkrescue;
herramental a construir). herramental a construir).
+103
View File
@@ -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>-<name>; 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 '<ausente>')"
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"