|
|
|
@@ -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)));
|
|
|
|
|
}
|
|
|
|
|
}
|