arranque-grafo: paso 3 del ADR 0010 — grafo de estados + hammer boot
El menú de arranque como navegación del grafo content-addressed de estados (no una lista de kernels). Sube el modelo de generaciones in-place a un grafo navegable y define el contrato de datos con mirada. - hammer_upgrade::boot_graph: BootGraph/BootNode (formato exacto del contrato), build() arma el DAG desde las generaciones (id = of_tree sin b3:, parents = of_tree del padre, Base para la raíz del linaje), nodo Recovery sintético colgando de la viva, emit() atómico a /run/hammer/boot-graph.json. - activate(): resuelve el id content-addressed a una generación y deja el sistema en ese nodo — no-op si ya viva, rollback paso a paso a un ancestro (reusa el rollback E4), recovery = un rollback, error honesto ante un "redo" hacia una generación huérfana (pide re-aplicar el árbol). - CLI `hammer boot graph [--out|--stdout]` y `hammer boot activate <id> [--from-select]` (lee el id que mirada deja en /run/hammer/boot-select). - Tests: DAG + activate a ancestro + recovery + redo-falla; rfc3339 sin deps. Validado e2e por el binario (3 generaciones → grafo → activar → recovery). Cierra el lado `proceso` de SDD 15 §H4 (arrancar = activar un nodo del DAG). mirada ya puede maquetar contra el grafo real (HANDOFF-arranque-grafo.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -404,6 +404,45 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
sub: UpgradeCmd,
|
||||
},
|
||||
|
||||
/// [ADR 0010] Arranque por grafo: el menú de boot es la navegación del grafo de estados del
|
||||
/// sistema. `graph` publica el grafo para mirada; `activate` deja el sistema en un nodo.
|
||||
Boot {
|
||||
/// Root del FHS vivo sobre el que se activa un nodo (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 al activar (default `/var/lib/hammer/journal`).
|
||||
#[arg(long, default_value = "/var/lib/hammer/journal", global = true)]
|
||||
journal: String,
|
||||
#[command(subcommand)]
|
||||
sub: BootCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum BootCmd {
|
||||
/// Emite el grafo de estados a `--out` (default `/run/hammer/boot-graph.json`) para que mirada
|
||||
/// dibuje el menú. Regenerable, world-readable.
|
||||
Graph {
|
||||
/// Path de salida del grafo.
|
||||
#[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_GRAPH_PATH)]
|
||||
out: PathBuf,
|
||||
/// En vez de escribir el fichero, imprime el grafo por stdout (para inspección/tests).
|
||||
#[arg(long)]
|
||||
stdout: bool,
|
||||
},
|
||||
/// Activa el nodo `<id>` del grafo (deja el sistema en ese estado). Con `--from-select` lee el id
|
||||
/// que mirada dejó en `/run/hammer/boot-select` en vez de pasarlo por argumento.
|
||||
Activate {
|
||||
/// Id (BLAKE3, con o sin `b3:`) del nodo a activar. Omitir con `--from-select`.
|
||||
id: Option<String>,
|
||||
/// Lee el id desde `/run/hammer/boot-select` (canal de vuelta de mirada).
|
||||
#[arg(long)]
|
||||
from_select: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -1170,6 +1209,50 @@ fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::Boot { root, state_root, journal, sub } => {
|
||||
use hammer_upgrade::boot_graph;
|
||||
let root = std::path::PathBuf::from(&root);
|
||||
let state_root = std::path::PathBuf::from(&state_root);
|
||||
match sub {
|
||||
BootCmd::Graph { out, stdout } => {
|
||||
if stdout {
|
||||
let g = boot_graph::build(&state_root)?;
|
||||
println!("{}", serde_json::to_string_pretty(&g)?);
|
||||
} else {
|
||||
boot_graph::emit(&state_root, &out)?;
|
||||
let g = boot_graph::build(&state_root)?;
|
||||
println!("✓ grafo de arranque escrito en {}", out.display());
|
||||
println!(" {} nodo(s); vivo: {}", g.nodes.len(),
|
||||
g.current.as_deref().unwrap_or("(ninguno)"));
|
||||
}
|
||||
}
|
||||
BootCmd::Activate { id, from_select } => {
|
||||
let id = match (id, from_select) {
|
||||
(Some(id), _) => id,
|
||||
(None, true) => std::fs::read_to_string(boot_graph::BOOT_SELECT_PATH)
|
||||
.map_err(|e| anyhow::anyhow!(
|
||||
"no pude leer {}: {e}", boot_graph::BOOT_SELECT_PATH))?
|
||||
.trim().to_string(),
|
||||
(None, false) => anyhow::bail!(
|
||||
"pasá un <id> o usá --from-select para leerlo de {}",
|
||||
boot_graph::BOOT_SELECT_PATH),
|
||||
};
|
||||
let j = hammer_journal::Journal::open(&journal)?;
|
||||
let r = boot_graph::activate(&root, &state_root, Some(&j), &id)?;
|
||||
if r.already_current {
|
||||
println!("✓ nodo {} ya era el vivo — no-op", r.target);
|
||||
} else if r.kind == boot_graph::NodeKind::Recovery {
|
||||
println!("✓ recuperación: generación {:?} revertida", r.rolled_back);
|
||||
} else {
|
||||
println!("✓ nodo {} activado", r.target);
|
||||
println!(" {} generación(es) revertida(s): {:?}",
|
||||
r.rolled_back.len(), r.rolled_back);
|
||||
}
|
||||
// Republica el grafo: la vista de mirada debe reflejar el nuevo nodo vivo.
|
||||
boot_graph::emit(&state_root, std::path::Path::new(boot_graph::BOOT_GRAPH_PATH)).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! **Arranque por grafo** ([ADR 0010](../../docs/adr/0010-arranque-grafo-mirada.md)).
|
||||
//!
|
||||
//! El menú de arranque de hammer no es una *lista* de kernels (GRUB) sino la **navegación del grafo
|
||||
//! content-addressed de estados** del sistema. Este módulo sube el modelo de generaciones in-place
|
||||
//! ([`crate`]) a ese grafo navegable y define el **contrato de datos** con `mirada` (el compositor de
|
||||
//! tawasuyu que dibuja el menú sobre KMS):
|
||||
//!
|
||||
//! - **hammer → mirada**: [`build`] arma el grafo y [`emit`] lo escribe en
|
||||
//! [`BOOT_GRAPH_PATH`] (`/run/hammer/boot-graph.json`), world-readable y regenerable.
|
||||
//! - **mirada → hammer**: mirada escribe el `id` elegido en [`BOOT_SELECT_PATH`] **o** invoca
|
||||
//! `hammer boot activate <id>`; [`activate`] valida el id contra el grafo y **activa** el nodo
|
||||
//! (pivota la generación vía el rollback existente).
|
||||
//!
|
||||
//! La frontera es *un fichero + un comando*, no una API viva: mirada puede maquetar el menú contra un
|
||||
//! `boot-graph.json` de ejemplo sin esperar a hammer, y hammer emite el grafo sin esperar a mirada.
|
||||
//!
|
||||
//! **Content-addressing.** El `id` de cada nodo es el `of_tree` (BLAKE3) del árbol que la generación
|
||||
//! proyectó — sin el prefijo `b3:`, para casar con el `<blake3>` del contrato. `parents` forma un DAG
|
||||
//! (no una lista lineal): el padre de una generación es el `of_tree` de su generación anterior. Activar
|
||||
//! un nodo es *reproducir/hidratar* un árbol sellado del store, no ejecutar algo nuevo (ADR 0007/0009).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use hammer_core::ArtifactHash;
|
||||
use hammer_journal::Journal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
current, list, live_chain, rollback, Error, GenerationManifest, Result, RollbackReport,
|
||||
};
|
||||
|
||||
/// Path bien conocido donde hammer publica el grafo para mirada. Regenerable, world-readable.
|
||||
pub const BOOT_GRAPH_PATH: &str = "/run/hammer/boot-graph.json";
|
||||
|
||||
/// Path bien conocido donde mirada deja el `id` del nodo a activar (canal de vuelta alternativo a
|
||||
/// `hammer boot activate <id>`).
|
||||
pub const BOOT_SELECT_PATH: &str = "/run/hammer/boot-select";
|
||||
|
||||
/// Qué representa un nodo del grafo de arranque. Guía cómo lo dibuja mirada y cómo lo activa hammer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum NodeKind {
|
||||
/// Una generación de upgrade normal (tiene padre).
|
||||
Generation,
|
||||
/// Un punto de captura explícito (reservado; hoy no se emite).
|
||||
Snapshot,
|
||||
/// La primera generación / raíz del linaje (sin padre).
|
||||
Base,
|
||||
/// Nodo sintético "deshacer la generación viva" — mapea al rollback.
|
||||
Recovery,
|
||||
}
|
||||
|
||||
/// Un nodo del grafo de arranque: un estado del sistema al que se puede arrancar.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BootNode {
|
||||
/// `of_tree` (BLAKE3 hex, sin `b3:`) del árbol de la generación. Content-addressed y estable.
|
||||
pub id: String,
|
||||
/// Texto corto para el usuario.
|
||||
pub label: String,
|
||||
pub kind: NodeKind,
|
||||
/// Instante del apply en RFC 3339 (UTC).
|
||||
pub created: String,
|
||||
/// Ids de los padres (DAG). Hoy 0 o 1 por nodo; el formato admite varios.
|
||||
pub parents: Vec<String>,
|
||||
/// `true` si el nodo se puede arrancar (siempre hoy; el flag existe para estados futuros parciales).
|
||||
pub bootable: bool,
|
||||
/// Qué cambió respecto del padre (opcional).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
/// El grafo entero — exactamente el JSON del contrato del ADR 0010.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BootGraph {
|
||||
pub version: u32,
|
||||
/// `id` del nodo vivo (la generación actual), o `None` si el sistema nunca aplicó un upgrade.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current: Option<String>,
|
||||
/// `id` a arrancar si el usuario no elige.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<String>,
|
||||
pub nodes: Vec<BootNode>,
|
||||
}
|
||||
|
||||
/// El `id` content-addressed de un nodo: el `of_tree` sin el prefijo `b3:`.
|
||||
fn node_id(tree_content: &str) -> String {
|
||||
tree_content.trim_start_matches("b3:").to_string()
|
||||
}
|
||||
|
||||
/// Id estable del nodo sintético de recuperación. Content-addressed (BLAKE3 de una etiqueta fija) para
|
||||
/// respetar el contrato "cada id es un hash", pero reservado: [`activate`] lo reconoce y hace rollback.
|
||||
pub fn recovery_id() -> String {
|
||||
node_id(ArtifactHash::of_bytes(b"hammer:boot:recovery").as_str())
|
||||
}
|
||||
|
||||
/// Resume los cambios de una generación para el `summary` (+añadidos ~reemplazados -retirados).
|
||||
fn summarize(m: &GenerationManifest) -> Option<String> {
|
||||
if m.changes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (mut a, mut r, mut d) = (0u32, 0u32, 0u32);
|
||||
for c in &m.changes {
|
||||
match c.op {
|
||||
crate::ChangeOp::Added => a += 1,
|
||||
crate::ChangeOp::Replaced => r += 1,
|
||||
crate::ChangeOp::Removed => d += 1,
|
||||
}
|
||||
}
|
||||
Some(format!("+{a} ~{r} -{d}"))
|
||||
}
|
||||
|
||||
/// El nombre legible del árbol: la cola de `tree_dir` (`<hash>-<name>` ⇒ `<name>`).
|
||||
fn tree_name(tree_dir: &str) -> &str {
|
||||
tree_dir.split_once('-').map_or(tree_dir, |(_, name)| name)
|
||||
}
|
||||
|
||||
/// Convierte segundos-desde-epoch a RFC 3339 (UTC) sin dependencias externas
|
||||
/// (algoritmo `civil_from_days` de Howard Hinnant). Determinista y testeable.
|
||||
fn rfc3339(secs: u64) -> String {
|
||||
let days = (secs / 86_400) as i64;
|
||||
let rem = secs % 86_400;
|
||||
let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
|
||||
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
||||
let year = if m <= 2 { y + 1 } else { y };
|
||||
format!("{year:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
/// Arma el [`BootGraph`] a partir del registro de generaciones en `state_root`.
|
||||
///
|
||||
/// Cada generación es un nodo (id = `of_tree`); su padre es el `of_tree` de la generación anterior.
|
||||
/// La primera generación del linaje (sin padre) es [`NodeKind::Base`]. Si hay una generación viva, se
|
||||
/// añade un nodo sintético [`NodeKind::Recovery`] colgando de ella ("deshacer la generación viva").
|
||||
pub fn build(state_root: &Path) -> Result<BootGraph> {
|
||||
let gens = list(state_root)?;
|
||||
let cur = current(state_root)?;
|
||||
|
||||
// Índice u64 → of_tree, para traducir `parent` (u64) al id del padre (hash).
|
||||
let by_gen: HashMap<u64, &GenerationManifest> = gens.iter().map(|m| (m.id, m)).collect();
|
||||
let hash_of = |gen: u64| -> Option<String> { by_gen.get(&gen).map(|m| node_id(&m.tree_content)) };
|
||||
|
||||
let mut nodes: Vec<BootNode> = Vec::with_capacity(gens.len() + 1);
|
||||
for m in &gens {
|
||||
let parents = m.parent.and_then(hash_of).into_iter().collect::<Vec<_>>();
|
||||
let kind = if m.parent.is_none() { NodeKind::Base } else { NodeKind::Generation };
|
||||
nodes.push(BootNode {
|
||||
id: node_id(&m.tree_content),
|
||||
label: format!("gen {} · {}", m.id, tree_name(&m.tree_dir)),
|
||||
kind,
|
||||
created: rfc3339(m.created_at),
|
||||
parents,
|
||||
bootable: true,
|
||||
summary: summarize(m),
|
||||
});
|
||||
}
|
||||
|
||||
let current_id = cur.and_then(hash_of);
|
||||
|
||||
// Nodo de recuperación: sólo tiene sentido si hay algo vivo que deshacer.
|
||||
if let Some(cur_hash) = ¤t_id {
|
||||
let created = cur.and_then(|g| by_gen.get(&g)).map_or(0, |m| m.created_at);
|
||||
nodes.push(BootNode {
|
||||
id: recovery_id(),
|
||||
label: "recuperación (deshacer la generación viva)".to_string(),
|
||||
kind: NodeKind::Recovery,
|
||||
created: rfc3339(created),
|
||||
parents: vec![cur_hash.clone()],
|
||||
bootable: true,
|
||||
summary: Some("rollback a la generación anterior".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(BootGraph { version: 1, current: current_id.clone(), default: current_id, nodes })
|
||||
}
|
||||
|
||||
/// Escribe el grafo en `out` (default [`BOOT_GRAPH_PATH`]) de forma atómica (temporal + rename).
|
||||
/// Crea el directorio padre (`/run/hammer`) si falta.
|
||||
pub fn emit(state_root: &Path, out: &Path) -> Result<()> {
|
||||
let graph = build(state_root)?;
|
||||
let bytes = serde_json::to_vec_pretty(&graph)?;
|
||||
if let Some(dir) = out.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = out.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, &bytes)?;
|
||||
std::fs::rename(&tmp, out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reporte de una [`activate`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActivateReport {
|
||||
/// Id (hash) que se pidió activar.
|
||||
pub target: String,
|
||||
pub kind: NodeKind,
|
||||
/// `true` si el nodo pedido ya era el vivo (no se hizo nada).
|
||||
pub already_current: bool,
|
||||
/// Generaciones revertidas, de la más nueva a la más vieja (vacío si no hubo rollback).
|
||||
pub rolled_back: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Activa el nodo `id` del grafo: deja el sistema en ese estado.
|
||||
///
|
||||
/// - Si `id` es el vivo ⇒ no-op ([`ActivateReport::already_current`]).
|
||||
/// - Si `id` es un **ancestro** de la generación viva ⇒ rollback paso a paso hasta él (cada paso es un
|
||||
/// [`rollback`] atómico del modelo E4).
|
||||
/// - Si `id` es el nodo de **recuperación** ⇒ un rollback de la generación viva.
|
||||
/// - Si `id` existe pero **no** está en la cadena viva (un "redo" hacia una generación huérfana) ⇒
|
||||
/// error honesto: eso requiere re-aplicar el árbol con `hammer upgrade apply`.
|
||||
pub fn activate(
|
||||
target_root: &Path,
|
||||
state_root: &Path,
|
||||
journal: Option<&Journal>,
|
||||
id: &str,
|
||||
) -> Result<ActivateReport> {
|
||||
let want = node_id(id); // tolera que llegue con o sin `b3:`
|
||||
|
||||
// Recuperación: un rollback de la generación viva.
|
||||
if want == recovery_id() {
|
||||
let RollbackReport { reverted, .. } = rollback(target_root, state_root, journal)?;
|
||||
return Ok(ActivateReport {
|
||||
target: want,
|
||||
kind: NodeKind::Recovery,
|
||||
already_current: false,
|
||||
rolled_back: vec![reverted],
|
||||
});
|
||||
}
|
||||
|
||||
let cur = current(state_root)?
|
||||
.ok_or_else(|| Error::Other("no hay generación viva a la que anclar la activación".into()))?;
|
||||
let cur_manifest = crate::get(state_root, cur)?
|
||||
.ok_or_else(|| Error::Other(format!("manifiesto de la generación viva {cur} ausente")))?;
|
||||
|
||||
// ¿Ya es la viva?
|
||||
if node_id(&cur_manifest.tree_content) == want {
|
||||
return Ok(ActivateReport {
|
||||
target: want,
|
||||
kind: NodeKind::Generation,
|
||||
already_current: true,
|
||||
rolled_back: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Buscar el nodo pedido dentro de la cadena viva (ancestros de la generación actual). El índice en
|
||||
// la cadena (0 = actual) es exactamente cuántos rollbacks hay que hacer para llegar a él.
|
||||
let chain = live_chain(state_root)?;
|
||||
let mut steps: Option<usize> = None;
|
||||
for (i, gen) in chain.iter().enumerate() {
|
||||
let m = crate::get(state_root, *gen)?
|
||||
.ok_or_else(|| Error::Other(format!("manifiesto de la generación {gen} ausente")))?;
|
||||
if node_id(&m.tree_content) == want {
|
||||
steps = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let steps = steps.ok_or_else(|| {
|
||||
Error::Other(format!(
|
||||
"el nodo {want} no es un ancestro de la generación viva; \
|
||||
activar hacia adelante (redo) aún no se soporta — re-aplicá el árbol con `hammer upgrade apply`"
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut rolled_back = Vec::with_capacity(steps);
|
||||
for _ in 0..steps {
|
||||
let RollbackReport { reverted, .. } = rollback(target_root, state_root, journal)?;
|
||||
rolled_back.push(reverted);
|
||||
}
|
||||
|
||||
Ok(ActivateReport {
|
||||
target: want,
|
||||
kind: NodeKind::Generation,
|
||||
already_current: false,
|
||||
rolled_back,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rfc3339_epoch_and_known_dates() {
|
||||
assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
|
||||
// 2026-07-06T12:00:00Z
|
||||
assert_eq!(rfc3339(1_783_339_200), "2026-07-06T12:00:00Z");
|
||||
// 2000-01-01T00:00:00Z (post-leap-year boundary)
|
||||
assert_eq!(rfc3339(946_684_800), "2000-01-01T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_id_is_stable_and_hex() {
|
||||
let a = recovery_id();
|
||||
assert_eq!(a, recovery_id());
|
||||
assert_eq!(a.len(), 64); // BLAKE3 hex, sin `b3:`
|
||||
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_name_strips_hash_prefix() {
|
||||
assert_eq!(tree_name("abc123-product-rootfs"), "product-rootfs");
|
||||
assert_eq!(tree_name("noseparator"), "noseparator");
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ use hammer_core::{ArtifactHash, Store};
|
||||
use hammer_journal::{Actor, Journal, MutationEvent, MutationOp, Source};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod boot_graph;
|
||||
|
||||
pub const DEFAULT_STATE_ROOT: &str = "/var/lib/hammer/upgrades";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -1089,6 +1091,65 @@ mod tests {
|
||||
assert!(prune(&state, None).unwrap().removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boot_graph_builds_dag_and_activate_rolls_back_to_ancestor() {
|
||||
use crate::boot_graph::{activate, build, recovery_id, NodeKind};
|
||||
|
||||
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("f"), b"1"));
|
||||
let v2 = seal_tree(&store, &h64("c2"), "v2", |w| write(&w.join("f"), b"2"));
|
||||
let v3 = seal_tree(&store, &h64("c3"), "v3", |w| write(&w.join("f"), b"3"));
|
||||
apply(&store, &v1, None, &root, &state, None).unwrap(); // gen 1 (Base)
|
||||
apply(&store, &v2, None, &root, &state, None).unwrap(); // gen 2
|
||||
apply(&store, &v3, None, &root, &state, None).unwrap(); // gen 3 (viva)
|
||||
|
||||
let g = build(&state).unwrap();
|
||||
assert_eq!(g.version, 1);
|
||||
// 3 generaciones + el nodo de recuperación sintético.
|
||||
assert_eq!(g.nodes.len(), 4);
|
||||
let gen1 = &g.nodes[0];
|
||||
assert_eq!(gen1.kind, NodeKind::Base, "la primera generación es la raíz del linaje");
|
||||
assert!(gen1.parents.is_empty());
|
||||
let gen2 = &g.nodes[1];
|
||||
assert_eq!(gen2.kind, NodeKind::Generation);
|
||||
assert_eq!(gen2.parents, vec![gen1.id.clone()], "el DAG cuelga del of_tree del padre");
|
||||
// current/default = la generación viva (gen 3).
|
||||
let gen3 = &g.nodes[2];
|
||||
assert_eq!(g.current.as_deref(), Some(gen3.id.as_str()));
|
||||
assert_eq!(g.default, g.current);
|
||||
// el nodo de recuperación cuelga de la viva.
|
||||
let rec = g.nodes.iter().find(|n| n.kind == NodeKind::Recovery).unwrap();
|
||||
assert_eq!(rec.id, recovery_id());
|
||||
assert_eq!(rec.parents, vec![gen3.id.clone()]);
|
||||
|
||||
// activar la viva ⇒ no-op.
|
||||
let r = activate(&root, &state, None, &gen3.id).unwrap();
|
||||
assert!(r.already_current);
|
||||
assert_eq!(current(&state).unwrap(), Some(3));
|
||||
|
||||
// activar la gen 1 (ancestro) ⇒ rollback paso a paso hasta ella.
|
||||
let r = activate(&root, &state, None, &gen1.id).unwrap();
|
||||
assert!(!r.already_current);
|
||||
assert_eq!(r.rolled_back, vec![3, 2], "revierte de la más nueva a la más vieja");
|
||||
assert_eq!(current(&state).unwrap(), Some(1));
|
||||
assert_eq!(std::fs::read(root.join("f")).unwrap(), b"1", "el FHS vivo es el de la gen 1");
|
||||
|
||||
// ahora las gens 2 y 3 son huérfanas (redo no soportado) ⇒ error honesto.
|
||||
let g2 = build(&state).unwrap();
|
||||
assert_eq!(g2.current.as_deref(), Some(gen1.id.as_str()));
|
||||
let err = activate(&root, &state, None, &gen3.id).unwrap_err();
|
||||
assert!(err.to_string().contains("redo"), "activar hacia adelante debe fallar claro: {err}");
|
||||
|
||||
// recuperación desde la gen 1 ⇒ rollback al estado pre-upgrades (sin current).
|
||||
let r = activate(&root, &state, None, &recovery_id()).unwrap();
|
||||
assert_eq!(r.kind, NodeKind::Recovery);
|
||||
assert_eq!(r.rolled_back, vec![1]);
|
||||
assert_eq!(current(&state).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_with_keep_trims_chain_depth() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user