chore: monorepo inicial con arje + minga + yahweh absorbidos
Workspace en 4 ejes (core/modules/apps/shared):
- core/: 24 crates de arje (Init systemd-compatible: ente-card, ente-zero,
ente-kernel, ente-bus, ente-cas, ente-soma, ente-wasm, ente-snapshot,
ente-brain, ente-echo, ente-policy-provider, + 12 crates *-compat)
- modules/semantic_dht/: 5 crates de minga (minga-core con AST/CAS/MST,
minga-p2p con libp2p Kad, minga-store, minga-vfs, minga-cli)
- modules/ui_engine/: 11 crates de yahweh (libs/{core,theme,bus,providers},
widgets/{tree,splitter,tabs,tiled,container_core,text_input})
- apps/: 5 crates de yahweh (file_explorer, database_explorer, text_viewer,
image_viewer, yahweh-shell)
- shared_wit/protocol.wit: handshake/lifecycle inicial
Cargo.toml unificado: thiserror bumped a 2 (transparente para arje), tokio
"full", paths intra-workspace de yahweh redirigidos a su nueva ubicación.
cargo check --workspace: 0 errores, 17 warnings (dead code preexistente).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
[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 = "../ente-card" }
|
||||
ente-bus = { path = "../ente-bus" }
|
||||
ente-cas = { path = "../ente-cas" }
|
||||
ente-kernel = { path = "../ente-kernel" }
|
||||
ente-soma = { path = "../ente-soma" }
|
||||
ente-wasm = { path = "../ente-wasm" }
|
||||
ente-snapshot = { path = "../ente-snapshot" }
|
||||
ente-brain = { path = "../ente-brain" }
|
||||
ente-echo = { path = "../ente-echo" } # solo para constantes del demo
|
||||
|
||||
# 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 }
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! 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.
|
||||
|
||||
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,95 @@
|
||||
//! 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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! `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;
|
||||
|
||||
pub use shutdown::SHUTDOWN_GRACE;
|
||||
|
||||
/// 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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
//! 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 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());
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! 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,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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![],
|
||||
}
|
||||
}
|
||||
|
||||
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![],
|
||||
})
|
||||
}
|
||||
|
||||
fn restart_supervision() -> Supervision {
|
||||
Supervision::Restart {
|
||||
initial: Duration::from_millis(100),
|
||||
max: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user