tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ main crates/takana-upgrade/src/boot_graph.rs Rust · 331 líneas · 13.6 KB Historia Quién tocó qué crudo

takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja b818f5249 · Sergio ·

1//! **Arranque por grafo** ([ADR 0010](../../docs/adr/0010-arranque-grafo-mirada.md)).
2//!
3//! El menú de arranque de takana no es una *lista* de kernels (GRUB) sino la **navegación del grafo
4//! content-addressed de estados** del sistema. Este módulo sube el modelo de generaciones in-place
5//! ([`crate`]) a ese grafo navegable y define el **contrato de datos** con `mirada` (el compositor de
6//! tawasuyu que dibuja el menú sobre KMS):
7//!
8//! - **takana → mirada**: [`build`] arma el grafo y [`emit`] lo escribe en
9//!   [`BOOT_GRAPH_PATH`] (`/run/hammer/boot-graph.json`), world-readable y regenerable.
10//! - **mirada → takana**: mirada escribe el `id` elegido en [`BOOT_SELECT_PATH`] **o** invoca
11//!   `takana boot activate <id>`; [`activate`] valida el id contra el grafo y **activa** el nodo
12//!   (pivota la generación vía el rollback existente).
13//!
14//! La frontera es *un fichero + un comando*, no una API viva: mirada puede maquetar el menú contra un
15//! `boot-graph.json` de ejemplo sin esperar a takana, y takana emite el grafo sin esperar a mirada.
16//!
17//! **Content-addressing.** El `id` de cada nodo es el `of_tree` (BLAKE3) del árbol que la generación
18//! proyectó — sin el prefijo `b3:`, para casar con el `<blake3>` del contrato. `parents` forma un DAG
19//! (no una lista lineal): el padre de una generación es el `of_tree` de su generación anterior. Activar
20//! un nodo es *reproducir/hidratar* un árbol sellado del store, no ejecutar algo nuevo (ADR 0007/0009).
21
22use std::collections::HashMap;
23use std::path::Path;
24
25use takana_core::ArtifactHash;
26use takana_journal::Journal;
27use serde::{Deserialize, Serialize};
28
29use crate::{
30    current, list, live_chain, rollback, Error, GenerationManifest, Result, RollbackReport,
31};
32
33/// Path bien conocido donde takana publica el grafo para mirada. Regenerable, world-readable.
34pub const BOOT_GRAPH_PATH: &str = "/run/hammer/boot-graph.json";
35
36/// Path bien conocido donde mirada deja el `id` del nodo a activar (canal de vuelta alternativo a
37/// `takana boot activate <id>`).
38pub const BOOT_SELECT_PATH: &str = "/run/hammer/boot-select";
39
40/// Qué representa un nodo del grafo de arranque. Guía cómo lo dibuja mirada y cómo lo activa takana.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "kebab-case")]
43pub enum NodeKind {
44    /// Una generación de upgrade normal (tiene padre).
45    Generation,
46    /// Un punto de captura explícito (reservado; hoy no se emite).
47    Snapshot,
48    /// La primera generación / raíz del linaje (sin padre).
49    Base,
50    /// Nodo sintético "deshacer la generación viva" — mapea al rollback.
51    Recovery,
52}
53
54/// Un nodo del grafo de arranque: un estado del sistema al que se puede arrancar.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct BootNode {
57    /// `of_tree` (BLAKE3 hex, sin `b3:`) del árbol de la generación. Content-addressed y estable.
58    pub id: String,
59    /// Texto corto para el usuario.
60    pub label: String,
61    pub kind: NodeKind,
62    /// Instante del apply en RFC 3339 (UTC).
63    pub created: String,
64    /// Ids de los padres (DAG). Hoy 0 o 1 por nodo; el formato admite varios.
65    pub parents: Vec<String>,
66    /// `true` si el nodo se puede arrancar (siempre hoy; el flag existe para estados futuros parciales).
67    pub bootable: bool,
68    /// Qué cambió respecto del padre (opcional).
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub summary: Option<String>,
71}
72
73/// El grafo entero — exactamente el JSON del contrato del ADR 0010.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct BootGraph {
76    pub version: u32,
77    /// `id` del nodo vivo (la generación actual). Cadena **vacía** si el sistema nunca aplicó un
78    /// upgrade (no hay generación registrada) — el lector de mirada exige el campo presente, así que
79    /// se emite siempre y degrada a "sin preselección" ante `""`.
80    #[serde(default)]
81    pub current: String,
82    /// `id` a arrancar si el usuario no elige. Vacío si no hay generación viva (ver [`current`]).
83    #[serde(default)]
84    pub default: String,
85    pub nodes: Vec<BootNode>,
86}
87
88/// El `id` content-addressed de un nodo: el `of_tree` sin el prefijo `b3:`.
89fn node_id(tree_content: &str) -> String {
90    tree_content.trim_start_matches("b3:").to_string()
91}
92
93/// Id estable del nodo sintético de recuperación. Content-addressed (BLAKE3 de una etiqueta fija) para
94/// respetar el contrato "cada id es un hash", pero reservado: [`activate`] lo reconoce y hace rollback.
95pub fn recovery_id() -> String {
96    node_id(ArtifactHash::of_bytes(b"hammer:boot:recovery").as_str())
97}
98
99/// Resume los cambios de una generación para el `summary` (+añadidos ~reemplazados -retirados).
100fn summarize(m: &GenerationManifest) -> Option<String> {
101    if m.changes.is_empty() {
102        return None;
103    }
104    let (mut a, mut r, mut d) = (0u32, 0u32, 0u32);
105    for c in &m.changes {
106        match c.op {
107            crate::ChangeOp::Added => a += 1,
108            crate::ChangeOp::Replaced => r += 1,
109            crate::ChangeOp::Removed => d += 1,
110        }
111    }
112    Some(format!("+{a} ~{r} -{d}"))
113}
114
115/// El nombre legible del árbol: la cola de `tree_dir` (`<hash>-<name>` ⇒ `<name>`).
116fn tree_name(tree_dir: &str) -> &str {
117    tree_dir.split_once('-').map_or(tree_dir, |(_, name)| name)
118}
119
120/// Convierte segundos-desde-epoch a RFC 3339 (UTC) sin dependencias externas
121/// (algoritmo `civil_from_days` de Howard Hinnant). Determinista y testeable.
122fn rfc3339(secs: u64) -> String {
123    let days = (secs / 86_400) as i64;
124    let rem = secs % 86_400;
125    let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
126
127    let z = days + 719_468;
128    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
129    let doe = z - era * 146_097; // [0, 146096]
130    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
131    let y = yoe + era * 400;
132    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
133    let mp = (5 * doy + 2) / 153; // [0, 11]
134    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
135    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
136    let year = if m <= 2 { y + 1 } else { y };
137    format!("{year:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
138}
139
140/// Arma el [`BootGraph`] a partir del registro de generaciones en `state_root`.
141///
142/// Cada generación es un nodo (id = `of_tree`); su padre es el `of_tree` de la generación anterior.
143/// La primera generación del linaje (sin padre) es [`NodeKind::Base`]. Si hay una generación viva, se
144/// añade un nodo sintético [`NodeKind::Recovery`] colgando de ella ("deshacer la generación viva").
145pub fn build(state_root: &Path) -> Result<BootGraph> {
146    let gens = list(state_root)?;
147    let cur = current(state_root)?;
148
149    // Índice u64 → of_tree, para traducir `parent` (u64) al id del padre (hash).
150    let by_gen: HashMap<u64, &GenerationManifest> = gens.iter().map(|m| (m.id, m)).collect();
151    let hash_of = |gen: u64| -> Option<String> { by_gen.get(&gen).map(|m| node_id(&m.tree_content)) };
152
153    let mut nodes: Vec<BootNode> = Vec::with_capacity(gens.len() + 1);
154    for m in &gens {
155        let parents = m.parent.and_then(hash_of).into_iter().collect::<Vec<_>>();
156        let kind = if m.parent.is_none() { NodeKind::Base } else { NodeKind::Generation };
157        nodes.push(BootNode {
158            id: node_id(&m.tree_content),
159            label: format!("gen {} · {}", m.id, tree_name(&m.tree_dir)),
160            kind,
161            created: rfc3339(m.created_at),
162            parents,
163            bootable: true,
164            summary: summarize(m),
165        });
166    }
167
168    let current_id = cur.and_then(hash_of);
169
170    // Nodo de recuperación: sólo tiene sentido si hay algo vivo que deshacer.
171    if let Some(cur_hash) = &current_id {
172        let created = cur.and_then(|g| by_gen.get(&g)).map_or(0, |m| m.created_at);
173        nodes.push(BootNode {
174            id: recovery_id(),
175            label: "recuperación (deshacer la generación viva)".to_string(),
176            kind: NodeKind::Recovery,
177            created: rfc3339(created),
178            parents: vec![cur_hash.clone()],
179            bootable: true,
180            summary: Some("rollback a la generación anterior".to_string()),
181        });
182    }
183
184    // `current`/`default` van siempre presentes (String); vacío = "sin generación viva".
185    let current_id = current_id.unwrap_or_default();
186    Ok(BootGraph { version: 1, current: current_id.clone(), default: current_id, nodes })
187}
188
189/// Escribe el grafo en `out` (default [`BOOT_GRAPH_PATH`]) de forma atómica (temporal + rename).
190/// Crea el directorio padre (`/run/hammer`) si falta.
191pub fn emit(state_root: &Path, out: &Path) -> Result<()> {
192    let graph = build(state_root)?;
193    let bytes = serde_json::to_vec_pretty(&graph)?;
194    if let Some(dir) = out.parent() {
195        std::fs::create_dir_all(dir)?;
196    }
197    let tmp = out.with_extension("json.tmp");
198    std::fs::write(&tmp, &bytes)?;
199    std::fs::rename(&tmp, out)?;
200    Ok(())
201}
202
203/// Reporte de una [`activate`].
204#[derive(Debug, Clone)]
205pub struct ActivateReport {
206    /// Id (hash) que se pidió activar.
207    pub target: String,
208    pub kind: NodeKind,
209    /// `true` si el nodo pedido ya era el vivo (no se hizo nada).
210    pub already_current: bool,
211    /// Generaciones revertidas, de la más nueva a la más vieja (vacío si no hubo rollback).
212    pub rolled_back: Vec<u64>,
213}
214
215/// Activa el nodo `id` del grafo: deja el sistema en ese estado.
216///
217/// - Si `id` es el vivo ⇒ no-op ([`ActivateReport::already_current`]).
218/// - Si `id` es un **ancestro** de la generación viva ⇒ rollback paso a paso hasta él (cada paso es un
219///   [`rollback`] atómico del modelo E4).
220/// - Si `id` es el nodo de **recuperación** ⇒ un rollback de la generación viva.
221/// - Si `id` existe pero **no** está en la cadena viva (un "redo" hacia una generación huérfana) ⇒
222///   error honesto: eso requiere re-aplicar el árbol con `takana upgrade apply`.
223pub fn activate(
224    target_root: &Path,
225    state_root: &Path,
226    journal: Option<&Journal>,
227    id: &str,
228) -> Result<ActivateReport> {
229    let want = node_id(id); // tolera que llegue con o sin `b3:`
230
231    // Recuperación: un rollback de la generación viva.
232    if want == recovery_id() {
233        let RollbackReport { reverted, .. } = rollback(target_root, state_root, journal)?;
234        return Ok(ActivateReport {
235            target: want,
236            kind: NodeKind::Recovery,
237            already_current: false,
238            rolled_back: vec![reverted],
239        });
240    }
241
242    let cur = current(state_root)?
243        .ok_or_else(|| Error::Other("no hay generación viva a la que anclar la activación".into()))?;
244    let cur_manifest = crate::get(state_root, cur)?
245        .ok_or_else(|| Error::Other(format!("manifiesto de la generación viva {cur} ausente")))?;
246
247    // ¿Ya es la viva?
248    if node_id(&cur_manifest.tree_content) == want {
249        return Ok(ActivateReport {
250            target: want,
251            kind: NodeKind::Generation,
252            already_current: true,
253            rolled_back: Vec::new(),
254        });
255    }
256
257    // Buscar el nodo pedido dentro de la cadena viva (ancestros de la generación actual). El índice en
258    // la cadena (0 = actual) es exactamente cuántos rollbacks hay que hacer para llegar a él.
259    let chain = live_chain(state_root)?;
260    let mut steps: Option<usize> = None;
261    for (i, gen) in chain.iter().enumerate() {
262        let m = crate::get(state_root, *gen)?
263            .ok_or_else(|| Error::Other(format!("manifiesto de la generación {gen} ausente")))?;
264        if node_id(&m.tree_content) == want {
265            steps = Some(i);
266            break;
267        }
268    }
269
270    let steps = steps.ok_or_else(|| {
271        Error::Other(format!(
272            "el nodo {want} no es un ancestro de la generación viva; \
273             activar hacia adelante (redo) aún no se soporta — re-aplicá el árbol con `hammer upgrade apply`"
274        ))
275    })?;
276
277    let mut rolled_back = Vec::with_capacity(steps);
278    for _ in 0..steps {
279        let RollbackReport { reverted, .. } = rollback(target_root, state_root, journal)?;
280        rolled_back.push(reverted);
281    }
282
283    Ok(ActivateReport {
284        target: want,
285        kind: NodeKind::Generation,
286        already_current: false,
287        rolled_back,
288    })
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn rfc3339_epoch_and_known_dates() {
297        assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
298        // 2026-07-06T12:00:00Z
299        assert_eq!(rfc3339(1_783_339_200), "2026-07-06T12:00:00Z");
300        // 2000-01-01T00:00:00Z (post-leap-year boundary)
301        assert_eq!(rfc3339(946_684_800), "2000-01-01T00:00:00Z");
302    }
303
304    #[test]
305    fn recovery_id_is_stable_and_hex() {
306        let a = recovery_id();
307        assert_eq!(a, recovery_id());
308        assert_eq!(a.len(), 64); // BLAKE3 hex, sin `b3:`
309        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
310    }
311
312    #[test]
313    fn empty_state_emits_present_string_fields() {
314        // Sistema sin upgrades: el grafo debe traer `current`/`default` PRESENTES (cadena vacía),
315        // no omitidos — el lector de mirada los exige como String obligatorio.
316        let tmp = tempfile::tempdir().unwrap();
317        let g = build(tmp.path()).unwrap();
318        assert!(g.nodes.is_empty());
319        assert_eq!(g.current, "");
320        assert_eq!(g.default, "");
321        let json = serde_json::to_string(&g).unwrap();
322        assert!(json.contains("\"current\""), "current debe emitirse aun vacío: {json}");
323        assert!(json.contains("\"default\""), "default debe emitirse aun vacío: {json}");
324    }
325
326    #[test]
327    fn tree_name_strips_hash_prefix() {
328        assert_eq!(tree_name("abc123-product-rootfs"), "product-rootfs");
329        assert_eq!(tree_name("noseparator"), "noseparator");
330    }
331}