|
|
|
@@ -58,6 +58,11 @@ pub enum Error {
|
|
|
|
|
Verify { dir: String, want: String, got: String },
|
|
|
|
|
#[error("upgrade: no hay generación viva que revertir (sistema sin upgrades aplicados)")]
|
|
|
|
|
NothingToRollback,
|
|
|
|
|
#[error(
|
|
|
|
|
"upgrade: hay un apply interrumpido pendiente (generación {generation}) — corré \
|
|
|
|
|
`hammer upgrade recover` (completa) o `hammer upgrade recover --rollback` (deshace) antes de seguir"
|
|
|
|
|
)]
|
|
|
|
|
PendingExists { generation: u64 },
|
|
|
|
|
#[error("upgrade: {0}")]
|
|
|
|
|
Other(String),
|
|
|
|
|
}
|
|
|
|
@@ -114,6 +119,17 @@ pub struct ApplyReport {
|
|
|
|
|
pub already_current: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Reporte de un [`recover`].
|
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
|
|
|
pub struct RecoverReport {
|
|
|
|
|
/// `false` si no había nada que recuperar (no había intento pendiente).
|
|
|
|
|
pub recovered: bool,
|
|
|
|
|
/// `true` = se completó el apply (roll-forward); `false` = se deshizo (roll-back).
|
|
|
|
|
pub rolled_forward: bool,
|
|
|
|
|
/// Generación afectada por el intento pendiente.
|
|
|
|
|
pub generation: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Reporte de un [`rollback`].
|
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
|
|
|
pub struct RollbackReport {
|
|
|
|
@@ -141,6 +157,33 @@ fn manifest_path(state_root: &Path, id: u64) -> PathBuf {
|
|
|
|
|
generations_dir(state_root).join(id.to_string()).join("manifest.json")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// El **journal de intención**: el plan de un apply en curso. Existe sólo entre el inicio de la
|
|
|
|
|
/// proyección y el commit; si sobrevive a un reinicio, un apply quedó a medias y hay que [`recover`].
|
|
|
|
|
fn pending_path(state_root: &Path) -> PathBuf {
|
|
|
|
|
state_root.join("pending.json")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Lee el plan pendiente (apply interrumpido), o `None` si no hay ninguno.
|
|
|
|
|
pub fn pending(state_root: &Path) -> Result<Option<GenerationManifest>> {
|
|
|
|
|
let p = pending_path(state_root);
|
|
|
|
|
if !p.is_file() {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
Ok(Some(serde_json::from_slice(&std::fs::read(&p)?)?))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_pending(state_root: &Path, manifest: &GenerationManifest) -> Result<()> {
|
|
|
|
|
write_atomic(&pending_path(state_root), &serde_json::to_vec_pretty(manifest)?)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn clear_pending(state_root: &Path) -> Result<()> {
|
|
|
|
|
match std::fs::remove_file(pending_path(state_root)) {
|
|
|
|
|
Ok(()) => Ok(()),
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
|
|
|
Err(e) => Err(e.into()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Id de la generación viva, o `None` si nunca se aplicó un upgrade.
|
|
|
|
|
pub fn current(state_root: &Path) -> Result<Option<u64>> {
|
|
|
|
|
let f = current_file(state_root);
|
|
|
|
@@ -329,6 +372,12 @@ pub fn apply(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Un apply previo interrumpido deja un `pending.json` ⇒ exigimos `recover` explícito antes de tocar
|
|
|
|
|
// nada (no pisamos un FHS a medias). El recover lo completa (roll-forward) o lo deshace (roll-back).
|
|
|
|
|
if let Some(p) = pending(state_root)? {
|
|
|
|
|
return Err(Error::PendingExists { generation: p.id });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Idempotencia: si el árbol vivo ya es éste, no hacemos nada.
|
|
|
|
|
if let Some(cur) = current(state_root)? {
|
|
|
|
|
if let Some(m) = get(state_root, cur)? {
|
|
|
|
@@ -338,69 +387,136 @@ pub fn apply(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- PLAN completo ANTES de tocar nada: entradas del árbol nuevo + ficheros a retirar + op/path ---
|
|
|
|
|
let new_entries = enumerate_tree(&tree_path)?;
|
|
|
|
|
let new_files: Vec<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)? {
|
|
|
|
|
let prev_id = current(state_root)?;
|
|
|
|
|
let prev_files: Vec<PathBuf> = match prev_id {
|
|
|
|
|
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.
|
|
|
|
|
let mut changes: Vec<Change> = Vec::with_capacity(new_entries.len() + removed_files.len());
|
|
|
|
|
for e in &new_entries {
|
|
|
|
|
let dst = target_root.join(&e.rel);
|
|
|
|
|
let existed = std::fs::symlink_metadata(&dst).is_ok();
|
|
|
|
|
if existed {
|
|
|
|
|
backup_existing(&dst, &backup_root.join(&e.rel))?;
|
|
|
|
|
}
|
|
|
|
|
project_entry(&tree_path, e, &dst)?;
|
|
|
|
|
let op = if existed { ChangeOp::Replaced } else { ChangeOp::Added };
|
|
|
|
|
match op {
|
|
|
|
|
ChangeOp::Added => report.added.push(e.rel.clone()),
|
|
|
|
|
ChangeOp::Replaced => report.replaced.push(e.rel.clone()),
|
|
|
|
|
ChangeOp::Removed => unreachable!(),
|
|
|
|
|
}
|
|
|
|
|
changes.push(Change { path: e.rel.clone(), op });
|
|
|
|
|
journal_change(journal, tree_dir, &dst, op);
|
|
|
|
|
let existed = std::fs::symlink_metadata(target_root.join(&e.rel)).is_ok();
|
|
|
|
|
changes.push(Change {
|
|
|
|
|
path: e.rel.clone(),
|
|
|
|
|
op: if existed { ChangeOp::Replaced } else { ChangeOp::Added },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Retirar los sobrantes del árbol previo.
|
|
|
|
|
for rel in &removed_files {
|
|
|
|
|
let dst = target_root.join(rel);
|
|
|
|
|
if std::fs::symlink_metadata(&dst).is_ok() {
|
|
|
|
|
backup_existing(&dst, &backup_root.join(rel))?;
|
|
|
|
|
remove_path(&dst)?;
|
|
|
|
|
report.removed.push(rel.clone());
|
|
|
|
|
if std::fs::symlink_metadata(target_root.join(rel)).is_ok() {
|
|
|
|
|
changes.push(Change { path: rel.clone(), op: ChangeOp::Removed });
|
|
|
|
|
journal_change(journal, tree_dir, &dst, ChangeOp::Removed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// (5) manifiesto + avance de `current` (commit-point).
|
|
|
|
|
let id = next_id(state_root)?;
|
|
|
|
|
let manifest = GenerationManifest {
|
|
|
|
|
id,
|
|
|
|
|
tree_dir: tree_dir.to_string(),
|
|
|
|
|
tree_content,
|
|
|
|
|
parent: current(state_root)?,
|
|
|
|
|
parent: prev_id,
|
|
|
|
|
created_at: now_secs(),
|
|
|
|
|
files: new_files,
|
|
|
|
|
changes,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let backup_root = generations_dir(state_root).join(id.to_string()).join("backup");
|
|
|
|
|
std::fs::create_dir_all(&backup_root)?;
|
|
|
|
|
|
|
|
|
|
// (INTENTO) escribir el plan ANTES de proyectar: el commit-point sigue siendo `current`, pero ahora
|
|
|
|
|
// un corte a media proyección es recuperable — `recover` re-ejecuta el plan (idempotente) o lo deshace.
|
|
|
|
|
write_pending(state_root, &manifest)?;
|
|
|
|
|
|
|
|
|
|
// (PROYECTAR) ejecutar el plan: backups idempotentes (no pisan el original) + escritura temporal+rename.
|
|
|
|
|
project_plan(&tree_path, &manifest, target_root, &backup_root, journal)?;
|
|
|
|
|
|
|
|
|
|
// (COMMIT) manifiesto definitivo + avance de `current` + limpiar el intento.
|
|
|
|
|
write_atomic(&manifest_path(state_root, id), &serde_json::to_vec_pretty(&manifest)?)?;
|
|
|
|
|
set_current(state_root, Some(id))?;
|
|
|
|
|
Ok(report)
|
|
|
|
|
clear_pending(state_root)?;
|
|
|
|
|
Ok(report_from_changes(id, &manifest.changes))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Construye el [`ApplyReport`] a partir de la lista de cambios del plan.
|
|
|
|
|
fn report_from_changes(id: u64, changes: &[Change]) -> ApplyReport {
|
|
|
|
|
let mut r = ApplyReport { generation: id, ..Default::default() };
|
|
|
|
|
for c in changes {
|
|
|
|
|
match c.op {
|
|
|
|
|
ChangeOp::Added => r.added.push(c.path.clone()),
|
|
|
|
|
ChangeOp::Replaced => r.replaced.push(c.path.clone()),
|
|
|
|
|
ChangeOp::Removed => r.removed.push(c.path.clone()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
r
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Ejecuta el plan de una generación sobre `target_root`: proyecta los Added/Replaced del árbol y
|
|
|
|
|
/// retira los Removed, respaldando el ORIGINAL de cada path tocado **idempotentemente** (no pisa un
|
|
|
|
|
/// backup ya existente — clave para que `recover` pueda re-ejecutar sin perder el estado previo). Es
|
|
|
|
|
/// **re-entrante**: correrlo dos veces deja el mismo resultado.
|
|
|
|
|
fn project_plan(
|
|
|
|
|
tree_path: &Path,
|
|
|
|
|
manifest: &GenerationManifest,
|
|
|
|
|
target_root: &Path,
|
|
|
|
|
backup_root: &Path,
|
|
|
|
|
journal: Option<&Journal>,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let entries = enumerate_tree(tree_path)?;
|
|
|
|
|
let by_rel: std::collections::BTreeMap<&PathBuf, &TreeEntry> =
|
|
|
|
|
entries.iter().map(|e| (&e.rel, e)).collect();
|
|
|
|
|
for ch in &manifest.changes {
|
|
|
|
|
let dst = target_root.join(&ch.path);
|
|
|
|
|
match ch.op {
|
|
|
|
|
ChangeOp::Added | ChangeOp::Replaced => {
|
|
|
|
|
if ch.op == ChangeOp::Replaced {
|
|
|
|
|
backup_existing_once(&dst, &backup_root.join(&ch.path))?;
|
|
|
|
|
}
|
|
|
|
|
if let Some(e) = by_rel.get(&ch.path) {
|
|
|
|
|
project_entry(tree_path, e, &dst)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ChangeOp::Removed => {
|
|
|
|
|
backup_existing_once(&dst, &backup_root.join(&ch.path))?;
|
|
|
|
|
remove_path(&dst)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
journal_change(journal, &manifest.tree_dir, &dst, ch.op);
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Deshace el plan de un manifiesto sobre `target_root` (orden inverso): borra los Added, restaura los
|
|
|
|
|
/// Replaced/Removed desde `backup_root`. Idempotente. Compartido por [`rollback`] y `recover --rollback`.
|
|
|
|
|
fn undo_changes(
|
|
|
|
|
manifest: &GenerationManifest,
|
|
|
|
|
target_root: &Path,
|
|
|
|
|
backup_root: &Path,
|
|
|
|
|
journal: Option<&Journal>,
|
|
|
|
|
report_restored: &mut Vec<PathBuf>,
|
|
|
|
|
report_deleted: &mut Vec<PathBuf>,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
for ch in manifest.changes.iter().rev() {
|
|
|
|
|
let dst = target_root.join(&ch.path);
|
|
|
|
|
match ch.op {
|
|
|
|
|
ChangeOp::Added => {
|
|
|
|
|
if std::fs::symlink_metadata(&dst).is_ok() {
|
|
|
|
|
remove_path(&dst)?;
|
|
|
|
|
report_deleted.push(ch.path.clone());
|
|
|
|
|
journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Removed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ChangeOp::Replaced | ChangeOp::Removed => {
|
|
|
|
|
restore_from_backup(&backup_root.join(&ch.path), &dst)?;
|
|
|
|
|
report_restored.push(ch.path.clone());
|
|
|
|
|
journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Replaced);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------------------------------
|
|
|
|
@@ -420,31 +536,69 @@ pub fn rollback(
|
|
|
|
|
let backup_root = generations_dir(state_root).join(cur.to_string()).join("backup");
|
|
|
|
|
|
|
|
|
|
let mut report = RollbackReport { reverted: cur, now_current: m.parent, ..Default::default() };
|
|
|
|
|
|
|
|
|
|
// Deshacer en orden inverso al apply (los Added al final, por si un Replaced creó su dir padre).
|
|
|
|
|
for ch in m.changes.iter().rev() {
|
|
|
|
|
let dst = target_root.join(&ch.path);
|
|
|
|
|
match ch.op {
|
|
|
|
|
ChangeOp::Added => {
|
|
|
|
|
if std::fs::symlink_metadata(&dst).is_ok() {
|
|
|
|
|
remove_path(&dst)?;
|
|
|
|
|
report.deleted.push(ch.path.clone());
|
|
|
|
|
journal_change(journal, &m.tree_dir, &dst, ChangeOp::Removed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ChangeOp::Replaced | ChangeOp::Removed => {
|
|
|
|
|
let bak = backup_root.join(&ch.path);
|
|
|
|
|
restore_from_backup(&bak, &dst)?;
|
|
|
|
|
report.restored.push(ch.path.clone());
|
|
|
|
|
journal_change(journal, &m.tree_dir, &dst, ChangeOp::Replaced);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
undo_changes(&m, target_root, &backup_root, journal, &mut report.restored, &mut report.deleted)?;
|
|
|
|
|
set_current(state_root, m.parent)?;
|
|
|
|
|
Ok(report)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Completa o deshace un apply interrumpido (un `pending.json` que sobrevivió a un corte/reinicio).
|
|
|
|
|
///
|
|
|
|
|
/// - **roll-forward** (`rollback=false`, default): re-ejecuta el plan pendiente (idempotente) y commitea
|
|
|
|
|
/// — el FHS queda como si el apply hubiera terminado. Re-verifica el `of_tree` del árbol contra el que
|
|
|
|
|
/// el plan registró (defensa ante un store que cambió bajo los pies).
|
|
|
|
|
/// - **roll-back** (`rollback=true`): deshace el plan (restaura backups, borra lo añadido), elimina la
|
|
|
|
|
/// generación a medias y vuelve `current` al padre — el FHS queda como ANTES del apply.
|
|
|
|
|
///
|
|
|
|
|
/// No-op (sin error) si no hay intento pendiente. Idempotente: re-correrlo es seguro.
|
|
|
|
|
pub fn recover(
|
|
|
|
|
store_root: &Path,
|
|
|
|
|
target_root: &Path,
|
|
|
|
|
state_root: &Path,
|
|
|
|
|
journal: Option<&Journal>,
|
|
|
|
|
rollback: bool,
|
|
|
|
|
) -> Result<RecoverReport> {
|
|
|
|
|
let manifest = match pending(state_root)? {
|
|
|
|
|
Some(m) => m,
|
|
|
|
|
None => return Ok(RecoverReport::default()),
|
|
|
|
|
};
|
|
|
|
|
let backup_root = generations_dir(state_root).join(manifest.id.to_string()).join("backup");
|
|
|
|
|
|
|
|
|
|
if rollback {
|
|
|
|
|
let (mut restored, mut deleted) = (Vec::new(), Vec::new());
|
|
|
|
|
undo_changes(&manifest, target_root, &backup_root, journal, &mut restored, &mut deleted)?;
|
|
|
|
|
// El apply nunca avanzó `current` (eso era el commit-point); por las dudas, asegurarlo en el padre.
|
|
|
|
|
if current(state_root)? == Some(manifest.id) {
|
|
|
|
|
set_current(state_root, manifest.parent)?;
|
|
|
|
|
}
|
|
|
|
|
let _ = std::fs::remove_dir_all(generations_dir(state_root).join(manifest.id.to_string()));
|
|
|
|
|
clear_pending(state_root)?;
|
|
|
|
|
return Ok(RecoverReport { recovered: true, rolled_forward: false, generation: manifest.id });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// roll-forward: re-resolver y re-verificar el árbol del store, completar la proyección, commitear.
|
|
|
|
|
let store = Store::open(store_root)?;
|
|
|
|
|
let tree_path = store.root().join(&manifest.tree_dir);
|
|
|
|
|
if !tree_path.is_dir() {
|
|
|
|
|
return Err(Error::Other(format!(
|
|
|
|
|
"recover: el árbol '{}' del intento ya no existe en el store; usá --rollback",
|
|
|
|
|
manifest.tree_dir
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
let got = ArtifactHash::of_tree(&tree_path)?;
|
|
|
|
|
if got.as_str() != manifest.tree_content {
|
|
|
|
|
return Err(Error::Verify {
|
|
|
|
|
dir: manifest.tree_dir.clone(),
|
|
|
|
|
want: manifest.tree_content.clone(),
|
|
|
|
|
got: got.as_str().to_string(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
project_plan(&tree_path, &manifest, target_root, &backup_root, journal)?;
|
|
|
|
|
write_atomic(&manifest_path(state_root, manifest.id), &serde_json::to_vec_pretty(&manifest)?)?;
|
|
|
|
|
set_current(state_root, Some(manifest.id))?;
|
|
|
|
|
clear_pending(state_root)?;
|
|
|
|
|
Ok(RecoverReport { recovered: true, rolled_forward: true, generation: manifest.id })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------------------------------
|
|
|
|
|
// Primitivas de filesystem.
|
|
|
|
|
// ---------------------------------------------------------------------------------------------------
|
|
|
|
@@ -497,6 +651,19 @@ fn backup_existing(src: &Path, bak: &Path) -> Result<()> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Respalda el original de `src` en `bak` **sólo si aún no hay backup** y `src` existe. Idempotente:
|
|
|
|
|
/// preserva el primer original capturado (clave para que un `recover` roll-forward re-ejecute la
|
|
|
|
|
/// proyección sin sobrescribir el backup con la versión ya proyectada).
|
|
|
|
|
fn backup_existing_once(src: &Path, bak: &Path) -> Result<()> {
|
|
|
|
|
if std::fs::symlink_metadata(bak).is_ok() {
|
|
|
|
|
return Ok(()); // ya respaldado
|
|
|
|
|
}
|
|
|
|
|
if std::fs::symlink_metadata(src).is_err() {
|
|
|
|
|
return Ok(()); // nada que respaldar
|
|
|
|
|
}
|
|
|
|
|
backup_existing(src, bak)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Restaura `dst` desde su backup (atómico vía temporal). Si el backup no existe, no hace nada
|
|
|
|
|
/// (idempotencia: ya restaurado o nunca existió).
|
|
|
|
|
fn restore_from_backup(bak: &Path, dst: &Path) -> Result<()> {
|
|
|
|
@@ -767,6 +934,134 @@ mod tests {
|
|
|
|
|
assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Simula un apply INTERRUMPIDO a media proyección: escribe el `pending.json` con el plan y deja el
|
|
|
|
|
/// FHS a medias (proyecta sólo el primer cambio), sin commitear. Devuelve el manifiesto del plan.
|
|
|
|
|
fn interrupt_apply(
|
|
|
|
|
store: &Path,
|
|
|
|
|
tree_dir: &str,
|
|
|
|
|
target_root: &Path,
|
|
|
|
|
state_root: &Path,
|
|
|
|
|
partial: usize,
|
|
|
|
|
) -> GenerationManifest {
|
|
|
|
|
let store_h = Store::open(store).unwrap();
|
|
|
|
|
let tree_path = store_h.root().join(tree_dir);
|
|
|
|
|
let tree_content = ArtifactHash::of_tree(&tree_path).unwrap().as_str().to_string();
|
|
|
|
|
let entries = enumerate_tree(&tree_path).unwrap();
|
|
|
|
|
let mut changes = Vec::new();
|
|
|
|
|
for e in &entries {
|
|
|
|
|
let existed = std::fs::symlink_metadata(target_root.join(&e.rel)).is_ok();
|
|
|
|
|
changes.push(Change { path: e.rel.clone(), op: if existed { ChangeOp::Replaced } else { ChangeOp::Added } });
|
|
|
|
|
}
|
|
|
|
|
let id = next_id(state_root).unwrap();
|
|
|
|
|
let manifest = GenerationManifest {
|
|
|
|
|
id, tree_dir: tree_dir.to_string(), tree_content,
|
|
|
|
|
parent: current(state_root).unwrap(), created_at: 0,
|
|
|
|
|
files: entries.iter().map(|e| e.rel.clone()).collect(), changes,
|
|
|
|
|
};
|
|
|
|
|
let backup_root = generations_dir(state_root).join(id.to_string()).join("backup");
|
|
|
|
|
std::fs::create_dir_all(&backup_root).unwrap();
|
|
|
|
|
write_pending(state_root, &manifest).unwrap();
|
|
|
|
|
// proyectar SÓLO `partial` entradas (corte a media proyección).
|
|
|
|
|
for e in entries.iter().take(partial) {
|
|
|
|
|
let dst = target_root.join(&e.rel);
|
|
|
|
|
if std::fs::symlink_metadata(&dst).is_ok() {
|
|
|
|
|
backup_existing_once(&dst, &backup_root.join(&e.rel)).unwrap();
|
|
|
|
|
}
|
|
|
|
|
project_entry(&tree_path, e, &dst).unwrap();
|
|
|
|
|
}
|
|
|
|
|
manifest
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn apply_refuses_when_a_pending_apply_exists() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let store = tmp.path().join("store");
|
|
|
|
|
let root = tmp.path().join("root");
|
|
|
|
|
let state = tmp.path().join("state");
|
|
|
|
|
let v1 = seal_tree(&store, &h64("e1"), "v1", |w| { write(&w.join("a"), b"A"); write(&w.join("b"), b"B"); });
|
|
|
|
|
interrupt_apply(&store, &v1, &root, &state, 1);
|
|
|
|
|
// con un intento pendiente, apply se NIEGA (exige recover).
|
|
|
|
|
let err = apply(&store, &v1, None, &root, &state, None).unwrap_err();
|
|
|
|
|
assert!(matches!(err, Error::PendingExists { .. }), "vino {err}");
|
|
|
|
|
assert!(pending(&state).unwrap().is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn recover_roll_forward_completes_interrupted_apply() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let store = tmp.path().join("store");
|
|
|
|
|
let root = tmp.path().join("root");
|
|
|
|
|
let state = tmp.path().join("state");
|
|
|
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
|
|
|
write(&root.join("etc/keep"), b"PRE");
|
|
|
|
|
let v1 = seal_tree(&store, &h64("e2"), "v1", |w| {
|
|
|
|
|
write(&w.join("usr/bin/ls"), b"NEW-ls");
|
|
|
|
|
write(&w.join("usr/bin/cat"), b"NEW-cat");
|
|
|
|
|
write(&w.join("usr/bin/grep"), b"NEW-grep");
|
|
|
|
|
});
|
|
|
|
|
// corte tras proyectar 1 de 3 ⇒ FHS a medias, sin generación viva.
|
|
|
|
|
interrupt_apply(&store, &v1, &root, &state, 1);
|
|
|
|
|
assert_eq!(current(&state).unwrap(), None, "el apply nunca commiteó");
|
|
|
|
|
assert!(pending(&state).unwrap().is_some());
|
|
|
|
|
|
|
|
|
|
let r = recover(&store, &root, &state, None, false).unwrap();
|
|
|
|
|
assert!(r.recovered && r.rolled_forward && r.generation == 1);
|
|
|
|
|
// el apply quedó COMPLETO: los 3 binarios, generación viva, intento limpio.
|
|
|
|
|
assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls");
|
|
|
|
|
assert_eq!(std::fs::read(root.join("usr/bin/cat")).unwrap(), b"NEW-cat");
|
|
|
|
|
assert_eq!(std::fs::read(root.join("usr/bin/grep")).unwrap(), b"NEW-grep");
|
|
|
|
|
assert_eq!(current(&state).unwrap(), Some(1));
|
|
|
|
|
assert!(pending(&state).unwrap().is_none());
|
|
|
|
|
// y como quedó committeado, un rollback posterior restaura el estado pre-apply EXACTO.
|
|
|
|
|
rollback(&root, &state, None).unwrap();
|
|
|
|
|
assert!(!root.join("usr/bin/ls").exists(), "lo añadido se revierte");
|
|
|
|
|
assert_eq!(std::fs::read(root.join("etc/keep")).unwrap(), b"PRE");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn recover_roll_back_undoes_interrupted_apply() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let store = tmp.path().join("store");
|
|
|
|
|
let root = tmp.path().join("root");
|
|
|
|
|
let state = tmp.path().join("state");
|
|
|
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
|
|
|
write(&root.join("usr/bin/ls"), b"ORIG-ls"); // existirá ⇒ Replaced (con backup)
|
|
|
|
|
let v1 = seal_tree(&store, &h64("e3"), "v1", |w| {
|
|
|
|
|
write(&w.join("usr/bin/ls"), b"NEW-ls");
|
|
|
|
|
write(&w.join("usr/bin/new"), b"NEW-only"); // Added
|
|
|
|
|
});
|
|
|
|
|
// corte tras proyectar el 1er cambio (orden alfabético: usr/bin/ls Replaced primero).
|
|
|
|
|
interrupt_apply(&store, &v1, &root, &state, 1);
|
|
|
|
|
assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls", "ls ya pisado a medias");
|
|
|
|
|
|
|
|
|
|
let r = recover(&store, &root, &state, None, true).unwrap();
|
|
|
|
|
assert!(r.recovered && !r.rolled_forward);
|
|
|
|
|
// el FHS volvió al estado PRE-apply: ls original restaurado, nada de la generación a medias.
|
|
|
|
|
assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"ORIG-ls", "ls restaurado del backup");
|
|
|
|
|
assert!(!root.join("usr/bin/new").exists());
|
|
|
|
|
assert_eq!(current(&state).unwrap(), None);
|
|
|
|
|
assert!(pending(&state).unwrap().is_none());
|
|
|
|
|
assert!(!generations_dir(&state).join("1").exists(), "la generación a medias se borró");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn recover_is_noop_without_pending() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let r = recover(&tmp.path().join("store"), &tmp.path().join("root"), &tmp.path().join("state"), None, false).unwrap();
|
|
|
|
|
assert!(!r.recovered);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn apply_clears_pending_on_success() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let store = tmp.path().join("store");
|
|
|
|
|
let root = tmp.path().join("root");
|
|
|
|
|
let state = tmp.path().join("state");
|
|
|
|
|
let v1 = seal_tree(&store, &h64("e4"), "v1", |w| write(&w.join("a"), b"A"));
|
|
|
|
|
apply(&store, &v1, None, &root, &state, None).unwrap();
|
|
|
|
|
assert!(pending(&state).unwrap().is_none(), "el apply exitoso limpia el intento");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prune_removes_orphans_but_keeps_live_chain() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|