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…
takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
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) = ¤t_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}