//! **Arranque por grafo** ([ADR 0010](../../docs/adr/0010-arranque-grafo-mirada.md)). //! //! El menú de arranque de takana 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): //! //! - **takana → mirada**: [`build`] arma el grafo y [`emit`] lo escribe en //! [`BOOT_GRAPH_PATH`] (`/run/hammer/boot-graph.json`), world-readable y regenerable. //! - **mirada → takana**: mirada escribe el `id` elegido en [`BOOT_SELECT_PATH`] **o** invoca //! `takana boot activate `; [`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 takana, y takana 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 `` 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 takana_core::ArtifactHash; use takana_journal::Journal; use serde::{Deserialize, Serialize}; use crate::{ current, list, live_chain, rollback, Error, GenerationManifest, Result, RollbackReport, }; /// Path bien conocido donde takana 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 /// `takana boot activate `). 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 takana. #[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, /// `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, } /// 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). Cadena **vacía** si el sistema nunca aplicó un /// upgrade (no hay generación registrada) — el lector de mirada exige el campo presente, así que /// se emite siempre y degrada a "sin preselección" ante `""`. #[serde(default)] pub current: String, /// `id` a arrancar si el usuario no elige. Vacío si no hay generación viva (ver [`current`]). #[serde(default)] pub default: String, pub nodes: Vec, } /// 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 { 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` (`-` ⇒ ``). 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 { 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 = gens.iter().map(|m| (m.id, m)).collect(); let hash_of = |gen: u64| -> Option { by_gen.get(&gen).map(|m| node_id(&m.tree_content)) }; let mut nodes: Vec = Vec::with_capacity(gens.len() + 1); for m in &gens { let parents = m.parent.and_then(hash_of).into_iter().collect::>(); 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()), }); } // `current`/`default` van siempre presentes (String); vacío = "sin generación viva". let current_id = current_id.unwrap_or_default(); 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, } /// 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 `takana upgrade apply`. pub fn activate( target_root: &Path, state_root: &Path, journal: Option<&Journal>, id: &str, ) -> Result { 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 = 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 empty_state_emits_present_string_fields() { // Sistema sin upgrades: el grafo debe traer `current`/`default` PRESENTES (cadena vacía), // no omitidos — el lector de mirada los exige como String obligatorio. let tmp = tempfile::tempdir().unwrap(); let g = build(tmp.path()).unwrap(); assert!(g.nodes.is_empty()); assert_eq!(g.current, ""); assert_eq!(g.default, ""); let json = serde_json::to_string(&g).unwrap(); assert!(json.contains("\"current\""), "current debe emitirse aun vacío: {json}"); assert!(json.contains("\"default\""), "default debe emitirse aun vacío: {json}"); } #[test] fn tree_name_strips_hash_prefix() { assert_eq!(tree_name("abc123-product-rootfs"), "product-rootfs"); assert_eq!(tree_name("noseparator"), "noseparator"); } }