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:
2026-07-06 20:36:04 -04:00
co-authored by Claude Opus 4.8
parent 7e21b0184b
commit 3c343bee9a
4 changed files with 464 additions and 2 deletions
+83
View File
@@ -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(())
}
+313
View File
@@ -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) = &current_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");
}
}
+61
View File
@@ -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();