refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG

Reorganización física de crates/:
- core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/
- shared/ (3 crates) se redistribuye en protocol/ e init/
- lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/

Renames de proyectos:
- shipote → shuma (runtime de sandboxes)
- nouser → akasha (explorador de Mónadas)
- yahweh → nahual (motor GPUI, antes ui_engine/)
- lapaloma → pineal (data-viz agnóstica)

Fraccionamiento UI → core agnóstico:
- vista-core (DeckState + snap, 175 LOC, 5 tests verdes)
- barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes)
- vista-web y barra-web ahora son thin DOM bindings

Documentación nueva:
- 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat
  + 10 módulos + apps/
- docs/STATUS.md con cifras reales por proyecto
- docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas)
- CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets)

Automatización:
- scripts/reorg.py — script idempotente que: git mv directorios, renombra
  package names, recomputa path = refs, reescribe imports rust, actualiza
  workspace Cargo.toml. Soporta --dry-run.
- scripts/split-changelog.py — particiona CHANGELOG por componente.

Validación:
- cargo check --workspace pasa (124 crates + 2 nuevos cores).
- 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-19 14:48:34 +00:00
parent 86fb6ae20b
commit 550c98f275
375 changed files with 8512 additions and 7155 deletions
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "ente-zero"
version = "0.0.1"
edition.workspace = true
license.workspace = true
publish.workspace = true
[[bin]]
name = "ente-zero"
path = "src/main.rs"
[dependencies]
# Lib crates del fractal — orden: contratos → infra → encarnación → orquestación
ente-card = { path = "../../protocol/ente-card" }
ente-bus = { path = "../../runtime/ente-bus" }
ente-cas = { path = "../../runtime/ente-cas" }
ente-kernel = { path = "../ente-kernel" }
ente-soma = { path = "../ente-soma" }
ente-wasm = { path = "../../runtime/ente-wasm" }
ente-snapshot = { path = "../ente-snapshot" }
ente-brain = { path = "../../runtime/ente-brain" }
ente-echo = { path = "../../runtime/ente-echo" } # solo para constantes del demo
# Brahman protocol — handshake para módulos brahman conscientes
brahman-handshake = { path = "../../protocol/brahman-handshake" }
brahman-broker = { path = "../../protocol/brahman-broker" }
brahman-admin = { path = "../../protocol/brahman-admin" }
brahman-net = { path = "../../protocol/brahman-net" }
# Runtime / utilidades de PID 1
serde = { workspace = true }
serde_json = { workspace = true }
ulid = { workspace = true }
tokio = { workspace = true }
nix = { workspace = true }
libc = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
+129
View File
@@ -0,0 +1,129 @@
//! Glue entre el bucle primordial y `ente-brain`.
//!
//! Tres responsabilidades:
//! 1. Traducir eventos del grafo (`GraphEvent`) a `ente_brain::EventKind`
//! + `SubjectInfo` para el observador y el motor.
//! 2. Implementar `ActionSink` para que las Acciones del cerebro tengan
//! un canal de salida hacia el grafo (Spawn → SpawnRequest, etc.).
//! 3. Encapsular el snapshot de SubjectInfo desde el grafo sin filtrar
//! detalles internos al cerebro.
use crate::events::GraphEvent;
use crate::graph::EnteGraph;
use ente_brain::{ActionSink, EventKind as BrainEventKind, SubjectInfo};
use ente_card::Capability;
use serde::Deserialize;
use tokio::sync::mpsc;
use tracing::warn;
use ulid::Ulid;
/// Traduce un GraphEvent a (EventKind, SubjectInfo) para alimentar el cerebro.
///
/// Devuelve `None` para eventos puramente internos del bus (Response, Close)
/// que no son interesantes para reglas o estadística.
pub fn graph_event_to_brain<'a>(
evt: &'a GraphEvent,
graph: &EnteGraph,
) -> Option<(BrainEventKind, SubjectInfo)> {
match evt {
GraphEvent::EnteDied { id, .. } => {
Some((BrainEventKind::EnteDied, subject_info_for(graph, *id)))
}
GraphEvent::SpawnRequest { card, .. } => {
// El "sujeto" del spawn es el child que va a nacer.
let info = SubjectInfo {
id: Some(card.id),
label: Some(card.label.clone()),
capabilities: card.provides.iter().cloned().collect(),
};
Some((BrainEventKind::EnteSpawned, info))
}
GraphEvent::BusRequest { from, request, .. } => {
let kind = match request {
ente_bus::BusRequest::Announce { .. } => BrainEventKind::BusAnnounce,
ente_bus::BusRequest::Invoke { cap, .. } => {
BrainEventKind::BusInvokeOf(cap.clone())
}
_ => BrainEventKind::BusInvoke,
};
let info = match from {
Some(id) => subject_info_for(graph, *id),
None => SubjectInfo::default(),
};
Some((kind, info))
}
GraphEvent::CapabilityRequested { from, .. } => {
Some((BrainEventKind::BusInvoke, subject_info_for(graph, *from)))
}
// Responses, ConnClosed, Shutdown — irrelevantes para reglas
_ => None,
}
}
fn subject_info_for(graph: &EnteGraph, id: Ulid) -> SubjectInfo {
// Acceso de sólo lectura — usamos el método público lookup_pid + cards
// virtuales en el grafo. Si el Ente no existe (ya disuelto), info vacía.
if let Some(card) = graph.card_for(&id) {
SubjectInfo {
id: Some(id),
label: Some(card.label.clone()),
capabilities: card.provides.iter().cloned().collect(),
}
} else {
SubjectInfo { id: Some(id), label: None, capabilities: Vec::new() }
}
}
/// `ActionSink` que enruta acciones del cerebro al bucle primordial.
pub struct GraphSink {
pub graph_tx: mpsc::Sender<GraphEvent>,
pub requester: Ulid,
}
impl ActionSink for GraphSink {
fn spawn(&self, card_blob: &str) {
// El blob es JSON de EntityCard.
match serde_json::from_str::<ente_card::EntityCard>(card_blob) {
Ok(card) => {
let evt = GraphEvent::SpawnRequest { card, requester: self.requester };
if self.graph_tx.try_send(evt).is_err() {
warn!("brain spawn: graph_tx lleno o cerrado");
}
}
Err(e) => warn!(?e, "brain spawn: blob no parseable como EntityCard JSON"),
}
}
fn invoke(&self, target_cap: Capability, blob: Vec<u8>) {
// Sin BusClient en proceso — el sink registra la intención. Una mejora
// futura: spawn un BusClient::connect + call. Por ahora log estructurado.
warn!(?target_cap, blob_len = blob.len(), "brain invoke: no bus client en glue (TODO)");
}
fn notify(&self, target_id: Ulid, message: &str) {
warn!(%target_id, %message, "brain notify: no implementado en glue");
}
fn inhibit(&self, reason: &str) {
warn!(%reason, "brain inhibit: no implementado en glue");
}
}
/// Helper para que el grafo exponga la Card de un Ente vivo. Lo añadimos como
/// trait extension porque graph::EnteGraph mantiene `incarnated` privado.
pub trait GraphCardLookup {
fn card_for(&self, id: &Ulid) -> Option<&ente_card::EntityCard>;
}
impl GraphCardLookup for EnteGraph {
fn card_for(&self, id: &Ulid) -> Option<&ente_card::EntityCard> {
// Acceso vía método público que añadiremos en graph/mod.rs.
self.peek_card(id)
}
}
// Eliminar el campo `_unused` que rustc puede quejarse — placeholder para
// evitar warning si algún field queda sin uso.
#[allow(dead_code)]
#[derive(Deserialize)]
struct _Touch {}
+143
View File
@@ -0,0 +1,143 @@
//! Listener del bus interno. Vive en PID 1, acepta conexiones de Entes hijos,
//! extrae credenciales del kernel vía SO_PEERCRED, y enruta cada request al
//! grafo. Conexión bidireccional: el grafo puede *empujar* requests hacia
//! una conexión registrada (forwarding de Invoke al proveedor).
//!
//! ## Por qué bidireccional
//!
//! Un Ente que provee `Capability::Endpoint` debe poder *recibir* invokes
//! sin abrir más sockets. Después de Announce, el grafo guarda el lado de
//! escritura de su conexión y lo usa para forwardear.
use crate::events::GraphEvent;
use ente_bus::{read_frame, write_frame, BusMessage, BusPayload, BusResponse, PeerCreds};
use nix::sys::socket::{getsockopt, sockopt::PeerCredentials};
use std::path::PathBuf;
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{mpsc, oneshot};
use tracing::{error, info, trace, warn};
use ulid::Ulid;
pub fn default_socket_path() -> PathBuf {
if let Ok(p) = std::env::var(ente_bus::ENV_BUS_SOCK) {
return p.into();
}
let runtime = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into()));
let user = std::env::var("USER").unwrap_or_else(|_| "ente".into());
format!("{runtime}/ente-bus-{user}.sock").into()
}
pub fn spawn_bus(path: PathBuf, graph_tx: mpsc::Sender<GraphEvent>) -> anyhow::Result<PathBuf> {
let _ = std::fs::remove_file(&path);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let listener = UnixListener::bind(&path)?;
info!(path = %path.display(), "bus interno escuchando");
let path_returned = path.clone();
tokio::spawn(async move {
loop {
match listener.accept().await {
Ok((stream, _addr)) => {
let tx = graph_tx.clone();
tokio::spawn(async move {
if let Err(e) = handle_conn(stream, tx).await {
warn!(?e, "bus connection ended");
}
});
}
Err(e) => {
error!(?e, "bus accept failed, listener cerrando");
return;
}
}
}
});
Ok(path_returned)
}
async fn handle_conn(stream: UnixStream, graph_tx: mpsc::Sender<GraphEvent>) -> anyhow::Result<()> {
// SO_PEERCRED: el kernel adjunta pid/uid/gid al socket en connect/accept.
// No-falsificable desde el cliente.
let creds = getsockopt(&stream, PeerCredentials)
.map_err(|e| anyhow::anyhow!("getsockopt PEERCRED: {e}"))?;
let peer = PeerCreds {
pid: creds.pid(),
uid: creds.uid(),
gid: creds.gid(),
};
trace!(?peer, "bus conn aceptada");
let (mut reader, mut writer) = stream.into_split();
let (out_tx, mut out_rx) = mpsc::channel::<BusMessage>(64);
// Writer task: única vía de escritura al socket. Multiplexa entre
// respuestas a peticiones del cliente y forwards iniciados por el grafo.
let writer_task = tokio::spawn(async move {
while let Some(msg) = out_rx.recv().await {
if let Err(e) = write_frame(&mut writer, &msg).await {
warn!(?e, "bus writer falló, terminando");
return;
}
}
});
let mut announced_id: Option<Ulid> = None;
let result: anyhow::Result<()> = (async {
loop {
let msg = match read_frame(&mut reader).await {
Ok(m) => m,
Err(e) => {
trace!(?e, "bus conn read terminó");
return Ok(());
}
};
match msg.payload {
BusPayload::Request(req) => {
let is_announce = matches!(req, ente_bus::BusRequest::Announce { .. });
let (reply_tx, reply_rx) = oneshot::channel();
if graph_tx.send(GraphEvent::BusRequest {
peer,
from: msg.from,
request: req,
outbound: out_tx.clone(),
reply: reply_tx,
}).await.is_err() {
warn!("graph cerrado, terminando bus connection");
return Ok(());
}
let response = reply_rx.await.unwrap_or_else(|_| {
BusResponse::Error("graph dropped reply channel".into())
});
if is_announce && matches!(response, BusResponse::Ok) {
// Auth del Announce ya fue verificada por el grafo;
// memorizamos para cleanup en cierre.
announced_id = msg.from;
}
let out = BusMessage {
from: None,
seq: msg.seq,
payload: BusPayload::Response(response),
};
if out_tx.send(out).await.is_err() { return Ok(()); }
}
BusPayload::Response(resp) => {
// Respuesta a un Invoke que el grafo forwardeó a este peer.
let _ = graph_tx.send(GraphEvent::BusResponse {
seq: msg.seq,
response: resp,
}).await;
}
}
}
}).await;
if let Some(id) = announced_id {
let _ = graph_tx.send(GraphEvent::BusConnClosed { ente_id: Some(id) }).await;
}
writer_task.abort();
result
}
+65
View File
@@ -0,0 +1,65 @@
//! Eventos internos del bucle primordial. Todo cambio de estado del fractal
//! pasa por aquí — la única vía de mutación del grafo desde tasks externas.
//!
//! Este módulo es **vocabulario**: declara el universo completo de eventos
//! del fractal. Algunas variantes/campos están reservados para flujos
//! aún no implementados (capabilities, signal-driven shutdown). Silenciar
//! `dead_code` evita ruido sin perder la declaración del contrato.
#![allow(dead_code)]
use ente_bus::{BusMessage, BusRequest, BusResponse, PeerCreds};
use ente_card::{Capability, EntityCard};
use nix::sys::signal::Signal;
use tokio::sync::{mpsc, oneshot};
use ulid::Ulid;
#[derive(Debug)]
pub enum GraphEvent {
EnteDied { id: Ulid, status: ExitStatus },
CapabilityRequested {
from: Ulid,
cap: Capability,
reply: oneshot::Sender<CapabilityGrant>,
},
SpawnRequest { card: EntityCard, requester: Ulid },
/// Request del bus interno. `peer` es no-falsificable (kernel-injected
/// via SO_PEERCRED). `from` es la identidad reclamada por el cliente —
/// el grafo la verifica contra `peer.pid`.
BusRequest {
peer: PeerCreds,
from: Option<Ulid>,
request: BusRequest,
outbound: mpsc::Sender<BusMessage>,
reply: oneshot::Sender<BusResponse>,
},
/// Response a un Invoke forwardeado por el grafo a un proveedor.
/// `seq` debe coincidir con una entry en pending_invokes.
BusResponse { seq: u64, response: BusResponse },
/// Cliente del bus cerró su conexión. Si había anunciado identidad,
/// el grafo retira esa conexión del registry.
BusConnClosed { ente_id: Option<Ulid> },
Shutdown { reason: ShutdownReason },
}
#[derive(Debug, Clone)]
pub enum ExitStatus {
Exit(i32),
Killed(Signal),
}
#[derive(Debug, Clone)]
pub enum ShutdownReason {
SeedRequested,
Signal(Signal),
}
#[derive(Debug)]
pub enum CapabilityGrant {
Granted { token: u64 },
NoProvider,
Denied { reason: &'static str },
/// El holder ya tiene el máximo de tokens activos para esta cap.
/// Debe esperar a que alguno expire o renovar uno existente.
QuotaExceeded { active: u32, limit: u32 },
}
@@ -0,0 +1,240 @@
//! Bus mediator: integración de `EnteGraph` con el bus interno.
//!
//! Responsabilidades:
//! - Auth de Announce (verificar identidad reclamada contra SO_PEERCRED)
//! - Registro de conexiones (`bus_connections` indexado por Ulid)
//! - Forwarding de Invokes a proveedores
//! - Tracking de invokes en vuelo (`pending_invokes` por seq)
//! - Cleanup en cierre de conexión
use super::{EnteGraph, SERVER_SEQ_FLAG};
use ente_bus::{BusMessage, BusPayload, BusRequest, BusResponse, EnteInfo, PeerCreds};
use ente_card::Capability;
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
use ulid::Ulid;
/// Operaciones que requieren identidad verificada en el bus.
///
/// - `Announce`: establece bus_connections para forwarding.
/// - `UpdateCapabilities`: muta dynamic_provides del Ente — sólo el dueño.
///
/// Invoke, ListEntes y power-mgmt se aceptan anonymous — políticas por
/// capacidad se aplican aguas abajo, no aquí.
fn requires_auth(req: &BusRequest) -> bool {
matches!(
req,
BusRequest::Announce { .. } | BusRequest::UpdateCapabilities { .. }
)
}
impl EnteGraph {
pub async fn on_bus_request(
&mut self,
peer: PeerCreds,
from: Option<Ulid>,
request: BusRequest,
outbound: mpsc::Sender<BusMessage>,
reply: oneshot::Sender<BusResponse>,
) {
// ---- Auth: kernel-injected SO_PEERCRED vs identidad reclamada ----
let from_authenticated = match from {
None => None,
Some(claimed) => {
let expected = self.incarnated.get(&claimed).and_then(|i| i.pid);
match expected {
Some(p) if p.as_raw() == peer.pid => Some(claimed),
Some(p) => {
warn!(
claimed = %claimed, expected_pid = p.as_raw(),
actual_pid = peer.pid,
"identity mismatch — rechazando request"
);
let _ = reply.send(BusResponse::Error("identity mismatch".into()));
return;
}
None => {
warn!(?claimed, peer_pid = peer.pid, "Ente desconocido reclamando identidad");
let _ = reply.send(BusResponse::Error("unknown ente claimed".into()));
return;
}
}
}
};
if requires_auth(&request) && from_authenticated.is_none() {
let _ = reply.send(BusResponse::Error("auth required for this request".into()));
return;
}
// ---- Dispatch ----
match request {
BusRequest::Announce { capabilities } => {
let id = from_authenticated.expect("auth-required guarantees Some");
let label = self.incarnated.get(&id).map(|i| i.card.label.clone())
.unwrap_or_else(|| "anónimo".into());
info!(%id, %label, ?capabilities, peer_pid = peer.pid, "Announce autenticado");
self.bus_connections.insert(id, outbound);
let _ = reply.send(BusResponse::Ok);
}
BusRequest::ListEntes => {
let entes = self.incarnated.values()
.map(|i| EnteInfo {
id: i.card.id,
label: i.card.label.clone(),
provides: i.card.provides.iter().cloned().collect(),
pid: i.pid.map(|p| p.as_raw()),
})
.collect();
let _ = reply.send(BusResponse::Entes(entes));
}
BusRequest::PowerOff { interactive } => {
info!(?from_authenticated, interactive, peer_pid = peer.pid, "PowerOff via bus");
let _ = reply.send(BusResponse::Ok);
}
BusRequest::Reboot { interactive } => {
info!(?from_authenticated, interactive, "Reboot via bus");
let _ = reply.send(BusResponse::Ok);
}
BusRequest::Suspend { interactive } => {
info!(?from_authenticated, interactive, "Suspend via bus");
let _ = reply.send(BusResponse::Ok);
}
BusRequest::Hibernate { interactive } => {
info!(?from_authenticated, interactive, "Hibernate via bus");
let _ = reply.send(BusResponse::Ok);
}
BusRequest::Invoke { cap, blob } => {
self.forward_invoke(from_authenticated, cap, blob, reply).await;
}
BusRequest::UpdateCapabilities { adds, removes } => {
let id = from_authenticated.expect("auth-required guarantees Some");
self.apply_capability_update(id, adds, removes);
let _ = reply.send(BusResponse::Ok);
}
}
}
/// Muta `dynamic_provides` del Ente y actualiza el índice global de
/// providers. La Card original (immutable) no se toca.
fn apply_capability_update(
&mut self,
ente_id: Ulid,
adds: Vec<Capability>,
removes: Vec<Capability>,
) {
// Adiciones: dedupe contra Card.provides + dynamic_provides existentes.
let mut added = Vec::new();
let mut removed = Vec::new();
if let Some(inc) = self.incarnated.get_mut(&ente_id) {
for cap in adds {
if inc.card.provides.contains(&cap) || inc.dynamic_provides.contains(&cap) {
continue; // ya provista, no-op
}
inc.dynamic_provides.insert(cap.clone());
added.push(cap);
}
for cap in removes {
if inc.dynamic_provides.remove(&cap) {
removed.push(cap);
}
// Caps de la Card original no se pueden quitar — silenciosamente
// ignoradas. Una Card es contrato; sólo el dynamic es mutable.
}
}
// Actualizar índice global. Hacemos esto fuera del scope `inc` para
// evitar el doble-borrow de self.
for cap in &added {
self.register_dynamic_cap(ente_id, cap.clone());
}
for cap in &removed {
self.unregister_dynamic_cap(ente_id, cap);
// Revocar grants emitidos contra esta cap por este Ente.
let revoked: Vec<u64> = self.grants.iter()
.filter(|(_, g)| g.provider == ente_id && &g.cap == cap)
.map(|(t, _)| *t)
.collect();
for t in revoked {
self.grants.remove(&t);
}
}
info!(
%ente_id,
added_count = added.len(),
removed_count = removed.len(),
"capabilities actualizadas en runtime"
);
}
/// Enruta un Invoke al proveedor real de la capacidad. Aloca un seq
/// server-side, registra el reply oneshot en `pending_invokes`, y empuja
/// el request por la conexión del proveedor.
async fn forward_invoke(
&mut self,
from: Option<Ulid>,
cap: Capability,
blob: Vec<u8>,
reply: oneshot::Sender<BusResponse>,
) {
let provider_id = match self.pick_invokable_provider(&cap) {
Some(id) => id,
None => {
let _ = reply.send(BusResponse::Error(format!("sin proveedor invokable para {cap:?}")));
return;
}
};
let outbound = match self.bus_connections.get(&provider_id) {
Some(o) => o.clone(),
None => {
let _ = reply.send(BusResponse::Error("proveedor no conectado al bus".into()));
return;
}
};
let seq = self.alloc_invoke_seq();
self.pending_invokes.insert(seq, reply);
debug!(?from, ?cap, ?provider_id, seq, blob_len = blob.len(), "forwardeando Invoke");
let msg = BusMessage {
from: None,
seq,
payload: BusPayload::Request(BusRequest::Invoke { cap, blob }),
};
if outbound.send(msg).await.is_err() {
if let Some(orig) = self.pending_invokes.remove(&seq) {
let _ = orig.send(BusResponse::Error("conn del proveedor cerrada".into()));
}
}
}
fn pick_invokable_provider(&self, cap: &Capability) -> Option<Ulid> {
// Sólo proveedores con conexión al bus pueden recibir forwards.
// El propio Ente #0 está en `providers` para varias caps pero no
// debe recibir forwards — se filtra implícitamente porque la Semilla
// no tiene conexión al bus.
self.providers.get(cap)?
.iter()
.find(|id| self.bus_connections.contains_key(id))
.copied()
}
pub(in crate::graph) fn alloc_invoke_seq(&mut self) -> u64 {
self.next_invoke_seq = self.next_invoke_seq.wrapping_add(1);
SERVER_SEQ_FLAG | self.next_invoke_seq
}
pub async fn on_bus_response(&mut self, seq: u64, response: BusResponse) {
if let Some(orig) = self.pending_invokes.remove(&seq) {
let _ = orig.send(response);
} else {
warn!(seq, "Response sin pending invoke");
}
}
pub async fn on_bus_conn_closed(&mut self, ente_id: Option<Ulid>) {
if let Some(id) = ente_id {
self.bus_connections.remove(&id);
// No revocamos providers — la capacidad sigue declarada en su
// Card. Sólo perdimos el canal de invocación.
debug!(%id, "bus connection cerrada");
}
}
}
@@ -0,0 +1,98 @@
//! Mediación de capabilities: emisión, renovación, revocación de tokens.
//!
//! Los grants tienen TTL (`DEFAULT_GRANT_TTL`). El cliente debe renovarlos
//! periódicamente con `renew_grant(token)`; en caso contrario, el background
//! task `purge_expired_grants` los revoca al vencimiento.
use super::{quota_for_capability, ttl_for_capability, EnteGraph, GrantedCapability};
use crate::events::CapabilityGrant;
use ente_card::Capability;
use std::time::Instant;
use tokio::sync::oneshot;
use tracing::debug;
use ulid::Ulid;
impl EnteGraph {
pub async fn mediate_capability(
&mut self,
from: Ulid,
cap: Capability,
reply: oneshot::Sender<CapabilityGrant>,
) {
let grant = match self.providers.get(&cap).and_then(|s| s.iter().next().copied()) {
None => CapabilityGrant::NoProvider,
Some(provider) => {
// Quota: contar tokens vivos para (from, cap). Si excede,
// rechazar antes de emitir uno nuevo.
let limit = quota_for_capability(&cap);
let active = self.active_tokens_for(from, &cap);
if active >= limit {
CapabilityGrant::QuotaExceeded { active, limit }
} else {
let token = self.next_token;
self.next_token += 1;
let ttl = ttl_for_capability(&cap);
let expires_at = Instant::now() + ttl;
self.grants.insert(token, GrantedCapability {
cap: cap.clone(),
provider,
holder: from,
expires_at,
});
CapabilityGrant::Granted { token }
}
}
};
let _ = reply.send(grant);
}
/// Cuenta tokens vivos (no expirados) emitidos a un holder para una cap.
pub fn active_tokens_for(&self, holder: Ulid, cap: &Capability) -> u32 {
let now = Instant::now();
self.grants.values()
.filter(|g| g.holder == holder && &g.cap == cap && g.expires_at > now)
.count() as u32
}
/// Extiende un grant existente. Devuelve `true` si renovó. Si el token
/// no existe o ya expiró, `false` (el cliente debe re-acquire).
/// Usa el TTL específico de la cap del grant.
///
/// Reservado para el flujo de capability renewal (no cableado todavía).
#[allow(dead_code)]
pub fn renew_grant(&mut self, token: u64) -> bool {
let now = Instant::now();
if let Some(g) = self.grants.get_mut(&token) {
if g.expires_at > now {
g.expires_at = now + ttl_for_capability(&g.cap);
return true;
}
// Expired — purgamos aquí mismo.
self.grants.remove(&token);
}
false
}
/// GC: elimina grants vencidos. Devuelve cuántos fueron purgados.
pub fn purge_expired_grants(&mut self) -> usize {
let now = Instant::now();
let expired: Vec<u64> = self.grants.iter()
.filter(|(_, g)| g.expires_at <= now)
.map(|(t, _)| *t)
.collect();
for t in &expired {
self.grants.remove(t);
}
if !expired.is_empty() {
debug!(count = expired.len(), "grants expirados purgados");
}
expired.len()
}
/// Cuenta de grants vivos (no expirados). Usado por métricas.
pub fn active_grants_count(&self) -> usize {
let now = Instant::now();
self.grants.values().filter(|g| g.expires_at > now).count()
}
}
@@ -0,0 +1,60 @@
//! Device registry: mantiene el índice de dispositivos del kernel presentes,
//! traduce uevents en cambios de `Capability::Device { class }`.
use super::EnteGraph;
use crate::events::GraphEvent;
use ente_card::{Capability, DeviceClass};
use ente_kernel::{UAction, UEvent};
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
impl EnteGraph {
pub async fn on_uevent(&mut self, evt: UEvent, _tx: &mpsc::Sender<GraphEvent>) {
let class = match &evt.device_class {
Some(c) => c.clone(),
None => return, // subsystems sin DeviceClass mapeada — ignoramos.
};
match evt.action {
UAction::Add | UAction::Bind | UAction::Online => {
let was_first = self.devices_of_class(&class) == 0;
self.devices.insert(evt.devpath.clone(), evt.clone());
if was_first {
// Primera instancia de la clase → la registramos como
// capacidad disponible. El "proveedor" virtual es el
// Ente #0 (kernel surface).
let cap = Capability::Device { class: class.clone() };
self.providers.entry(cap).or_default().insert(self.seed.id);
info!(?class, devpath = %evt.devpath, "device capability disponible");
}
}
UAction::Remove | UAction::Unbind | UAction::Offline => {
self.devices.remove(&evt.devpath);
if self.devices_of_class(&class) == 0 {
let cap = Capability::Device { class: class.clone() };
if let Some(set) = self.providers.get_mut(&cap) {
set.remove(&self.seed.id);
}
let revoked: Vec<u64> = self.grants.iter()
.filter(|(_, g)| g.cap == cap)
.map(|(t, _)| *t)
.collect();
for t in revoked {
self.grants.remove(&t);
}
warn!(?class, "última instancia removida — capacidad revocada");
}
}
UAction::Change | UAction::Move => {
self.devices.insert(evt.devpath.clone(), evt);
debug!(?class, "device modified");
}
UAction::Unknown => {}
}
}
fn devices_of_class(&self, class: &DeviceClass) -> usize {
self.devices.values()
.filter(|e| e.device_class.as_ref() == Some(class))
.count()
}
}
@@ -0,0 +1,154 @@
//! Encarnación, muerte y supervisión.
//!
//! Aquí vive el flujo: Card → autorizar → soma::incarnate / wasm → registro
//! en el grafo → SIGCHLD → on_death → Restart/OneShot/Delegate.
use super::{EnteGraph, Incarnated};
use crate::events::{ExitStatus, GraphEvent};
use ente_bus::{BusMessage, BusPayload, BusRequest};
use ente_card::{Capability, EntityCard, Payload, Supervision};
use tokio::sync::mpsc;
use tracing::{info, warn};
use ulid::Ulid;
impl EnteGraph {
/// Encarna las dependencias declaradas en la Semilla. Único punto donde
/// el Init "decide": después sólo reacciona.
pub async fn instantiate_seed_dependencies(
&mut self,
_tx: &mpsc::Sender<GraphEvent>,
) -> anyhow::Result<()> {
let cards = std::mem::take(&mut self.pending_genesis);
if cards.is_empty() {
info!(seed = %self.seed.label, "semilla sin genesis cards");
return Ok(());
}
info!(seed = %self.seed.label, count = cards.len(), "instanciando genesis");
let seed_id = self.seed.id;
for card in cards {
if let Err(e) = self.authorize_and_spawn(card, seed_id).await {
warn!(?e, "genesis card falló");
}
}
Ok(())
}
/// Spawn solicitado por un Ente con `Capability::Spawn`. Verifica auth,
/// requires del grafo, y delega la encarnación al backend correspondiente
/// (`ente_soma` para procesos, `ente_wasm` para Wasm).
pub async fn authorize_and_spawn(
&mut self,
mut card: EntityCard,
requester: Ulid,
) -> anyhow::Result<()> {
if !self.holder_has(requester, &Capability::Spawn) {
warn!(?requester, "spawn denied: lacks Capability::Spawn");
return Ok(());
}
if let Err(e) = card.validate() {
warn!(?e, label = %card.label, "card inválida, spawn rechazado");
return Ok(());
}
// Falla rápida sobre `requires` — mejor que daemons en bucle.
for req in &card.requires {
if !self.providers.contains_key(req) {
warn!(?req, label = %card.label, "requires no satisfecho");
return Ok(());
}
}
// Lineage por defecto = quien pidió el spawn.
if card.lineage.is_none() {
card.lineage = Some(requester);
}
let pid = match &card.payload {
Payload::Virtual => None,
Payload::Native { .. } | Payload::Legacy { .. } => {
Some(ente_soma::incarnate(&card)?)
}
Payload::Wasm { module_sha256, entry } => {
// Wasm: hilo dedicado, sin PID. Su muerte se observa por
// estado del runtime, no por SIGCHLD.
let bytes = ente_cas::resolve(module_sha256)
.map_err(|e| anyhow::anyhow!("CAS resolve para {}: {e}", card.label))?;
ente_wasm::incarnate_wasm(&card, bytes, entry.clone())?;
None
}
};
if let Some(p) = pid {
self.by_pid.insert(p.as_raw(), card.id);
}
self.register_provider(&card);
if let Some(parent) = card.lineage {
self.children.entry(parent).or_default().push(card.id);
}
info!(label = %card.label, ?pid, lineage = ?card.lineage, "Ente encarnado");
self.incarnated.insert(card.id, Incarnated {
card, pid,
dynamic_provides: std::collections::BTreeSet::new(),
});
Ok(())
}
pub async fn on_death(
&mut self,
id: Ulid,
status: ExitStatus,
_tx: &mpsc::Sender<GraphEvent>,
) {
let Some(inc) = self.incarnated.remove(&id) else { return };
if let Some(p) = inc.pid {
self.by_pid.remove(&p.as_raw());
}
self.unregister_provider(&inc.card);
if let Some(parent) = inc.card.lineage {
if let Some(siblings) = self.children.get_mut(&parent) {
siblings.retain(|c| c != &id);
}
}
info!(label = %inc.card.label, ?status, "Ente disuelto");
match inc.card.supervision.clone() {
Supervision::Restart { initial, max: _ } => {
// Backoff exponencial: TODO real con timer del runtime.
tokio::time::sleep(initial).await;
let new_card = EntityCard { id: Ulid::new(), ..inc.card };
if let Err(e) = self.authorize_and_spawn(new_card, self.seed.id).await {
warn!(?e, "restart falló");
}
}
Supervision::OneShot => {}
Supervision::Delegate => {
self.notify_lineage_of_death(&inc, &status);
}
}
}
/// Fire-and-forget: si el parent tiene conexión al bus, le forwardeamos
/// un Invoke con la muerte del hijo. Sin retry, sin backpressure.
fn notify_lineage_of_death(&mut self, inc: &Incarnated, status: &ExitStatus) {
let Some(parent) = inc.card.lineage else { return };
info!(
child = %inc.card.id, parent = %parent, label = %inc.card.label,
?status,
"Supervision::Delegate — muerte notificada al lineage"
);
if let Some(out) = self.bus_connections.get(&parent).cloned() {
let blob = format!("{}:{:?}", inc.card.id, status);
let seq = self.alloc_invoke_seq();
let msg = BusMessage {
from: None,
seq,
payload: BusPayload::Request(BusRequest::Invoke {
cap: Capability::Endpoint {
interface: ente_card::InterfaceId([0xde; 16]),
version: 1,
},
blob: blob.into_bytes(),
}),
};
let _ = out.try_send(msg);
}
}
}
+229
View File
@@ -0,0 +1,229 @@
//! `EnteGraph`: estado del fractal vivo en PID 1.
//!
//! Diseño:
//! - Submódulos por concern: lifecycle, topology, shutdown, bus_mediator,
//! devices, capabilities. Cada uno extiende `impl EnteGraph` con métodos
//! relacionados.
//! - Estado plano (no substructs todavía) — la separación es por
//! comportamiento, no por compartimentación de datos.
//! - Toda mutación pasa por el bucle primordial vía `GraphEvent`. Los
//! submódulos se llaman desde `main.rs::primordial_loop`.
mod bus_mediator;
mod capabilities;
mod devices;
mod lifecycle;
mod shutdown;
mod topology;
use ente_bus::{BusMessage, BusResponse};
use ente_card::{Capability, EntityCard};
use nix::unistd::Pid;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use tokio::sync::{mpsc, oneshot};
use ulid::Ulid;
// `SHUTDOWN_GRACE` está re-exportado bajo `crate::graph::shutdown::SHUTDOWN_GRACE`
// directo; la re-export adicional aquí no se usa todavía.
/// Bit alto encendido en `seq` para invokes server-iniciados — evita choque
/// con secuencias allocadas por clientes.
pub(in crate::graph) const SERVER_SEQ_FLAG: u64 = 1u64 << 63;
pub struct EnteGraph {
pub(in crate::graph) seed: EntityCard,
/// Entes encarnados como proceso o nodo virtual. id↔pid bidireccional.
pub(in crate::graph) incarnated: HashMap<Ulid, Incarnated>,
pub(in crate::graph) by_pid: HashMap<i32, Ulid>,
/// Quién provee qué capacidad. Resuelve `requires` y `pick_invokable`.
pub(in crate::graph) providers: BTreeMap<Capability, BTreeSet<Ulid>>,
/// Tokens de capability emitidos. Revocables al morir el proveedor.
pub(in crate::graph) next_token: u64,
pub(in crate::graph) grants: HashMap<u64, GrantedCapability>,
/// Dispositivos del kernel presentes (devpath → última UEvent).
pub(in crate::graph) devices: HashMap<String, ente_kernel::UEvent>,
/// Cards genesis pendientes de instanciar (extraídas de la Semilla).
pub(in crate::graph) pending_genesis: Vec<EntityCard>,
/// Hijos directos por lineage. parent → [child, ...].
pub(in crate::graph) children: HashMap<Ulid, Vec<Ulid>>,
/// Conexiones del bus indexadas por la identidad anunciada y verificada
/// con SO_PEERCRED. El value es el extremo de escritura del writer task.
pub(in crate::graph) bus_connections: HashMap<Ulid, mpsc::Sender<BusMessage>>,
/// Invokes forwardeados pendientes de respuesta del proveedor.
pub(in crate::graph) pending_invokes: HashMap<u64, oneshot::Sender<BusResponse>>,
pub(in crate::graph) next_invoke_seq: u64,
}
pub(in crate::graph) struct Incarnated {
pub card: EntityCard,
pub pid: Option<Pid>,
/// Capacidades añadidas en runtime vía BusRequest::UpdateCapabilities.
/// La Card original es immutable; la "vista efectiva" del Ente es
/// `card.provides dynamic_provides`.
pub dynamic_provides: BTreeSet<Capability>,
}
pub(in crate::graph) struct GrantedCapability {
pub cap: Capability,
pub provider: Ulid,
pub holder: Ulid,
/// Instante en el que el grant deja de ser válido. El garbage collector
/// del cerebro purga grants con `Instant::now() > expires_at`.
pub expires_at: std::time::Instant,
}
/// TTL default para grants cuando la cap no tiene override. 60s es un
/// compromiso: largo enough para evitar churn en patrones interactivos,
/// corto enough para que credenciales filtradas expiren rápidamente.
///
/// Reservado para el flujo de capability granting (no cableado todavía).
#[allow(dead_code)]
pub const DEFAULT_GRANT_TTL: std::time::Duration = std::time::Duration::from_secs(60);
/// Quota máxima de tokens activos por (holder, cap). Caps escaladas tienen
/// quota baja para limitar fugas de credenciales; caps de uso frecuente
/// (Endpoint, Journal) son más laxas.
pub fn quota_for_capability(cap: &Capability) -> u32 {
match cap {
// Caps escaladas: pocos tokens, fuerza patrón request-act-release.
Capability::Spawn => 2,
Capability::FilesystemRoot => 2,
Capability::Device { .. } => 4,
// Caps de propósito general.
Capability::Endpoint { .. } => 16,
Capability::KernelNetlink(_) => 4,
Capability::LegacyLogind => 8,
// Logging: hasta 32 streams.
Capability::Journal => 32,
}
}
/// TTL específico por variante de Capability. Caps de mayor riesgo / costo
/// (Spawn, FilesystemRoot) tienen TTL más corto; caps "logging" como
/// Journal pueden vivir más.
///
/// Cualquier cap no listada cae al `DEFAULT_GRANT_TTL`.
pub fn ttl_for_capability(cap: &Capability) -> std::time::Duration {
use std::time::Duration;
match cap {
// Caps escaladas: TTL corto para forzar renovación frecuente.
Capability::Spawn => Duration::from_secs(30),
Capability::FilesystemRoot => Duration::from_secs(30),
Capability::Device { .. } => Duration::from_secs(60),
// Caps de propósito general.
Capability::Endpoint { .. } => Duration::from_secs(300), // 5 min
Capability::KernelNetlink(_) => Duration::from_secs(300),
Capability::LegacyLogind => Duration::from_secs(300),
// Logging puede vivir mucho.
Capability::Journal => Duration::from_secs(3600), // 1h
}
}
impl EnteGraph {
pub fn new(mut seed: EntityCard) -> Self {
// Extraemos genesis antes de almacenar la Semilla — evita duplicación
// en `incarnated[seed.id]`.
let pending_genesis = std::mem::take(&mut seed.genesis);
let mut g = Self {
seed: seed.clone(),
incarnated: HashMap::new(),
by_pid: HashMap::new(),
providers: BTreeMap::new(),
next_token: 1,
grants: HashMap::new(),
devices: HashMap::new(),
pending_genesis,
children: HashMap::new(),
bus_connections: HashMap::new(),
pending_invokes: HashMap::new(),
next_invoke_seq: 0,
};
// El Ente #0 se inscribe a sí mismo como proveedor de las capacidades
// que su Card declara — sólo así los hijos pueden requerirlas.
g.register_provider(&seed);
g.incarnated.insert(seed.id, Incarnated {
card: seed, pid: None,
dynamic_provides: BTreeSet::new(),
});
g
}
pub fn lookup_pid(&self, pid: Pid) -> Option<Ulid> {
self.by_pid.get(&pid.as_raw()).copied()
}
/// Acceso read-only a la Card de un Ente vivo. Usado por el cerebro
/// para hidratar `SubjectInfo` sin clonar todo el mapa.
pub fn peek_card(&self, id: &Ulid) -> Option<&EntityCard> {
self.incarnated.get(id).map(|i| &i.card)
}
/// Identidad de la Semilla. Usado como `requester` para spawns generados
/// por reglas auto-cristalizadas (única identidad con Capability::Spawn).
pub fn seed_id(&self) -> Ulid {
self.seed.id
}
/// Captura el estado live como snapshot serializable. Excluye la Semilla
/// (será re-sintetizada al restore con su seed_id preservado).
pub fn snapshot(&self) -> ente_snapshot::FractalSnapshot {
let entes: Vec<EntityCard> = self.incarnated.iter()
.filter(|(id, _)| **id != self.seed.id)
.map(|(_, inc)| inc.card.clone())
.collect();
ente_snapshot::FractalSnapshot {
version: ente_snapshot::SNAPSHOT_VERSION,
timestamp_ms: ente_snapshot::now_ms(),
seed_id: self.seed.id,
seed_label: self.seed.label.clone(),
entes,
}
}
pub(in crate::graph) fn register_provider(&mut self, card: &EntityCard) {
for cap in &card.provides {
self.providers.entry(cap.clone()).or_default().insert(card.id);
}
}
pub(in crate::graph) fn unregister_provider(&mut self, card: &EntityCard) {
for cap in &card.provides {
if let Some(set) = self.providers.get_mut(cap) {
set.remove(&card.id);
}
}
// Revocar grants emitidos por el Ente fallecido.
let revoked: Vec<u64> = self.grants.iter()
.filter(|(_, g)| g.provider == card.id)
.map(|(t, _)| *t)
.collect();
for t in revoked {
self.grants.remove(&t);
}
}
/// Quita una capacidad dinámica del índice de providers para un Ente
/// específico. Usado al recibir UpdateCapabilities con `removes`.
pub(in crate::graph) fn unregister_dynamic_cap(&mut self, ente_id: Ulid, cap: &Capability) {
if let Some(set) = self.providers.get_mut(cap) {
set.remove(&ente_id);
}
}
/// Inserta una capacidad dinámica al índice de providers para un Ente.
pub(in crate::graph) fn register_dynamic_cap(&mut self, ente_id: Ulid, cap: Capability) {
self.providers.entry(cap).or_default().insert(ente_id);
}
/// El Ente #0 (semilla) tiene todas sus capacidades declaradas. Otros
/// las tienen si su Card las declara o si poseen un grant vivo.
pub(in crate::graph) fn holder_has(&self, holder: Ulid, cap: &Capability) -> bool {
if holder == self.seed.id {
return self.seed.provides.contains(cap);
}
if let Some(inc) = self.incarnated.get(&holder) {
if inc.card.provides.contains(cap) { return true; }
}
self.grants.values().any(|g| g.holder == holder && &g.cap == cap)
}
}
+100
View File
@@ -0,0 +1,100 @@
//! Cascade shutdown: SIGTERM en orden topológico (hojas primero), grace
//! period, SIGKILL para stragglers, reap final.
use super::EnteGraph;
use nix::errno::Errno;
use nix::sys::signal::{kill, Signal};
use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
use nix::unistd::Pid;
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
/// Tiempo que damos a los Entes tras SIGTERM antes de escalar a SIGKILL.
pub const SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
impl EnteGraph {
pub async fn cascade_shutdown(&mut self) {
let order = self.topo_order();
let pids: Vec<Pid> = order.iter()
.filter_map(|id| self.incarnated.get(id).and_then(|i| i.pid))
.collect();
if pids.is_empty() {
info!("cascade shutdown: ningún Ente encarnado, salida limpia");
return;
}
info!(
count = pids.len(), grace_ms = SHUTDOWN_GRACE.as_millis() as u64,
"SIGTERM cascade (topológico, hojas primero)"
);
for pid in &pids {
match kill(*pid, Signal::SIGTERM) {
Ok(()) => {}
Err(Errno::ESRCH) => {} // ya muerto, lo cosecharemos abajo
Err(e) => warn!(?pid, ?e, "kill SIGTERM falló"),
}
}
let deadline = Instant::now() + SHUTDOWN_GRACE;
while Instant::now() < deadline {
if !self.incarnated.values().any(|i| i.pid.is_some()) {
break;
}
match waitpid(None, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::Exited(pid, code)) => {
self.reap_during_shutdown(pid);
debug!(?pid, code, "reaped (exited)");
}
Ok(WaitStatus::Signaled(pid, sig, _)) => {
self.reap_during_shutdown(pid);
debug!(?pid, ?sig, "reaped (signaled)");
}
Ok(WaitStatus::StillAlive) | Err(Errno::EINTR) => {
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(Errno::ECHILD) => return,
Ok(_) => {}
Err(e) => {
warn!(?e, "waitpid fallo en shutdown grace");
break;
}
}
}
let stragglers: Vec<Pid> = self.incarnated.values()
.filter_map(|i| i.pid)
.collect();
if stragglers.is_empty() {
info!("cascade shutdown completo (todos los Entes terminaron en gracia)");
return;
}
warn!(count = stragglers.len(), "stragglers post-SIGTERM, escalando a SIGKILL");
for pid in &stragglers {
let _ = kill(*pid, Signal::SIGKILL);
}
loop {
match waitpid(None, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::Exited(pid, _)) | Ok(WaitStatus::Signaled(pid, _, _)) => {
self.reap_during_shutdown(pid);
}
Ok(WaitStatus::StillAlive) => {
std::thread::sleep(Duration::from_millis(20));
}
Err(Errno::ECHILD) => break,
_ => break,
}
if !self.incarnated.values().any(|i| i.pid.is_some()) { break; }
}
info!("cascade shutdown completo (con SIGKILL)");
}
fn reap_during_shutdown(&mut self, pid: Pid) {
let Some(id) = self.by_pid.remove(&pid.as_raw()) else { return };
if let Some(inc) = self.incarnated.remove(&id) {
self.unregister_provider(&inc.card);
}
}
}
@@ -0,0 +1,35 @@
//! Topología del fractal: índice de hijos por lineage y orden topológico
//! para shutdown.
use super::EnteGraph;
use std::collections::BTreeSet;
use ulid::Ulid;
impl EnteGraph {
/// DFS post-order desde la Semilla. Hojas primero, raíz al final.
/// Garantiza que SIGTERM va a un padre sólo cuando sus hijos ya recibieron
/// la señal (evita orfandad transitoria que confunda Restart supervisors).
pub(in crate::graph) fn topo_order(&self) -> Vec<Ulid> {
let mut visited = BTreeSet::new();
let mut order = Vec::new();
self.dfs_post(self.seed.id, &mut visited, &mut order);
// Entes encarnados sin lineage hacia el seed (no debería pasar pero
// protege contra grafos huérfanos): añadirlos al final.
for id in self.incarnated.keys() {
if !visited.contains(id) {
self.dfs_post(*id, &mut visited, &mut order);
}
}
order
}
fn dfs_post(&self, node: Ulid, visited: &mut BTreeSet<Ulid>, order: &mut Vec<Ulid>) {
if !visited.insert(node) { return; }
if let Some(children) = self.children.get(&node) {
for c in children.clone() {
self.dfs_post(c, visited, order);
}
}
order.push(node);
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Persistencia de la keypair Ed25519 de identidad libp2p de Arje.
//!
//! El `peer_id` que Arje presenta en la malla `brahman-net` deriva de
//! esta keypair. Si se regenera en cada arranque, el peer_id cambia
//! y los nodos remotos pierden la referencia. Persistir el secret a
//! disco (32 bytes raw, permisos 0o600) garantiza identidad estable.
//!
//! ## Path
//!
//! Por orden de precedencia:
//! 1. `BRAHMAN_KEYPAIR_PATH` env var (override explícito).
//! 2. Si PID 1 / root: `/var/lib/brahman/init-keypair.bin`.
//! 3. Si dev mode: `$XDG_DATA_HOME/brahman/init-keypair.bin`, fallback
//! a `$HOME/.local/share/brahman/init-keypair.bin`, último recurso
//! `/tmp/brahman-init-keypair.bin` (sin persistencia útil pero al
//! menos no rompe en CI minimalista).
//!
//! ## Formato
//!
//! 32 bytes raw del secret Ed25519. Sin header, sin metadata. La
//! public key se deriva determinísticamente al cargar. Esto evita
//! depender de un schema de serialización (postcard, json) que
//! pudiera bumpear y romper compat de identidad.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use brahman_net::Keypair;
/// Tamaño exacto del secret Ed25519.
const SECRET_LEN: usize = 32;
/// Carga la keypair desde `path` si existe, o genera una nueva,
/// la persiste y la devuelve. Devuelve también si fue cargada (true)
/// o generada (false), para logging.
pub fn load_or_generate(path: &Path) -> Result<(Keypair, bool)> {
if path.exists() {
let bytes = std::fs::read(path)
.with_context(|| format!("leer keypair de {}", path.display()))?;
if bytes.len() != SECRET_LEN {
bail!(
"keypair en {} tiene {} bytes, esperaba {}",
path.display(),
bytes.len(),
SECRET_LEN
);
}
let mut secret = [0u8; SECRET_LEN];
secret.copy_from_slice(&bytes);
let kp = Keypair::ed25519_from_bytes(secret)
.with_context(|| format!("decodificar keypair en {}", path.display()))?;
Ok((kp, true))
} else {
let kp = Keypair::generate_ed25519();
save(path, &kp).context("persistir keypair recién generada")?;
Ok((kp, false))
}
}
/// Persiste el secret de `keypair` a `path`. Crea directorios padres,
/// escribe atómico (vía rename), y aplica permisos 0o600 (sólo dueño).
fn save(path: &Path, keypair: &Keypair) -> Result<()> {
let secret = extract_secret_bytes(keypair)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("crear dir {}", parent.display()))?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, secret).with_context(|| format!("write tmp {}", tmp.display()))?;
apply_owner_only_perms(&tmp).context("permisos 0o600 en tmp")?;
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {}{}", tmp.display(), path.display()))?;
Ok(())
}
fn extract_secret_bytes(keypair: &Keypair) -> Result<[u8; SECRET_LEN]> {
// libp2p::Keypair no expone secret() directo; pasamos
// por la variante ed25519. Solo Ed25519 soportado en brahman-net,
// así que el unwrap es seguro tras with_keypair.
let ed = keypair
.clone()
.try_into_ed25519()
.map_err(|_| anyhow::anyhow!("la keypair no es Ed25519 (no debería pasar)"))?;
let bytes = ed.secret();
let raw: &[u8] = bytes.as_ref();
if raw.len() != SECRET_LEN {
bail!("ed25519 secret no es {} bytes", SECRET_LEN);
}
let mut out = [0u8; SECRET_LEN];
out.copy_from_slice(raw);
Ok(out)
}
#[cfg(unix)]
fn apply_owner_only_perms(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
fn apply_owner_only_perms(_path: &Path) -> std::io::Result<()> {
Ok(())
}
/// Resuelve el path del keystore según convención (env > root path >
/// XDG > HOME > tmp).
pub fn default_path(dev_mode: bool) -> PathBuf {
if let Ok(p) = std::env::var("BRAHMAN_KEYPAIR_PATH") {
return PathBuf::from(p);
}
if !dev_mode {
// PID 1: paths del sistema. /var/lib es el lugar canónico
// para state persistente de servicios root.
return PathBuf::from("/var/lib/brahman/init-keypair.bin");
}
// Dev mode: paths de usuario.
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
return PathBuf::from(xdg).join("brahman").join("init-keypair.bin");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home)
.join(".local")
.join("share")
.join("brahman")
.join("init-keypair.bin");
}
PathBuf::from("/tmp/brahman-init-keypair.bin")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn generate_persist_and_reload_yields_same_peer_id() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("identity.bin");
let (kp1, loaded) = load_or_generate(&path).unwrap();
assert!(!loaded, "primera vez debe generar");
let peer1 = kp1.public().to_peer_id();
let (kp2, loaded) = load_or_generate(&path).unwrap();
assert!(loaded, "segunda vez debe cargar");
let peer2 = kp2.public().to_peer_id();
assert_eq!(peer1, peer2, "peer_id estable across reloads");
}
#[test]
fn rejects_corrupted_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("bad.bin");
std::fs::write(&path, b"too short").unwrap();
assert!(load_or_generate(&path).is_err());
}
#[test]
#[cfg(unix)]
fn persisted_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("perm.bin");
let _ = load_or_generate(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"permisos del keypair file deben ser 0o600 (solo dueño), got {:o}",
mode & 0o777
);
}
#[test]
fn default_path_honors_env() {
std::env::set_var("BRAHMAN_KEYPAIR_PATH", "/custom/path.bin");
assert_eq!(default_path(false), PathBuf::from("/custom/path.bin"));
assert_eq!(default_path(true), PathBuf::from("/custom/path.bin"));
std::env::remove_var("BRAHMAN_KEYPAIR_PATH");
}
}
+791
View File
@@ -0,0 +1,791 @@
//! Ente #0 — el primer Ente. PID 1 del fractal.
//!
//! Reglas no negociables:
//! 1. NUNCA lógica de servicio aquí. Sólo: leer Semilla, cosechar zombis,
//! mediar capacidades, propagar eventos.
//! 2. Single-threaded. Cualquier paralelismo se delega a Entes worker.
//! Un panic en un thread de PID 1 = kernel panic.
//! 3. Errores de hijos son *eventos* en `graph_tx`, no `Result` propagado.
//!
//! Este archivo es sólo wireup. La lógica vive en:
//! - `seed` : construcción/restauración de la Tarjeta Semilla
//! - `bus` : listener Unix + auth via SO_PEERCRED
//! - `graph::*` : estado del fractal (lifecycle, topology, shutdown,
//! bus_mediator, devices, capabilities)
//! - `events` : tipos de eventos del bucle primordial
//! - crates externos del workspace para CAS, soma, wasm, snapshot, kernel.
mod brain_glue;
mod bus;
mod events;
mod graph;
mod keypair_store;
mod seed;
use anyhow::Context;
use ente_brain::{BrainState, IntrospectServer};
use ente_kernel::{become_child_subreaper, bootstrap_kernel_surface, spawn_sigchld_stream, spawn_uevent_stream};
use events::{ExitStatus, GraphEvent, ShutdownReason};
use graph::EnteGraph;
use nix::errno::Errno;
use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
use nix::unistd::{getpid, Pid};
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{error, info, warn};
struct CliArgs {
checkpoint: Option<PathBuf>,
restore: Option<PathBuf>,
rules: Option<PathBuf>,
rules_out: Option<PathBuf>,
audit_head: Option<PathBuf>,
metrics_addr: Option<String>,
brain_half_life: Option<f64>,
autopromote_secs: Option<u64>,
}
fn parse_args() -> CliArgs {
let mut args = std::env::args().skip(1);
let mut checkpoint = None;
let mut restore = None;
let mut rules = None;
let mut rules_out = None;
let mut audit_head = None;
let mut metrics_addr = None;
let mut brain_half_life = None;
let mut autopromote_secs = None;
while let Some(a) = args.next() {
match a.as_str() {
"--checkpoint" => checkpoint = args.next().map(PathBuf::from),
"--restore" => restore = args.next().map(PathBuf::from),
"--rules" => rules = args.next().map(PathBuf::from),
"--rules-out" => rules_out = args.next().map(PathBuf::from),
"--audit-head" => audit_head = args.next().map(PathBuf::from),
"--metrics-addr" => metrics_addr = args.next(),
"--brain-half-life" => brain_half_life = args.next().and_then(|s| s.parse().ok()),
"--autopromote-secs" => autopromote_secs = args.next().and_then(|s| s.parse().ok()),
other => warn!(arg = %other, "argumento desconocido, ignorado"),
}
}
CliArgs {
checkpoint, restore, rules, rules_out, audit_head,
metrics_addr, brain_half_life, autopromote_secs,
}
}
fn main() -> anyhow::Result<()> {
init_tracing();
let cli = parse_args();
let pid = getpid();
let dev_mode = pid != Pid::from_raw(1);
if dev_mode {
warn!(?pid, "ente-zero corriendo en DEV MODE (no PID 1) — kernel surface no se monta");
} else {
info!("ente-zero despierta como PID 1");
bootstrap_kernel_surface().context("bootstrap kernel surface")?;
become_child_subreaper().context("PR_SET_CHILD_SUBREAPER")?;
}
let card = seed::load(dev_mode, cli.restore.as_deref())?;
// current_thread runtime: ver doctrina al inicio del módulo.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()?;
rt.block_on(primordial_loop(
card, dev_mode,
cli.checkpoint, cli.restore, cli.rules, cli.rules_out,
cli.audit_head, cli.metrics_addr, cli.brain_half_life,
cli.autopromote_secs,
))
}
async fn primordial_loop(
seed_card: ente_card::EntityCard,
dev_mode: bool,
checkpoint_path: Option<PathBuf>,
restore_path: Option<PathBuf>,
rules_path: Option<PathBuf>,
rules_out: Option<PathBuf>,
audit_head: Option<PathBuf>,
metrics_addr: Option<String>,
brain_half_life: Option<f64>,
autopromote_secs: Option<u64>,
) -> anyhow::Result<()> {
info!(seed_id = %seed_card.id, label = %seed_card.label, "Ente #0 entra al bucle primordial");
let (graph_tx, mut graph_rx) = mpsc::channel::<GraphEvent>(64);
let mut sigchld = spawn_sigchld_stream()?;
// Uevents puede fallar en dev (sin CAP_NET_ADMIN). Degradamos a un
// canal nunca-listo en lugar de abortar el bucle primordial.
let mut uevents = match spawn_uevent_stream() {
Ok(rx) => rx,
Err(e) => {
warn!(?e, "uevents deshabilitados (probablemente falta CAP_NET_ADMIN)");
let (_keep_tx, rx) = mpsc::channel::<ente_kernel::UEvent>(1);
std::mem::forget(_keep_tx);
rx
}
};
// Bus interno: listener antes de spawn de hijos para que su Announce
// tenga adónde llegar. Su path se inyecta en ENTE_BUS_SOCK por soma.
let bus_sock = bus::default_socket_path();
let bus_path = bus::spawn_bus(bus_sock, graph_tx.clone())?;
ente_soma::set_bus_sock(bus_path.to_string_lossy().into_owned());
// Brahman protocol: handshake socket + broker compartido.
//
// Es un canal paralelo al ente-bus, dedicado a módulos "brahman
// conscientes" que se presentan con una Card y declaran flujos
// tipados. Si el bind falla (socket en uso, FS no escribible),
// degradamos a "modo bus-only" — la doctrina de PID 1 no rompe
// por subsistemas opcionales.
// Contexto operativo del broker: configurable por env var. Útil para
// distinguir test/prod/foreground sin recompilar. Sin la var, los
// biases per-contexto declarados en las Cards quedan inactivos.
let broker_context = std::env::var("BRAHMAN_BROKER_CONTEXT").ok();
if let Some(ctx) = &broker_context {
info!(context = %ctx, "brahman broker bajo contexto operativo");
}
let brahman_broker = std::sync::Arc::new(tokio::sync::Mutex::new(
brahman_broker::Broker::new(brahman_broker::BrokerConfig {
strategy: brahman_broker::MatchStrategy::default(),
current_context: broker_context.clone(),
}),
));
// Brahman-net opcional: si BRAHMAN_LISTEN_MULTIADDR está set,
// levantamos la malla P2P y la pasamos como ServerConfig.net (Fase
// 2 wire) para que cada Card con outputs se anuncie al DHT y
// pueda ser descubierta por nodos remotos. Identidad libp2p
// persistida en disco vía keypair_store (peer_id estable across
// reboots).
let brahman_net = setup_brahman_net(dev_mode).await;
// Política opcional de peers libp2p: allowlist + denylist + hot
// reload. Activada si BRAHMAN_PEER_ALLOWLIST o BRAHMAN_PEER_DENYLIST
// están set. Sin ninguna, modo totalmente abierto (Fase 3 sin
// restricción adicional). El watcher se queda vivo en background
// observando los archivos para hot reload.
let (brahman_policy, _policy_watcher) = setup_brahman_policy();
// Si tenemos AMBOS net y policy, attachamos: el deny de la
// policy se proyecta al block_list del swarm para rechazar
// conexiones ANTES del Noise handshake (más eficiente que
// rechazar en el handshake brahman). Cada hot-reload de la
// policy también re-sincroniza vía diff.
if let (Some(net), Some(policy)) = (&brahman_net, &brahman_policy) {
policy.attach_to_net(net.clone());
let (allow, deny) = policy.sizes();
info!(
allow = ?allow,
deny = deny,
"policy attached al swarm — denies enforcedeados a nivel libp2p"
);
}
let brahman_sock = brahman_handshake::transport::default_socket_path();
match brahman_handshake::server::Server::bind(
&brahman_sock,
brahman_handshake::server::ServerConfig {
init_attached: true,
broker: Some(brahman_broker.clone()),
net: brahman_net.clone(),
policy: brahman_policy.clone(),
},
) {
Ok(server) => {
info!(socket = %brahman_sock.display(), "brahman handshake escuchando (Unix)");
// Si hay malla P2P, además del Unix accept loop levantamos
// el accept loop libp2p sobre el mismo Server compartido.
// Las sesiones locales y remotas conviven en las mismas
// tablas (sessions, push_table, broker).
let server = std::sync::Arc::new(server);
if let Some(net) = brahman_net.clone() {
let s_libp2p = server.clone();
let n_libp2p = net.clone();
tokio::spawn(async move {
if let Err(e) = brahman_handshake::network::run_libp2p_accept_loop(
s_libp2p, n_libp2p,
)
.await
{
warn!(?e, "brahman handshake libp2p accept loop cayó");
}
});
info!(
"brahman handshake escuchando también vía libp2p (peer_id {})",
net.peer_id
);
}
// Unix accept loop: usa Arc<Server> en lugar del consume
// de run() para coexistir con el libp2p accept loop.
let s_unix = server.clone();
tokio::spawn(async move {
loop {
match s_unix.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
if let Err(e) = session.handle().await {
warn!(?e, "session Unix terminó con error");
}
});
}
Err(e) => {
warn!(?e, "brahman handshake accept_one Unix falló");
break;
}
}
}
});
}
Err(e) => {
warn!(?e, socket = %brahman_sock.display(), "brahman handshake deshabilitado");
}
}
// Brahman admin: socket separado para snapshots de estado (sesiones +
// matches del broker). Misma política de degradación grácil.
let admin_sock = brahman_admin::transport::default_socket_path();
match brahman_admin::server::AdminServer::bind(
&admin_sock,
brahman_broker.clone(),
brahman_admin::server::AdminConfig {
init_attached: true,
current_context: broker_context.clone(),
},
) {
Ok(admin) => {
info!(socket = %admin_sock.display(), "brahman admin escuchando");
tokio::spawn(async move {
if let Err(e) = admin.run().await {
warn!(?e, "brahman admin server cayó");
}
});
}
Err(e) => {
warn!(?e, socket = %admin_sock.display(), "brahman admin deshabilitado");
}
}
let mut graph = EnteGraph::new(seed_card);
graph.instantiate_seed_dependencies(&graph_tx).await?;
// Cerebro: BrainState compartido + servidor de introspección.
// Window de 1024 eventos — suficiente para correlaciones interesantes
// sin gastar memoria de PID 1. En dev bajamos el umbral de cristalización
// para que el demo (pocos eventos) produzca cristales observables.
let mut brain = if dev_mode {
// Umbrales relajados para que el demo (pocos eventos) produzca
// cristales observables. Con P(b|a) normalizada a [0,1], los
// valores típicos en muestras pequeñas son 0.2-0.5.
BrainState::with_params(1024, ente_brain::CrystallizationParams {
min_support: 2,
min_conditional_prob: 0.3,
min_pmi: 1.0,
})
} else {
BrainState::new(1024)
};
if let Some(out_path) = rules_out {
brain = brain.with_rules_out(out_path);
}
if let Some(hl) = brain_half_life {
let mut obs = brain.observer.write().await;
// Reemplazar con un observer nuevo que tenga half-life. Estado
// anterior (vacío en este punto) descartado.
*obs = ente_brain::Observer::new(1024).with_half_life(hl);
info!(hl_secs = hl, "observer con time-decay activo");
}
if let Some(secs) = autopromote_secs {
ente_brain::spawn_autopromote_loop(
brain.clone(),
ente_brain::AutopromoteParams {
interval_secs: secs,
threshold: brain.params, // mismo threshold que crystals manuales
},
);
}
// Brain restore: si hay --restore <path>, cargamos el snapshot adjunto
// <path>.brain.json. Counters preservados across reboots.
if let Some(rpath) = &restore_path {
let brain_path = rpath.with_extension("brain.json");
if brain_path.exists() {
match read_brain_snapshot(&brain_path) {
Ok(snap) => {
let total = snap.total;
let kinds = snap.marginal.len();
let restored = ente_brain::Observer::from_snapshot(snap);
*brain.observer.write().await = restored;
info!(
path = %brain_path.display(),
total, kinds,
"brain snapshot restaurado"
);
}
Err(e) => warn!(?e, path = %brain_path.display(), "brain snapshot read falló"),
}
}
}
// Si --audit-head, configuramos el head pointer y arrancamos auto-flush.
if let Some(head_path) = audit_head {
// Re-creamos el AuditLog con head pointer.
let new_audit = ente_brain::audit::AuditLog::new()
.with_head_pointer(head_path);
*brain.audit.write().await = new_audit;
spawn_audit_auto_flush(brain.clone());
}
// Carga inicial de reglas desde JSON/JSONL si --rules path proporcionado.
if let Some(path) = &rules_path {
match ente_brain::load_rules_file(path) {
Ok(rules) => {
let mut engine = brain.engine.write().await;
for r in rules {
engine.insert(r);
}
info!(count = engine.len(), path = %path.display(), "reglas cargadas");
}
Err(e) => warn!(?e, path = %path.display(), "carga de reglas falló"),
}
}
// Endpoint Prometheus opcional. En dev por defecto en 127.0.0.1:9911 si
// el flag no se pasó.
let metrics_addr = metrics_addr.or_else(|| {
if dev_mode { Some("127.0.0.1:9911".to_string()) } else { None }
});
if let Some(addr_s) = metrics_addr {
match addr_s.parse::<std::net::SocketAddr>() {
Ok(addr) => {
let s = brain.clone();
tokio::spawn(async move {
if let Err(e) = ente_brain::serve_metrics(s, addr).await {
warn!(?e, "metrics server cayó");
}
});
}
Err(e) => warn!(?e, addr = %addr_s, "metrics-addr inválido"),
}
}
spawn_brain_introspect(brain.clone());
let brain_sink = brain_glue::GraphSink {
graph_tx: graph_tx.clone(),
// Spawns auto-disparados desde reglas usan la identidad de la Semilla
// (único Ente con Capability::Spawn por construcción).
requester: graph.seed_id(),
};
// Demo automático del forwarding (sólo dev, sólo si el binario existe).
if dev_mode && std::path::Path::new("target/debug/ente-echo").exists() {
spawn_echo_smoke_test(bus_path.clone());
}
// En dev mode no tenemos hijos por defecto y el bucle se quedaría inerte.
let dev_exit = if dev_mode {
Some(tokio::time::sleep(Duration::from_secs(4)))
} else {
None
};
tokio::pin!(dev_exit);
// GC de capability grants expirados — corre cada 10 segundos.
let mut grant_purge = tokio::time::interval(Duration::from_secs(10));
grant_purge.tick().await; // descartar primer tick inmediato
loop {
tokio::select! {
biased;
Some(_) = sigchld.recv() => {
reap_until_empty(&mut graph, &graph_tx).await;
}
Some(uevt) = uevents.recv() => {
graph.on_uevent(uevt, &graph_tx).await;
}
Some(evt) = graph_rx.recv() => {
// Cerebro observa antes que el grafo mute. Snapshot del
// SubjectInfo se hace contra el estado pre-mutación.
feed_brain(&brain, &brain_sink, &graph, &evt).await;
if dispatch_graph_event(&mut graph, evt, &graph_tx, &checkpoint_path, &brain).await {
return Ok(());
}
}
_ = grant_purge.tick() => {
let n = graph.purge_expired_grants();
if n > 0 {
info!(purged = n, active = graph.active_grants_count(), "GC capability grants");
}
}
_ = async { dev_exit.as_mut().as_pin_mut().unwrap().await }, if dev_mode => {
info!("dev mode: timer expirado, cerrando bucle primordial");
let _ = graph_tx.send(GraphEvent::Shutdown {
reason: ShutdownReason::SeedRequested,
}).await;
}
}
}
}
/// Devuelve `true` si el bucle primordial debe terminar.
async fn dispatch_graph_event(
graph: &mut EnteGraph,
evt: GraphEvent,
tx: &mpsc::Sender<GraphEvent>,
checkpoint: &Option<PathBuf>,
brain: &BrainState,
) -> bool {
match evt {
GraphEvent::EnteDied { id, status } => {
graph.on_death(id, status, tx).await;
}
GraphEvent::CapabilityRequested { from, cap, reply } => {
graph.mediate_capability(from, cap, reply).await;
}
GraphEvent::SpawnRequest { card, requester } => {
if let Err(e) = graph.authorize_and_spawn(card, requester).await {
warn!(?e, "spawn request error");
}
}
GraphEvent::BusRequest { peer, from, request, outbound, reply } => {
graph.on_bus_request(peer, from, request, outbound, reply).await;
}
GraphEvent::BusResponse { seq, response } => {
graph.on_bus_response(seq, response).await;
}
GraphEvent::BusConnClosed { ente_id } => {
graph.on_bus_conn_closed(ente_id).await;
}
GraphEvent::Shutdown { reason } => {
warn!(?reason, "shutdown del fractal");
if let Some(path) = checkpoint.as_ref() {
// Snapshot del grafo
let snap = graph.snapshot();
match snap.write(path) {
Ok(()) => info!(path = %path.display(), entes = snap.entes.len(), "snapshot fractal persistido"),
Err(e) => warn!(?e, "snapshot write falló"),
}
// Snapshot del cerebro (observer state) en archivo adjunto
let brain_path = path.with_extension("brain.json");
let obs_snap = brain.observer.write().await.snapshot();
match write_brain_snapshot(&brain_path, &obs_snap) {
Ok(()) => info!(
path = %brain_path.display(),
total = obs_snap.total,
kinds = obs_snap.marginal.len(),
"snapshot brain persistido"
),
Err(e) => warn!(?e, "brain snapshot write falló"),
}
}
graph.cascade_shutdown().await;
return true;
}
}
false
}
async fn reap_until_empty(graph: &mut EnteGraph, tx: &mpsc::Sender<GraphEvent>) {
loop {
match waitpid(None, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::StillAlive) => return,
Ok(WaitStatus::Exited(pid, code)) => {
emit_death(graph, tx, pid, ExitStatus::Exit(code)).await;
}
Ok(WaitStatus::Signaled(pid, sig, _core)) => {
emit_death(graph, tx, pid, ExitStatus::Killed(sig)).await;
}
Ok(_) => continue, // Stopped/Continued — irrelevantes
Err(Errno::ECHILD) => return,
Err(e) => {
error!(?e, "waitpid fallo no recuperable en bucle de reaping");
return;
}
}
}
}
async fn emit_death(
graph: &EnteGraph,
tx: &mpsc::Sender<GraphEvent>,
pid: Pid,
status: ExitStatus,
) {
let id = match graph.lookup_pid(pid) {
Some(id) => id,
None => {
// Proceso adoptado (subreaper): no está en nuestro grafo.
info!(?pid, ?status, "huérfano cosechado (no en grafo)");
return;
}
};
let _ = tx.send(GraphEvent::EnteDied { id, status }).await;
}
fn spawn_echo_smoke_test(bus_path: PathBuf) {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
match ente_bus::BusClient::connect(&bus_path).await {
Ok(mut client) => {
let req = ente_bus::BusRequest::Invoke {
cap: ente_echo::echo_capability(),
blob: b"hola fractal forwardeado".to_vec(),
};
match client.call(req).await {
Ok(ente_bus::BusResponse::Invoked { result }) => {
info!(echo = %String::from_utf8_lossy(&result), "Invoke ECHO round-trip OK");
}
Ok(other) => warn!(?other, "Invoke ECHO respuesta inesperada"),
Err(e) => warn!(?e, "Invoke ECHO falló"),
}
}
Err(e) => warn!(?e, "no se pudo conectar al bus para test"),
}
});
}
fn write_brain_snapshot(path: &std::path::Path, snap: &ente_brain::observer::ObserverSnapshot) -> anyhow::Result<()> {
let bytes = serde_json::to_vec_pretty(snap)?;
if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
fn read_brain_snapshot(path: &std::path::Path) -> anyhow::Result<ente_brain::observer::ObserverSnapshot> {
let bytes = std::fs::read(path)?;
let snap: ente_brain::observer::ObserverSnapshot = serde_json::from_slice(&bytes)?;
Ok(snap)
}
fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("ente_zero=debug,info"));
fmt().with_env_filter(filter).with_target(true).init();
}
fn brain_introspect_path() -> PathBuf {
if let Ok(p) = std::env::var("ENTE_BRAIN_SOCK") {
return p.into();
}
let runtime = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into()));
format!("{runtime}/ente-brain.sock").into()
}
/// Auto-flush del audit log a CAS cada 10 segundos. Ejecuta best-effort:
/// si el flush falla lo logeamos pero no abortamos. La integridad del log
/// queda garantizada por su hash chain — re-flushar es idempotente.
fn spawn_audit_auto_flush(state: BrainState) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(10));
tick.tick().await; // descartar primer tick inmediato
loop {
tick.tick().await;
let mut audit = state.audit.write().await;
match audit.flush_to_cas() {
Ok(0) => {} // nada nuevo
Ok(n) => info!(written = n, total = audit.flushed_count(), "audit auto-flush"),
Err(e) => warn!(?e, "audit auto-flush falló"),
}
}
});
}
fn spawn_brain_introspect(state: BrainState) {
let path = brain_introspect_path();
tokio::spawn(async move {
let server = IntrospectServer::new(state);
if let Err(e) = server.serve(&path).await {
warn!(?e, "introspect server cayó");
}
});
}
/// Registra el evento en el observer y dispatcha cualquier regla matched.
/// Para reglas Sequence: pasamos los últimos N eventos del observer como
/// history al engine.
async fn feed_brain(
brain: &BrainState,
sink: &brain_glue::GraphSink,
graph: &EnteGraph,
evt: &GraphEvent,
) {
let Some((kind, subj)) = brain_glue::graph_event_to_brain(evt, graph) else { return };
let history: Vec<ente_brain::TimedEvent> = {
let mut obs = brain.observer.write().await;
obs.record(kind.clone());
// Snapshot de los últimos 16 eventos — suficiente para cualquier
// Sequence pattern razonable. Clone hace una sola alocación.
obs.recent(16).cloned().collect()
};
let rules = {
let engine = brain.engine.read().await;
engine.dispatch(&kind, &subj, &history)
};
if !rules.is_empty() {
ente_brain::dispatch_actions(&rules, sink).await;
}
}
/// Inicializa la malla `brahman-net` opcional. Activa sólo si
/// `BRAHMAN_LISTEN_MULTIADDR` está set. Identidad libp2p persistente
/// vía `keypair_store`. Bootstrap del DHT vía `BRAHMAN_BOOTSTRAP_PEERS`
/// (lista coma-separada de multiaddrs, opcional).
///
/// Toda fase de setup degrada grácilmente: si la keypair no carga,
/// si el listen falla, si bootstrap dial falla — loggea y devuelve
/// `None`. El Init sigue funcionando en modo Unix-only.
async fn setup_brahman_net(
dev_mode: bool,
) -> Option<std::sync::Arc<brahman_net::BrahmanNet>> {
let listen_addr = match std::env::var("BRAHMAN_LISTEN_MULTIADDR") {
Ok(s) if !s.is_empty() => s,
_ => {
tracing::debug!(
"brahman-net deshabilitado (sin BRAHMAN_LISTEN_MULTIADDR)"
);
return None;
}
};
let multiaddr: brahman_net::Multiaddr = match listen_addr.parse() {
Ok(m) => m,
Err(e) => {
warn!(addr = %listen_addr, ?e, "BRAHMAN_LISTEN_MULTIADDR inválido — net deshabilitado");
return None;
}
};
let keypair_path = keypair_store::default_path(dev_mode);
let (keypair, loaded) = match keypair_store::load_or_generate(&keypair_path) {
Ok(kp) => kp,
Err(e) => {
warn!(path = %keypair_path.display(), ?e, "no pude cargar/generar keypair libp2p — net deshabilitado");
return None;
}
};
info!(
path = %keypair_path.display(),
peer_id = %keypair.public().to_peer_id(),
loaded = loaded,
"identidad libp2p {}",
if loaded { "cargada" } else { "generada y persistida" }
);
let net = match brahman_net::BrahmanNet::with_keypair(keypair) {
Ok(n) => std::sync::Arc::new(n),
Err(e) => {
warn!(?e, "BrahmanNet::with_keypair falló — net deshabilitado");
return None;
}
};
let actual = net.listen(multiaddr).await;
info!(addr = %actual, peer_id = %net.peer_id, "brahman-net escuchando");
// Bootstrap opcional: dial-ar a peers conocidos para entrar al
// DHT. Sin bootstrap, el nodo arranca aislado hasta que alguien
// se conecte a él.
if let Ok(bootstrap) = std::env::var("BRAHMAN_BOOTSTRAP_PEERS") {
let mut dialed = 0usize;
for entry in bootstrap.split(',').filter(|s| !s.is_empty()) {
match entry.parse::<brahman_net::Multiaddr>() {
Ok(addr) => {
net.dial(addr.clone());
dialed += 1;
tracing::debug!(peer = %addr, "dial bootstrap");
}
Err(e) => {
warn!(entry = %entry, ?e, "bootstrap multiaddr inválido — saltado");
}
}
}
if dialed > 0 {
info!(count = dialed, "bootstrap peers dial-eados");
}
}
Some(net)
}
/// Carga la política de peers libp2p desde los archivos apuntados por
/// `BRAHMAN_PEER_ALLOWLIST` y/o `BRAHMAN_PEER_DENYLIST`, y arranca un
/// watcher para hot reload sobre cualquier cambio.
///
/// - Sin ninguna env: `(None, None)` → modo totalmente abierto.
/// - Con cualquiera (o ambas) set: política activa + watcher vivo.
/// - Si los archivos fallan al cargar: degrada a `(None, None)`,
/// loggea, NO rompe el bucle primordial (doctrina PID 1).
///
/// Devuelve la política y el `JoinHandle` del watcher (que el caller
/// debe mantener para que el thread no se aborte). Si no hay paths,
/// el watcher es un no-op que termina inmediato.
fn setup_brahman_policy() -> (
Option<brahman_handshake::peer_policy::PeerPolicy>,
Option<std::thread::JoinHandle<()>>,
) {
let allow_path = std::env::var("BRAHMAN_PEER_ALLOWLIST")
.ok()
.filter(|s| !s.is_empty());
let deny_path = std::env::var("BRAHMAN_PEER_DENYLIST")
.ok()
.filter(|s| !s.is_empty());
if allow_path.is_none() && deny_path.is_none() {
tracing::debug!(
"BRAHMAN_PEER_ALLOWLIST y BRAHMAN_PEER_DENYLIST no set — modo abierto (todo peer pasa)"
);
return (None, None);
}
let allow_pb = allow_path.as_deref().map(std::path::Path::new);
let deny_pb = deny_path.as_deref().map(std::path::Path::new);
let policy = match brahman_handshake::peer_policy::PeerPolicy::from_files(allow_pb, deny_pb) {
Ok(p) => p,
Err(e) => {
warn!(
?e,
allow = ?allow_path,
deny = ?deny_path,
"policy de peers inválida — degradando a modo abierto (sin restricción)"
);
return (None, None);
}
};
let (allow_count, deny_count) = policy.sizes();
info!(
allow = ?allow_count,
deny = deny_count,
allow_path = ?allow_path,
deny_path = ?deny_path,
"policy de peers libp2p cargada"
);
// Spawn watcher para hot reload. Errores aquí no son fatales —
// tendrías política sin reload, que es razonable.
let watcher = match policy.spawn_watcher() {
Ok(h) => Some(h),
Err(e) => {
warn!(?e, "policy watcher no se pudo crear — hot reload deshabilitado");
None
}
};
(Some(policy), watcher)
}
+249
View File
@@ -0,0 +1,249 @@
//! Construcción de la Tarjeta Semilla.
//!
//! Tres caminos:
//! 1. `--restore <path>`: leer `FractalSnapshot` y reconstruir Semilla
//! con seed_id preservado + entes anteriores como genesis.
//! 2. `seed.card.json` en disco: deserialize directo (prod o dev).
//! 3. Fallback dev: sintetizar Semilla + 6 genesis Entes que ejercitan
//! todas las capacidades del fractal.
use anyhow::Context;
use ente_card::{
Capability, CardError, CgroupSpec, EntityCard, NamespaceSet, Payload,
ResourceLimits, SomaSpec, Supervision, CARD_SCHEMA_VERSION,
};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::{info, warn};
use ulid::Ulid;
const SEED_PATH_PROD: &str = "/ente/seed.card";
const SEED_PATH_DEV: &str = "seed.card";
pub fn load(dev_mode: bool, restore: Option<&Path>) -> anyhow::Result<EntityCard> {
let card = if let Some(path) = restore {
load_from_snapshot(path)?
} else {
load_or_synthesize(dev_mode)?
};
card.validate()
.map_err(|e: CardError| anyhow::anyhow!("semilla inválida: {e}"))?;
Ok(card)
}
fn load_from_snapshot(path: &Path) -> anyhow::Result<EntityCard> {
let snap = ente_snapshot::FractalSnapshot::read(path)
.with_context(|| format!("read snapshot {}", path.display()))?;
info!(
path = %path.display(),
seed_id = %snap.seed_id,
entes = snap.entes.len(),
timestamp_ms = snap.timestamp_ms,
"snapshot cargado, restaurando fractal"
);
// Reconstruimos la Semilla con su Ulid original. Las Cards persistidas
// van a `genesis` con sus Ulids preservados — son las mismas identidades
// que vivieron antes del checkpoint.
let mut provides = BTreeSet::new();
provides.insert(Capability::Spawn);
provides.insert(Capability::Journal);
Ok(EntityCard {
schema_version: CARD_SCHEMA_VERSION,
id: snap.seed_id,
lineage: None,
label: snap.seed_label,
provides,
requires: BTreeSet::new(),
soma: SomaSpec::default(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
genesis: snap.entes,
..Default::default()
})
}
fn load_or_synthesize(dev_mode: bool) -> anyhow::Result<EntityCard> {
// Buscamos primero `.json` (canónico), luego sin extensión por
// compatibilidad con instalaciones que dejan el archivo crudo. La puerta
// genética se cruza vía `ente_brain::load_card_file` que pasa por
// `validate()` extendido.
let candidates: &[&str] = if dev_mode {
&["seed.card.json", SEED_PATH_DEV]
} else {
&["/ente/seed.card.json", SEED_PATH_PROD]
};
for cand in candidates {
let path = PathBuf::from(cand);
if !path.exists() { continue; }
let card = ente_brain::load_card_file(&path)
.with_context(|| format!("load {}", path.display()))?;
info!(path = %path.display(), "Tarjeta Semilla cargada y validada");
return Ok(card);
}
if dev_mode {
info!("sin seed.card — sintetizando semilla mínima (dev)");
return Ok(synthesize_dev_seed());
}
anyhow::bail!("seed.card no encontrada en /ente/seed.card.json ni /ente/seed.card")
}
fn synthesize_dev_seed() -> EntityCard {
let mut provides = BTreeSet::new();
provides.insert(Capability::Spawn);
provides.insert(Capability::Journal);
// Pre-registramos el módulo Wasm demo en el CAS y obtenemos su SHA real.
// Si el CAS no es escribible (raro en dev) caemos a un SHA cero — la
// resolución fallará y el Wasm no encarnará, pero el resto queda intacto.
let demo_wasm_sha = match ente_wasm::demo_module_bytes()
.and_then(|b| ente_cas::store(&b))
{
Ok(sha) => sha,
Err(e) => {
warn!(?e, "CAS no disponible — demo-wasm no encarnará");
[0u8; 32]
}
};
let mut genesis = Vec::new();
genesis.push(make_card("demo-sleep", Payload::Native {
exec: "/bin/sleep".into(), argv: vec!["1".into()], envp: vec![],
}, Supervision::OneShot));
genesis.push(make_card("demo-persist", Payload::Native {
exec: "/bin/sleep".into(), argv: vec!["60".into()], envp: vec![],
}, restart_supervision()));
// Card namespaced: padre escribe uid_map, hijo cat /proc/self/uid_map.
let mut ns_card = make_card("demo-userns", Payload::Native {
exec: "/bin/cat".into(),
argv: vec!["/proc/self/uid_map".into()],
envp: vec![],
}, Supervision::OneShot);
ns_card.soma = SomaSpec {
namespaces: NamespaceSet { user: true, ..Default::default() },
..Default::default()
};
genesis.push(ns_card);
genesis.push(make_card("demo-wasm", Payload::Wasm {
module_sha256: demo_wasm_sha,
entry: "_start".into(),
}, Supervision::OneShot));
if let Some(card) = optional_native_card(
"demo-echo", "target/debug/ente-echo",
[ente_echo::echo_capability()].into_iter().collect(),
restart_supervision(),
) {
genesis.push(card);
}
if let Some(card) = optional_native_card(
"compat-logind", "target/debug/ente-logind-compat",
[Capability::LegacyLogind].into_iter().collect(),
restart_supervision(),
) {
genesis.push(card);
}
// Constelación de shims D-Bus que reemplazan systemd: cada uno provee
// un nombre `org.freedesktop.X1` que GNOME/KDE consultan al boot.
for (label, bin) in &[
("compat-hostnamed", "target/debug/ente-hostnamed-compat"),
("compat-timedated", "target/debug/ente-timedated-compat"),
("compat-localed", "target/debug/ente-localed-compat"),
("compat-journald", "target/debug/ente-journald-compat"),
("compat-resolved", "target/debug/ente-resolved-compat"),
("compat-polkit", "target/debug/ente-polkit-compat"),
("compat-machined", "target/debug/ente-machined-compat"),
("policy-provider", "target/debug/ente-policy-provider"),
("compat-systemd1", "target/debug/ente-systemd1-compat"),
("compat-notify", "target/debug/ente-notify-compat"),
("compat-timer", "target/debug/ente-timer-compat"),
] {
if let Some(card) = optional_native_card(
label, bin,
std::collections::BTreeSet::new(),
restart_supervision(),
) {
genesis.push(card);
}
}
EntityCard {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: "ente-zero-dev".into(),
provides,
requires: BTreeSet::new(),
soma: SomaSpec {
namespaces: NamespaceSet::default(),
rlimits: ResourceLimits::default(),
cgroup: CgroupSpec {
path: "ente.slice/zero".into(),
cpu_weight: None,
io_weight: None,
},
cpu_affinity: None,
},
payload: Payload::Virtual,
supervision: Supervision::OneShot,
genesis,
..Default::default()
}
}
fn make_card(label: &str, payload: Payload, supervision: Supervision) -> EntityCard {
EntityCard {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
soma: SomaSpec::default(),
payload,
supervision,
genesis: vec![],
..Default::default()
}
}
fn optional_native_card(
label: &str,
bin_path: &str,
provides: BTreeSet<Capability>,
supervision: Supervision,
) -> Option<EntityCard> {
let path = Path::new(bin_path);
if !path.exists() {
return None;
}
Some(EntityCard {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: label.into(),
provides,
requires: BTreeSet::new(),
soma: SomaSpec::default(),
payload: Payload::Native {
exec: path.to_string_lossy().into_owned(),
argv: vec![],
envp: vec![],
},
supervision,
genesis: vec![],
..Default::default()
})
}
fn restart_supervision() -> Supervision {
Supervision::Restart {
initial: Duration::from_millis(100),
max: Duration::from_secs(30),
}
}