feat: segundo módulo (nakui) + admin API + brahman-status

Dos cosas en una sesión, en el orden discutido:

(1) Segundo módulo brahman vivo: nakui-core
  - crates/modules/nakui/core/Cargo.toml: deps brahman-card,
    brahman-sidecar, ulid.
  - crates/modules/nakui/core/src/bin/nakui.rs: brahman_card_for_nakui()
    construye una Card como Lifecycle::Daemon, Supervision::Restart,
    flow.input "command" (json) + flow.output "report" (json). El
    cmd_run llama brahman_sidecar::spawn antes de levantar el server
    de nakui.

(2) crates/shared/brahman-sidecar (estrena crates/shared/)
  Boilerplate del sidecar extraído (DRY): el thread con tokio current
  thread runtime, conexión vía Client::connect, ping loop. Yahweh y
  nakui ahora consumen este crate. API:
  - spawn(card)                    fire-and-forget
  - spawn_with_handle(config)      con JoinHandle
  Example "presence" útil para demos: módulo dummy con label tomado
  del primer arg que se queda vivo hasta SIGTERM.

(3) crates/core/brahman-admin: observabilidad del broker
  Socket Unix paralelo en \$BRAHMAN_ADMIN_SOCKET (default
  \$XDG_RUNTIME_DIR/brahman-admin.sock). Cada conexión recibe un
  StatusSnapshot JSON line-delimited y se cierra. Compatible con nc/socat.
  - StatusSnapshot { server, protocol, init_attached, sessions, matches }
  - server::AdminServer
  - client::query(path)
  - example "brahman-status" CLI

(4) Wiring de ente-zero
  En primordial_loop, junto al handshake server, ahora también levanta
  AdminServer con misma política de degradación grácil.

(5) brahman-broker: BrokeredCard ahora incluye lifecycle. Endpoint y
  Match derivan Serialize/Deserialize. Nuevo método cards() expone
  iterador de BrokeredCard para que el admin pueda construir snapshots.

(6) brahman-card: re-export pub use ulid::* para que módulos no
  necesiten depender de ulid directamente.

(7) yahweh-shell migrado al sidecar compartido. Su brahman_client.rs
  pasa de 96 a 53 líneas: sólo declara la Card, delega el spawn.

Demo end-to-end:
  $ ente-zero &
  $ presence demo.producer &
  $ presence demo.consumer &
  $ brahman-status

  Init: server=0.1.0 protocol=0.1.0 attached=true
  Sessions (2):
    01KR42TY1J... demo.producer  lifecycle=Daemon  priority=Normal
    01KR42TY1K... demo.consumer  lifecycle=Daemon  priority=Normal
  Matches (2):
    demo.producer.in  ←  demo.consumer.out  via Exact
    demo.consumer.in  ←  demo.producer.out  via Exact

El broker matchea bidireccional por tipo. El admin lo expone.

