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:
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user