Files
takana/crates/takana-upgrade/src/boot_graph.rs
T
Sergio b818f5249f takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
205 lineas en 73 ficheros de crates, mas la prosa de CLAUDE.md y del skill,
que se me habian quedado afuera de los barridos anteriores (no eran ni recetas
ni docs/ ni scripts/).

EL BARRIDO ANCHO ESTUVO A UN COMMIT DE ROMPER EL CORPUS ENTERO.

El primer intento reescribia los .rs completos, no solo los comentarios. Entre
las lineas de codigo que tocaba estaban SIETE etiquetas de separacion de
dominio, que son ENTRADA DE HASH:

  b"hammer-tree-v1"        <- el prefijo de ArtifactHash::of_tree (hash.rs:60)
  b"hammer-seed-v1"           la funcion que hashea TODOS los artefactos:
  b"hammer-stage1-rootfs-v2"  cambiarla mueve los 4750 hashes del store
  b"hammer-product-rootfs-v3"
  b"hammer-product-attested-v2"
  b"hammer-builder-rootfs-v1"
  b"hammer-attest-dev-rootkey-0001!!"  <- clave raiz de atestacion, [u8;32]

Revertido y rehecho solo sobre comentarios, esquivando ademas las cadenas
crudas de Rust (r#"..."#) porque el SYSTEM_PROMPT del traductor tiene lineas
que empiezan como comentario.

Controles: las 7 etiquetas siguen ahi, el diff toca CERO lineas de codigo,
605 tests en verde y el hash de zlib sigue en b3:dc363f26.

La leccion es la misma de toda esta etapa: un literal que parece prosa puede
ser entrada de hash, y la unica forma de saberlo es mirar donde se usa.
2026-09-09 19:38:33 +00:00

332 lines
14 KiB
Rust

//! **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 <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 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 `<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 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 <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 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<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). 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<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()),
});
}
// `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<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 `takana 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 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");
}
}