Tests: 27/27. cargo check --workspace: 0 errores.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-08 15:21:49 +00:00
parent 595f68e252
commit 70a7a0d46d
20 changed files with 627 additions and 76 deletions
+32
View File
@@ -0,0 +1,32 @@
//! Cliente admin: lee un `StatusSnapshot` desde un socket admin.
use std::path::Path;
use thiserror::Error;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::UnixStream;
use crate::snapshot::StatusSnapshot;
#[derive(Debug, Error)]
pub enum AdminError {
#[error("E/S: {0}")]
Io(#[from] std::io::Error),
#[error("respuesta vacía")]
Empty,
#[error("JSON inválido: {0}")]
Json(#[from] serde_json::Error),
}
/// Conecta al socket admin, lee la línea JSON y deserializa.
pub async fn query(path: impl AsRef<Path>) -> Result<StatusSnapshot, AdminError> {
let stream = UnixStream::connect(path).await?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Err(AdminError::Empty);
}
let snapshot = serde_json::from_str(&line)?;
Ok(snapshot)
}
+23
View File
@@ -0,0 +1,23 @@
//! `brahman-admin` — observabilidad del broker.
//!
//! Expone un Unix socket separado (no se mezcla con el handshake) en el
//! que cada conexión recibe un `StatusSnapshot` JSON y se cierra. Es
//! single-shot por conexión: pensado para herramientas como
//! `brahman-status`, dashboards y health-checks.
//!
//! Wire format: una línea JSON por conexión, terminada en `\n`. Esto
//! hace trivial inspeccionar con `nc` o `socat` además del cliente
//! tipado de este crate.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod client;
pub mod server;
pub mod snapshot;
pub mod transport;
pub use snapshot::StatusSnapshot;
/// Versión del crate de admin.
pub const ADMIN_VERSION: &str = env!("CARGO_PKG_VERSION");
+107
View File
@@ -0,0 +1,107 @@
//! Servidor admin: emite un `StatusSnapshot` JSON por conexión y cierra.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use brahman_broker::Broker;
use tokio::io::AsyncWriteExt;
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::Mutex;
use tracing::warn;
use crate::snapshot::StatusSnapshot;
/// Configuración del servidor admin.
#[derive(Debug, Clone, Default)]
pub struct AdminConfig {
/// `true` si el Init está atado al servidor que aloja este admin.
pub init_attached: bool,
}
/// Servidor admin escuchando en un Unix socket.
pub struct AdminServer {
listener: UnixListener,
socket_path: PathBuf,
broker: Arc<Mutex<Broker>>,
config: AdminConfig,
}
impl AdminServer {
/// Crea el listener. Si `path` existe, lo elimina (asume socket stale).
pub fn bind(
path: impl Into<PathBuf>,
broker: Arc<Mutex<Broker>>,
config: AdminConfig,
) -> std::io::Result<Self> {
let socket_path = path.into();
if socket_path.exists() {
std::fs::remove_file(&socket_path)?;
}
if let Some(parent) = socket_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let listener = UnixListener::bind(&socket_path)?;
Ok(Self {
listener,
socket_path,
broker,
config,
})
}
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
/// Loop de aceptación: cada conexión recibe un snapshot y se cierra.
pub async fn run(self) -> std::io::Result<()> {
loop {
let (stream, _addr) = self.listener.accept().await?;
let broker = self.broker.clone();
let config = self.config.clone();
tokio::spawn(async move {
if let Err(e) = handle_conn(stream, broker, config).await {
warn!(error = %e, "admin conn falló");
}
});
}
}
}
impl Drop for AdminServer {
fn drop(&mut self) {
if let Err(e) = std::fs::remove_file(&self.socket_path) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(path = %self.socket_path.display(), error = %e, "no se pudo limpiar admin socket");
}
}
}
}
async fn handle_conn(
mut stream: UnixStream,
broker: Arc<Mutex<Broker>>,
config: AdminConfig,
) -> std::io::Result<()> {
let snapshot = build_snapshot(&broker, &config).await;
let mut json = serde_json::to_string(&snapshot)?;
json.push('\n');
stream.write_all(json.as_bytes()).await?;
stream.shutdown().await?;
Ok(())
}
async fn build_snapshot(broker: &Arc<Mutex<Broker>>, config: &AdminConfig) -> StatusSnapshot {
let b = broker.lock().await;
let sessions: Vec<_> = b.cards().cloned().collect();
let matches = b.all_matches();
StatusSnapshot {
server_version: crate::ADMIN_VERSION.to_string(),
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
init_attached: config.init_attached,
sessions,
matches,
}
}
+19
View File
@@ -0,0 +1,19 @@
//! Tipos del snapshot que el admin server emite.
use brahman_broker::{BrokeredCard, Match};
use serde::{Deserialize, Serialize};
/// Snapshot completo del estado del Init en un instante.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusSnapshot {
/// Versión del crate del Init que respondió.
pub server_version: String,
/// Versión del protocolo brahman.
pub protocol_version: String,
/// `true` si el Init está atado al servidor.
pub init_attached: bool,
/// Cards actualmente registradas (sesiones vivas).
pub sessions: Vec<BrokeredCard>,
/// Matches consumer↔producer derivados del set actual.
pub matches: Vec<Match>,
}
@@ -0,0 +1,20 @@
//! Convenciones de transporte para el socket admin.
use std::path::PathBuf;
/// Variable de entorno que sobreescribe la ruta del socket admin.
pub const SOCKET_ENV: &str = "BRAHMAN_ADMIN_SOCKET";
/// Nombre del socket admin dentro del runtime dir.
pub const SOCKET_NAME: &str = "brahman-admin.sock";
/// Ruta canónica al socket admin del Init.
pub fn default_socket_path() -> PathBuf {
if let Ok(p) = std::env::var(SOCKET_ENV) {
return PathBuf::from(p);
}
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
base.join(SOCKET_NAME)
}