refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG
Reorganización física de crates/: - core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/ - shared/ (3 crates) se redistribuye en protocol/ e init/ - lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/ Renames de proyectos: - shipote → shuma (runtime de sandboxes) - nouser → akasha (explorador de Mónadas) - yahweh → nahual (motor GPUI, antes ui_engine/) - lapaloma → pineal (data-viz agnóstica) Fraccionamiento UI → core agnóstico: - vista-core (DeckState + snap, 175 LOC, 5 tests verdes) - barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes) - vista-web y barra-web ahora son thin DOM bindings Documentación nueva: - 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat + 10 módulos + apps/ - docs/STATUS.md con cifras reales por proyecto - docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas) - CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets) Automatización: - scripts/reorg.py — script idempotente que: git mv directorios, renombra package names, recomputa path = refs, reescribe imports rust, actualiza workspace Cargo.toml. Soporta --dry-run. - scripts/split-changelog.py — particiona CHANGELOG por componente. Validación: - cargo check --workspace pasa (124 crates + 2 nuevos cores). - 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# protocol/ — Contratos canónicos + routing
|
||||
|
||||
**Propósito.** Núcleo de intercomunicación del fractal: tipos canónicos
|
||||
(`Card`, capabilities, soma), handshake módulo↔Init, broker que empareja
|
||||
flows por tipo, y wire types de red. No corre lógica de dominio.
|
||||
|
||||
## Crates
|
||||
|
||||
| crate | tipo | rol |
|
||||
| ---------------------- | ---- | -------------------------------------------------------- |
|
||||
| `brahman-card` | lib | `Card { soma, payload, flow, permissions, supervision }` |
|
||||
| `brahman-card-wit` | lib | Extracción WIT desde componentes WASM |
|
||||
| `brahman-cards` | lib | Templates Nickel + helpers consumer/producer/broker |
|
||||
| `brahman-handshake` | lib | Init↔módulo: Hello, Ping, ListSessions, ListMatches |
|
||||
| `brahman-broker` | lib | Service locator: flow.input ↔ flow.output por tipo |
|
||||
| `brahman-admin` | lib | Socket admin separado: snapshots de sesiones+matches |
|
||||
| `brahman-sidecar` | lib | `spawn(card)` para que apps se presenten al Init |
|
||||
| `brahman-net` | lib | Malla libp2p opcional (TCP+noise+yamux+kad) |
|
||||
| `ente-card` | lib | Alias legacy — re-export de `brahman-card` |
|
||||
|
||||
## Dependencias
|
||||
|
||||
- Intra: handshake → card + broker; sidecar → card; net → handshake.
|
||||
- Cross: consumido por todos los `modules/*` y `apps/*`.
|
||||
- No depende de `init/` ni de `runtime/` (es la base).
|
||||
|
||||
## Invariantes
|
||||
|
||||
- Serialización: `postcard` length-prefixed sobre Unix SOCK_STREAM, JSON
|
||||
sobre HTTP (gateway). Schemas reproducibles vía Nickel.
|
||||
- Identidad: `peer_id` libp2p + firma Ed25519 anclada al peer.
|
||||
- Sesiones: el handshake mantiene una sesión por módulo encarnado.
|
||||
|
||||
## Estado
|
||||
|
||||
Base estable (6,260 LOC con tests). 19 TODOs concentrados en handshake
|
||||
(trust remoto fase 3) y broker. Ver `docs/changelog/protocol.md`.
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "brahman-admin"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Brahman — admin API: snapshot del estado del broker (sesiones + matches) por Unix socket, formato JSON."
|
||||
|
||||
[dependencies]
|
||||
brahman-broker = { path = "../brahman-broker" }
|
||||
brahman-card = { path = "../brahman-card" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "brahman-status"
|
||||
path = "examples/brahman-status.rs"
|
||||
@@ -0,0 +1,98 @@
|
||||
//! `brahman-status` — CLI para inspeccionar el estado del Init.
|
||||
//!
|
||||
//! Conecta al socket admin (default `$XDG_RUNTIME_DIR/brahman-admin.sock`,
|
||||
//! override con `$BRAHMAN_ADMIN_SOCKET`), recibe el snapshot, y lo imprime.
|
||||
|
||||
use brahman_admin::{client, transport};
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let path = transport::default_socket_path();
|
||||
let snap = client::query(&path).await?;
|
||||
|
||||
println!(
|
||||
"Init: server={} protocol={} attached={}",
|
||||
snap.server_version, snap.protocol_version, snap.init_attached
|
||||
);
|
||||
if let Some(ctx) = &snap.current_context {
|
||||
println!("Context: {}", ctx);
|
||||
}
|
||||
println!();
|
||||
println!("Sessions ({}):", snap.sessions.len());
|
||||
if snap.sessions.is_empty() {
|
||||
println!(" (ninguna)");
|
||||
} else {
|
||||
for s in &snap.sessions {
|
||||
let conscious_marker = if s.wit.is_some() { " 🧠" } else { "" };
|
||||
let kind_marker = match s.kind {
|
||||
brahman_card::CardKind::Ente => "ente",
|
||||
brahman_card::CardKind::Data => "data",
|
||||
};
|
||||
println!(
|
||||
" [{}] {} {}{} lifecycle={:?} priority={:?}",
|
||||
kind_marker, s.session, s.label, conscious_marker, s.lifecycle, s.priority
|
||||
);
|
||||
if let Some(sock) = &s.service_socket {
|
||||
println!(" socket: {}", sock.display());
|
||||
}
|
||||
for r in &s.references {
|
||||
println!(
|
||||
" ref {:?} → {} ({})",
|
||||
r.kind, r.target_label, r.target_id
|
||||
);
|
||||
}
|
||||
if let Some(data) = &s.data {
|
||||
if !data.summary.is_empty() {
|
||||
println!(" summary: {}", data.summary);
|
||||
}
|
||||
if data.member_count > 0 {
|
||||
println!(
|
||||
" members: {} (dispersion={:.2})",
|
||||
data.member_count, data.dispersion
|
||||
);
|
||||
}
|
||||
if !data.keywords.is_empty() {
|
||||
println!(" keywords: {}", data.keywords.join(", "));
|
||||
}
|
||||
if !data.presentation_hint.is_empty() {
|
||||
println!(" lens hint: {}", data.presentation_hint);
|
||||
}
|
||||
}
|
||||
if let Some(wit) = &s.wit {
|
||||
println!(" wit: {} / {}", wit.package, wit.world);
|
||||
if !wit.imports.is_empty() {
|
||||
println!(" imports: {}", wit.imports.join(", "));
|
||||
}
|
||||
if !wit.exports.is_empty() {
|
||||
println!(" exports: {}", wit.exports.join(", "));
|
||||
}
|
||||
}
|
||||
for f in &s.inputs {
|
||||
println!(" in {}: {:?}", f.name, f.ty);
|
||||
}
|
||||
for f in &s.outputs {
|
||||
println!(" out {}: {:?}", f.name, f.ty);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!("Matches ({}):", snap.matches.len());
|
||||
if snap.matches.is_empty() {
|
||||
println!(" (ninguno)");
|
||||
} else {
|
||||
for m in &snap.matches {
|
||||
let pin_marker = if m.pinned { "📌" } else { " " };
|
||||
println!(
|
||||
" {} {}.{} ← {}.{} via {:?}",
|
||||
pin_marker,
|
||||
m.consumer_label,
|
||||
m.consumer.flow_name,
|
||||
m.producer_label,
|
||||
m.producer.flow_name,
|
||||
m.via
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! 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)
|
||||
}
|
||||
|
||||
/// Variante sync de [`query`] para callers que no tienen runtime tokio
|
||||
/// (típicamente: GUIs con su propio executor, como GPUI).
|
||||
pub fn query_blocking(path: impl AsRef<Path>) -> Result<StatusSnapshot, AdminError> {
|
||||
use std::io::{BufRead, BufReader as StdBufReader};
|
||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
||||
let stream = StdUnixStream::connect(path)?;
|
||||
let mut reader = StdBufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line)?;
|
||||
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,110 @@
|
||||
//! 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,
|
||||
/// Contexto operativo del broker, espejado en el snapshot.
|
||||
pub current_context: Option<String>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
current_context: config.current_context.clone(),
|
||||
sessions,
|
||||
matches,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! 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,
|
||||
/// Contexto operativo activo del broker (p. ej. `"test"`, `"prod"`).
|
||||
/// `None` si no hay contexto configurado — los biases per-contexto
|
||||
/// declarados en las Cards quedan inactivos.
|
||||
#[serde(default)]
|
||||
pub current_context: Option<String>,
|
||||
/// 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)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "brahman-broker"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Brahman — broker de tipos: empareja productores↔consumidores por TypeRef con matching híbrido (exact + structural) y prioridad."
|
||||
|
||||
[dependencies]
|
||||
brahman-card = { path = "../brahman-card" }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
@@ -0,0 +1,995 @@
|
||||
//! `brahman-broker` — empareja productores y consumidores por tipo de flujo.
|
||||
//!
|
||||
//! El broker indexa [`brahman_card::Card`]s registradas por `SessionId` y,
|
||||
//! para cada `flow.input` de un consumidor, busca el `flow.output`
|
||||
//! compatible de mejor calidad entre los demás. Tres ejes:
|
||||
//!
|
||||
//! 1. **Estrategia de matching** ([`MatchStrategy`]):
|
||||
//! - `Exact`: igualdad estricta de [`brahman_card::TypeRef`].
|
||||
//! - `Structural`: misma forma (mismo `package` + `name` para Wit;
|
||||
//! ignora `interface`).
|
||||
//! - `ExactThenStructural`: prefiere exact; cae en structural si no hay.
|
||||
//!
|
||||
//! 2. **Override `pin_to`**: si el consumidor declara `pin_to = "label"`,
|
||||
//! el broker prefiere productores cuya Card tenga ese `label` (siempre
|
||||
//! que el tipo siga matcheando). Si la pista no resuelve, cae en
|
||||
//! matching por tipo normal.
|
||||
//!
|
||||
//! 3. **Prioridad**: empate de tipo se resuelve por
|
||||
//! [`brahman_card::Priority`] del productor (mayor gana). Empate de
|
||||
//! prioridad se resuelve lexicográficamente por `label` (estable y
|
||||
//! determinista).
|
||||
//!
|
||||
//! El broker es **stateless w.r.t. routes**: cada `find_producer_for` o
|
||||
//! `all_matches` se calcula bajo demanda. La única persistencia es el
|
||||
//! índice de Cards registradas. Esto permite re-evaluar matches cuando
|
||||
//! cambia el set sin invalidar caches.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use brahman_card::{
|
||||
Card, CardKind, CardReference, ContextBias, DataFacet, Flow, Lifecycle, Priority, TypeRef,
|
||||
WitInterface,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ulid::Ulid;
|
||||
|
||||
/// Identificador de sesión emitido por el handshake. Idéntico al usado por
|
||||
/// `brahman-handshake` (no es un re-export para evitar la dependencia).
|
||||
pub type SessionId = Ulid;
|
||||
|
||||
/// Estrategia de matching de tipos.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum MatchStrategy {
|
||||
/// Igualdad estricta de `TypeRef`.
|
||||
Exact,
|
||||
/// Misma forma: para `Wit`, mismo `package` + `name`; para
|
||||
/// `Primitive`, mismo `name`.
|
||||
Structural,
|
||||
/// Híbrido: intenta `Exact` primero; si no matchea, `Structural`.
|
||||
/// Reporta cuál estrategia ganó en [`Match::via`].
|
||||
#[default]
|
||||
ExactThenStructural,
|
||||
}
|
||||
|
||||
/// Configuración del broker.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BrokerConfig {
|
||||
pub strategy: MatchStrategy,
|
||||
/// Contexto operativo activo. Si una Card declara un
|
||||
/// `priority_contexts.<this>`, ese bias se aplica durante el match.
|
||||
/// `None` = sin biases per-contexto, sólo se usa lo estático.
|
||||
pub current_context: Option<String>,
|
||||
}
|
||||
|
||||
/// Vista mínima de una Card que el broker necesita.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BrokeredCard {
|
||||
pub session: SessionId,
|
||||
pub label: String,
|
||||
pub lifecycle: Lifecycle,
|
||||
pub priority: Priority,
|
||||
pub inputs: Vec<Flow>,
|
||||
pub outputs: Vec<Flow>,
|
||||
/// Interfaz WIT extraída si el módulo es "consciente"; `None` si agnóstico.
|
||||
pub wit: Option<WitInterface>,
|
||||
/// Biases per-contexto, propagados desde `Card.priority_contexts`.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub priority_contexts: BTreeMap<String, ContextBias>,
|
||||
/// Naturaleza de la entidad. Diferencia procesos (Ente) de
|
||||
/// agrupaciones de datos (Data — p. ej. Mónadas Nouser).
|
||||
#[serde(default)]
|
||||
pub kind: CardKind,
|
||||
/// Faceta de datos cuando `kind != Ente`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<DataFacet>,
|
||||
/// Socket de servicio (data plane) si lo declara la Card.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub service_socket: Option<PathBuf>,
|
||||
/// Referencias a otras Cards (relaciones declaradas por esta Card).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub references: Vec<CardReference>,
|
||||
}
|
||||
|
||||
impl BrokeredCard {
|
||||
fn from_card(session: SessionId, card: &Card, wit: Option<WitInterface>) -> Self {
|
||||
Self {
|
||||
session,
|
||||
label: card.label.clone(),
|
||||
lifecycle: card.lifecycle,
|
||||
priority: card.priority,
|
||||
inputs: card.flow.input.clone(),
|
||||
outputs: card.flow.output.clone(),
|
||||
wit,
|
||||
priority_contexts: card.priority_contexts.clone(),
|
||||
kind: card.kind,
|
||||
data: card.data.clone(),
|
||||
service_socket: card.service_socket.clone(),
|
||||
references: card.references.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Punto extremo de un flujo: qué sesión + nombre del flow dentro de su Card.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct Endpoint {
|
||||
pub session: SessionId,
|
||||
pub flow_name: String,
|
||||
}
|
||||
|
||||
/// Match concreto entre un consumidor y un productor.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Match {
|
||||
pub consumer: Endpoint,
|
||||
pub consumer_label: String,
|
||||
pub producer: Endpoint,
|
||||
pub producer_label: String,
|
||||
/// Tipo del flow (lado consumidor — lado productor coincide en
|
||||
/// estrategia Exact, puede diferir en `interface` en Structural).
|
||||
pub ty: TypeRef,
|
||||
/// Estrategia que efectivamente matcheó.
|
||||
pub via: MatchStrategy,
|
||||
/// `true` si el match fue resuelto por `pin_to` y no por type-search.
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Broker
|
||||
// =====================================================================
|
||||
|
||||
/// El broker. Registra Cards por SessionId, computa matches bajo demanda.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Broker {
|
||||
cards: BTreeMap<SessionId, BrokeredCard>,
|
||||
config: BrokerConfig,
|
||||
}
|
||||
|
||||
impl Broker {
|
||||
pub fn new(config: BrokerConfig) -> Self {
|
||||
Self {
|
||||
cards: BTreeMap::new(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Registra una Card con su WIT opcional. Devuelve `Some(prev)` si
|
||||
/// reemplazó una existente. Pasar `None` en `wit` indica módulo
|
||||
/// agnóstico (sin contrato WIT extraído).
|
||||
pub fn register(
|
||||
&mut self,
|
||||
session: SessionId,
|
||||
card: &Card,
|
||||
wit: Option<WitInterface>,
|
||||
) -> Option<BrokeredCard> {
|
||||
self.cards
|
||||
.insert(session, BrokeredCard::from_card(session, card, wit))
|
||||
}
|
||||
|
||||
/// Quita una Card por sesión.
|
||||
pub fn unregister(&mut self, session: SessionId) -> Option<BrokeredCard> {
|
||||
self.cards.remove(&session)
|
||||
}
|
||||
|
||||
/// Cardinalidad del registro.
|
||||
pub fn len(&self) -> usize {
|
||||
self.cards.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.cards.is_empty()
|
||||
}
|
||||
|
||||
/// Iterador sobre las sesiones registradas.
|
||||
pub fn sessions(&self) -> impl Iterator<Item = SessionId> + '_ {
|
||||
self.cards.keys().copied()
|
||||
}
|
||||
|
||||
/// Iterador sobre las Cards registradas (vista compartida).
|
||||
pub fn cards(&self) -> impl Iterator<Item = &BrokeredCard> + '_ {
|
||||
self.cards.values()
|
||||
}
|
||||
|
||||
/// Busca el mejor productor para un input específico de un consumidor.
|
||||
///
|
||||
/// Algoritmo:
|
||||
/// 1. Resuelve el flow input en el consumidor.
|
||||
/// 2. Si tiene `pin_to`, prefiere productores con ese `label` que
|
||||
/// matcheen el tipo (cualquier estrategia configurada).
|
||||
/// 3. Si no hay pin_to o la pista falló, escanea todos los outputs
|
||||
/// de las otras Cards. Filtra por compatibilidad de tipo.
|
||||
/// 4. Ordena por (priority desc, label asc) y devuelve el primero.
|
||||
pub fn find_producer_for(&self, consumer: SessionId, input_name: &str) -> Option<Match> {
|
||||
let cons = self.cards.get(&consumer)?;
|
||||
let input = cons.inputs.iter().find(|f| f.name == input_name)?;
|
||||
|
||||
// pin_to efectivo: bias del contexto activo (si la Card declara
|
||||
// override consumer-side) > pin_to estático del Flow.
|
||||
let context_pin = self
|
||||
.context_bias(cons)
|
||||
.and_then(|b| b.pin_to.as_deref());
|
||||
let effective_pin = context_pin.or(input.pin_to.as_deref());
|
||||
|
||||
if let Some(pin) = effective_pin {
|
||||
for prod in self.cards.values() {
|
||||
if prod.session == consumer || prod.label != pin {
|
||||
continue;
|
||||
}
|
||||
for out in &prod.outputs {
|
||||
if let Some(via) = self.types_match(&input.ty, &out.ty) {
|
||||
return Some(self.make_match(cons, prod, input, out, via, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall through: pin no resuelto, type-search general.
|
||||
}
|
||||
|
||||
let mut candidates: Vec<(&BrokeredCard, &Flow, MatchStrategy)> = Vec::new();
|
||||
for prod in self.cards.values() {
|
||||
if prod.session == consumer {
|
||||
continue;
|
||||
}
|
||||
for out in &prod.outputs {
|
||||
if let Some(via) = self.types_match(&input.ty, &out.ty) {
|
||||
candidates.push((prod, out, via));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort por (effective priority desc, label asc). El bias del
|
||||
// contexto puede subir o bajar la priority del productor.
|
||||
candidates.sort_by(|(a, _, _), (b, _, _)| {
|
||||
self.effective_priority(b)
|
||||
.cmp(&self.effective_priority(a))
|
||||
.then_with(|| a.label.cmp(&b.label))
|
||||
});
|
||||
|
||||
let (prod, out, via) = candidates.into_iter().next()?;
|
||||
Some(self.make_match(cons, prod, input, out, via, false))
|
||||
}
|
||||
|
||||
/// Devuelve el `ContextBias` que aplica a este Card en el contexto
|
||||
/// activo (si lo hay).
|
||||
fn context_bias<'a>(&self, card: &'a BrokeredCard) -> Option<&'a ContextBias> {
|
||||
self.config
|
||||
.current_context
|
||||
.as_ref()
|
||||
.and_then(|ctx| card.priority_contexts.get(ctx))
|
||||
}
|
||||
|
||||
/// Priority efectiva del Card como productor, considerando el bias
|
||||
/// del contexto activo. El offset se clampa a `[Low=0, Critical=3]`.
|
||||
fn effective_priority(&self, card: &BrokeredCard) -> i16 {
|
||||
let base = priority_value(card.priority);
|
||||
let offset = self
|
||||
.context_bias(card)
|
||||
.map(|b| b.priority_offset as i16)
|
||||
.unwrap_or(0);
|
||||
(base + offset).clamp(0, 3)
|
||||
}
|
||||
|
||||
/// Calcula todos los matches consumer→producer en el set actual.
|
||||
/// Útil para introspección o para que el Admin emita rutas en lote.
|
||||
pub fn all_matches(&self) -> Vec<Match> {
|
||||
let mut out = Vec::new();
|
||||
for cons in self.cards.values() {
|
||||
for input in &cons.inputs {
|
||||
if let Some(m) = self.find_producer_for(cons.session, &input.name) {
|
||||
out.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn types_match(&self, consumer_ty: &TypeRef, producer_ty: &TypeRef) -> Option<MatchStrategy> {
|
||||
match self.config.strategy {
|
||||
MatchStrategy::Exact => exact_match(consumer_ty, producer_ty).then_some(MatchStrategy::Exact),
|
||||
MatchStrategy::Structural => {
|
||||
structural_match(consumer_ty, producer_ty).then_some(MatchStrategy::Structural)
|
||||
}
|
||||
MatchStrategy::ExactThenStructural => {
|
||||
if exact_match(consumer_ty, producer_ty) {
|
||||
Some(MatchStrategy::Exact)
|
||||
} else if structural_match(consumer_ty, producer_ty) {
|
||||
Some(MatchStrategy::Structural)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_match(
|
||||
&self,
|
||||
cons: &BrokeredCard,
|
||||
prod: &BrokeredCard,
|
||||
input: &Flow,
|
||||
output: &Flow,
|
||||
via: MatchStrategy,
|
||||
pinned: bool,
|
||||
) -> Match {
|
||||
Match {
|
||||
consumer: Endpoint {
|
||||
session: cons.session,
|
||||
flow_name: input.name.clone(),
|
||||
},
|
||||
consumer_label: cons.label.clone(),
|
||||
producer: Endpoint {
|
||||
session: prod.session,
|
||||
flow_name: output.name.clone(),
|
||||
},
|
||||
producer_label: prod.label.clone(),
|
||||
ty: input.ty.clone(),
|
||||
via,
|
||||
pinned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Predicados de matching (libres, testeables aislados)
|
||||
// =====================================================================
|
||||
|
||||
fn priority_value(p: Priority) -> i16 {
|
||||
match p {
|
||||
Priority::Low => 0,
|
||||
Priority::Normal => 1,
|
||||
Priority::High => 2,
|
||||
Priority::Critical => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_match(a: &TypeRef, b: &TypeRef) -> bool {
|
||||
a == b
|
||||
}
|
||||
|
||||
fn structural_match(a: &TypeRef, b: &TypeRef) -> bool {
|
||||
match (a, b) {
|
||||
(TypeRef::Primitive { name: na }, TypeRef::Primitive { name: nb }) => na == nb,
|
||||
(
|
||||
TypeRef::Wit {
|
||||
package: pa, name: na, ..
|
||||
},
|
||||
TypeRef::Wit {
|
||||
package: pb, name: nb, ..
|
||||
},
|
||||
) => pa == pb && na == nb,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests
|
||||
// =====================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use brahman_card::{Card, Flows, Payload, Supervision, CARD_SCHEMA_VERSION};
|
||||
|
||||
fn card(label: &str, priority: Priority, flows: Flows) -> Card {
|
||||
Card {
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
label: label.into(),
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::OneShot,
|
||||
priority,
|
||||
flow: flows,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn prim(name: &str) -> TypeRef {
|
||||
TypeRef::Primitive { name: name.into() }
|
||||
}
|
||||
|
||||
fn wit(pkg: &str, iface: Option<&str>, name: &str) -> TypeRef {
|
||||
TypeRef::Wit {
|
||||
package: pkg.into(),
|
||||
interface: iface.map(|s| s.into()),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn flow(name: &str, ty: TypeRef, pin: Option<&str>) -> Flow {
|
||||
Flow {
|
||||
name: name.into(),
|
||||
ty,
|
||||
pin_to: pin.map(|s| s.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_match_same_typeref() {
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::Exact,
|
||||
current_context: None,
|
||||
});
|
||||
let producer = card(
|
||||
"dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("results", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("query", prim("string"), None)],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
let s_prod = Ulid::new();
|
||||
let s_cons = Ulid::new();
|
||||
b.register(s_prod, &producer, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
|
||||
let m = b.find_producer_for(s_cons, "query").expect("match");
|
||||
assert_eq!(m.producer_label, "dht");
|
||||
assert_eq!(m.via, MatchStrategy::Exact);
|
||||
assert!(!m.pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_ignores_interface() {
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::Structural,
|
||||
current_context: None,
|
||||
});
|
||||
let producer = card(
|
||||
"dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow(
|
||||
"out",
|
||||
wit("brahman:dht", Some("v1"), "entity-result"),
|
||||
None,
|
||||
)],
|
||||
},
|
||||
);
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow(
|
||||
"in",
|
||||
wit("brahman:dht", Some("v2"), "entity-result"),
|
||||
None,
|
||||
)],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
let s_prod = Ulid::new();
|
||||
let s_cons = Ulid::new();
|
||||
b.register(s_prod, &producer, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
|
||||
let m = b.find_producer_for(s_cons, "in").expect("match");
|
||||
assert_eq!(m.via, MatchStrategy::Structural);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_strategy_rejects_interface_mismatch() {
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::Exact,
|
||||
current_context: None,
|
||||
});
|
||||
let producer = card(
|
||||
"dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow(
|
||||
"out",
|
||||
wit("brahman:dht", Some("v1"), "entity-result"),
|
||||
None,
|
||||
)],
|
||||
},
|
||||
);
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow(
|
||||
"in",
|
||||
wit("brahman:dht", Some("v2"), "entity-result"),
|
||||
None,
|
||||
)],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
b.register(Ulid::new(), &producer, None);
|
||||
let s_cons = Ulid::new();
|
||||
b.register(s_cons, &consumer, None);
|
||||
|
||||
assert!(b.find_producer_for(s_cons, "in").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_then_structural_prefers_exact() {
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::ExactThenStructural,
|
||||
current_context: None,
|
||||
});
|
||||
// Productor 1: match estructural (interface diferente)
|
||||
let p_struct = card(
|
||||
"dht-cache",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow(
|
||||
"out",
|
||||
wit("brahman:dht", Some("v2"), "entity-result"),
|
||||
None,
|
||||
)],
|
||||
},
|
||||
);
|
||||
// Productor 2: match exact (interface igual)
|
||||
let p_exact = card(
|
||||
"dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow(
|
||||
"out",
|
||||
wit("brahman:dht", Some("v1"), "entity-result"),
|
||||
None,
|
||||
)],
|
||||
},
|
||||
);
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow(
|
||||
"in",
|
||||
wit("brahman:dht", Some("v1"), "entity-result"),
|
||||
None,
|
||||
)],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
b.register(Ulid::new(), &p_struct, None);
|
||||
b.register(Ulid::new(), &p_exact, None);
|
||||
let s_cons = Ulid::new();
|
||||
b.register(s_cons, &consumer, None);
|
||||
|
||||
let m = b.find_producer_for(s_cons, "in").expect("match");
|
||||
// El exact gana incluso si tiene priority igual: por estrategia.
|
||||
assert_eq!(m.producer_label, "dht");
|
||||
assert_eq!(m.via, MatchStrategy::Exact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_to_overrides_type_search() {
|
||||
let mut b = Broker::new(BrokerConfig::default());
|
||||
// Dos productores que producen el mismo tipo.
|
||||
let p1 = card(
|
||||
"dht-prod",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let p2 = card(
|
||||
"dht-test",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), Some("dht-test"))],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
b.register(Ulid::new(), &p1, None);
|
||||
b.register(Ulid::new(), &p2, None);
|
||||
let s_cons = Ulid::new();
|
||||
b.register(s_cons, &consumer, None);
|
||||
|
||||
let m = b.find_producer_for(s_cons, "in").expect("match");
|
||||
assert_eq!(m.producer_label, "dht-test");
|
||||
assert!(m.pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_to_unresolvable_falls_back_to_type_match() {
|
||||
let mut b = Broker::new(BrokerConfig::default());
|
||||
let p = card(
|
||||
"real-dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), Some("nonexistent"))],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
b.register(Ulid::new(), &p, None);
|
||||
let s_cons = Ulid::new();
|
||||
b.register(s_cons, &consumer, None);
|
||||
|
||||
let m = b.find_producer_for(s_cons, "in").expect("match");
|
||||
assert_eq!(m.producer_label, "real-dht");
|
||||
assert!(!m.pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_breaks_ties() {
|
||||
let mut b = Broker::new(BrokerConfig::default());
|
||||
let p_low = card(
|
||||
"z-dht",
|
||||
Priority::Low,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let p_high = card(
|
||||
"a-dht",
|
||||
Priority::High,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), None)],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
b.register(Ulid::new(), &p_low, None);
|
||||
b.register(Ulid::new(), &p_high, None);
|
||||
let s_cons = Ulid::new();
|
||||
b.register(s_cons, &consumer, None);
|
||||
|
||||
let m = b.find_producer_for(s_cons, "in").expect("match");
|
||||
assert_eq!(m.producer_label, "a-dht"); // priority High > Low
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_alpha_breaks_priority_ties() {
|
||||
let mut b = Broker::new(BrokerConfig::default());
|
||||
let p1 = card(
|
||||
"z-dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let p2 = card(
|
||||
"a-dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), None)],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
b.register(Ulid::new(), &p1, None);
|
||||
b.register(Ulid::new(), &p2, None);
|
||||
let s_cons = Ulid::new();
|
||||
b.register(s_cons, &consumer, None);
|
||||
|
||||
let m = b.find_producer_for(s_cons, "in").expect("match");
|
||||
assert_eq!(m.producer_label, "a-dht"); // alfabético gana
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_removes_producer() {
|
||||
let mut b = Broker::new(BrokerConfig::default());
|
||||
let p = card(
|
||||
"dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), None)],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
let s_p = Ulid::new();
|
||||
b.register(s_p, &p, None);
|
||||
let s_c = Ulid::new();
|
||||
b.register(s_c, &consumer, None);
|
||||
|
||||
assert!(b.find_producer_for(s_c, "in").is_some());
|
||||
b.unregister(s_p);
|
||||
assert!(b.find_producer_for(s_c, "in").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_self_loops() {
|
||||
let mut b = Broker::new(BrokerConfig::default());
|
||||
let same = card(
|
||||
"echo",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), None)],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let s = Ulid::new();
|
||||
b.register(s, &same, None);
|
||||
|
||||
// Solo una Card registrada — no hay otra que produzca string.
|
||||
assert!(b.find_producer_for(s, "in").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_matches_lists_pairs() {
|
||||
let mut b = Broker::new(BrokerConfig::default());
|
||||
let dht = card(
|
||||
"dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("query", prim("string"), None)],
|
||||
output: vec![flow("results", prim("bytes"), None)],
|
||||
},
|
||||
);
|
||||
let ui = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("data", prim("bytes"), None)],
|
||||
output: vec![flow("user-input", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
b.register(Ulid::new(), &dht, None);
|
||||
b.register(Ulid::new(), &ui, None);
|
||||
|
||||
let matches = b.all_matches();
|
||||
assert_eq!(matches.len(), 2);
|
||||
// dht.query ← ui.user-input y ui.data ← dht.results
|
||||
let pairs: Vec<_> = matches
|
||||
.iter()
|
||||
.map(|m| (m.consumer_label.as_str(), m.producer_label.as_str()))
|
||||
.collect();
|
||||
assert!(pairs.contains(&("dht", "ui")));
|
||||
assert!(pairs.contains(&("ui", "dht")));
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// Priority contexts
|
||||
// ===========================================================
|
||||
|
||||
#[test]
|
||||
fn context_priority_offset_lifts_producer_above_alphabetic_winner() {
|
||||
// Sin contexto, "a-prod" gana contra "b-prod" (alfabético).
|
||||
// En contexto "test", b-prod tiene offset +1 → debería ganar.
|
||||
let mut a_prod = card(
|
||||
"a-prod",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
a_prod.priority_contexts = std::collections::BTreeMap::new(); // explícito vacío
|
||||
|
||||
let mut b_prod = card(
|
||||
"b-prod",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
b_prod.priority_contexts.insert(
|
||||
"test".into(),
|
||||
ContextBias {
|
||||
pin_to: None,
|
||||
priority_offset: 1,
|
||||
},
|
||||
);
|
||||
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), None)],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
let s_cons = Ulid::new();
|
||||
|
||||
// Caso 1: sin contexto → a-prod gana (alfabético).
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::default(),
|
||||
current_context: None,
|
||||
});
|
||||
b.register(Ulid::new(), &a_prod, None);
|
||||
b.register(Ulid::new(), &b_prod, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
let m = b.find_producer_for(s_cons, "in").unwrap();
|
||||
assert_eq!(m.producer_label, "a-prod");
|
||||
|
||||
// Caso 2: contexto "test" → b-prod gana por offset +1.
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::default(),
|
||||
current_context: Some("test".into()),
|
||||
});
|
||||
b.register(Ulid::new(), &a_prod, None);
|
||||
b.register(Ulid::new(), &b_prod, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
let m = b.find_producer_for(s_cons, "in").unwrap();
|
||||
assert_eq!(m.producer_label, "b-prod");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_pin_to_overrides_static_pin() {
|
||||
// Consumer pinea estático a "real-dht", pero en contexto "test"
|
||||
// declara override a "mock-dht".
|
||||
let real = card(
|
||||
"real-dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let mock = card(
|
||||
"mock-dht",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let mut consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), Some("real-dht"))],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
consumer.priority_contexts.insert(
|
||||
"test".into(),
|
||||
ContextBias {
|
||||
pin_to: Some("mock-dht".into()),
|
||||
priority_offset: 0,
|
||||
},
|
||||
);
|
||||
|
||||
let s_cons = Ulid::new();
|
||||
|
||||
// Caso 1: sin contexto → static pin gana ("real-dht").
|
||||
let mut b = Broker::new(BrokerConfig::default());
|
||||
b.register(Ulid::new(), &real, None);
|
||||
b.register(Ulid::new(), &mock, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
let m = b.find_producer_for(s_cons, "in").unwrap();
|
||||
assert_eq!(m.producer_label, "real-dht");
|
||||
assert!(m.pinned);
|
||||
|
||||
// Caso 2: contexto "test" → context override gana ("mock-dht").
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::default(),
|
||||
current_context: Some("test".into()),
|
||||
});
|
||||
b.register(Ulid::new(), &real, None);
|
||||
b.register(Ulid::new(), &mock, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
let m = b.find_producer_for(s_cons, "in").unwrap();
|
||||
assert_eq!(m.producer_label, "mock-dht");
|
||||
assert!(m.pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_context_no_op() {
|
||||
// Si la Card declara biases para "test" pero el broker está en
|
||||
// "prod", los biases no aplican.
|
||||
let mut b_prod = card(
|
||||
"b-prod",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
b_prod.priority_contexts.insert(
|
||||
"test".into(),
|
||||
ContextBias {
|
||||
pin_to: None,
|
||||
priority_offset: 5,
|
||||
},
|
||||
);
|
||||
let a_prod = card(
|
||||
"a-prod",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
let consumer = card(
|
||||
"ui",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![flow("in", prim("string"), None)],
|
||||
output: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
let mut b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::default(),
|
||||
current_context: Some("prod".into()),
|
||||
});
|
||||
let s_cons = Ulid::new();
|
||||
b.register(Ulid::new(), &a_prod, None);
|
||||
b.register(Ulid::new(), &b_prod, None);
|
||||
b.register(s_cons, &consumer, None);
|
||||
|
||||
// En contexto "prod" sin biases declarados, gana por alfabético.
|
||||
let m = b.find_producer_for(s_cons, "in").unwrap();
|
||||
assert_eq!(m.producer_label, "a-prod");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_offset_clamps_to_critical() {
|
||||
// Offset enorme no debe hacer overflow ni saltar fuera del rango.
|
||||
let mut prod = card(
|
||||
"p",
|
||||
Priority::Normal,
|
||||
Flows {
|
||||
input: vec![],
|
||||
output: vec![flow("out", prim("string"), None)],
|
||||
},
|
||||
);
|
||||
prod.priority_contexts.insert(
|
||||
"x".into(),
|
||||
ContextBias {
|
||||
pin_to: None,
|
||||
priority_offset: 100,
|
||||
},
|
||||
);
|
||||
|
||||
let b = Broker::new(BrokerConfig {
|
||||
strategy: MatchStrategy::default(),
|
||||
current_context: Some("x".into()),
|
||||
});
|
||||
let bc = BrokeredCard::from_card(Ulid::new(), &prod, None);
|
||||
// effective_priority debe estar clampada a 3 (Critical), no 101.
|
||||
assert_eq!(b.effective_priority(&bc), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "brahman-card-wit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Brahman — extractor opcional: parsea contratos WIT y devuelve `WitInterface` listo para acoplar a una `Card`."
|
||||
|
||||
[dependencies]
|
||||
brahman-card = { path = "../brahman-card" }
|
||||
wit-parser = "0.230"
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "brahman-wit-info"
|
||||
path = "examples/brahman-wit-info.rs"
|
||||
@@ -0,0 +1,45 @@
|
||||
//! `brahman-wit-info` — inspecciona un archivo WIT y lista sus worlds.
|
||||
//!
|
||||
//! Uso:
|
||||
//! ```sh
|
||||
//! cargo run -p brahman-card-wit --example brahman-wit-info -- shared_wit/protocol.wit
|
||||
//! ```
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let path = match std::env::args().nth(1) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("uso: brahman-wit-info <ruta.wit>");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
let worlds = match brahman_card_wit::parse_wit_file(&path) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
eprintln!("error parseando {path}: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
|
||||
if worlds.is_empty() {
|
||||
println!("(ningún world declarado)");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
println!("{} world(s):", worlds.len());
|
||||
for w in &worlds {
|
||||
println!();
|
||||
println!(" package: {}", w.package);
|
||||
println!(" world: {}", w.world);
|
||||
if !w.imports.is_empty() {
|
||||
println!(" imports: {}", w.imports.join(", "));
|
||||
}
|
||||
if !w.exports.is_empty() {
|
||||
println!(" exports: {}", w.exports.join(", "));
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! `brahman-card-wit` — extractor de contratos WIT.
|
||||
//!
|
||||
//! Crate **opcional** (no es dep de `brahman-card`). Parsea texto WIT
|
||||
//! mediante [`wit-parser`] y devuelve una lista de [`WitInterface`]
|
||||
//! (uno por `world`) lista para acoplarse a una [`brahman_card::Card`]
|
||||
//! cuando se construye una [`brahman_card::ResolvedCard`].
|
||||
//!
|
||||
//! Casos de uso:
|
||||
//!
|
||||
//! - El Init lee `<modulo>/wit/protocol.wit` durante el descubrimiento
|
||||
//! y lo combina con la Card del módulo para obtener una
|
||||
//! `ResolvedCard::from_conscious(card, wit)`.
|
||||
//! - Tooling (`brahman-wit-info`) inspecciona un `.wit` y muestra
|
||||
//! sus mundos, exports e imports.
|
||||
//!
|
||||
//! No depende de `wasm-tools`/`wit-component` — sólo del parser texto.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use brahman_card::WitInterface;
|
||||
use thiserror::Error;
|
||||
use wit_parser::{Resolve, WorldKey};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WitError {
|
||||
#[error("parse: {0}")]
|
||||
Parse(String),
|
||||
#[error("E/S: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Parsea WIT desde una string. Devuelve un `WitInterface` por cada
|
||||
/// `world` declarado.
|
||||
pub fn parse_wit(source: &str) -> Result<Vec<WitInterface>, WitError> {
|
||||
parse_with_path(source, Path::new("inline.wit"))
|
||||
}
|
||||
|
||||
/// Parsea WIT desde un archivo. Útil para `module/wit/protocol.wit`.
|
||||
pub fn parse_wit_file(path: impl AsRef<Path>) -> Result<Vec<WitInterface>, WitError> {
|
||||
let p = path.as_ref();
|
||||
let source = std::fs::read_to_string(p)?;
|
||||
parse_with_path(&source, p)
|
||||
}
|
||||
|
||||
fn parse_with_path(source: &str, path: &Path) -> Result<Vec<WitInterface>, WitError> {
|
||||
let mut resolve = Resolve::new();
|
||||
let path_buf: PathBuf = path.to_path_buf();
|
||||
resolve
|
||||
.push_str(&path_buf, source)
|
||||
.map_err(|e| WitError::Parse(e.to_string()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (_pkg_id, pkg) in resolve.packages.iter() {
|
||||
let pkg_name = pkg.name.to_string();
|
||||
for (_name, &world_id) in &pkg.worlds {
|
||||
let world = &resolve.worlds[world_id];
|
||||
let exports = collect_keys(world.exports.iter().map(|(k, _)| k), &resolve);
|
||||
let imports = collect_keys(world.imports.iter().map(|(k, _)| k), &resolve);
|
||||
out.push(WitInterface {
|
||||
package: pkg_name.clone(),
|
||||
world: world.name.clone(),
|
||||
exports,
|
||||
imports,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn collect_keys<'a, I>(keys: I, resolve: &Resolve) -> Vec<String>
|
||||
where
|
||||
I: Iterator<Item = &'a WorldKey>,
|
||||
{
|
||||
keys.map(|k| match k {
|
||||
WorldKey::Name(n) => n.clone(),
|
||||
WorldKey::Interface(id) => resolve.interfaces[*id]
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("<interface#{}>", id.index())),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"
|
||||
package brahman:test@0.1.0;
|
||||
|
||||
interface handshake {
|
||||
hello: func() -> result<_, string>;
|
||||
}
|
||||
|
||||
interface lifecycle {
|
||||
report: func();
|
||||
}
|
||||
|
||||
world module {
|
||||
import handshake;
|
||||
import lifecycle;
|
||||
export run: func() -> result<_, string>;
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_inline_wit() {
|
||||
let worlds = parse_wit(SAMPLE).unwrap();
|
||||
assert_eq!(worlds.len(), 1, "esperaba un único world");
|
||||
let w = &worlds[0];
|
||||
assert!(w.package.starts_with("brahman:test"));
|
||||
assert_eq!(w.world, "module");
|
||||
assert!(
|
||||
w.imports.iter().any(|i| i == "handshake"),
|
||||
"imports={:?}",
|
||||
w.imports
|
||||
);
|
||||
assert!(
|
||||
w.imports.iter().any(|i| i == "lifecycle"),
|
||||
"imports={:?}",
|
||||
w.imports
|
||||
);
|
||||
assert!(
|
||||
w.exports.iter().any(|e| e == "run"),
|
||||
"exports={:?}",
|
||||
w.exports
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_shared_protocol() {
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../shared_wit/protocol.wit");
|
||||
let worlds = parse_wit_file(path).unwrap();
|
||||
assert!(
|
||||
worlds.iter().any(|w| w.world == "module"),
|
||||
"no encontró world 'module' en {:?}",
|
||||
worlds.iter().map(|w| &w.world).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
worlds.iter().any(|w| w.world == "admin-host"),
|
||||
"no encontró world 'admin-host'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_on_garbage() {
|
||||
let bad = "this is not wit at all { } } ;;;;";
|
||||
assert!(matches!(parse_wit(bad), Err(WitError::Parse(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_world_handled() {
|
||||
let src = r#"
|
||||
package brahman:empty@0.1.0;
|
||||
world hollow {}
|
||||
"#;
|
||||
let worlds = parse_wit(src).unwrap();
|
||||
assert_eq!(worlds.len(), 1);
|
||||
assert!(worlds[0].exports.is_empty());
|
||||
assert!(worlds[0].imports.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "brahman-card"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Brahman — Tarjeta de Presentación canónica (identidad arje + flujos tipados brahman)."
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
postcard = { workspace = true }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "brahman-cards"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Brahman — brazo unificado: lee múltiples formatos de Card (Ente/Monad/UiModule/...) y los proyecta a una estructura canónica única."
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
brahman-card = { path = "../brahman-card" }
|
||||
akasha-card = { path = "../../modules/akasha/card" }
|
||||
nahual-meta-schema = { path = "../../modules/nahual/libs/meta-schema" }
|
||||
nickel-lang = "2.0.0"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,334 @@
|
||||
//! `brahman-cards` — brazo unificado de Cards.
|
||||
//!
|
||||
//! Brahman maneja varios formatos legítimos de "Card" (la unidad
|
||||
//! declarativa que describe identidad, datos, módulos, widgets, ...).
|
||||
//! Cada formato vive en su propio crate de origen y conserva su shape
|
||||
//! público; lo que este crate aporta es **un único punto de entrada**
|
||||
//! que sabe interpretar cada uno de ellos y proyectarlos a una sola
|
||||
//! estructura interna canónica [`Card`].
|
||||
//!
|
||||
//! Diseño:
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
//! │ Ente JSON │ │ Monad JSON │ │ UiModule │ … futuro
|
||||
//! │ (brahman- │ │ (akasha- │ │ (nakui-ui- │
|
||||
//! │ card) │ │ card) │ │ schema) │
|
||||
//! └─────┬───────┘ └──────┬───────┘ └──────┬──────┘
|
||||
//! │ │ │
|
||||
//! └────────┬────────┴────────┬────────┘
|
||||
//! │ brahman-cards │
|
||||
//! │ (este crate) │
|
||||
//! └────────┬────────┘
|
||||
//! │
|
||||
//! ┌──────▼──────┐
|
||||
//! │ `Card` │ ← único tipo canónico
|
||||
//! │ wrapper │ que consumen UI runtime,
|
||||
//! │ común + │ storage, DHT, wire.
|
||||
//! │ variant │
|
||||
//! │ body │
|
||||
//! └─────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! Los formatos NO se disuelven. Si en el futuro hay que soportar un
|
||||
//! formato simplificado nuevo, se agrega un reader acá y nadie aguas
|
||||
//! abajo se entera — siguen recibiendo `Card`.
|
||||
//!
|
||||
//! V1 (este commit) sólo soporta inputs JSON. La extensión a Nickel
|
||||
//! (con templates de defaults vía merge nativo de Nickel) llega en un
|
||||
//! commit separado para aislar la dependencia `nickel-lang-core`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
pub use brahman_card::Card as EnteCard;
|
||||
pub use akasha_card::MonadManifest;
|
||||
pub use nahual_meta_schema::Module as UiModuleSpec;
|
||||
|
||||
/// Estructura canónica única que consumen los downstream del sistema
|
||||
/// (UI runtime, storage, DHT, wire). Cada formato input se proyecta
|
||||
/// a ésta vía un reader del brazo.
|
||||
///
|
||||
/// El wrapper común agrupa lo que TODOS los formatos comparten
|
||||
/// (identidad legible + extensiones forward-compat); el body preserva
|
||||
/// el typing rico de cada dominio sin colapsarlos.
|
||||
// PartialEq se omite porque algunos body variants vienen de crates
|
||||
// que no lo implementan (MonadManifest, nahual_meta_schema::Module).
|
||||
// Si downstream necesita igualdad, comparar via JSON round-trip o
|
||||
// agregar PartialEq en los crates origen.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Card {
|
||||
/// Identificador opaco. String en el wrapper para no obligar a
|
||||
/// los formatos a un mismo tipo concreto (Ente/Monad usan ULID,
|
||||
/// UiModule usa slug human-friendly como `"sales_engine"`).
|
||||
/// Cada reader documenta qué formato exige.
|
||||
pub id: String,
|
||||
|
||||
/// Versión del schema canónico de este wrapper. Bump = romper
|
||||
/// compat de los consumers downstream. Distinto de los
|
||||
/// `schema_version` internos de cada body variant, que siguen
|
||||
/// su propio versioning.
|
||||
pub schema_version: u16,
|
||||
|
||||
/// Ancestro del que esta Card desciende (si aplica). Significado
|
||||
/// específico al body variant (Ente: lineage del proceso; Monad:
|
||||
/// split/merge de Mónada padre; UiModule: típicamente None).
|
||||
#[serde(default)]
|
||||
pub lineage: Option<String>,
|
||||
|
||||
/// Etiqueta humana legible. Cada reader la deriva del campo
|
||||
/// equivalente del input (label/title/etc.).
|
||||
pub label: String,
|
||||
|
||||
/// Campos no reconocidos del input se preservan acá. Permite
|
||||
/// forward-compat: leer un input con campos nuevos no rompe la
|
||||
/// carga, y volver a serializar conserva el extra.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub extensions: BTreeMap<String, Value>,
|
||||
|
||||
/// Cuerpo tipado por dominio. La elección del variant es
|
||||
/// responsabilidad del reader (basada en el input shape).
|
||||
pub body: CardBody,
|
||||
}
|
||||
|
||||
/// Versión actual del schema canónico de [`Card`]. Bump cuando cambie
|
||||
/// la shape del wrapper o las invariantes que comparten todos los
|
||||
/// variants.
|
||||
pub const CARD_SCHEMA_VERSION: u16 = 1;
|
||||
|
||||
/// Variantes tipadas del body de [`Card`]. Una por dominio.
|
||||
///
|
||||
/// **Convención de extensión**: agregar un variant nuevo aquí + un
|
||||
/// reader que produzca ese variant. Los consumers que sólo manejen
|
||||
/// algunos variants pueden hacer `match { Ente(..) => ..., _ => skip }`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum CardBody {
|
||||
/// Entidad runtime con proceso/payload/supervision (lo que era
|
||||
/// `brahman_card::Card` directo).
|
||||
Ente(EnteCard),
|
||||
|
||||
/// Agrupación semántica de archivos (Mónada de Nouser). No tiene
|
||||
/// proceso; describe membership + signals semánticas (centroid,
|
||||
/// keywords, lens).
|
||||
Monad(MonadManifest),
|
||||
|
||||
/// Descriptor de módulo de UI: entities + views + menu + actions.
|
||||
/// Lo que hoy lee la metainterface de Nakui desde
|
||||
/// `examples/nakui-modules/<id>/module.json`.
|
||||
UiModule(UiModuleSpec),
|
||||
}
|
||||
|
||||
impl CardBody {
|
||||
/// Etiqueta corta del variant — útil para mensajes de error y
|
||||
/// dispatch en la UI sin necesitar match exhaustivo.
|
||||
pub fn kind_name(&self) -> &'static str {
|
||||
match self {
|
||||
CardBody::Ente(_) => "ente",
|
||||
CardBody::Monad(_) => "monad",
|
||||
CardBody::UiModule(_) => "ui_module",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errores de carga del brazo.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CardLoadError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("parse JSON: {0}")]
|
||||
JsonParse(#[from] serde_json::Error),
|
||||
|
||||
#[error("ningún reader registrado matcheó el input (shape no reconocido)")]
|
||||
NoMatchingReader,
|
||||
|
||||
#[error("reader '{reader}' falló: {message}")]
|
||||
ReaderFailed { reader: &'static str, message: String },
|
||||
|
||||
#[error("formato no soportado: extensión '{ext}'. Soportadas: {supported:?}")]
|
||||
UnsupportedExtension {
|
||||
ext: String,
|
||||
supported: Vec<&'static str>,
|
||||
},
|
||||
|
||||
#[error("evaluación Nickel: {0}")]
|
||||
Nickel(#[from] NickelEvalError),
|
||||
}
|
||||
|
||||
/// Trait de reader. Cada formato implementa una instancia.
|
||||
///
|
||||
/// El dispatcher del brazo (`load_card`) prueba los readers en el
|
||||
/// orden registrado y se queda con el primero cuyo `can_read`
|
||||
/// devuelve `true`. Por eso el orden importa: poner los más
|
||||
/// específicos antes que los más laxos.
|
||||
pub trait CardReader: Send + Sync {
|
||||
/// Nombre del reader, para mensajes de error.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Dado un JSON Value (el input ya parseado a serde Value),
|
||||
/// decide si este reader puede manejarlo. Heurística estructural
|
||||
/// — el shape del input identifica el formato, no flags
|
||||
/// explícitos (los inputs legacy no los tienen).
|
||||
fn can_read(&self, input: &Value) -> bool;
|
||||
|
||||
/// Produce el [`Card`] canónico. Sólo se llama si `can_read`
|
||||
/// devolvió `true`.
|
||||
fn read(&self, input: Value) -> Result<Card, CardLoadError>;
|
||||
}
|
||||
|
||||
mod nickel_eval;
|
||||
mod readers;
|
||||
pub use nickel_eval::{eval_nickel_file, NickelEvalError, BRAHMAN_CARDS_TEMPLATES_ENV};
|
||||
pub use readers::{EnteJsonReader, MonadJsonReader, UiModuleJsonReader};
|
||||
|
||||
/// Path al directorio de templates Nickel canónicos shipped con el
|
||||
/// crate (`crates/core/brahman-cards/templates/` en el repo).
|
||||
///
|
||||
/// Este directorio contiene los `*_basic.ncl` para cada body kind:
|
||||
/// - `ente_basic.ncl`
|
||||
/// - `monad_basic.ncl`
|
||||
/// - `ui_module_basic.ncl`
|
||||
///
|
||||
/// Usar como path para [`BRAHMAN_CARDS_TEMPLATES_ENV`] o pasarlo
|
||||
/// directo a Nickel via env. Resuelto via `CARGO_MANIFEST_DIR` —
|
||||
/// funciona en `cargo test`/`cargo run` desde el workspace. Para
|
||||
/// distribución del binary standalone (cuando emerja el caso de
|
||||
/// uso), incluir los templates como recursos via `include_dir!` o
|
||||
/// instalar el directorio junto al ejecutable.
|
||||
pub fn canonical_templates_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates")
|
||||
}
|
||||
|
||||
/// Construye el set default de readers para inputs JSON. El orden
|
||||
/// es deliberado: el más específico (UiModule, que tiene `entities`
|
||||
/// y `views` simultáneamente) antes que el más laxo. Si dos readers
|
||||
/// matchean, gana el primero.
|
||||
pub fn default_readers() -> Vec<Box<dyn CardReader>> {
|
||||
vec![
|
||||
Box::new(UiModuleJsonReader),
|
||||
Box::new(MonadJsonReader),
|
||||
Box::new(EnteJsonReader),
|
||||
]
|
||||
}
|
||||
|
||||
/// Carga un Card desde una ruta. Detecta formato por extensión, y
|
||||
/// dentro de JSON detecta el shape probando los readers default en
|
||||
/// orden.
|
||||
///
|
||||
/// Para custom reader sets, usar [`load_card_with`].
|
||||
pub fn load_card(path: impl AsRef<Path>) -> Result<Card, CardLoadError> {
|
||||
load_card_with(path, &default_readers())
|
||||
}
|
||||
|
||||
/// Variante de [`load_card`] con readers custom. Útil para tests o
|
||||
/// para apps que quieren restringir formatos soportados.
|
||||
pub fn load_card_with(
|
||||
path: impl AsRef<Path>,
|
||||
readers: &[Box<dyn CardReader>],
|
||||
) -> Result<Card, CardLoadError> {
|
||||
let path = path.as_ref();
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"json" => {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let value: Value = serde_json::from_slice(&bytes)?;
|
||||
dispatch_to_reader(value, readers)
|
||||
}
|
||||
"ncl" => {
|
||||
// Nickel pipeline: leer archivo → evaluar deeply → exportar
|
||||
// a JSON → parsear como Value → dispatch a los readers JSON
|
||||
// estándar. Templates funcionan via los `import` nativos de
|
||||
// Nickel; el evaluator resuelve relativo al input y al
|
||||
// `BRAHMAN_CARDS_TEMPLATES_DIR` env (si está set).
|
||||
let value = eval_nickel_file(path)?;
|
||||
dispatch_to_reader(value, readers)
|
||||
}
|
||||
other => Err(CardLoadError::UnsupportedExtension {
|
||||
ext: other.to_string(),
|
||||
supported: vec!["json", "ncl"],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recorre los readers en orden, se queda con el primero que matchea
|
||||
/// y delega la conversión.
|
||||
fn dispatch_to_reader(
|
||||
input: Value,
|
||||
readers: &[Box<dyn CardReader>],
|
||||
) -> Result<Card, CardLoadError> {
|
||||
for r in readers {
|
||||
if r.can_read(&input) {
|
||||
return r.read(input);
|
||||
}
|
||||
}
|
||||
Err(CardLoadError::NoMatchingReader)
|
||||
}
|
||||
|
||||
/// Filenames convencionales que [`load_cards_from_dir`] busca dentro
|
||||
/// de cada subdir, en orden de preferencia. Si `card.ncl` existe se
|
||||
/// usa ese; sino `card.json`; sino los aliases legacy `module.*`. Los
|
||||
/// últimos dos son por compat con el layout actual de
|
||||
/// `examples/nakui-modules/<id>/module.json`.
|
||||
pub const DEFAULT_CARD_FILENAMES: &[&str] =
|
||||
&["card.ncl", "card.json", "module.ncl", "module.json"];
|
||||
|
||||
/// Carga todas las Cards encontradas como subdirs inmediatos de
|
||||
/// `dir`. Por cada subdir, busca los filenames convencionales (ver
|
||||
/// [`DEFAULT_CARD_FILENAMES`]) y carga el primero que existe. Subdirs
|
||||
/// sin ningún filename matching se skipean silenciosamente — permite
|
||||
/// que un dir contenga subdirs auxiliares (assets, fixtures, etc.).
|
||||
///
|
||||
/// Devuelve las Cards en orden lexicográfico por subdir name (estable
|
||||
/// across runs). NO ordena por `Card.id` — el caller decide si quiere
|
||||
/// re-ordenar y/o dedupar.
|
||||
///
|
||||
/// Errores: cualquier I/O al leer el `dir` mismo, o cualquier
|
||||
/// `CardLoadError` de un archivo encontrado (NO continúa tras el
|
||||
/// primer fallo — fallo loud > corrupción silenciosa).
|
||||
pub fn load_cards_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Card>, CardLoadError> {
|
||||
load_cards_from_dir_with(dir, DEFAULT_CARD_FILENAMES, &default_readers())
|
||||
}
|
||||
|
||||
/// Variante de [`load_cards_from_dir`] con filenames y readers
|
||||
/// custom. Útil para apps que quieren restringir formatos o usar un
|
||||
/// nombre canónico distinto.
|
||||
pub fn load_cards_from_dir_with(
|
||||
dir: impl AsRef<Path>,
|
||||
filenames: &[&str],
|
||||
readers: &[Box<dyn CardReader>],
|
||||
) -> Result<Vec<Card>, CardLoadError> {
|
||||
let dir = dir.as_ref();
|
||||
let mut subdir_paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)?
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
if p.is_dir() { Some(p) } else { None }
|
||||
})
|
||||
.collect();
|
||||
// Orden estable por subdir name — el output del brazo no debería
|
||||
// depender del orden de read_dir (que varía por filesystem).
|
||||
subdir_paths.sort();
|
||||
|
||||
let mut out: Vec<Card> = Vec::new();
|
||||
for sub in subdir_paths {
|
||||
for fname in filenames {
|
||||
let candidate = sub.join(fname);
|
||||
if candidate.exists() {
|
||||
out.push(load_card_with(&candidate, readers)?);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Evaluador Nickel para inputs `.ncl`.
|
||||
//!
|
||||
//! El brazo de Cards lee Nickel como **fuente** y produce JSON como
|
||||
//! **representación intermedia** que después dispatcha por los readers
|
||||
//! estándar. Esto significa que un `.ncl` puede producir cualquier
|
||||
//! variant del [`super::CardBody`] siempre que evalúe a una shape JSON
|
||||
//! que alguno de los readers reconozca.
|
||||
//!
|
||||
//! # Templates
|
||||
//!
|
||||
//! Nickel soporta `import "..."` y el operador `&` de merge nativo. Un
|
||||
//! Card "concreto" puede ser un template + override:
|
||||
//!
|
||||
//! ```nickel
|
||||
//! let base = import "ente_basic.ncl" in
|
||||
//! base & { id = "01ARZ...", label = "mi-ente" }
|
||||
//! ```
|
||||
//!
|
||||
//! **Convención obligatoria del template**: las fields que el usuario
|
||||
//! va a sobrescribir tienen que estar marcadas `| default` (o
|
||||
//! `| optional`). Nickel rechaza el merge de dos strings/numbers
|
||||
//! distintos con la misma prioridad — el `| default` baja la prioridad
|
||||
//! del template y deja que el override del user gane:
|
||||
//!
|
||||
//! ```nickel
|
||||
//! # template ui_module_basic.ncl
|
||||
//! {
|
||||
//! id | String | default = "TEMPLATE_ID",
|
||||
//! label | String | default = "TEMPLATE_LABEL",
|
||||
//! # ...
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Resolución de imports (en orden):
|
||||
//! 1. Relativo al directorio del archivo input (default de Nickel).
|
||||
//! 2. `BRAHMAN_CARDS_TEMPLATES_DIR` (env). Permite tener un
|
||||
//! registry global de templates accesible por nombre desnudo:
|
||||
//! `import "ui_module_basic.ncl"`.
|
||||
//!
|
||||
//! No agregamos magic resolución por kind — el autor decide qué
|
||||
//! template importa explícitamente.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Variable de entorno opcional. Si está set, su path se agrega al
|
||||
/// search path de imports de Nickel después del parent dir del input,
|
||||
/// permitiendo `import "<nombre>.ncl"` desde cualquier ubicación.
|
||||
pub const BRAHMAN_CARDS_TEMPLATES_ENV: &str = "BRAHMAN_CARDS_TEMPLATES_DIR";
|
||||
|
||||
/// Errores específicos del pipeline Nickel. Wrap del error de Nickel
|
||||
/// formateado como texto plano (sin ANSI) + el path del input para
|
||||
/// contexto.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum NickelEvalError {
|
||||
#[error("io leyendo {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("evaluación de '{path}' falló:\n{message}")]
|
||||
Eval { path: String, message: String },
|
||||
|
||||
#[error("export a JSON de '{path}' falló:\n{message}")]
|
||||
Export { path: String, message: String },
|
||||
|
||||
#[error("JSON exportado por Nickel no parsea de vuelta: {source}")]
|
||||
JsonReparse {
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Lee `path` (debe ser un `.ncl` válido), lo evalúa profundamente vía
|
||||
/// `nickel-lang` y devuelve el resultado como `serde_json::Value`
|
||||
/// listo para dispatch a un reader JSON.
|
||||
///
|
||||
/// El parent dir del input se agrega como import path para que
|
||||
/// imports relativos tipo `import "./template.ncl"` funcionen sin
|
||||
/// configuración extra. Si `BRAHMAN_CARDS_TEMPLATES_DIR` está set,
|
||||
/// también se agrega.
|
||||
pub fn eval_nickel_file(path: &Path) -> Result<Value, NickelEvalError> {
|
||||
let path_display = path.display().to_string();
|
||||
let source = std::fs::read_to_string(path).map_err(|e| NickelEvalError::Io {
|
||||
path: path_display.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let mut import_paths: Vec<OsString> = Vec::new();
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
import_paths.push(parent.into());
|
||||
}
|
||||
}
|
||||
if let Ok(reg) = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV) {
|
||||
if !reg.is_empty() {
|
||||
import_paths.push(reg.into());
|
||||
}
|
||||
}
|
||||
|
||||
let mut ctx = nickel_lang::Context::new()
|
||||
.with_added_import_paths(import_paths)
|
||||
.with_source_name(path_display.clone());
|
||||
|
||||
let expr = ctx
|
||||
.eval_deep_for_export(&source)
|
||||
.map_err(|e| NickelEvalError::Eval {
|
||||
path: path_display.clone(),
|
||||
message: format_nickel_error(&e),
|
||||
})?;
|
||||
|
||||
let json_str = ctx
|
||||
.expr_to_json(&expr)
|
||||
.map_err(|e| NickelEvalError::Export {
|
||||
path: path_display.clone(),
|
||||
message: format_nickel_error(&e),
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&json_str).map_err(|e| NickelEvalError::JsonReparse { source: e })
|
||||
}
|
||||
|
||||
/// Formatea un error de Nickel como texto plano. Usa `ErrorFormat::Text`
|
||||
/// (sin ANSI) para que sea legible en logs y mensajes de UI sin
|
||||
/// escape sequences.
|
||||
fn format_nickel_error(err: &nickel_lang::Error) -> String {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
if err
|
||||
.format(&mut buf, nickel_lang::ErrorFormat::Text)
|
||||
.is_err()
|
||||
{
|
||||
// Si la propia formateación falla, devolvemos el Debug —
|
||||
// peor mensaje que el normal pero no perdemos info.
|
||||
return format!("{err:?}");
|
||||
}
|
||||
String::from_utf8(buf).unwrap_or_else(|_| format!("{err:?}"))
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Readers V1: tres formatos JSON ya existentes en el monorepo.
|
||||
//!
|
||||
//! Cada reader implementa:
|
||||
//! - `can_read`: heurística estructural para decidir si el JSON es
|
||||
//! suyo. No requiere flag explícito en el input — los inputs legacy
|
||||
//! no los tienen.
|
||||
//! - `read`: deserializa el JSON al tipo del crate origen (sin tocarlo)
|
||||
//! y lo envuelve en [`Card`] derivando los campos del wrapper.
|
||||
//!
|
||||
//! Convenciones para derivar el wrapper:
|
||||
//! - `id`: del campo `id` del input (cada formato lo expone). Si es
|
||||
//! ULID se serializa a string canónico.
|
||||
//! - `label`: del campo `label`.
|
||||
//! - `lineage`: del campo `lineage` cuando existe (Ente/Monad).
|
||||
//! - `extensions`: campos JSON desconocidos respecto a la struct del
|
||||
//! crate origen. Hoy lo mantenemos vacío (los crates origen ya
|
||||
//! tienen sus propios `extensions` internos via `#[serde(flatten)]`)
|
||||
//! — no duplicamos. Si en el futuro queremos mover el "extras" del
|
||||
//! crate origen al wrapper, esta es la palanca.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Card, CardBody, CardLoadError, CardReader, EnteCard, MonadManifest, UiModuleSpec, CARD_SCHEMA_VERSION};
|
||||
|
||||
// ============================================================================
|
||||
// Ente (brahman-card)
|
||||
// ============================================================================
|
||||
|
||||
/// Reader para el shape JSON de [`brahman_card::Card`].
|
||||
///
|
||||
/// Heurística de detección: el input tiene `payload` Y `supervision`
|
||||
/// — son los campos requeridos del schema Ente que ningún otro
|
||||
/// formato del monorepo tiene.
|
||||
pub struct EnteJsonReader;
|
||||
|
||||
impl CardReader for EnteJsonReader {
|
||||
fn name(&self) -> &'static str {
|
||||
"ente-json"
|
||||
}
|
||||
|
||||
fn can_read(&self, input: &Value) -> bool {
|
||||
let obj = match input.as_object() {
|
||||
Some(o) => o,
|
||||
None => return false,
|
||||
};
|
||||
obj.contains_key("payload") && obj.contains_key("supervision")
|
||||
}
|
||||
|
||||
fn read(&self, input: Value) -> Result<Card, CardLoadError> {
|
||||
let id = pull_string(&input, "id").unwrap_or_default();
|
||||
let label = pull_string(&input, "label").unwrap_or_default();
|
||||
let lineage = pull_string(&input, "lineage");
|
||||
|
||||
let ente: EnteCard =
|
||||
serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed {
|
||||
reader: "ente-json",
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(Card {
|
||||
id,
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
lineage,
|
||||
label,
|
||||
extensions: Default::default(),
|
||||
body: CardBody::Ente(ente),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Monad (akasha-card)
|
||||
// ============================================================================
|
||||
|
||||
/// Reader para el shape JSON de [`akasha_card::MonadManifest`].
|
||||
///
|
||||
/// Heurística: tiene `members` (BTreeSet<FileId>) Y `cardinality`
|
||||
/// (u32). La combinación es exclusiva del MonadManifest.
|
||||
pub struct MonadJsonReader;
|
||||
|
||||
impl CardReader for MonadJsonReader {
|
||||
fn name(&self) -> &'static str {
|
||||
"monad-json"
|
||||
}
|
||||
|
||||
fn can_read(&self, input: &Value) -> bool {
|
||||
let obj = match input.as_object() {
|
||||
Some(o) => o,
|
||||
None => return false,
|
||||
};
|
||||
obj.contains_key("members") && obj.contains_key("cardinality")
|
||||
}
|
||||
|
||||
fn read(&self, input: Value) -> Result<Card, CardLoadError> {
|
||||
let id = pull_string(&input, "id").unwrap_or_default();
|
||||
let label = pull_string(&input, "label").unwrap_or_default();
|
||||
let lineage = pull_string(&input, "lineage");
|
||||
|
||||
let monad: MonadManifest =
|
||||
serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed {
|
||||
reader: "monad-json",
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(Card {
|
||||
id,
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
lineage,
|
||||
label,
|
||||
extensions: Default::default(),
|
||||
body: CardBody::Monad(monad),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UiModule (nahual-meta-schema)
|
||||
// ============================================================================
|
||||
|
||||
/// Reader para el shape JSON de los `module.json` de la metainterfaz
|
||||
/// ([`nahual_meta_schema::Module`]).
|
||||
///
|
||||
/// Heurística: tiene `entities` Y `views` Y `menu`. Es el shape más
|
||||
/// específico del repo, así que va primero en el orden default — si
|
||||
/// matchea, ningún otro reader debería intentar.
|
||||
pub struct UiModuleJsonReader;
|
||||
|
||||
impl CardReader for UiModuleJsonReader {
|
||||
fn name(&self) -> &'static str {
|
||||
"ui-module-json"
|
||||
}
|
||||
|
||||
fn can_read(&self, input: &Value) -> bool {
|
||||
let obj = match input.as_object() {
|
||||
Some(o) => o,
|
||||
None => return false,
|
||||
};
|
||||
obj.contains_key("entities") && obj.contains_key("views") && obj.contains_key("menu")
|
||||
}
|
||||
|
||||
fn read(&self, input: Value) -> Result<Card, CardLoadError> {
|
||||
let id = pull_string(&input, "id").unwrap_or_default();
|
||||
let label = pull_string(&input, "label").unwrap_or_default();
|
||||
// UiModule no tiene lineage en su schema, queda None.
|
||||
let module: UiModuleSpec =
|
||||
serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed {
|
||||
reader: "ui-module-json",
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(Card {
|
||||
id,
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
lineage: None,
|
||||
label,
|
||||
extensions: Default::default(),
|
||||
body: CardBody::UiModule(module),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
fn pull_string(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key)?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# `ente_basic.ncl` — template canónico para Cards de tipo Ente.
|
||||
#
|
||||
# Use case típico: declarar una entity runtime mínima (Virtual
|
||||
# payload, OneShot supervision) sobrescribiendo sólo `id` y `label`:
|
||||
#
|
||||
# let base = import "ente_basic.ncl" in
|
||||
# base & {
|
||||
# id = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
# label = "mi-ente",
|
||||
# }
|
||||
#
|
||||
# El brazo `brahman-cards::load_card` lo dispatcha al
|
||||
# `EnteJsonReader` porque el shape resultante tiene `payload` Y
|
||||
# `supervision` (los campos detect-key del reader Ente).
|
||||
#
|
||||
# **Convención obligatoria**: cada field que el usuario va a
|
||||
# sobrescribir está marcada `| default`. Sin eso Nickel rebota el
|
||||
# merge de strings/numbers no-iguales con misma prioridad.
|
||||
|
||||
{
|
||||
schema_version | Number | default = 1,
|
||||
|
||||
# Identidad: el usuario casi siempre las sobrescribe.
|
||||
id | String | default = "TEMPLATE_ID",
|
||||
label | String | default = "TEMPLATE_LABEL",
|
||||
|
||||
# Runtime defaults razonables: nodo lógico sin proceso, sin
|
||||
# restart. Override si querés un ente con payload Wasm/Native.
|
||||
payload | default = "Virtual",
|
||||
supervision | default = "OneShot",
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# `monad_basic.ncl` — template canónico para Cards de tipo Monad.
|
||||
#
|
||||
# Use case típico: declarar una agrupación semántica de archivos
|
||||
# (Mónada de Nouser) con metadata mínima:
|
||||
#
|
||||
# let base = import "monad_basic.ncl" in
|
||||
# base & {
|
||||
# id = "01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
||||
# label = "fotos-2026",
|
||||
# members = ["01ARZ3FILE1", "01ARZ3FILE2"],
|
||||
# cardinality = 2,
|
||||
# }
|
||||
#
|
||||
# El brazo lo dispatcha al `MonadJsonReader` por la presencia
|
||||
# simultánea de `members` Y `cardinality`.
|
||||
|
||||
{
|
||||
schema_version | Number | default = 1,
|
||||
|
||||
# Identidad: override siempre.
|
||||
id | String | default = "TEMPLATE_ID",
|
||||
label | String | default = "TEMPLATE_LABEL",
|
||||
|
||||
# Metadata semántica: defaults vacíos. El usuario typically
|
||||
# override `members` + `cardinality`, opcionalmente `summary`
|
||||
# / `keywords` / `dominant_lens`.
|
||||
summary | String | default = "",
|
||||
keywords | default = [],
|
||||
centroid | default = [],
|
||||
cardinality | Number | default = 0,
|
||||
entropy | Number | default = 0.0,
|
||||
# Lens variants serialize lowercase (serde rename_all): grid /
|
||||
# code / gallery / database / markdown / tree.
|
||||
dominant_lens | default = "grid",
|
||||
|
||||
# Membership: vacío por default. El usuario llena con los IDs
|
||||
# de archivo cuando los conoce.
|
||||
members | default = [],
|
||||
pins | default = [],
|
||||
|
||||
# Timestamps Unix ms — default 0 = "no timestamp registrado".
|
||||
# Override con el momento real cuando importa.
|
||||
created_at_ms | Number | default = 0,
|
||||
updated_at_ms | Number | default = 0,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# `ui_module_basic.ncl` — template canónico para Cards de tipo
|
||||
# UiModule (descriptores de módulos para metainterfaz yahweh).
|
||||
#
|
||||
# Use case típico: declarar un módulo nuevo sobrescribiendo `id`,
|
||||
# `label`, y aportando los `entities`/`menu`/`views` propios:
|
||||
#
|
||||
# let base = import "ui_module_basic.ncl" in
|
||||
# base & {
|
||||
# id = "customers",
|
||||
# label = "Clientes",
|
||||
# entities = [
|
||||
# { name = "Customer", label = "Cliente", fields = [...] },
|
||||
# ],
|
||||
# menu = [{ label = "Listar", view = "list" }],
|
||||
# views = { list = { kind = "list", ... } },
|
||||
# }
|
||||
#
|
||||
# El brazo lo dispatcha al `UiModuleJsonReader` por la presencia
|
||||
# simultánea de `entities` Y `views` Y `menu`.
|
||||
|
||||
{
|
||||
# Identidad: override siempre.
|
||||
id | String | default = "TEMPLATE_ID",
|
||||
label | String | default = "TEMPLATE_LABEL",
|
||||
|
||||
# Subtítulo opcional (tooltip en el sidebar). null por default.
|
||||
description | default = null,
|
||||
|
||||
# Las 3 listas/maps son el **payload** real del módulo. El
|
||||
# template las deja vacías para que el usuario las defina sin
|
||||
# heredar nada útil-pero-equivocado de un default.
|
||||
entities | default = [],
|
||||
menu | default = [],
|
||||
views | default = {},
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//! Integration tests del brazo brahman-cards.
|
||||
//!
|
||||
//! Cubre:
|
||||
//! 1. Cada reader matchea sólo el shape correcto.
|
||||
//! 2. El dispatcher (`load_card`/`dispatch`) elige el reader
|
||||
//! correcto sin ambigüedad.
|
||||
//! 3. Round-trip: cada formato JSON cargado produce el variant
|
||||
//! esperado del Card canónico con los campos del wrapper bien
|
||||
//! derivados.
|
||||
//! 4. Rechazo gracioso de inputs no-matched + extensiones no
|
||||
//! soportadas.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use brahman_cards::{
|
||||
default_readers, load_card_with, Card, CardBody, CardLoadError, CardReader, EnteJsonReader,
|
||||
MonadJsonReader, UiModuleJsonReader,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Helper: dispatch in-process desde un Value, sin tocar disco.
|
||||
/// Reproduce la lógica interna del dispatcher para no exigir I/O en
|
||||
/// los tests.
|
||||
fn dispatch(input: Value, readers: &[Box<dyn CardReader>]) -> Result<Card, CardLoadError> {
|
||||
for r in readers {
|
||||
if r.can_read(&input) {
|
||||
return r.read(input);
|
||||
}
|
||||
}
|
||||
Err(CardLoadError::NoMatchingReader)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Reader detection (can_read)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn ui_module_reader_detects_only_ui_module_shape() {
|
||||
let r = UiModuleJsonReader;
|
||||
let ui = json!({"id": "x", "label": "X", "menu": [], "views": {}, "entities": []});
|
||||
let ente = json!({"id": "x", "label": "X", "payload": "Virtual", "supervision": "OneShot"});
|
||||
let monad = json!({"id": "x", "label": "X", "members": [], "cardinality": 0});
|
||||
assert!(r.can_read(&ui), "UiModule reader debe matchear ui shape");
|
||||
assert!(!r.can_read(&ente), "no debe matchear Ente");
|
||||
assert!(!r.can_read(&monad), "no debe matchear Monad");
|
||||
assert!(!r.can_read(&Value::Null), "no debe matchear non-object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ente_reader_detects_only_ente_shape() {
|
||||
let r = EnteJsonReader;
|
||||
let ente = json!({"payload": "Virtual", "supervision": "OneShot"});
|
||||
let monad = json!({"members": [], "cardinality": 0});
|
||||
let ui = json!({"menu": [], "views": {}, "entities": []});
|
||||
assert!(r.can_read(&ente));
|
||||
assert!(!r.can_read(&monad));
|
||||
assert!(!r.can_read(&ui));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monad_reader_detects_only_monad_shape() {
|
||||
let r = MonadJsonReader;
|
||||
let monad = json!({"members": [], "cardinality": 0});
|
||||
let ente = json!({"payload": "Virtual", "supervision": "OneShot"});
|
||||
let ui = json!({"menu": [], "views": {}, "entities": []});
|
||||
assert!(r.can_read(&monad));
|
||||
assert!(!r.can_read(&ente));
|
||||
assert!(!r.can_read(&ui));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Dispatch + variant projection
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn loads_ui_module_to_card_ui_module_variant() {
|
||||
let input = json!({
|
||||
"id": "sales_engine",
|
||||
"label": "Ventas",
|
||||
"description": "Demo",
|
||||
"entities": [],
|
||||
"menu": [{"label": "Stock", "view": "stock_list"}],
|
||||
"views": {
|
||||
"stock_list": {
|
||||
"kind": "list",
|
||||
"title": "Stock",
|
||||
"entity": "Stock",
|
||||
"columns": []
|
||||
}
|
||||
}
|
||||
});
|
||||
let card = dispatch(input, &default_readers()).expect("dispatch ok");
|
||||
assert_eq!(card.id, "sales_engine");
|
||||
assert_eq!(card.label, "Ventas");
|
||||
assert!(card.lineage.is_none(), "UiModule sin lineage");
|
||||
assert_eq!(card.body.kind_name(), "ui_module");
|
||||
match card.body {
|
||||
CardBody::UiModule(m) => {
|
||||
assert_eq!(m.id, "sales_engine");
|
||||
assert_eq!(m.menu.len(), 1);
|
||||
}
|
||||
other => panic!("variant inesperado: {:?}", other.kind_name()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_ente_to_card_ente_variant() {
|
||||
// Ulid mínimo: 26 chars Crockford. Usamos uno conocido.
|
||||
let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
|
||||
let input = json!({
|
||||
"schema_version": 1,
|
||||
"id": ulid,
|
||||
"label": "test-ente",
|
||||
"payload": "Virtual",
|
||||
"supervision": "OneShot"
|
||||
});
|
||||
let card = dispatch(input, &default_readers()).expect("dispatch ok");
|
||||
assert_eq!(card.id, ulid);
|
||||
assert_eq!(card.label, "test-ente");
|
||||
assert_eq!(card.body.kind_name(), "ente");
|
||||
match card.body {
|
||||
CardBody::Ente(e) => {
|
||||
assert_eq!(e.label, "test-ente");
|
||||
assert_eq!(e.id.to_string(), ulid);
|
||||
}
|
||||
other => panic!("variant inesperado: {:?}", other.kind_name()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_monad_to_card_monad_variant() {
|
||||
let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FB1";
|
||||
let input = json!({
|
||||
"schema_version": 1,
|
||||
"id": ulid,
|
||||
"label": "test-monad",
|
||||
"members": [],
|
||||
"cardinality": 0,
|
||||
"created_at_ms": 0,
|
||||
"updated_at_ms": 0
|
||||
});
|
||||
let card = dispatch(input, &default_readers()).expect("dispatch ok");
|
||||
assert_eq!(card.id, ulid);
|
||||
assert_eq!(card.label, "test-monad");
|
||||
assert_eq!(card.body.kind_name(), "monad");
|
||||
match card.body {
|
||||
CardBody::Monad(m) => {
|
||||
assert_eq!(m.label, "test-monad");
|
||||
assert_eq!(m.cardinality, 0);
|
||||
}
|
||||
other => panic!("variant inesperado: {:?}", other.kind_name()),
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Negative cases
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn rejects_input_no_matching_reader() {
|
||||
let input = json!({"random": "shape", "without": "fingerprint"});
|
||||
let err = dispatch(input, &default_readers()).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, CardLoadError::NoMatchingReader),
|
||||
"expected NoMatchingReader, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_object_input() {
|
||||
let input = json!([1, 2, 3]);
|
||||
let err = dispatch(input, &default_readers()).unwrap_err();
|
||||
assert!(matches!(err, CardLoadError::NoMatchingReader));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_module_takes_priority_when_shape_overlaps_partial() {
|
||||
// Sanity del orden: si alguien armara un input híbrido con
|
||||
// `menu`+`views`+`entities` Y también `payload`+`supervision`,
|
||||
// el UiModuleReader (primero en orden) debería ganar. Esto no
|
||||
// debería ocurrir con inputs reales pero defendemos el contrato
|
||||
// de orden documentado.
|
||||
let input = json!({
|
||||
"id": "weird",
|
||||
"label": "Weird",
|
||||
"menu": [],
|
||||
"views": {},
|
||||
"entities": [],
|
||||
"payload": "Virtual",
|
||||
"supervision": "OneShot"
|
||||
});
|
||||
let card = dispatch(input, &default_readers()).expect("dispatch ok");
|
||||
assert_eq!(
|
||||
card.body.kind_name(),
|
||||
"ui_module",
|
||||
"el UiModuleReader debería ganar por orden"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// load_card desde disco (e2e fino)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn load_card_from_disk_round_trip_ui_module() {
|
||||
let tmp = tempfile_path("ui_module.json");
|
||||
let input = json!({
|
||||
"id": "demo",
|
||||
"label": "Demo",
|
||||
"entities": [],
|
||||
"menu": [],
|
||||
"views": {}
|
||||
});
|
||||
std::fs::write(&tmp, serde_json::to_vec_pretty(&input).unwrap()).unwrap();
|
||||
|
||||
let card = load_card_with(&tmp, &default_readers()).expect("load ok");
|
||||
assert_eq!(card.body.kind_name(), "ui_module");
|
||||
assert_eq!(card.id, "demo");
|
||||
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_card_rejects_unsupported_extension() {
|
||||
let tmp = tempfile_path("foo.toml");
|
||||
std::fs::write(&tmp, b"[anything]\nx = 1").unwrap();
|
||||
let err = load_card_with(&tmp, &default_readers()).unwrap_err();
|
||||
match err {
|
||||
CardLoadError::UnsupportedExtension { ext, supported } => {
|
||||
assert_eq!(ext, "toml");
|
||||
assert!(supported.contains(&"json"));
|
||||
}
|
||||
other => panic!("expected UnsupportedExtension, got {other:?}"),
|
||||
}
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Custom reader sets
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn custom_reader_set_can_restrict_supported_formats() {
|
||||
// Sólo Ente: un input Monad debería rechazarse.
|
||||
let only_ente: Vec<Box<dyn CardReader>> = vec![Box::new(EnteJsonReader)];
|
||||
let monad_input = json!({"members": [], "cardinality": 0});
|
||||
let err = dispatch(monad_input, &only_ente).unwrap_err();
|
||||
assert!(matches!(err, CardLoadError::NoMatchingReader));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Wrapper field invariants
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn extensions_field_starts_empty_in_v1() {
|
||||
// Documented: V1 no mueve el "extras" del crate origen al
|
||||
// wrapper.extensions. Si esto cambia, este test se rompe como
|
||||
// signal para actualizar el doc de readers.rs.
|
||||
let input = json!({
|
||||
"id": "demo",
|
||||
"label": "Demo",
|
||||
"entities": [],
|
||||
"menu": [],
|
||||
"views": {}
|
||||
});
|
||||
let card = dispatch(input, &default_readers()).unwrap();
|
||||
assert_eq!(card.extensions, BTreeMap::new());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// load_cards_from_dir (subdir walking)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn load_cards_from_dir_walks_subdirs_and_finds_module_json() {
|
||||
let root = unique_dir("dir-walk");
|
||||
// Subdir A: tiene module.json (UiModule).
|
||||
let a = root.join("alpha");
|
||||
std::fs::create_dir(&a).unwrap();
|
||||
std::fs::write(
|
||||
a.join("module.json"),
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"id": "alpha",
|
||||
"label": "Alpha",
|
||||
"entities": [],
|
||||
"menu": [],
|
||||
"views": {}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
// Subdir B: tiene module.json (UiModule).
|
||||
let b = root.join("bravo");
|
||||
std::fs::create_dir(&b).unwrap();
|
||||
std::fs::write(
|
||||
b.join("module.json"),
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"id": "bravo",
|
||||
"label": "Bravo",
|
||||
"entities": [],
|
||||
"menu": [],
|
||||
"views": {}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
// Subdir C: NO tiene ninguno de los filenames convencionales —
|
||||
// se debe skipear sin error.
|
||||
let c = root.join("charlie");
|
||||
std::fs::create_dir(&c).unwrap();
|
||||
std::fs::write(c.join("readme.txt"), b"sin card aca").unwrap();
|
||||
|
||||
let cards = brahman_cards::load_cards_from_dir(&root).expect("ok");
|
||||
let ids: Vec<&str> = cards.iter().map(|c| c.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["alpha", "bravo"],
|
||||
"orden lexicográfico por subdir name"
|
||||
);
|
||||
for c in &cards {
|
||||
assert_eq!(c.body.kind_name(), "ui_module");
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cards_from_dir_prefers_ncl_over_json_when_both_present() {
|
||||
let root = unique_dir("dir-prefer");
|
||||
let sub = root.join("only");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
// Ambos archivos existen; el .ncl debería ganar.
|
||||
std::fs::write(
|
||||
sub.join("card.ncl"),
|
||||
r#"{ id = "from_ncl", label = "Ncl", entities = [], menu = [], views = {} }"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
sub.join("card.json"),
|
||||
serde_json::to_vec(&json!({
|
||||
"id": "from_json",
|
||||
"label": "Json",
|
||||
"entities": [], "menu": [], "views": {}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cards = brahman_cards::load_cards_from_dir(&root).expect("ok");
|
||||
assert_eq!(cards.len(), 1);
|
||||
assert_eq!(cards[0].id, "from_ncl", "card.ncl tiene prioridad");
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cards_from_dir_propagates_per_file_errors_loud() {
|
||||
let root = unique_dir("dir-error-loud");
|
||||
let sub = root.join("broken");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
std::fs::write(sub.join("card.json"), b"{ this is not valid json").unwrap();
|
||||
|
||||
let err = brahman_cards::load_cards_from_dir(&root).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, CardLoadError::JsonParse(_)),
|
||||
"el error de un file roto debe propagar fail-loud, got {err:?}"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cards_from_dir_with_custom_filenames() {
|
||||
let root = unique_dir("dir-custom-fname");
|
||||
let sub = root.join("only");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
// Filename custom que NO está en el default.
|
||||
std::fs::write(
|
||||
sub.join("manifest.json"),
|
||||
serde_json::to_vec(&json!({
|
||||
"id": "x",
|
||||
"label": "X",
|
||||
"entities": [], "menu": [], "views": {}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Default no encuentra nada (skipea):
|
||||
let with_default = brahman_cards::load_cards_from_dir(&root).unwrap();
|
||||
assert_eq!(with_default.len(), 0, "default filenames no incluye manifest.json");
|
||||
|
||||
// Custom filename encuentra:
|
||||
let with_custom = brahman_cards::load_cards_from_dir_with(
|
||||
&root,
|
||||
&["manifest.json"],
|
||||
&brahman_cards::default_readers(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(with_custom.len(), 1);
|
||||
assert_eq!(with_custom[0].id, "x");
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Helpers de tests
|
||||
// ===========================================================================
|
||||
|
||||
fn tempfile_path(name: &str) -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"brahman-cards-test-{}-{}",
|
||||
std::process::id(),
|
||||
name
|
||||
));
|
||||
p
|
||||
}
|
||||
|
||||
fn unique_dir(name: &str) -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"brahman-cards-test-{}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0),
|
||||
name
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Nickel reader + templates.
|
||||
//!
|
||||
//! V2 del brazo: la dispatcher acepta archivos `.ncl`. La evaluación
|
||||
//! produce JSON intermedio que va a los readers estándar, así que un
|
||||
//! `.ncl` puede generar cualquier `CardBody` siempre que su shape sea
|
||||
//! reconocida.
|
||||
//!
|
||||
//! Templates: Nickel `import` + `&` merge nativos. El brazo no
|
||||
//! inventa nada — sólo agrega el parent dir + el env
|
||||
//! `BRAHMAN_CARDS_TEMPLATES_DIR` al import path.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use brahman_cards::{
|
||||
eval_nickel_file, load_card, CardBody, CardLoadError, NickelEvalError,
|
||||
BRAHMAN_CARDS_TEMPLATES_ENV,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// ===========================================================================
|
||||
// Helpers
|
||||
// ===========================================================================
|
||||
|
||||
fn unique_dir(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"brahman-cards-nickel-{}-{}-{}",
|
||||
std::process::id(),
|
||||
nanos(),
|
||||
name
|
||||
));
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn nanos() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn write_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
|
||||
let p = dir.join(name);
|
||||
fs::write(&p, content).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 1. Evaluación directa: Nickel → Value
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn eval_nickel_file_returns_value_for_valid_input() {
|
||||
let dir = unique_dir("eval-basic");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"card.ncl",
|
||||
r#"
|
||||
{
|
||||
id = "demo",
|
||||
label = "Demo",
|
||||
entities = [],
|
||||
menu = [],
|
||||
views = {},
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let v = eval_nickel_file(&p).expect("eval ok");
|
||||
assert_eq!(v.get("id"), Some(&json!("demo")));
|
||||
assert_eq!(v.get("label"), Some(&json!("Demo")));
|
||||
assert!(v.get("entities").is_some());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_nickel_file_surfaces_evaluation_error() {
|
||||
let dir = unique_dir("eval-err");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"broken.ncl",
|
||||
r#"
|
||||
{
|
||||
id = "x",
|
||||
label = doesnotexist,
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let err = eval_nickel_file(&p).unwrap_err();
|
||||
match err {
|
||||
NickelEvalError::Eval { path, message } => {
|
||||
assert!(path.contains("broken.ncl"));
|
||||
assert!(!message.is_empty(), "el msg debe traer info de Nickel");
|
||||
}
|
||||
other => panic!("expected Eval error, got {other:?}"),
|
||||
}
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 2. load_card pipeline: .ncl → Card
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn load_card_dispatches_ncl_to_ui_module_variant() {
|
||||
let dir = unique_dir("dispatch-ui");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"module.ncl",
|
||||
r#"
|
||||
{
|
||||
id = "demo",
|
||||
label = "Demo",
|
||||
entities = [],
|
||||
menu = [{ label = "Stock", view = "stock_list" }],
|
||||
views = {
|
||||
stock_list = {
|
||||
kind = "list",
|
||||
title = "Stock",
|
||||
entity = "Stock",
|
||||
columns = [],
|
||||
},
|
||||
},
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let card = load_card(&p).expect("load ok");
|
||||
assert_eq!(card.body.kind_name(), "ui_module");
|
||||
assert_eq!(card.id, "demo");
|
||||
assert_eq!(card.label, "Demo");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_card_dispatches_ncl_to_ente_variant() {
|
||||
let dir = unique_dir("dispatch-ente");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"ente.ncl",
|
||||
r#"
|
||||
{
|
||||
schema_version = 1,
|
||||
id = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
label = "test-ente",
|
||||
payload = "Virtual",
|
||||
supervision = "OneShot",
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let card = load_card(&p).expect("load ok");
|
||||
assert_eq!(card.body.kind_name(), "ente");
|
||||
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 3. Templates: import + merge native de Nickel
|
||||
// ===========================================================================
|
||||
|
||||
/// El caso de uso que el usuario describió: "un Card simple usa un
|
||||
/// Card ya hecho cambiando sólo nombre y id". Template define la
|
||||
/// shape full; el archivo concreto importa + override.
|
||||
#[test]
|
||||
fn template_merge_overrides_id_and_label_only() {
|
||||
let dir = unique_dir("template-merge");
|
||||
|
||||
// Template con la shape full de un UiModule. Los campos
|
||||
// sobrescribibles se marcan `| default` — Nickel sólo permite
|
||||
// override en merge cuando hay diferencia de prioridad. Sin
|
||||
// `| default` los strings no-iguales fallan con "non mergeable".
|
||||
write_file(
|
||||
&dir,
|
||||
"ui_module_basic.ncl",
|
||||
r#"
|
||||
{
|
||||
id | String | default = "TEMPLATE_ID",
|
||||
label | String | default = "TEMPLATE_LABEL",
|
||||
description = "stock + form básico",
|
||||
entities = [
|
||||
{ name = "Item", label = "Item", fields = [] },
|
||||
],
|
||||
menu = [
|
||||
{ label = "Items", view = "items_list" },
|
||||
{ label = "+ Item", view = "items_form" },
|
||||
],
|
||||
views = {
|
||||
items_list = {
|
||||
kind = "list",
|
||||
title = "Items",
|
||||
entity = "Item",
|
||||
columns = [],
|
||||
},
|
||||
items_form = {
|
||||
kind = "form",
|
||||
title = "Nuevo item",
|
||||
entity = "Item",
|
||||
fields = [],
|
||||
on_submit = {
|
||||
kind = "seed_entity",
|
||||
entity = "Item",
|
||||
next_view = "items_list",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Card concreto: import + merge override.
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"my_module.ncl",
|
||||
r#"
|
||||
let base = import "ui_module_basic.ncl" in
|
||||
base & {
|
||||
id = "my_module",
|
||||
label = "Mi Módulo",
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
let card = load_card(&p).expect("template merge ok");
|
||||
assert_eq!(card.id, "my_module", "el override del id se aplicó");
|
||||
assert_eq!(card.label, "Mi Módulo", "el override del label se aplicó");
|
||||
assert_eq!(card.body.kind_name(), "ui_module");
|
||||
match card.body {
|
||||
CardBody::UiModule(m) => {
|
||||
// El resto viene del template intacto.
|
||||
assert_eq!(m.menu.len(), 2);
|
||||
assert_eq!(m.entities.len(), 1);
|
||||
assert_eq!(m.entities[0].name, "Item");
|
||||
}
|
||||
other => panic!("variant inesperado: {:?}", other.kind_name()),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// El env `BRAHMAN_CARDS_TEMPLATES_DIR` permite tener un registry
|
||||
/// global: el usuario importa por nombre desnudo desde cualquier
|
||||
/// ubicación.
|
||||
///
|
||||
/// Este test setea/unset el env de forma local (no thread-safe en
|
||||
/// tests paralelos contra el mismo env, pero usamos una key dedicada
|
||||
/// y borramos después). Si se vuelve flaky, agregar mutex.
|
||||
#[test]
|
||||
fn template_resolves_via_env_registry() {
|
||||
let registry = unique_dir("template-registry");
|
||||
let inputs = unique_dir("template-input");
|
||||
|
||||
write_file(
|
||||
®istry,
|
||||
"ui_module_minimal.ncl",
|
||||
r#"
|
||||
{
|
||||
id | String | default = "X",
|
||||
label | String | default = "X",
|
||||
entities = [],
|
||||
menu = [],
|
||||
views = {},
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
let p = write_file(
|
||||
&inputs,
|
||||
"from_registry.ncl",
|
||||
r#"
|
||||
let base = import "ui_module_minimal.ncl" in
|
||||
base & { id = "registry_user", label = "Usado del Registry" }
|
||||
"#,
|
||||
);
|
||||
|
||||
// Set env, evaluar, restaurar.
|
||||
let prev = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV).ok();
|
||||
// SAFETY: nickel-lang tests modifican un env ad-hoc que no es
|
||||
// referenciado por nada externo y se restaura al salir. Ningún
|
||||
// otro test del crate lee este env.
|
||||
unsafe {
|
||||
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, ®istry);
|
||||
}
|
||||
|
||||
let result = load_card(&p);
|
||||
|
||||
unsafe {
|
||||
if let Some(v) = prev {
|
||||
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, v);
|
||||
} else {
|
||||
std::env::remove_var(BRAHMAN_CARDS_TEMPLATES_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
let card = result.expect("template via registry ok");
|
||||
assert_eq!(card.id, "registry_user");
|
||||
assert_eq!(card.body.kind_name(), "ui_module");
|
||||
|
||||
fs::remove_dir_all(®istry).ok();
|
||||
fs::remove_dir_all(&inputs).ok();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 4. Errores propagan limpios al CardLoadError
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn load_card_wraps_nickel_error_in_card_load_error() {
|
||||
let dir = unique_dir("wrap-err");
|
||||
let p = write_file(&dir, "bad.ncl", "let x = unknown in x");
|
||||
let err = load_card(&p).unwrap_err();
|
||||
match err {
|
||||
CardLoadError::Nickel(NickelEvalError::Eval { .. }) => {} // expected
|
||||
other => panic!("expected Nickel(Eval), got {other:?}"),
|
||||
}
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// El value-add concreto de Nickel sobre JSON: un contract
|
||||
/// violation se captura en evaluación, ANTES de que el reader
|
||||
/// JSON tenga oportunidad de aceptar un shape mal-tipado. Acá un
|
||||
/// `id | String` con un value que no es String falla en eval-time
|
||||
/// con un mensaje legible. JSON puro lo aceptaría y rompería más
|
||||
/// tarde aguas abajo.
|
||||
#[test]
|
||||
fn nickel_contract_violation_caught_at_eval_time() {
|
||||
let dir = unique_dir("contract-violation");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"bad_id.ncl",
|
||||
r#"
|
||||
{
|
||||
id | String = 42,
|
||||
label = "X",
|
||||
entities = [],
|
||||
menu = [],
|
||||
views = {},
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let err = load_card(&p).unwrap_err();
|
||||
match err {
|
||||
CardLoadError::Nickel(NickelEvalError::Eval { message, .. }) => {
|
||||
// Mensaje de contract violation legible.
|
||||
assert!(
|
||||
message.contains("contract") || message.contains("String"),
|
||||
"msg debe mencionar contract o String: {message}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Nickel(Eval), got {other:?}"),
|
||||
}
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Sanity: un Nickel que evalúa a un shape NO-reconocible (no
|
||||
/// matchea ningún reader) cae en `NoMatchingReader` — la cadena
|
||||
/// Nickel + dispatcher se mantiene coherente.
|
||||
#[test]
|
||||
fn ncl_evaluating_to_unknown_shape_returns_no_matching_reader() {
|
||||
let dir = unique_dir("unknown-shape");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"weird.ncl",
|
||||
r#"{ random = "shape", without = "fingerprint" }"#,
|
||||
);
|
||||
let err = load_card(&p).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, CardLoadError::NoMatchingReader),
|
||||
"expected NoMatchingReader, got {err:?}"
|
||||
);
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Tests E2E de los templates canónicos shipped con el crate.
|
||||
//!
|
||||
//! Cada test escribe un Card user-side en un tempdir, importa el
|
||||
//! template canónico, override id/label/etc., y verifica que el
|
||||
//! brazo lo dispatcha al variant correcto del CardBody con los
|
||||
//! valores merged.
|
||||
//!
|
||||
//! `BRAHMAN_CARDS_TEMPLATES_DIR` se setea localmente en cada test.
|
||||
//! Como Nickel también busca relativo al input file, usamos el env
|
||||
//! para que `import "ente_basic.ncl"` (sin path) resuelva desde
|
||||
//! cualquier ubicación del input.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use brahman_cards::{
|
||||
canonical_templates_dir, load_card, CardBody, BRAHMAN_CARDS_TEMPLATES_ENV,
|
||||
};
|
||||
|
||||
/// Helper: corre `f()` con `BRAHMAN_CARDS_TEMPLATES_ENV` set al
|
||||
/// directorio de templates canónicos, restaurando el env al salir.
|
||||
///
|
||||
/// Tests no son thread-safe entre sí cuando comparten env. Por eso
|
||||
/// quedan en serial via `nextest --test-threads=1` o `cargo test`
|
||||
/// que paralelizara sólo entre `tests/*.rs` distintos. Como este
|
||||
/// archivo encapsula todo el setup de env, aún en paralelo entre
|
||||
/// archivos de tests no chocan (cada thread setea/restaura).
|
||||
fn with_canonical_templates<F: FnOnce()>(f: F) {
|
||||
let prev = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV).ok();
|
||||
let dir = canonical_templates_dir();
|
||||
// SAFETY: env mutation single-threaded en este test.
|
||||
unsafe {
|
||||
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, &dir);
|
||||
}
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, v),
|
||||
None => std::env::remove_var(BRAHMAN_CARDS_TEMPLATES_ENV),
|
||||
}
|
||||
}
|
||||
if let Err(panic) = result {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_dir(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"brahman-cards-templates-{}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0),
|
||||
name
|
||||
));
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ente_basic_template_overridden_loads_as_ente_card() {
|
||||
with_canonical_templates(|| {
|
||||
let dir = unique_dir("ente");
|
||||
let card_path = dir.join("my_ente.ncl");
|
||||
fs::write(
|
||||
&card_path,
|
||||
r#"
|
||||
let base = import "ente_basic.ncl" in
|
||||
base & {
|
||||
id = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
label = "mi-ente",
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let card = load_card(&card_path).expect("load ente");
|
||||
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
|
||||
assert_eq!(card.label, "mi-ente");
|
||||
assert_eq!(card.body.kind_name(), "ente");
|
||||
match card.body {
|
||||
CardBody::Ente(e) => {
|
||||
assert_eq!(e.label, "mi-ente");
|
||||
// Defaults del template intactos.
|
||||
assert_eq!(e.schema_version, 1);
|
||||
// Payload es el "Virtual" del template default.
|
||||
assert!(
|
||||
matches!(e.payload, brahman_card::Payload::Virtual),
|
||||
"payload debería ser Virtual, got {:?}",
|
||||
e.payload
|
||||
);
|
||||
}
|
||||
other => panic!("variant inesperado: {:?}", other.kind_name()),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monad_basic_template_overridden_loads_as_monad_card() {
|
||||
with_canonical_templates(|| {
|
||||
let dir = unique_dir("monad");
|
||||
let card_path = dir.join("my_monad.ncl");
|
||||
fs::write(
|
||||
&card_path,
|
||||
r#"
|
||||
let base = import "monad_basic.ncl" in
|
||||
base & {
|
||||
id = "01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
||||
label = "fotos-2026",
|
||||
cardinality = 5,
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let card = load_card(&card_path).expect("load monad");
|
||||
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAW");
|
||||
assert_eq!(card.label, "fotos-2026");
|
||||
assert_eq!(card.body.kind_name(), "monad");
|
||||
match card.body {
|
||||
CardBody::Monad(m) => {
|
||||
assert_eq!(m.label, "fotos-2026");
|
||||
assert_eq!(m.cardinality, 5);
|
||||
// Defaults del template intactos.
|
||||
assert_eq!(m.schema_version, 1);
|
||||
assert!(m.members.is_empty());
|
||||
assert!(m.summary.is_empty());
|
||||
}
|
||||
other => panic!("variant inesperado: {:?}", other.kind_name()),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_module_basic_template_overridden_loads_as_ui_module_card() {
|
||||
with_canonical_templates(|| {
|
||||
let dir = unique_dir("ui");
|
||||
let card_path = dir.join("my_module.ncl");
|
||||
fs::write(
|
||||
&card_path,
|
||||
r#"
|
||||
let base = import "ui_module_basic.ncl" in
|
||||
base & {
|
||||
id = "customers",
|
||||
label = "Clientes",
|
||||
menu = [{ label = "Lista", view = "list" }],
|
||||
views = {
|
||||
list = {
|
||||
kind = "list",
|
||||
title = "Customers",
|
||||
entity = "Customer",
|
||||
columns = [],
|
||||
},
|
||||
},
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let card = load_card(&card_path).expect("load ui_module");
|
||||
assert_eq!(card.id, "customers");
|
||||
assert_eq!(card.label, "Clientes");
|
||||
assert_eq!(card.body.kind_name(), "ui_module");
|
||||
match card.body {
|
||||
CardBody::UiModule(m) => {
|
||||
assert_eq!(m.id, "customers");
|
||||
assert_eq!(m.menu.len(), 1);
|
||||
assert!(m.views.contains_key("list"));
|
||||
// Defaults del template: entities vacío.
|
||||
assert!(m.entities.is_empty());
|
||||
}
|
||||
other => panic!("variant inesperado: {:?}", other.kind_name()),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_default_id_and_label_pass_through_when_not_overridden() {
|
||||
// Sanity: si el usuario importa el template SIN override de
|
||||
// id/label, los defaults `"TEMPLATE_ID"` y `"TEMPLATE_LABEL"`
|
||||
// pasan al wrapper Card.id/label. El brazo no falla — sólo
|
||||
// los muestra como están. Validar este flow garantiza que un
|
||||
// user "vacío" (importa y no override) carga sin error.
|
||||
with_canonical_templates(|| {
|
||||
let dir = unique_dir("defaults");
|
||||
let card_path = dir.join("noop.ncl");
|
||||
fs::write(&card_path, r#"import "ui_module_basic.ncl""#).unwrap();
|
||||
|
||||
let card = load_card(&card_path).expect("load defaults");
|
||||
assert_eq!(card.id, "TEMPLATE_ID");
|
||||
assert_eq!(card.label, "TEMPLATE_LABEL");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_templates_dir_actually_exists() {
|
||||
// Sanity: el path expuesto por canonical_templates_dir tiene
|
||||
// que apuntar a un directorio que existe físicamente, sino los
|
||||
// tests anteriores fallarían silenciosamente (Nickel reporta
|
||||
// import-not-found pero el test ya estaría roto).
|
||||
let d = canonical_templates_dir();
|
||||
assert!(d.is_dir(), "templates dir no existe: {}", d.display());
|
||||
for fname in ["ente_basic.ncl", "monad_basic.ncl", "ui_module_basic.ncl"] {
|
||||
let p = d.join(fname);
|
||||
assert!(p.is_file(), "template missing: {}", p.display());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "brahman-handshake"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Brahman — handshake runtime Init↔módulo. Local sobre Unix socket; remoto sobre stream libp2p (brahman-net)."
|
||||
|
||||
[dependencies]
|
||||
brahman-card = { path = "../brahman-card" }
|
||||
brahman-broker = { path = "../brahman-broker" }
|
||||
brahman-net = { path = "../brahman-net" }
|
||||
blake3 = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "probe"
|
||||
path = "examples/probe.rs"
|
||||
|
||||
[[example]]
|
||||
name = "subscriber"
|
||||
path = "examples/subscriber.rs"
|
||||
@@ -0,0 +1,51 @@
|
||||
//! probe — herramienta de diagnóstico del handshake.
|
||||
//!
|
||||
//! Conecta a un Init brahman vivo, hace handshake, un ping, y se va.
|
||||
//! Ruta del socket: `$BRAHMAN_INIT_SOCKET` o el default
|
||||
//! ([`brahman_handshake::transport::default_socket_path`]).
|
||||
//!
|
||||
//! Uso:
|
||||
//! ```sh
|
||||
//! cargo run -p brahman-handshake --example probe
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use brahman_card::{Card, Payload, Supervision, CARD_SCHEMA_VERSION};
|
||||
use brahman_handshake::{client::Client, transport};
|
||||
use ulid::Ulid;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let card = Card {
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
label: "brahman-probe".into(),
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::OneShot,
|
||||
provides: BTreeSet::new(),
|
||||
requires: BTreeSet::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let path = transport::default_socket_path();
|
||||
println!("connecting to {}", path.display());
|
||||
|
||||
let mut client = Client::connect(&path, card).await?;
|
||||
let info = client.server_info();
|
||||
println!(
|
||||
" HelloAck: session={} server={} protocol={} init_attached={}",
|
||||
client.session(),
|
||||
info.server_version,
|
||||
info.protocol_version,
|
||||
info.init_attached
|
||||
);
|
||||
|
||||
let ts = client.ping().await?;
|
||||
println!(" Pong: ts={}ms", ts);
|
||||
|
||||
client.farewell().await?;
|
||||
println!(" Farewell OK");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! `subscriber` — cliente brahman que loguea cada `MatchEvent` recibido.
|
||||
//!
|
||||
//! Declara una Card con un input `in` de tipo `json`. Cada vez que el
|
||||
//! broker matchea (o desmatch) ese input contra un productor, imprime
|
||||
//! una línea. Útil para visualizar la dinámica del broker en vivo.
|
||||
//!
|
||||
//! Uso:
|
||||
//! ```sh
|
||||
//! cargo run -p brahman-handshake --example subscriber [label]
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use brahman_card::{
|
||||
ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
|
||||
CARD_SCHEMA_VERSION,
|
||||
};
|
||||
use brahman_handshake::{client::Client, transport};
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let label = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "subscriber".into());
|
||||
|
||||
let card = Card {
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
label: label.clone(),
|
||||
provides: BTreeSet::new(),
|
||||
requires: BTreeSet::new(),
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::OneShot,
|
||||
lifecycle: Lifecycle::Daemon,
|
||||
priority: Priority::Normal,
|
||||
flow: Flows {
|
||||
input: vec![Flow {
|
||||
name: "in".into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: "json".into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
output: vec![],
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let path = transport::default_socket_path();
|
||||
eprintln!("[{label}] connecting to {}", path.display());
|
||||
let mut client = Client::connect(&path, card).await?;
|
||||
eprintln!(
|
||||
"[{label}] attached: session={} init={}",
|
||||
client.session(),
|
||||
client.server_info().init_attached
|
||||
);
|
||||
|
||||
// Loop: espera hasta 25s por un MatchEvent. Si timeout, ping para
|
||||
// mantener la conexión viva.
|
||||
loop {
|
||||
match client.await_event(Duration::from_secs(25)).await? {
|
||||
Some(ev) => {
|
||||
eprintln!(
|
||||
"[{label}] {:?} {} ← {}.{} via={:?}{}",
|
||||
ev.kind,
|
||||
ev.consumer_flow,
|
||||
if ev.producer_label.is_empty() {
|
||||
"<none>"
|
||||
} else {
|
||||
&ev.producer_label
|
||||
},
|
||||
ev.producer_flow,
|
||||
ev.via,
|
||||
if ev.pinned { " 📌" } else { "" }
|
||||
);
|
||||
}
|
||||
None => {
|
||||
let _ts = client.ping().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Cliente de handshake. Conecta a un Unix socket y mantiene la sesión.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use brahman_card::{Card, WitInterface, CARD_SCHEMA_VERSION};
|
||||
use brahman_net::Keypair;
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
use crate::codec::{read_frame, write_frame};
|
||||
use crate::identity::SessionCert;
|
||||
use crate::messages::{Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, Ping, SessionId};
|
||||
use crate::signature::{sign_hello, SignatureError};
|
||||
|
||||
/// Errores del cliente.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ClientError {
|
||||
#[error("E/S: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// El servidor respondió con un error explícito.
|
||||
#[error("servidor: {0}")]
|
||||
Server(#[source] HandshakeError),
|
||||
|
||||
/// El servidor envió un frame que no esperábamos en este punto del protocolo.
|
||||
#[error("frame inesperado: {got}")]
|
||||
UnexpectedFrame { got: &'static str },
|
||||
|
||||
/// La Card que el cliente intentó enviar no pasa su propia validación.
|
||||
#[error("card inválida pre-envío: {0}")]
|
||||
InvalidCard(String),
|
||||
|
||||
/// Firma del Hello falló al construirse (rara — sólo puede pasar
|
||||
/// si la keypair pasada está en un estado inválido).
|
||||
#[error("firma del Hello falló: {0}")]
|
||||
Signature(#[from] SignatureError),
|
||||
}
|
||||
|
||||
/// Cliente conectado y autenticado. Tras `connect` ya completó el handshake
|
||||
/// y tiene su `SessionId`. Los `MatchEvent` recibidos durante operaciones
|
||||
/// request/response se buferean en `pending_events` y se obtienen vía
|
||||
/// [`Client::take_event`] o [`Client::await_event`].
|
||||
///
|
||||
/// Genérico sobre el transport (`AsyncRead + AsyncWrite + Unpin + Send`):
|
||||
/// funciona indistintamente sobre `UnixStream` (path local) o sobre un
|
||||
/// stream libp2p wrapped con `tokio_util::compat` (path remoto, vía
|
||||
/// `brahman_handshake::network`).
|
||||
#[derive(Debug)]
|
||||
pub struct Client<S = UnixStream> {
|
||||
stream: S,
|
||||
session: SessionId,
|
||||
server_info: HelloAck,
|
||||
pending_events: VecDeque<MatchEvent>,
|
||||
}
|
||||
|
||||
impl Client<UnixStream> {
|
||||
/// Conecta como módulo agnóstico (sin WIT) sobre Unix socket.
|
||||
/// Equivalente a `connect_with(path, card, None)`.
|
||||
pub async fn connect(path: impl AsRef<Path>, card: Card) -> Result<Self, ClientError> {
|
||||
Self::connect_with(path, card, None).await
|
||||
}
|
||||
|
||||
/// Conecta al socket Unix enviando Hello con la Card dada y
|
||||
/// opcionalmente una `WitInterface` ya extraída. Si `wit` es `Some`,
|
||||
/// el server registra el módulo como "consciente".
|
||||
pub async fn connect_with(
|
||||
path: impl AsRef<Path>,
|
||||
card: Card,
|
||||
wit: Option<WitInterface>,
|
||||
) -> Result<Self, ClientError> {
|
||||
let stream = UnixStream::connect(path).await?;
|
||||
Self::connect_with_stream(stream, card, wit).await
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Client<S>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send,
|
||||
{
|
||||
/// Constructor genérico sobre un stream ya abierto, **sin firma**.
|
||||
/// Apto para path Unix (donde SO_PEERCRED del kernel ya autentica)
|
||||
/// o tests in-memory. Para libp2p remoto usá
|
||||
/// [`connect_with_stream_signed`](Self::connect_with_stream_signed) —
|
||||
/// el server libp2p rechaza Hello sin firma.
|
||||
pub async fn connect_with_stream(
|
||||
stream: S,
|
||||
card: Card,
|
||||
wit: Option<WitInterface>,
|
||||
) -> Result<Self, ClientError> {
|
||||
Self::connect_inner(stream, card, wit, None, None).await
|
||||
}
|
||||
|
||||
/// Igual que `connect_with_stream` pero firma el Hello con
|
||||
/// `keypair`. Usar para conexiones libp2p donde el server exige
|
||||
/// firma. La public key derivada de `keypair` debe coincidir con
|
||||
/// el `peer_id` libp2p autenticado por Noise — típicamente la
|
||||
/// keypair pasada a [`brahman_net::BrahmanNet::with_keypair`].
|
||||
pub async fn connect_with_stream_signed(
|
||||
stream: S,
|
||||
card: Card,
|
||||
wit: Option<WitInterface>,
|
||||
keypair: &Keypair,
|
||||
) -> Result<Self, ClientError> {
|
||||
Self::connect_inner(stream, card, wit, Some(keypair), None).await
|
||||
}
|
||||
|
||||
/// Igual que `connect_with_stream_signed` pero además adjunta un
|
||||
/// `SessionCert` que vincula la session keypair a una identity
|
||||
/// master estable. El server, al recibir el cert, evalúa la
|
||||
/// política de admisión contra el `master_peer_id` (no contra
|
||||
/// el session peer_id) — permitiendo rotar la session sin perder
|
||||
/// la identidad reconocida en allowlists remotas.
|
||||
pub async fn connect_with_stream_signed_with_cert(
|
||||
stream: S,
|
||||
card: Card,
|
||||
wit: Option<WitInterface>,
|
||||
session_keypair: &Keypair,
|
||||
identity_cert: SessionCert,
|
||||
) -> Result<Self, ClientError> {
|
||||
Self::connect_inner(stream, card, wit, Some(session_keypair), Some(identity_cert)).await
|
||||
}
|
||||
|
||||
async fn connect_inner(
|
||||
mut stream: S,
|
||||
card: Card,
|
||||
wit: Option<WitInterface>,
|
||||
keypair: Option<&Keypair>,
|
||||
identity_cert: Option<SessionCert>,
|
||||
) -> Result<Self, ClientError> {
|
||||
card.validate()
|
||||
.map_err(|e| ClientError::InvalidCard(e.to_string()))?;
|
||||
|
||||
let wire_card = brahman_card::WireCard::from(card);
|
||||
let signature = match keypair {
|
||||
Some(kp) => Some(sign_hello(kp, &wire_card, &wit)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let hello = Hello {
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
|
||||
card: wire_card,
|
||||
wit,
|
||||
signature,
|
||||
identity_cert,
|
||||
};
|
||||
write_frame(&mut stream, &Frame::Hello(hello)).await?;
|
||||
|
||||
let frame = read_frame(&mut stream).await?;
|
||||
let ack = match frame {
|
||||
Frame::HelloAck(a) => a,
|
||||
Frame::Error(e) => return Err(ClientError::Server(e)),
|
||||
Frame::Hello(_) => return Err(ClientError::UnexpectedFrame { got: "Hello" }),
|
||||
Frame::Ping(_) => return Err(ClientError::UnexpectedFrame { got: "Ping" }),
|
||||
Frame::Pong(_) => return Err(ClientError::UnexpectedFrame { got: "Pong" }),
|
||||
Frame::Farewell(_) => return Err(ClientError::UnexpectedFrame { got: "Farewell" }),
|
||||
Frame::MatchEvent(_) => {
|
||||
return Err(ClientError::UnexpectedFrame {
|
||||
got: "MatchEvent (pre-handshake)",
|
||||
});
|
||||
}
|
||||
Frame::ListSessions(_) => {
|
||||
return Err(ClientError::UnexpectedFrame {
|
||||
got: "ListSessions (pre-handshake)",
|
||||
});
|
||||
}
|
||||
Frame::SessionList(_) => {
|
||||
return Err(ClientError::UnexpectedFrame {
|
||||
got: "SessionList (pre-handshake)",
|
||||
});
|
||||
}
|
||||
Frame::ListMatches(_) => {
|
||||
return Err(ClientError::UnexpectedFrame {
|
||||
got: "ListMatches (pre-handshake)",
|
||||
});
|
||||
}
|
||||
Frame::MatchList(_) => {
|
||||
return Err(ClientError::UnexpectedFrame {
|
||||
got: "MatchList (pre-handshake)",
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
stream,
|
||||
session: ack.session,
|
||||
server_info: ack,
|
||||
pending_events: VecDeque::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `SessionId` asignado por el servidor.
|
||||
pub fn session(&self) -> SessionId {
|
||||
self.session
|
||||
}
|
||||
|
||||
/// Información del servidor recibida en el handshake.
|
||||
pub fn server_info(&self) -> &HelloAck {
|
||||
&self.server_info
|
||||
}
|
||||
|
||||
/// Envía un Ping y devuelve el timestamp del servidor. Los frames
|
||||
/// `MatchEvent` que lleguen mezclados se buferean en `pending_events`.
|
||||
pub async fn ping(&mut self) -> Result<u64, ClientError> {
|
||||
write_frame(
|
||||
&mut self.stream,
|
||||
&Frame::Ping(Ping {
|
||||
session: self.session,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
loop {
|
||||
match read_frame(&mut self.stream).await? {
|
||||
Frame::Pong(p) => return Ok(p.timestamp_ms),
|
||||
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
|
||||
Frame::Error(e) => return Err(ClientError::Server(e)),
|
||||
_ => return Err(ClientError::UnexpectedFrame { got: "non-pong" }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saca un evento pendiente del buffer, sin bloquear ni leer del wire.
|
||||
pub fn take_event(&mut self) -> Option<MatchEvent> {
|
||||
self.pending_events.pop_front()
|
||||
}
|
||||
|
||||
/// Espera un `MatchEvent` con timeout. Drena primero el buffer; si
|
||||
/// está vacío, lee del wire hasta el timeout. Otros frames recibidos
|
||||
/// (Pong huérfano, Error) cortan la espera con error.
|
||||
pub async fn await_event(
|
||||
&mut self,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<MatchEvent>, ClientError> {
|
||||
if let Some(ev) = self.pending_events.pop_front() {
|
||||
return Ok(Some(ev));
|
||||
}
|
||||
match tokio::time::timeout(timeout, read_frame(&mut self.stream)).await {
|
||||
Err(_) => Ok(None),
|
||||
Ok(Err(e)) => Err(ClientError::Io(e)),
|
||||
Ok(Ok(Frame::MatchEvent(ev))) => Ok(Some(ev)),
|
||||
Ok(Ok(Frame::Error(e))) => Err(ClientError::Server(e)),
|
||||
Ok(Ok(_)) => Err(ClientError::UnexpectedFrame {
|
||||
got: "non-event en await_event",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pide al servidor el listado de sesiones activas. Pensado para
|
||||
/// observadores (broker-explorer, CLIs de diagnóstico). Como
|
||||
/// `ping`, los `MatchEvent` que lleguen intercalados se bufean
|
||||
/// en `pending_events` y no rompen la respuesta.
|
||||
pub async fn list_sessions(&mut self) -> Result<crate::messages::SessionList, ClientError> {
|
||||
write_frame(
|
||||
&mut self.stream,
|
||||
&Frame::ListSessions(crate::messages::ListSessions {
|
||||
session: self.session,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
loop {
|
||||
match read_frame(&mut self.stream).await? {
|
||||
Frame::SessionList(list) => return Ok(list),
|
||||
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
|
||||
Frame::Error(e) => return Err(ClientError::Server(e)),
|
||||
_ => {
|
||||
return Err(ClientError::UnexpectedFrame {
|
||||
got: "non-session-list",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pide al servidor el listado de matches actuales del broker
|
||||
/// (consumer↔producer pares con tipo y estrategia). Mismo patrón
|
||||
/// de drenado de `MatchEvent`s intermedios.
|
||||
pub async fn list_matches(&mut self) -> Result<crate::messages::MatchList, ClientError> {
|
||||
write_frame(
|
||||
&mut self.stream,
|
||||
&Frame::ListMatches(crate::messages::ListMatches {
|
||||
session: self.session,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
loop {
|
||||
match read_frame(&mut self.stream).await? {
|
||||
Frame::MatchList(list) => return Ok(list),
|
||||
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
|
||||
Frame::Error(e) => return Err(ClientError::Server(e)),
|
||||
_ => {
|
||||
return Err(ClientError::UnexpectedFrame {
|
||||
got: "non-match-list",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cierre cooperativo. Consume el cliente.
|
||||
pub async fn farewell(mut self) -> Result<(), ClientError> {
|
||||
write_frame(
|
||||
&mut self.stream,
|
||||
&Frame::Farewell(Farewell {
|
||||
session: self.session,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Codec de wire: frames length-prefixed con cuerpo postcard.
|
||||
//!
|
||||
//! Cada frame en el stream tiene la forma:
|
||||
//! ```text
|
||||
//! [4 bytes LE: longitud N] [N bytes: postcard(Frame)]
|
||||
//! ```
|
||||
//!
|
||||
//! El `MAX_FRAME_BYTES` evita que un cliente malicioso/buggy reserve memoria
|
||||
//! arbitraria al anunciar un length absurdo.
|
||||
|
||||
use std::io::{Error, ErrorKind, Result};
|
||||
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
use crate::messages::Frame;
|
||||
|
||||
/// Tamaño máximo de un frame antes de que el reader rechace la conexión.
|
||||
/// 4 MiB cubre cualquier Card razonable con margen amplio.
|
||||
pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Escribe un frame al stream.
|
||||
pub async fn write_frame<W: AsyncWrite + Unpin>(w: &mut W, frame: &Frame) -> Result<()> {
|
||||
let bytes = postcard::to_allocvec(frame)
|
||||
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("postcard encode: {e}")))?;
|
||||
if bytes.len() > MAX_FRAME_BYTES {
|
||||
return Err(Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("frame demasiado grande: {} bytes", bytes.len()),
|
||||
));
|
||||
}
|
||||
let len = bytes.len() as u32;
|
||||
w.write_all(&len.to_le_bytes()).await?;
|
||||
w.write_all(&bytes).await?;
|
||||
w.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lee un frame del stream.
|
||||
pub async fn read_frame<R: AsyncRead + Unpin>(r: &mut R) -> Result<Frame> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
r.read_exact(&mut len_buf).await?;
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
if len > MAX_FRAME_BYTES {
|
||||
return Err(Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("frame anunciado demasiado grande: {len} bytes"),
|
||||
));
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
r.read_exact(&mut buf).await?;
|
||||
postcard::from_bytes(&buf)
|
||||
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("postcard decode: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::messages::{Frame, HandshakeError};
|
||||
|
||||
#[tokio::test]
|
||||
async fn frame_roundtrip() {
|
||||
let frame = Frame::Error(HandshakeError::Rejected("test".into()));
|
||||
let mut buf = Vec::new();
|
||||
write_frame(&mut buf, &frame).await.unwrap();
|
||||
let mut cursor = std::io::Cursor::new(buf);
|
||||
let decoded = read_frame(&mut cursor).await.unwrap();
|
||||
match decoded {
|
||||
Frame::Error(HandshakeError::Rejected(s)) => assert_eq!(s, "test"),
|
||||
_ => panic!("variant mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//! Identidad multi-key del nodo: separación entre **identity** (master,
|
||||
//! persistente forever) y **session** (keypair libp2p efímera, rotable).
|
||||
//!
|
||||
//! ## Problema que resuelve
|
||||
//!
|
||||
//! Hasta Fase 3, el `peer_id` libp2p era la única identidad. Rotar la
|
||||
//! keypair (por compromiso, por higiene, por cambio de hardware)
|
||||
//! cambiaba el peer_id, lo que invalidaba todas las allowlists
|
||||
//! remotas y desconectaba al nodo de la malla. Imposible rotar sin
|
||||
//! coordinar.
|
||||
//!
|
||||
//! ## Modelo
|
||||
//!
|
||||
//! Cada nodo tiene **dos** keypairs Ed25519:
|
||||
//!
|
||||
//! - **Identity** (master): persistente para siempre. Identifica al
|
||||
//! nodo como entidad lógica. Su `peer_id` es lo que va en
|
||||
//! allowlists/denylists remotas.
|
||||
//! - **Session** (operacional): la que libp2p usa para Noise. Puede
|
||||
//! rotarse libremente sin coordinar — el nodo emite un
|
||||
//! [`SessionCert`] firmado con la identity que prueba "esta session
|
||||
//! key pertenece a mí".
|
||||
//!
|
||||
//! ## Wire
|
||||
//!
|
||||
//! El cert viaja en `Hello.identity_cert: Option<SessionCert>`. El
|
||||
//! server valida:
|
||||
//! 1. La session key del cert == public key de `Hello.signature` ==
|
||||
//! deriva al peer_id autenticado por Noise (consistencia interna).
|
||||
//! 2. La firma del cert verifica con la master pubkey declarada.
|
||||
//! 3. El cert no está expirado.
|
||||
//! 4. La política (allowlist/denylist) se evalúa contra
|
||||
//! `master.to_peer_id()`, NO contra el session peer_id.
|
||||
//!
|
||||
//! Sin cert, el server cae al modelo de Fase 3: policy contra session
|
||||
//! peer_id (compat). Esto permite migración gradual.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use brahman_net::{Keypair, PeerId, PublicKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// TTL recomendado para un session cert: 24 horas. Suficiente para
|
||||
/// que un nodo "viva" un día sin re-emitir; corto enough para que
|
||||
/// un cert robado no sirva por mucho. Operadores con políticas
|
||||
/// estrictas pueden bajarlo; con uptime largo, subirlo.
|
||||
pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Identidad lógica del nodo. Wraps la master keypair y emite certs
|
||||
/// de session firmados.
|
||||
///
|
||||
/// **Critical**: la master keypair NUNCA debe filtrarse a la red.
|
||||
/// Sólo se usa para firmar certs locales y para derivar
|
||||
/// `master_peer_id`. Ni siquiera el swarm libp2p la ve — ese usa la
|
||||
/// session keypair.
|
||||
#[derive(Clone)]
|
||||
pub struct Identity {
|
||||
master: Arc<Keypair>,
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
/// Construye una Identity a partir de una keypair existente.
|
||||
/// Típicamente cargada desde disco vía `keypair_store::load_or_generate`.
|
||||
pub fn from_keypair(master: Keypair) -> Self {
|
||||
Self {
|
||||
master: Arc::new(master),
|
||||
}
|
||||
}
|
||||
|
||||
/// Variante para callers que ya tienen la keypair en `Arc`.
|
||||
pub fn from_arc(master: Arc<Keypair>) -> Self {
|
||||
Self { master }
|
||||
}
|
||||
|
||||
/// PeerId derivado de la master pubkey. Ésta es la identidad
|
||||
/// "lógica" estable del nodo — lo que va en allowlists/denylists.
|
||||
pub fn master_peer_id(&self) -> PeerId {
|
||||
self.master.public().to_peer_id()
|
||||
}
|
||||
|
||||
/// Emite un [`SessionCert`] firmado: certifica que la session
|
||||
/// keypair `session` pertenece a esta identity hasta `now + ttl`.
|
||||
pub fn issue_session_cert(
|
||||
&self,
|
||||
session: &Keypair,
|
||||
ttl: Duration,
|
||||
) -> Result<SessionCert, CertError> {
|
||||
let now_ms = now_unix_ms();
|
||||
let expires_at_ms = now_ms.saturating_add(ttl.as_millis() as u64);
|
||||
let session_pubkey = session.public().encode_protobuf();
|
||||
let master_pubkey = self.master.public().encode_protobuf();
|
||||
let payload = sign_payload(&session_pubkey, expires_at_ms);
|
||||
let signature = self
|
||||
.master
|
||||
.sign(&payload)
|
||||
.map_err(|e| CertError::Sign(e.to_string()))?;
|
||||
Ok(SessionCert {
|
||||
version: SESSION_CERT_VERSION,
|
||||
session_pubkey,
|
||||
master_pubkey,
|
||||
expires_at_ms,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Identity {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Identity")
|
||||
.field("master_peer_id", &self.master_peer_id())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Versión del esquema del cert. Bump al cambiar `sign_payload` o
|
||||
/// el shape de `SessionCert`.
|
||||
pub const SESSION_CERT_VERSION: u8 = 1;
|
||||
|
||||
/// Certificado firmado por la identity que vincula una session key
|
||||
/// libp2p a la identidad master del nodo, con expiración.
|
||||
///
|
||||
/// **Wire**: viaja en `Hello.identity_cert`. Las pubkeys van en
|
||||
/// formato canónico libp2p (`encode_protobuf`) — mismo encoding que
|
||||
/// `HelloSignature.public_key`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionCert {
|
||||
/// Versión del esquema (ver `SESSION_CERT_VERSION`).
|
||||
pub version: u8,
|
||||
/// Public key de la session libp2p (la que firma el Hello), en
|
||||
/// formato libp2p protobuf.
|
||||
pub session_pubkey: Vec<u8>,
|
||||
/// Public key de la master identity, en formato libp2p protobuf.
|
||||
/// El verificador deriva el `master_peer_id` desde acá.
|
||||
pub master_pubkey: Vec<u8>,
|
||||
/// Expiración en milisegundos desde UNIX_EPOCH. Tras esto, el
|
||||
/// cert no es válido y el nodo debe re-emitirse uno nuevo
|
||||
/// (rotando o re-firmando la misma session).
|
||||
pub expires_at_ms: u64,
|
||||
/// Firma Ed25519 del master sobre `sign_payload(session_pubkey, expires_at_ms)`.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CertError {
|
||||
#[error("versión de cert desconocida: {0} (esperaba {SESSION_CERT_VERSION})")]
|
||||
UnknownVersion(u8),
|
||||
#[error("decode master_pubkey: {0}")]
|
||||
DecodeMaster(String),
|
||||
#[error("decode session_pubkey: {0}")]
|
||||
DecodeSession(String),
|
||||
#[error("firma del cert inválida")]
|
||||
InvalidSignature,
|
||||
#[error("cert expirado: expires_at_ms={expires}, now_ms={now}")]
|
||||
Expired { expires: u64, now: u64 },
|
||||
#[error("session_pubkey del cert no coincide con la del Hello.signature")]
|
||||
SessionMismatch,
|
||||
#[error("error al firmar: {0}")]
|
||||
Sign(String),
|
||||
}
|
||||
|
||||
impl SessionCert {
|
||||
/// Verifica el cert: versión, firma criptográfica, no expiración.
|
||||
/// Devuelve el `(master_peer_id, session_peer_id)` derivados.
|
||||
///
|
||||
/// El caller debe además chequear que `session_peer_id` coincide
|
||||
/// con el peer_id autenticado por Noise (lo verifica
|
||||
/// [`verify_against_session`]).
|
||||
pub fn verify(&self) -> Result<(PeerId, PeerId), CertError> {
|
||||
if self.version != SESSION_CERT_VERSION {
|
||||
return Err(CertError::UnknownVersion(self.version));
|
||||
}
|
||||
let master_pk = PublicKey::try_decode_protobuf(&self.master_pubkey)
|
||||
.map_err(|e| CertError::DecodeMaster(e.to_string()))?;
|
||||
let session_pk = PublicKey::try_decode_protobuf(&self.session_pubkey)
|
||||
.map_err(|e| CertError::DecodeSession(e.to_string()))?;
|
||||
let payload = sign_payload(&self.session_pubkey, self.expires_at_ms);
|
||||
if !master_pk.verify(&payload, &self.signature) {
|
||||
return Err(CertError::InvalidSignature);
|
||||
}
|
||||
let now = now_unix_ms();
|
||||
if now >= self.expires_at_ms {
|
||||
return Err(CertError::Expired {
|
||||
expires: self.expires_at_ms,
|
||||
now,
|
||||
});
|
||||
}
|
||||
Ok((master_pk.to_peer_id(), session_pk.to_peer_id()))
|
||||
}
|
||||
|
||||
/// Verifica el cert Y exige que su `session_pubkey` matchee a
|
||||
/// `expected_session_pubkey` (la que firmó el Hello). Esto
|
||||
/// previene que un atacante reutilice un cert válido con una
|
||||
/// session key distinta.
|
||||
///
|
||||
/// Devuelve el `master_peer_id` derivado, que es el que el server
|
||||
/// debe usar para evaluar la política de admisión.
|
||||
pub fn verify_against_session(
|
||||
&self,
|
||||
expected_session_pubkey: &[u8],
|
||||
) -> Result<PeerId, CertError> {
|
||||
if self.session_pubkey.as_slice() != expected_session_pubkey {
|
||||
return Err(CertError::SessionMismatch);
|
||||
}
|
||||
let (master_peer, _session_peer) = self.verify()?;
|
||||
Ok(master_peer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Concat canónico de los campos firmados. Cualquier cambio aquí
|
||||
/// rompe compatibilidad — bump `SESSION_CERT_VERSION`.
|
||||
fn sign_payload(session_pubkey: &[u8], expires_at_ms: u64) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(1 + 4 + session_pubkey.len() + 8);
|
||||
buf.push(SESSION_CERT_VERSION);
|
||||
buf.extend_from_slice(b"sess");
|
||||
buf.extend_from_slice(&(session_pubkey.len() as u32).to_le_bytes());
|
||||
buf.extend_from_slice(session_pubkey);
|
||||
buf.extend_from_slice(&expires_at_ms.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn issue_and_verify_cert() {
|
||||
let master = Keypair::generate_ed25519();
|
||||
let session = Keypair::generate_ed25519();
|
||||
let id = Identity::from_keypair(master);
|
||||
let cert = id
|
||||
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
|
||||
.unwrap();
|
||||
let (master_peer, session_peer) = cert.verify().unwrap();
|
||||
assert_eq!(master_peer, id.master_peer_id());
|
||||
assert_eq!(session_peer, session.public().to_peer_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_against_session_admits_matching() {
|
||||
let master = Keypair::generate_ed25519();
|
||||
let session = Keypair::generate_ed25519();
|
||||
let id = Identity::from_keypair(master);
|
||||
let cert = id
|
||||
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
|
||||
.unwrap();
|
||||
let session_pk = session.public().encode_protobuf();
|
||||
let master_peer = cert.verify_against_session(&session_pk).unwrap();
|
||||
assert_eq!(master_peer, id.master_peer_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_against_session_rejects_mismatch() {
|
||||
let master = Keypair::generate_ed25519();
|
||||
let session_a = Keypair::generate_ed25519();
|
||||
let session_b = Keypair::generate_ed25519();
|
||||
let id = Identity::from_keypair(master);
|
||||
let cert = id
|
||||
.issue_session_cert(&session_a, DEFAULT_SESSION_TTL)
|
||||
.unwrap();
|
||||
let other_pk = session_b.public().encode_protobuf();
|
||||
let err = cert.verify_against_session(&other_pk).unwrap_err();
|
||||
assert!(matches!(err, CertError::SessionMismatch), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_with_zero_ttl_is_expired() {
|
||||
let master = Keypair::generate_ed25519();
|
||||
let session = Keypair::generate_ed25519();
|
||||
let id = Identity::from_keypair(master);
|
||||
let cert = id
|
||||
.issue_session_cert(&session, Duration::from_secs(0))
|
||||
.unwrap();
|
||||
// Pequeña espera para asegurar que now_ms > expires_at_ms.
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
let err = cert.verify().unwrap_err();
|
||||
assert!(matches!(err, CertError::Expired { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_signature_rejected() {
|
||||
let master = Keypair::generate_ed25519();
|
||||
let session = Keypair::generate_ed25519();
|
||||
let id = Identity::from_keypair(master);
|
||||
let mut cert = id
|
||||
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
|
||||
.unwrap();
|
||||
if let Some(b) = cert.signature.last_mut() {
|
||||
*b ^= 0x01;
|
||||
}
|
||||
let err = cert.verify().unwrap_err();
|
||||
assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_expires_at_rejected() {
|
||||
// Si alguien extiende el expires_at sin re-firmar, la firma
|
||||
// no cuadra → InvalidSignature.
|
||||
let master = Keypair::generate_ed25519();
|
||||
let session = Keypair::generate_ed25519();
|
||||
let id = Identity::from_keypair(master);
|
||||
let mut cert = id
|
||||
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
|
||||
.unwrap();
|
||||
cert.expires_at_ms = cert.expires_at_ms.saturating_add(1_000_000);
|
||||
let err = cert.verify().unwrap_err();
|
||||
assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_version_rejected() {
|
||||
let master = Keypair::generate_ed25519();
|
||||
let session = Keypair::generate_ed25519();
|
||||
let id = Identity::from_keypair(master);
|
||||
let mut cert = id
|
||||
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
|
||||
.unwrap();
|
||||
cert.version = 99;
|
||||
let err = cert.verify().unwrap_err();
|
||||
assert!(matches!(err, CertError::UnknownVersion(99)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_session_with_same_master_yields_same_master_peer_id() {
|
||||
// La propiedad fundamental: rotar la session key NO cambia el
|
||||
// master_peer_id derivado del cert.
|
||||
let master = Keypair::generate_ed25519();
|
||||
let id = Identity::from_keypair(master);
|
||||
let original_master_peer = id.master_peer_id();
|
||||
|
||||
let session1 = Keypair::generate_ed25519();
|
||||
let cert1 = id
|
||||
.issue_session_cert(&session1, DEFAULT_SESSION_TTL)
|
||||
.unwrap();
|
||||
let (master_from_cert1, _) = cert1.verify().unwrap();
|
||||
|
||||
// Rotar: nueva session keypair, mismo master.
|
||||
let session2 = Keypair::generate_ed25519();
|
||||
let cert2 = id
|
||||
.issue_session_cert(&session2, DEFAULT_SESSION_TTL)
|
||||
.unwrap();
|
||||
let (master_from_cert2, _) = cert2.verify().unwrap();
|
||||
|
||||
assert_eq!(master_from_cert1, original_master_peer);
|
||||
assert_eq!(master_from_cert2, original_master_peer);
|
||||
assert_eq!(
|
||||
master_from_cert1, master_from_cert2,
|
||||
"rotar session NO debe cambiar el master_peer_id"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! `brahman-handshake` — protocolo runtime Init↔módulo sobre Unix socket.
|
||||
//!
|
||||
//! Implementa la versión concreta de `shared_wit/protocol.wit` (handshake +
|
||||
//! lifecycle): un servidor que vive en el Init (o un Admin proxy) y clientes
|
||||
//! que son los módulos Brahman. Cada conexión arranca con un `Hello` que
|
||||
//! lleva una [`brahman_card::Card`]; el servidor valida la Card, deriva el
|
||||
//! [`TrustLevel`], emite un `HelloAck` con `session-id` ULID, y a partir de
|
||||
//! ahí acepta `Ping`/`Farewell`.
|
||||
//!
|
||||
//! Wire format: frames length-prefixed (4 bytes LE) con cuerpo
|
||||
//! [`postcard`]-codificado. Compacto, rápido y reversible.
|
||||
//!
|
||||
//! Esto NO es la implementación WIT/WASM (que generaría wit-bindgen). Es la
|
||||
//! implementación nativa Rust↔Rust que cubre el caso común antes de que los
|
||||
//! módulos WASM consuman el mismo contrato vía ABI generada.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
pub mod codec;
|
||||
pub mod identity;
|
||||
pub mod messages;
|
||||
pub mod server;
|
||||
pub mod client;
|
||||
pub mod network;
|
||||
pub mod peer_policy;
|
||||
pub mod signature;
|
||||
pub mod transport;
|
||||
|
||||
pub use brahman_card::PROTOCOL_VERSION;
|
||||
|
||||
/// Versión del crate de handshake (independiente de `PROTOCOL_VERSION`).
|
||||
pub const HANDSHAKE_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Mensajes del protocolo de handshake.
|
||||
//!
|
||||
//! Todos los mensajes que cruzan el wire son variantes de [`Frame`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use brahman_broker::MatchStrategy;
|
||||
use brahman_card::{TypeRef, WireCard, WitInterface};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ulid::Ulid;
|
||||
|
||||
/// Identificador de sesión emitido por el servidor en `HelloAck`.
|
||||
pub type SessionId = Ulid;
|
||||
|
||||
/// Saludo inicial del módulo. Lleva la Card en forma `WireCard`
|
||||
/// (postcard-friendly: sin extensiones JSON arbitrarias). El servidor
|
||||
/// la convierte a `Card` para uso interno. Opcionalmente, una
|
||||
/// `WitInterface` ya extraída — si está presente, el módulo es
|
||||
/// "consciente" y el server lo registra como `ResolvedCard::from_conscious`.
|
||||
///
|
||||
/// **Firma (Fase 3, trust remoto)**: el campo `signature` es
|
||||
/// obligatorio para conexiones libp2p (donde el server exige que la
|
||||
/// public key derive al `peer_id` autenticado por Noise) y opcional
|
||||
/// para Unix socket (donde SO_PEERCRED del kernel ya provee
|
||||
/// autenticación). La firma cubre los bytes postcard de
|
||||
/// `(WireCard, Option<WitInterface>)` — ver
|
||||
/// [`HelloSignature::sign_payload`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
/// Versión del schema de Card que el cliente sigue.
|
||||
pub schema_version: u16,
|
||||
/// Versión del protocolo handshake del cliente.
|
||||
pub protocol_version: String,
|
||||
/// Tarjeta de Presentación, proyectada al wire.
|
||||
pub card: WireCard,
|
||||
/// Interfaz WIT extraída por el cliente (típicamente con
|
||||
/// `brahman-card-wit`). `None` si el módulo es agnóstico.
|
||||
#[serde(default)]
|
||||
pub wit: Option<WitInterface>,
|
||||
/// Firma Ed25519 sobre `(card, wit)`. Requerida para conexiones
|
||||
/// remotas (libp2p); opcional para Unix socket. Ver módulo
|
||||
/// [`super::signature`] para construcción y verificación.
|
||||
#[serde(default)]
|
||||
pub signature: Option<HelloSignature>,
|
||||
/// Cert opcional que vincula la session keypair (la que firma el
|
||||
/// Hello) a una **identity master** estable. Si está presente,
|
||||
/// la política de admisión se evalúa contra el `master_peer_id`
|
||||
/// derivado del cert — no contra el session peer_id. Esto permite
|
||||
/// rotar la session sin invalidar las allowlists remotas.
|
||||
///
|
||||
/// Ver [`super::identity::SessionCert`] para shape y semantics.
|
||||
/// Si es `None`, fallback al modelo de Fase 3: la política
|
||||
/// evalúa el session peer_id directamente.
|
||||
#[serde(default)]
|
||||
pub identity_cert: Option<crate::identity::SessionCert>,
|
||||
}
|
||||
|
||||
/// Firma de un Hello. La `public_key` viaja en el formato canónico
|
||||
/// libp2p (protobuf) — el verificador la decodifica y compara su
|
||||
/// `peer_id` derivado con la identidad libp2p autenticada por Noise.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HelloSignature {
|
||||
/// Public key del firmante, encoded como `libp2p::identity::PublicKey::encode_protobuf()`.
|
||||
pub public_key: Vec<u8>,
|
||||
/// Bytes de la firma Ed25519 sobre el payload canonical.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Respuesta del servidor a un `Hello` aceptado.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HelloAck {
|
||||
/// Versión del crate del servidor.
|
||||
pub server_version: String,
|
||||
/// Versión del protocolo soportada por el servidor.
|
||||
pub protocol_version: String,
|
||||
/// Identificador de sesión asignado.
|
||||
pub session: SessionId,
|
||||
/// `true` si el Init está vinculado al servidor; `false` si el servidor
|
||||
/// corre standalone (modo degradado).
|
||||
pub init_attached: bool,
|
||||
}
|
||||
|
||||
/// Latido del cliente. El servidor responde con [`Pong`] llevando su reloj.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Ping {
|
||||
pub session: SessionId,
|
||||
}
|
||||
|
||||
/// Respuesta a un `Ping` con timestamp del servidor (ms desde UNIX_EPOCH).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Pong {
|
||||
pub timestamp_ms: u64,
|
||||
}
|
||||
|
||||
/// Cierre cooperativo de la sesión por parte del cliente.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Farewell {
|
||||
pub session: SessionId,
|
||||
}
|
||||
|
||||
/// Errores del protocolo emitidos por el servidor.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
|
||||
pub enum HandshakeError {
|
||||
#[error("protocolo incompatible: {0}")]
|
||||
ProtocolMismatch(String),
|
||||
#[error("card inválida: {0}")]
|
||||
InvalidCard(String),
|
||||
#[error("schema de card incompatible: cliente={client}, servidor={server}")]
|
||||
SchemaMismatch { client: u16, server: u16 },
|
||||
#[error("sin autorización: {0}")]
|
||||
Unauthorized(String),
|
||||
#[error("capacidad requerida no satisfecha: {0}")]
|
||||
CapabilityUnmet(String),
|
||||
#[error("rechazado: {0}")]
|
||||
Rejected(String),
|
||||
#[error("error interno: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Notificación push del server al consumer: un match disponible o perdido.
|
||||
///
|
||||
/// El server emite `Available` cuando un productor empieza a satisfacer un
|
||||
/// `flow.input` del consumer (ya sea porque el productor acaba de
|
||||
/// registrarse, o porque cambió el match anterior). Emite `Lost` cuando
|
||||
/// el productor previo dejó de satisfacer el input (desregistro o
|
||||
/// cambio de match).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MatchEvent {
|
||||
pub kind: MatchEventKind,
|
||||
/// Nombre del input del consumer al que aplica el evento.
|
||||
pub consumer_flow: String,
|
||||
/// Sesión y label del productor (en `Lost` puede ser nil/vacío).
|
||||
pub producer_session: SessionId,
|
||||
pub producer_label: String,
|
||||
pub producer_flow: String,
|
||||
/// Tipo del flujo matcheado.
|
||||
pub ty: TypeRef,
|
||||
/// Estrategia que ganó (relevante en `Available`).
|
||||
pub via: MatchStrategy,
|
||||
/// `true` si fue resuelto por `pin_to`.
|
||||
pub pinned: bool,
|
||||
/// Socket de servicio (data plane) que declaró el productor.
|
||||
/// Si está presente, el consumer puede conectar directo sin
|
||||
/// pasar por discovery adicional. `None` si el productor no
|
||||
/// declaró service_socket en su Card.
|
||||
#[serde(default)]
|
||||
pub producer_service_socket: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum MatchEventKind {
|
||||
Available,
|
||||
Lost,
|
||||
}
|
||||
|
||||
/// Pedido de listado de sesiones activas registradas en el broker. La
|
||||
/// `session` es el id propio del que pregunta — el server lo valida
|
||||
/// contra la sesión actual de la conexión, mismo patrón que `Ping`.
|
||||
///
|
||||
/// Pensado para herramientas de observabilidad (broker-explorer y
|
||||
/// CLIs de diagnóstico). No expone secrets: sólo metadata pública
|
||||
/// que el módulo ya anunció en su `Hello`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListSessions {
|
||||
pub session: SessionId,
|
||||
}
|
||||
|
||||
/// Una entrada en la respuesta a `ListSessions`. Slim por diseño —
|
||||
/// el observer arma la UI con esto sin tener que abrir conexiones
|
||||
/// adicionales por sesión.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionEntry {
|
||||
pub session: SessionId,
|
||||
/// Label declarado en `WireCard.label` — el "nombre humano" del
|
||||
/// módulo.
|
||||
pub label: String,
|
||||
/// Versión del schema de Card que el módulo declaró.
|
||||
pub schema_version: u16,
|
||||
/// Nombres de los `flow.output` que la Card declara producir.
|
||||
pub outputs: Vec<String>,
|
||||
/// Nombres de los `flow.input` que la Card declara consumir.
|
||||
pub inputs: Vec<String>,
|
||||
/// `true` si el módulo se anunció como "consciente" (trajo
|
||||
/// `WitInterface` extraída en el Hello).
|
||||
pub conscious: bool,
|
||||
}
|
||||
|
||||
/// Respuesta a `ListSessions`. El orden no está garantizado — los
|
||||
/// clientes que necesiten estabilidad pueden ordenar por `session`
|
||||
/// (Ulid es ordenable temporal).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionList {
|
||||
pub entries: Vec<SessionEntry>,
|
||||
}
|
||||
|
||||
/// Pedido del listado de matches actuales del broker. La `session`
|
||||
/// se valida igual que `ListSessions`. Si el server no tiene broker
|
||||
/// configurado, devuelve la lista vacía (no es un error — refleja
|
||||
/// que no hay matching activo).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListMatches {
|
||||
pub session: SessionId,
|
||||
}
|
||||
|
||||
/// Respuesta a `ListMatches` con el snapshot de matches consumidor↔productor
|
||||
/// actualmente computados por el broker.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MatchList {
|
||||
pub matches: Vec<brahman_broker::Match>,
|
||||
}
|
||||
|
||||
/// Frame único de wire — discriminada por variante. Cada conexión es un
|
||||
/// stream de frames.
|
||||
///
|
||||
/// Direcciones:
|
||||
/// - Cliente → Server: `Hello`, `Ping`, `Farewell`, `ListSessions`,
|
||||
/// `ListMatches`.
|
||||
/// - Server → Cliente: `HelloAck`, `Pong`, `Error`, `MatchEvent`,
|
||||
/// `SessionList`, `MatchList`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Frame {
|
||||
Hello(Hello),
|
||||
HelloAck(HelloAck),
|
||||
Ping(Ping),
|
||||
Pong(Pong),
|
||||
Farewell(Farewell),
|
||||
Error(HandshakeError),
|
||||
MatchEvent(MatchEvent),
|
||||
ListSessions(ListSessions),
|
||||
SessionList(SessionList),
|
||||
ListMatches(ListMatches),
|
||||
MatchList(MatchList),
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Backend libp2p del handshake brahman: el mismo protocolo (Hello /
|
||||
//! HelloAck / Ping / Pong / MatchEvent / Farewell, frames postcard
|
||||
//! length-prefixed) ahora también viaja sobre streams libp2p de la
|
||||
//! malla `brahman-net`.
|
||||
//!
|
||||
//! El servidor expone el bucle [`run_libp2p_accept_loop`] que acepta
|
||||
//! streams del protocolo `BRAHMAN_HANDSHAKE_PROTOCOL` y los delega al
|
||||
//! mismo `Server` que ya escucha por Unix socket — la `Session` es
|
||||
//! genérica sobre el transporte, así que ambas vías comparten broker,
|
||||
//! tablas de sesiones, push de MatchEvents, todo.
|
||||
//!
|
||||
//! El cliente se conecta vía [`connect_libp2p`]: abre un stream
|
||||
//! libp2p hacia un `PeerId` ya conocido y arranca el handshake como
|
||||
//! cualquier `Client`.
|
||||
//!
|
||||
//! Identidad: cada nodo libp2p tiene su `PeerId` (ed25519 derivado).
|
||||
//! La identidad del Ente (Card.id ULID + futura firma) viaja en el
|
||||
//! Hello, como en el path Unix. Trust remoto (verificación de firma
|
||||
//! antes de aceptar el Hello) es Fase 3.
|
||||
//!
|
||||
//! Ejemplo (servidor — Arje):
|
||||
//! ```ignore
|
||||
//! let server = Arc::new(Server::bind("/run/brahman-init.sock", config)?);
|
||||
//! let net = Arc::new(BrahmanNet::new()?);
|
||||
//! net.listen("/ip4/0.0.0.0/tcp/4101".parse()?).await;
|
||||
//!
|
||||
//! tokio::spawn(brahman_handshake::network::run_libp2p_accept_loop(
|
||||
//! server.clone(),
|
||||
//! net.clone(),
|
||||
//! ));
|
||||
//! // Server::run sigue escuchando Unix en paralelo.
|
||||
//! ```
|
||||
//!
|
||||
//! Ejemplo (cliente — sidecar de un Ente remoto):
|
||||
//! ```ignore
|
||||
//! let net = BrahmanNet::new()?;
|
||||
//! net.dial(remote_multiaddr);
|
||||
//! let mut client = brahman_handshake::network::connect_libp2p(
|
||||
//! &net, peer_id, my_card, None,
|
||||
//! ).await?;
|
||||
//! client.ping().await?;
|
||||
//! ```
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use brahman_card::{Card, TypeRef, WitInterface};
|
||||
use brahman_net::{BrahmanNet, Keypair, OpenStreamError, PeerId, Stream, StreamProtocol};
|
||||
|
||||
use crate::identity::SessionCert;
|
||||
use futures::StreamExt;
|
||||
use tokio_util::compat::{Compat, FuturesAsyncReadCompatExt};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::client::{Client, ClientError};
|
||||
use crate::server::Server;
|
||||
|
||||
/// Sub-protocolo del handshake brahman sobre streams libp2p.
|
||||
pub const BRAHMAN_HANDSHAKE_PROTOCOL: StreamProtocol =
|
||||
StreamProtocol::new("/brahman/handshake/1.0.0");
|
||||
|
||||
/// Tipo del stream que ve la lógica del handshake una vez convertido
|
||||
/// del mundo `futures::AsyncRead/Write` (libp2p) al mundo
|
||||
/// `tokio::io::AsyncRead/Write` (resto del crate).
|
||||
pub type LibP2pHandshakeStream = Compat<Stream>;
|
||||
|
||||
/// Errores específicos del backend libp2p.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NetworkError {
|
||||
#[error("abrir stream libp2p: {0}")]
|
||||
OpenStream(#[from] OpenStreamError),
|
||||
|
||||
#[error("handshake: {0}")]
|
||||
Handshake(#[from] ClientError),
|
||||
|
||||
#[error("aceptar stream libp2p: {0}")]
|
||||
AcceptStream(String),
|
||||
}
|
||||
|
||||
/// Loop de aceptación de streams libp2p del protocolo handshake.
|
||||
/// Cada stream entrante se construye como `Session` reutilizando las
|
||||
/// tablas compartidas del `Server`, así que conviven con sesiones
|
||||
/// Unix indistinguibles.
|
||||
///
|
||||
/// Vive hasta que el control libp2p se cierre o el caller drop el
|
||||
/// future. Errores por sesión se loggean (no tumban el loop).
|
||||
pub async fn run_libp2p_accept_loop(
|
||||
server: Arc<Server>,
|
||||
net: Arc<BrahmanNet>,
|
||||
) -> Result<(), NetworkError> {
|
||||
let mut control = net.control.clone();
|
||||
let mut incoming = control
|
||||
.accept(BRAHMAN_HANDSHAKE_PROTOCOL)
|
||||
.map_err(|e| NetworkError::AcceptStream(e.to_string()))?;
|
||||
|
||||
while let Some((peer, stream)) = incoming.next().await {
|
||||
let server = server.clone();
|
||||
// .compat() debe pasar al spawn ADENTRO; si lo hacemos afuera
|
||||
// y capturamos `Compat<Stream>` en la closure, el future
|
||||
// resultante hereda traits que dyn AsyncReadWrite no satisface
|
||||
// (compatibility con thread-safety de tokio::spawn).
|
||||
tokio::spawn(handle_libp2p_session(server, stream, peer));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_libp2p_session(
|
||||
server: Arc<Server>,
|
||||
stream: Stream,
|
||||
peer: PeerId,
|
||||
) {
|
||||
// session_from_libp2p_stream propaga el peer_id al `do_handshake`,
|
||||
// que exige firma del Hello cuya public key derive a este peer.
|
||||
let session = server.session_from_libp2p_stream(stream.compat(), peer);
|
||||
if let Err(e) = session.handle().await {
|
||||
warn!(
|
||||
target: "brahman_handshake::network",
|
||||
peer = %peer,
|
||||
error = %e,
|
||||
"sesión libp2p terminó con error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Conecta como cliente a un Ente remoto vía libp2p y completa el
|
||||
/// handshake **firmado** con `keypair`. Requiere que `net` ya tenga
|
||||
/// conexión (o pueda dial-ar) al `peer`; típicamente el caller hace
|
||||
/// `net.dial(multiaddr)` antes.
|
||||
///
|
||||
/// La `keypair` debe ser la misma que la del nodo libp2p (la que
|
||||
/// pasaste a [`brahman_net::BrahmanNet::with_keypair`]). Si no coincide
|
||||
/// con el `peer_id` autenticado por Noise, el server rechaza el Hello
|
||||
/// con `Unauthorized`.
|
||||
///
|
||||
/// Devuelve un `Client` típico — los métodos `ping`, `await_event`,
|
||||
/// `farewell` funcionan idéntico al path Unix. El stream subyacente
|
||||
/// es libp2p convertido vía `tokio_util::compat`.
|
||||
pub async fn connect_libp2p(
|
||||
net: &BrahmanNet,
|
||||
peer: PeerId,
|
||||
card: Card,
|
||||
wit: Option<WitInterface>,
|
||||
keypair: &Keypair,
|
||||
) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
|
||||
let mut control = net.control.clone();
|
||||
let stream = control
|
||||
.open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
|
||||
.await?;
|
||||
let client = Client::connect_with_stream_signed(stream.compat(), card, wit, keypair).await?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Igual que `connect_libp2p` pero adjunta un `SessionCert` al Hello.
|
||||
/// El server, al verificar el cert, evalúa la política de admisión
|
||||
/// contra el `master_peer_id` derivado — no contra el `peer_id`
|
||||
/// libp2p. Esto permite **rotar** la session keypair sin perder
|
||||
/// reconocimiento en allowlists remotas.
|
||||
///
|
||||
/// El `keypair` debe ser la session libp2p (la que firma la conexión
|
||||
/// Noise); el `cert` debe haber sido emitido por una identity master
|
||||
/// para esa misma session pubkey (ver
|
||||
/// [`crate::identity::Identity::issue_session_cert`]).
|
||||
pub async fn connect_libp2p_with_cert(
|
||||
net: &BrahmanNet,
|
||||
peer: PeerId,
|
||||
card: Card,
|
||||
wit: Option<WitInterface>,
|
||||
session_keypair: &Keypair,
|
||||
cert: SessionCert,
|
||||
) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
|
||||
let mut control = net.control.clone();
|
||||
let stream = control
|
||||
.open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
|
||||
.await?;
|
||||
let client = Client::connect_with_stream_signed_with_cert(
|
||||
stream.compat(),
|
||||
card,
|
||||
wit,
|
||||
session_keypair,
|
||||
cert,
|
||||
)
|
||||
.await?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Discovery remoto via DHT — Fase 2
|
||||
// =====================================================================
|
||||
//
|
||||
// Cuando un Ente registra una Card con outputs en el Init local, el
|
||||
// Init anuncia al DHT (`net.start_providing(key)`) bajo una key
|
||||
// derivada de `(flow_name, TypeRef)`. Cualquier nodo conectado al
|
||||
// mismo DHT puede consultar `find_remote_providers(flow_name, type)`
|
||||
// y obtener la lista de `PeerId`s que dijeron proveer ese flow.
|
||||
//
|
||||
// La key es **estable y libre de colisiones** entre versiones del
|
||||
// monorepo: usa blake3 sobre un canon textual `brahman-flow|{name}|{type_canon}`.
|
||||
// Cambiar la canonicalización rompe el discovery cross-version, así
|
||||
// que cualquier modificación requiere bump de versión documentado.
|
||||
|
||||
/// Prefijo de namespace para todas las keys DHT del subprotocolo
|
||||
/// brahman. Discrimina contra otros usos del mismo DHT (sync minga,
|
||||
/// futuros) — protege contra colisiones accidentales.
|
||||
const FLOW_KEY_PREFIX: &str = "brahman-flow|v1|";
|
||||
|
||||
/// Canonicaliza un `TypeRef` a string estable. Cambios aquí rompen
|
||||
/// la compatibilidad de discovery cross-version; bump documentado
|
||||
/// en `FLOW_KEY_PREFIX` al modificar.
|
||||
fn canonicalize_type(t: &TypeRef) -> String {
|
||||
match t {
|
||||
TypeRef::Primitive { name } => format!("prim:{}", name),
|
||||
TypeRef::Wit {
|
||||
package,
|
||||
interface,
|
||||
name,
|
||||
} => format!(
|
||||
"wit:{}#{}#{}",
|
||||
package,
|
||||
interface.as_deref().unwrap_or(""),
|
||||
name
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deriva la key del DHT para un `(flow_name, type_ref)` específico.
|
||||
/// blake3-32B determinístico — la misma tupla en cualquier máquina
|
||||
/// produce la misma key.
|
||||
pub fn flow_dht_key(flow_name: &str, type_ref: &TypeRef) -> [u8; 32] {
|
||||
let canon = format!(
|
||||
"{}{}|{}",
|
||||
FLOW_KEY_PREFIX,
|
||||
flow_name,
|
||||
canonicalize_type(type_ref)
|
||||
);
|
||||
*blake3::hash(canon.as_bytes()).as_bytes()
|
||||
}
|
||||
|
||||
/// Anuncia al DHT que este nodo provee cada output flow declarado
|
||||
/// en `card`. Llamarlo tras `register_session` propaga la
|
||||
/// disponibilidad a todos los peers que comparten DHT con éste.
|
||||
///
|
||||
/// Idempotente: re-anunciar la misma key actualiza el TTL del record
|
||||
/// en el DHT. Best-effort: si `start_providing` falla por falta de
|
||||
/// peers cercanos (DHT vacío), el record vive en el store local
|
||||
/// hasta que llegue una conexión.
|
||||
pub fn announce_outputs(net: &BrahmanNet, card: &Card) {
|
||||
for flow in &card.flow.output {
|
||||
let key = flow_dht_key(&flow.name, &flow.ty);
|
||||
debug!(
|
||||
target: "brahman_handshake::network",
|
||||
flow = %flow.name,
|
||||
"announce_output → DHT"
|
||||
);
|
||||
net.start_providing(&key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retira los anuncios DHT previos de [`announce_outputs`] para esta
|
||||
/// `card`. Llamado desde `cleanup` cuando una sesión cierra (Farewell,
|
||||
/// EOF, error). El record local se borra al instante; copias
|
||||
/// replicadas en peers remotos expiran por TTL natural de kad.
|
||||
pub fn withdraw_outputs(net: &BrahmanNet, card: &Card) {
|
||||
for flow in &card.flow.output {
|
||||
let key = flow_dht_key(&flow.name, &flow.ty);
|
||||
debug!(
|
||||
target: "brahman_handshake::network",
|
||||
flow = %flow.name,
|
||||
"withdraw_output → DHT (stop_providing)"
|
||||
);
|
||||
net.stop_providing(&key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Consulta el DHT por peers que han anunciado proveer el flow
|
||||
/// `(flow_name, type_ref)`. Devuelve la lista resuelta de `PeerId`s.
|
||||
/// Lista vacía si nadie anuncia, si la query timeout-ea, o si el
|
||||
/// DHT no ha encontrado providers.
|
||||
///
|
||||
/// Para cada `PeerId` devuelto, el caller puede luego dial-ar al
|
||||
/// peer (a sus addrs conocidas vía Identify) y abrir un sub-handshake
|
||||
/// remoto con [`connect_libp2p`].
|
||||
pub async fn find_remote_providers(
|
||||
net: &BrahmanNet,
|
||||
flow_name: &str,
|
||||
type_ref: &TypeRef,
|
||||
) -> Vec<PeerId> {
|
||||
let key = flow_dht_key(flow_name, type_ref);
|
||||
net.find_providers(&key).await
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
//! Política de admisión de peers libp2p: allowlist + denylist con hot
|
||||
//! reload opcional.
|
||||
//!
|
||||
//! Capa de política sobre el trust criptográfico de Fase 3. Combina:
|
||||
//!
|
||||
//! - **Denylist**: peers explícitamente baneados. Si está, deny gana.
|
||||
//! - **Allowlist**: si está set, sólo los peers listados pasan.
|
||||
//! Si no está set, modo abierto (todo peer Ed25519-válido pasa,
|
||||
//! sujeto sólo a denylist).
|
||||
//!
|
||||
//! Sin denylist y sin allowlist → modo totalmente abierto (compat
|
||||
//! con todo lo anterior). Con allowlist y denylist a la vez, el
|
||||
//! orden de evaluación es: deny first → allow check → admit.
|
||||
//!
|
||||
//! Aplica únicamente al path libp2p — el path Unix usa SO_PEERCRED
|
||||
//! del kernel para autenticación local, no PeerId.
|
||||
//!
|
||||
//! ## Hot reload
|
||||
//!
|
||||
//! Si la política se construyó con [`PeerPolicy::watch_files`], un
|
||||
//! thread dedicado vigila los archivos de allow/deny vía `notify`.
|
||||
//! Cualquier cambio (write, create, modify, remove) dispara una
|
||||
//! recarga atómica con debounce de 250ms (los editores típicos
|
||||
//! producen varios eventos por save).
|
||||
//!
|
||||
//! Errores de reload (parse fallido, archivo eliminado) se loggean
|
||||
//! pero NO bajan la política existente — aceptamos la versión
|
||||
//! anterior hasta que el archivo vuelva a parsearse limpio. Esto
|
||||
//! evita que un error de tipeo deje al Init en modo inseguro.
|
||||
//!
|
||||
//! ## Formato del archivo
|
||||
//!
|
||||
//! Idéntico para allow y deny: PeerId base58 por línea, `#` para
|
||||
//! comentarios (línea entera o inline), líneas vacías ignoradas.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use brahman_net::{BrahmanNet, PeerId};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Política de admisión combinada (allow + deny). Clone barato (todos
|
||||
/// los campos son Arc o referencias inmutables).
|
||||
#[derive(Clone)]
|
||||
pub struct PeerPolicy {
|
||||
inner: Arc<RwLock<PolicyInner>>,
|
||||
paths: Arc<PolicyPaths>,
|
||||
/// `BrahmanNet` opcional asociado vía [`Self::attach_to_net`].
|
||||
/// Si está set, cada cambio en la denylist se sincroniza con el
|
||||
/// `block_list` behaviour del swarm — los peers baneados son
|
||||
/// rechazados ANTES del Noise handshake. `RwLock<Option<...>>`
|
||||
/// para que `attach_to_net` se pueda llamar después del
|
||||
/// constructor (típico en ente-zero: primero arma la policy,
|
||||
/// después el net, después attach).
|
||||
net: Arc<RwLock<Option<Arc<BrahmanNet>>>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PolicyInner {
|
||||
/// `Some(set)`: sólo peers en el set pasan. `None`: modo abierto.
|
||||
allow: Option<BTreeSet<PeerId>>,
|
||||
/// Peers baneados. Vacío = sin denylist.
|
||||
deny: BTreeSet<PeerId>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PolicyPaths {
|
||||
allow_path: Option<PathBuf>,
|
||||
deny_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Decisión del gate de política para un peer dado.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
/// El peer es admitido (no está en deny y, si hay allow, está en allow).
|
||||
Admit,
|
||||
/// El peer está explícitamente en la denylist.
|
||||
DeniedByDenylist,
|
||||
/// Hay allowlist configurada y el peer no está en ella.
|
||||
NotInAllowlist,
|
||||
}
|
||||
|
||||
impl Decision {
|
||||
pub fn is_admitted(self) -> bool {
|
||||
matches!(self, Decision::Admit)
|
||||
}
|
||||
|
||||
pub fn reason(self) -> &'static str {
|
||||
match self {
|
||||
Decision::Admit => "admit",
|
||||
Decision::DeniedByDenylist => "explicitly denied",
|
||||
Decision::NotInAllowlist => "not in allowlist",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PolicyError {
|
||||
#[error("leer política en {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("línea {line_no} de {path}: PeerId inválido '{value}'")]
|
||||
InvalidPeerId {
|
||||
path: PathBuf,
|
||||
line_no: usize,
|
||||
value: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl PeerPolicy {
|
||||
/// Política totalmente abierta: todo peer pasa. Útil como default
|
||||
/// cuando no hay archivos configurados.
|
||||
pub fn open() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(PolicyInner::default())),
|
||||
paths: Arc::new(PolicyPaths::default()),
|
||||
net: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construye una política inline con sets explícitos. Sin paths
|
||||
/// asociados, así que `reload` y `watch_files` son no-ops.
|
||||
pub fn from_sets(allow: Option<BTreeSet<PeerId>>, deny: BTreeSet<PeerId>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(PolicyInner { allow, deny })),
|
||||
paths: Arc::new(PolicyPaths::default()),
|
||||
net: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Carga política desde archivos. Cada path es opcional: `None`
|
||||
/// significa "esa lista no aplica" (allow=None ⇒ modo abierto;
|
||||
/// deny=None ⇒ sin baneados). Asocia los paths internamente para
|
||||
/// que `reload` y `watch_files` los re-lean.
|
||||
pub fn from_files(
|
||||
allow_path: Option<&Path>,
|
||||
deny_path: Option<&Path>,
|
||||
) -> Result<Self, PolicyError> {
|
||||
let allow = match allow_path {
|
||||
Some(p) => Some(parse_peer_set(p)?),
|
||||
None => None,
|
||||
};
|
||||
let deny = match deny_path {
|
||||
Some(p) => parse_peer_set(p)?,
|
||||
None => BTreeSet::new(),
|
||||
};
|
||||
Ok(Self {
|
||||
inner: Arc::new(RwLock::new(PolicyInner { allow, deny })),
|
||||
paths: Arc::new(PolicyPaths {
|
||||
allow_path: allow_path.map(Path::to_path_buf),
|
||||
deny_path: deny_path.map(Path::to_path_buf),
|
||||
}),
|
||||
net: Arc::new(RwLock::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Evalúa si `peer` puede registrarse. Toma read lock — barato,
|
||||
/// concurrente, sin awaits.
|
||||
pub fn evaluate(&self, peer: &PeerId) -> Decision {
|
||||
let inner = match self.inner.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
// Lock envenenado: degrada a "deny por seguridad".
|
||||
warn!("policy lock envenenado — deny por defecto");
|
||||
return Decision::DeniedByDenylist;
|
||||
}
|
||||
};
|
||||
if inner.deny.contains(peer) {
|
||||
return Decision::DeniedByDenylist;
|
||||
}
|
||||
if let Some(allow) = &inner.allow {
|
||||
if !allow.contains(peer) {
|
||||
return Decision::NotInAllowlist;
|
||||
}
|
||||
}
|
||||
Decision::Admit
|
||||
}
|
||||
|
||||
/// Tamaño actual de cada lista, para logging. Tupla `(allow_count,
|
||||
/// deny_count)`. `allow_count = None` significa "modo abierto"
|
||||
/// (sin allowlist).
|
||||
pub fn sizes(&self) -> (Option<usize>, usize) {
|
||||
match self.inner.read() {
|
||||
Ok(g) => (g.allow.as_ref().map(|s| s.len()), g.deny.len()),
|
||||
Err(_) => (Some(0), 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recarga atómica desde los paths asociados. Si un archivo
|
||||
/// falla, la versión anterior persiste y el error se devuelve.
|
||||
/// Esto evita que un typo en el archivo deje al Init en modo
|
||||
/// inseguro.
|
||||
///
|
||||
/// Si hay un `BrahmanNet` attached vía [`Self::attach_to_net`],
|
||||
/// el cambio de denylist se sincroniza con el `block_list` del
|
||||
/// swarm: se calcula el diff (added/removed) y se aplican
|
||||
/// `block_peer`/`unblock_peer` por cada cambio.
|
||||
pub fn reload(&self) -> Result<(), PolicyError> {
|
||||
let new_allow = match &self.paths.allow_path {
|
||||
Some(p) => Some(parse_peer_set(p)?),
|
||||
None => None,
|
||||
};
|
||||
let new_deny = match &self.paths.deny_path {
|
||||
Some(p) => parse_peer_set(p)?,
|
||||
None => BTreeSet::new(),
|
||||
};
|
||||
// Snapshot de la deny actual ANTES de mutar, para diff.
|
||||
let prev_deny = self
|
||||
.inner
|
||||
.read()
|
||||
.map(|g| g.deny.clone())
|
||||
.unwrap_or_default();
|
||||
if let Ok(mut inner) = self.inner.write() {
|
||||
inner.allow = new_allow;
|
||||
inner.deny = new_deny.clone();
|
||||
}
|
||||
self.sync_deny_to_swarm(&prev_deny, &new_deny);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Asocia esta política a un `BrahmanNet`. Sincroniza el snapshot
|
||||
/// actual de la denylist con el `block_list` behaviour del swarm
|
||||
/// (cada peer baneado se rechaza ANTES del Noise handshake), y
|
||||
/// registra el net para re-sincronizarse en cada [`Self::reload`].
|
||||
///
|
||||
/// Si ya había un net attached, se reemplaza (caso esperado:
|
||||
/// un Init no debería tener dos `BrahmanNet`s).
|
||||
pub fn attach_to_net(&self, net: Arc<BrahmanNet>) {
|
||||
// Sync inicial: bloquear todos los peers actualmente en deny.
|
||||
if let Ok(inner) = self.inner.read() {
|
||||
for peer in &inner.deny {
|
||||
net.block_peer(*peer);
|
||||
}
|
||||
}
|
||||
if let Ok(mut slot) = self.net.write() {
|
||||
*slot = Some(net);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcula el diff entre `prev` y `new` y aplica
|
||||
/// `block_peer`/`unblock_peer` al net asociado (si hay).
|
||||
/// No-op si no hay net attached.
|
||||
fn sync_deny_to_swarm(&self, prev: &BTreeSet<PeerId>, new: &BTreeSet<PeerId>) {
|
||||
let net = match self.net.read() {
|
||||
Ok(g) => match g.as_ref() {
|
||||
Some(n) => n.clone(),
|
||||
None => return,
|
||||
},
|
||||
Err(_) => return,
|
||||
};
|
||||
for added in new.difference(prev) {
|
||||
net.block_peer(*added);
|
||||
}
|
||||
for removed in prev.difference(new) {
|
||||
net.unblock_peer(*removed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Arranca un thread que vigila los archivos asociados con
|
||||
/// `notify` y llama [`Self::reload`] cuando cambian. Debounce
|
||||
/// 250ms para coalescer múltiples eventos por save (los editores
|
||||
/// hacen Create+Modify+más).
|
||||
///
|
||||
/// Devuelve un `JoinHandle` que el caller debe mantener vivo.
|
||||
/// Drop del handle no detiene el thread (notify watcher es
|
||||
/// sticky); para detener, terminar el proceso.
|
||||
///
|
||||
/// No-op si no hay paths asociados (devuelve un handle dummy
|
||||
/// que termina inmediatamente).
|
||||
pub fn spawn_watcher(&self) -> std::io::Result<std::thread::JoinHandle<()>> {
|
||||
let allow_path = self.paths.allow_path.clone();
|
||||
let deny_path = self.paths.deny_path.clone();
|
||||
let policy = self.clone();
|
||||
|
||||
if allow_path.is_none() && deny_path.is_none() {
|
||||
// Sin archivos a vigilar: spawn un thread que termina ya.
|
||||
return std::thread::Builder::new()
|
||||
.name("brahman-policy-watcher-noop".into())
|
||||
.spawn(|| {});
|
||||
}
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("brahman-policy-watcher".into())
|
||||
.spawn(move || {
|
||||
run_watcher(policy, allow_path, deny_path);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PeerPolicy {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let (allow, deny) = self.sizes();
|
||||
f.debug_struct("PeerPolicy")
|
||||
.field("allow", &allow)
|
||||
.field("deny", &deny)
|
||||
.field("allow_path", &self.paths.allow_path)
|
||||
.field("deny_path", &self.paths.deny_path)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_peer_set(path: &Path) -> Result<BTreeSet<PeerId>, PolicyError> {
|
||||
let contents = std::fs::read_to_string(path).map_err(|e| PolicyError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
let mut out = BTreeSet::new();
|
||||
for (idx, raw) in contents.lines().enumerate() {
|
||||
let line_no = idx + 1;
|
||||
let trimmed = raw.split('#').next().unwrap_or("").trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let peer = trimmed
|
||||
.parse::<PeerId>()
|
||||
.map_err(|_| PolicyError::InvalidPeerId {
|
||||
path: path.to_path_buf(),
|
||||
line_no,
|
||||
value: trimmed.to_string(),
|
||||
})?;
|
||||
out.insert(peer);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS: u64 = 250;
|
||||
|
||||
fn run_watcher(
|
||||
policy: PeerPolicy,
|
||||
allow_path: Option<PathBuf>,
|
||||
deny_path: Option<PathBuf>,
|
||||
) {
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
|
||||
let mut watcher = match notify::recommended_watcher(move |res| {
|
||||
let _ = tx.send(res);
|
||||
}) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
warn!(?e, "notify watcher para policy no se pudo crear — hot reload deshabilitado");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Vigilamos los DIRECTORIOS de los archivos, no los archivos
|
||||
// directos. Los editores típicos hacen rename-and-replace (escriben
|
||||
// a tmp, rename al destino), lo que rompe el watch del archivo
|
||||
// pero NO el del directorio. Trade-off: recibimos más eventos
|
||||
// (cualquier archivo del dir), filtramos por path al procesar.
|
||||
for p in [&allow_path, &deny_path].iter().filter_map(|x| x.as_ref()) {
|
||||
if let Some(parent) = p.parent() {
|
||||
if let Err(e) = watcher.watch(parent, RecursiveMode::NonRecursive) {
|
||||
warn!(path = %parent.display(), ?e, "watch failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let debounce = Duration::from_millis(DEBOUNCE_MS);
|
||||
let mut pending_at: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
let timeout = match pending_at {
|
||||
Some(at) => debounce.saturating_sub(at.elapsed()).max(Duration::from_millis(10)),
|
||||
None => Duration::from_secs(60), // wakeup periódico, no esencial
|
||||
};
|
||||
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(Ok(event)) => {
|
||||
// Sólo nos interesan eventos sobre los paths exactos.
|
||||
let touches_us = event.paths.iter().any(|p| {
|
||||
Some(p) == allow_path.as_ref() || Some(p) == deny_path.as_ref()
|
||||
});
|
||||
if !touches_us {
|
||||
continue;
|
||||
}
|
||||
debug!(?event.kind, "policy file event recibido — debounce");
|
||||
pending_at = Some(Instant::now());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!(?e, "notify error en policy watcher");
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if let Some(at) = pending_at {
|
||||
if at.elapsed() >= debounce {
|
||||
match policy.reload() {
|
||||
Ok(()) => {
|
||||
let (a, d) = policy.sizes();
|
||||
info!(
|
||||
allow = ?a,
|
||||
deny = d,
|
||||
"policy hot-reload completo"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(?e, "policy hot-reload falló — manteniendo versión anterior");
|
||||
}
|
||||
}
|
||||
pending_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
warn!("policy watcher channel cerrado — terminando thread");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use brahman_net::Keypair;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fresh_peer() -> PeerId {
|
||||
Keypair::generate_ed25519().public().to_peer_id()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_admits_anyone() {
|
||||
let p = PeerPolicy::open();
|
||||
assert_eq!(p.evaluate(&fresh_peer()), Decision::Admit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_only_admits_listed() {
|
||||
let p1 = fresh_peer();
|
||||
let p2 = fresh_peer();
|
||||
let policy = PeerPolicy::from_sets(
|
||||
Some([p1].into_iter().collect()),
|
||||
BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(policy.evaluate(&p1), Decision::Admit);
|
||||
assert_eq!(policy.evaluate(&p2), Decision::NotInAllowlist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_overrides_open() {
|
||||
let p1 = fresh_peer();
|
||||
let p2 = fresh_peer();
|
||||
let policy = PeerPolicy::from_sets(None, [p1].into_iter().collect());
|
||||
assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
|
||||
assert_eq!(policy.evaluate(&p2), Decision::Admit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_overrides_allow() {
|
||||
// Conflicto explícito: p1 está en ambas. Deny gana.
|
||||
let p1 = fresh_peer();
|
||||
let policy = PeerPolicy::from_sets(
|
||||
Some([p1].into_iter().collect()),
|
||||
[p1].into_iter().collect(),
|
||||
);
|
||||
assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_files_with_both_lists() {
|
||||
let p1 = fresh_peer();
|
||||
let p2 = fresh_peer();
|
||||
let p3 = fresh_peer();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let allow = tmp.path().join("allow.txt");
|
||||
let deny = tmp.path().join("deny.txt");
|
||||
std::fs::write(&allow, format!("{}\n{}\n", p1, p2)).unwrap();
|
||||
std::fs::write(&deny, format!("# baneado\n{}\n", p2)).unwrap();
|
||||
let policy = PeerPolicy::from_files(Some(&allow), Some(&deny)).unwrap();
|
||||
assert_eq!(policy.evaluate(&p1), Decision::Admit);
|
||||
assert_eq!(policy.evaluate(&p2), Decision::DeniedByDenylist); // deny gana
|
||||
assert_eq!(policy.evaluate(&p3), Decision::NotInAllowlist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_files_only_deny() {
|
||||
let p1 = fresh_peer();
|
||||
let p2 = fresh_peer();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let deny = tmp.path().join("deny.txt");
|
||||
std::fs::write(&deny, format!("{}\n", p1)).unwrap();
|
||||
let policy = PeerPolicy::from_files(None, Some(&deny)).unwrap();
|
||||
assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
|
||||
assert_eq!(policy.evaluate(&p2), Decision::Admit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_picks_up_changes() {
|
||||
let p1 = fresh_peer();
|
||||
let p2 = fresh_peer();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let allow = tmp.path().join("allow.txt");
|
||||
std::fs::write(&allow, format!("{}\n", p1)).unwrap();
|
||||
|
||||
let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
|
||||
assert_eq!(policy.evaluate(&p1), Decision::Admit);
|
||||
assert_eq!(policy.evaluate(&p2), Decision::NotInAllowlist);
|
||||
|
||||
// Mutar el archivo: ahora p2 está, p1 no.
|
||||
std::fs::write(&allow, format!("{}\n", p2)).unwrap();
|
||||
policy.reload().unwrap();
|
||||
assert_eq!(policy.evaluate(&p1), Decision::NotInAllowlist);
|
||||
assert_eq!(policy.evaluate(&p2), Decision::Admit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_failure_preserves_previous_state() {
|
||||
let p1 = fresh_peer();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let allow = tmp.path().join("allow.txt");
|
||||
std::fs::write(&allow, format!("{}\n", p1)).unwrap();
|
||||
let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
|
||||
assert_eq!(policy.evaluate(&p1), Decision::Admit);
|
||||
|
||||
// Romper el archivo con basura.
|
||||
std::fs::write(&allow, "this-is-not-a-peer-id\n").unwrap();
|
||||
let err = policy.reload();
|
||||
assert!(err.is_err(), "reload con typo debe fallar");
|
||||
|
||||
// Estado anterior se mantiene.
|
||||
assert_eq!(
|
||||
policy.evaluate(&p1),
|
||||
Decision::Admit,
|
||||
"policy debe conservar la versión anterior tras fallo de reload"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_file_rejected_at_load() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("bad.txt");
|
||||
std::fs::write(&path, "not-a-peer-id\n").unwrap();
|
||||
let err = PeerPolicy::from_files(Some(&path), None).unwrap_err();
|
||||
assert!(matches!(err, PolicyError::InvalidPeerId { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_reloads_on_file_change() {
|
||||
// Test integración del watcher: arma policy con file, spawn
|
||||
// watcher, modifica el archivo, espera el debounce, verifica
|
||||
// que la policy refleja el cambio.
|
||||
let p1 = fresh_peer();
|
||||
let p2 = fresh_peer();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let allow = tmp.path().join("allow.txt");
|
||||
std::fs::write(&allow, format!("{}\n", p1)).unwrap();
|
||||
|
||||
let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
|
||||
let _watcher = policy.spawn_watcher().unwrap();
|
||||
|
||||
// Le damos un instante al watcher para subscribirse al dir.
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
||||
// Mutamos el archivo: p2 reemplaza a p1.
|
||||
std::fs::write(&allow, format!("{}\n", p2)).unwrap();
|
||||
|
||||
// Esperamos > debounce + margen.
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < deadline {
|
||||
if policy.evaluate(&p2) == Decision::Admit {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
policy.evaluate(&p2),
|
||||
Decision::Admit,
|
||||
"watcher debería haber recargado la policy"
|
||||
);
|
||||
assert_eq!(
|
||||
policy.evaluate(&p1),
|
||||
Decision::NotInAllowlist,
|
||||
"p1 debería haber salido tras el reload"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
//! Servidor de handshake. Listener Unix socket → sesiones por conexión.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use brahman_broker::{Broker, Endpoint};
|
||||
use brahman_card::{Card, ResolvedCard, WitInterface, CARD_SCHEMA_VERSION};
|
||||
use brahman_net::{BrahmanNet, PeerId};
|
||||
use tokio::io::{split, AsyncRead, AsyncWrite, WriteHalf};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tracing::{debug, warn};
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::codec::{read_frame, write_frame};
|
||||
use crate::messages::{
|
||||
Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, MatchEventKind, Ping, Pong,
|
||||
SessionId,
|
||||
};
|
||||
|
||||
/// Tabla de sesiones vivas indexada por `SessionId`.
|
||||
pub type SessionRegistry = Arc<Mutex<HashMap<SessionId, ResolvedCard>>>;
|
||||
|
||||
/// Broker compartido (opcional) que el servidor mantiene en sincronía con
|
||||
/// el ciclo de vida de las sesiones.
|
||||
pub type SharedBroker = Arc<Mutex<Broker>>;
|
||||
|
||||
/// Tabla de canales push por sesión: el server inyecta frames hacia el
|
||||
/// cliente (p. ej. `MatchEvent`) sin requerir que el cliente haga request.
|
||||
type SessionTxTable = Arc<Mutex<HashMap<SessionId, mpsc::Sender<Frame>>>>;
|
||||
|
||||
/// Por sesión, último match conocido por nombre de input. Se usa para
|
||||
/// emitir diffs (Available/Lost) en lugar del estado completo.
|
||||
type LastMatches = Arc<Mutex<HashMap<SessionId, HashMap<String, Endpoint>>>>;
|
||||
|
||||
/// Capacidad del canal push por sesión. Si se llena (cliente lento), los
|
||||
/// eventos extra se descartan — el cliente puede re-consultar el estado.
|
||||
const PUSH_CHANNEL_CAPACITY: usize = 32;
|
||||
|
||||
/// Configuración del servidor.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ServerConfig {
|
||||
/// `true` si el Init está atado al servidor (se reporta en `HelloAck`).
|
||||
pub init_attached: bool,
|
||||
/// Broker compartido. Si está presente, el servidor llama
|
||||
/// `register` tras un Hello aceptado y `unregister` al cerrar la
|
||||
/// sesión (Farewell o EOF). Si es `None`, el broker no se usa.
|
||||
pub broker: Option<SharedBroker>,
|
||||
/// Capa P2P compartida. Si está presente, cada Card registrada
|
||||
/// con outputs se anuncia automáticamente al DHT vía
|
||||
/// [`brahman_handshake::network::announce_outputs`], permitiendo
|
||||
/// que un consumer remoto los descubra con
|
||||
/// [`brahman_handshake::network::find_remote_providers`]. Si es
|
||||
/// `None`, el server queda "ciego al DHT" — sólo matchea sesiones
|
||||
/// locales (lo cual es correcto cuando no hay conectividad o no
|
||||
/// se desea exponer al exterior).
|
||||
pub net: Option<Arc<BrahmanNet>>,
|
||||
/// Política de admisión de peers libp2p (allow + deny + hot
|
||||
/// reload opcional). Si está presente, el trust gate del path
|
||||
/// libp2p evalúa cada `peer_id` (ya autenticado por Noise)
|
||||
/// contra esta política. `None` → modo totalmente abierto
|
||||
/// (cualquier peer Ed25519-válido pasa). El path Unix la ignora.
|
||||
pub policy: Option<crate::peer_policy::PeerPolicy>,
|
||||
}
|
||||
|
||||
// Manual Debug porque BrahmanNet no implementa Debug (libp2p Swarm
|
||||
// no es Debug). Sólo loggeamos los campos relevantes para tracing.
|
||||
impl std::fmt::Debug for ServerConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ServerConfig")
|
||||
.field("init_attached", &self.init_attached)
|
||||
.field("broker", &self.broker.as_ref().map(|_| "<broker>"))
|
||||
.field("net", &self.net.as_ref().map(|_| "<net>"))
|
||||
.field("policy", &self.policy.as_ref().map(|p| p.sizes()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Servidor de handshake escuchando en un Unix socket.
|
||||
pub struct Server {
|
||||
listener: UnixListener,
|
||||
socket_path: PathBuf,
|
||||
sessions: SessionRegistry,
|
||||
push_table: SessionTxTable,
|
||||
last_matches: LastMatches,
|
||||
config: ServerConfig,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Crea el listener en `path`. Si el archivo existe, lo elimina (asume
|
||||
/// que es un socket stale de una sesión previa).
|
||||
pub fn bind(path: impl Into<PathBuf>, config: ServerConfig) -> 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,
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
push_table: Arc::new(Mutex::new(HashMap::new())),
|
||||
last_matches: Arc::new(Mutex::new(HashMap::new())),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// Devuelve la ruta del socket (útil para clientes en el mismo proceso).
|
||||
pub fn socket_path(&self) -> &Path {
|
||||
&self.socket_path
|
||||
}
|
||||
|
||||
/// Vista compartida del registro de sesiones — útil para el Init/Admin
|
||||
/// para inspeccionar quién está conectado.
|
||||
pub fn sessions(&self) -> SessionRegistry {
|
||||
self.sessions.clone()
|
||||
}
|
||||
|
||||
/// Acepta UNA conexión Unix, devuelve la `Session` lista para `handle()`.
|
||||
/// No corre el handler — eso es responsabilidad del llamante.
|
||||
/// Path Unix → `expected_peer = None` (firma del Hello opcional;
|
||||
/// SO_PEERCRED del kernel ya autentica al cliente).
|
||||
pub async fn accept_one(&self) -> std::io::Result<Session<UnixStream>> {
|
||||
let (stream, _addr) = self.listener.accept().await?;
|
||||
Ok(self.session_from_stream(stream))
|
||||
}
|
||||
|
||||
/// Construye una `Session` a partir de un stream arbitrario que
|
||||
/// implemente `AsyncRead + AsyncWrite + Unpin + Send`. Path
|
||||
/// agnóstico al transport (Unix, in-memory, etc.) — `expected_peer`
|
||||
/// queda en `None`, así que la firma del Hello es opcional.
|
||||
pub fn session_from_stream<S>(&self, stream: S) -> Session<S>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
self.session_from_stream_inner(stream, None)
|
||||
}
|
||||
|
||||
/// Variante para conexiones libp2p: el `peer_id` viene autenticado
|
||||
/// por Noise. La sesión exige firma del Hello cuya public key
|
||||
/// derive a este `peer_id` exacto. Ver
|
||||
/// [`super::network::run_libp2p_accept_loop`].
|
||||
pub fn session_from_libp2p_stream<S>(
|
||||
&self,
|
||||
stream: S,
|
||||
peer: PeerId,
|
||||
) -> Session<S>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
self.session_from_stream_inner(stream, Some(peer))
|
||||
}
|
||||
|
||||
fn session_from_stream_inner<S>(
|
||||
&self,
|
||||
stream: S,
|
||||
expected_peer: Option<PeerId>,
|
||||
) -> Session<S>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
Session {
|
||||
stream,
|
||||
sessions: self.sessions.clone(),
|
||||
push_table: self.push_table.clone(),
|
||||
last_matches: self.last_matches.clone(),
|
||||
config: self.config.clone(),
|
||||
expected_peer,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loop de aceptación: cada conexión se despacha en una task separada.
|
||||
/// Vive hasta que el listener falle o el caller drop el future.
|
||||
pub async fn run(self) -> std::io::Result<()> {
|
||||
loop {
|
||||
let session = self.accept_one().await?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = session.handle().await {
|
||||
warn!(error = %e, "session terminó con error");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
// Limpieza best-effort del socket. Si falla, log y seguir.
|
||||
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 socket");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conexión individual aceptada por el servidor. Genérica sobre el
|
||||
/// transport — funciona indistinguiblemente sobre `UnixStream` (modo
|
||||
/// local), libp2p stream wrapped con `tokio_util::compat`, in-memory
|
||||
/// duplex (tests), etc.
|
||||
pub struct Session<S> {
|
||||
stream: S,
|
||||
sessions: SessionRegistry,
|
||||
push_table: SessionTxTable,
|
||||
last_matches: LastMatches,
|
||||
config: ServerConfig,
|
||||
/// Si está set, el path es libp2p y `do_handshake` exige firma
|
||||
/// del Hello cuya public key derive a este `peer_id`. Si es
|
||||
/// `None`, el path es Unix/in-memory y la firma es opcional
|
||||
/// (pero si está, se verifica anyway por defensa en profundidad).
|
||||
expected_peer: Option<PeerId>,
|
||||
}
|
||||
|
||||
impl<S> Session<S>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
/// Procesa la conexión hasta `Farewell` o EOF.
|
||||
///
|
||||
/// Estructura: handshake (sobre el stream entero) → split en
|
||||
/// halves (read + write) → reader loop principal + writer task
|
||||
/// que drena el push channel. Garantiza cleanup (sessions + broker
|
||||
/// + canales) sin importar la rama de salida.
|
||||
///
|
||||
/// El split es necesario para soportar streams `!Sync` como
|
||||
/// `libp2p::Stream`: `tokio::select!` sobre `&mut self.stream`
|
||||
/// requeriría `S: Sync`. Con `tokio::io::split` cada mitad va a
|
||||
/// su propia task, eliminando el requirement y permitiendo que
|
||||
/// la misma `Session` sirva indistinta sobre Unix socket o
|
||||
/// stream libp2p remoto.
|
||||
pub async fn handle(self) -> std::io::Result<()> {
|
||||
let Self {
|
||||
mut stream,
|
||||
sessions,
|
||||
push_table,
|
||||
last_matches,
|
||||
config,
|
||||
expected_peer,
|
||||
} = self;
|
||||
|
||||
let session_id = match do_handshake(&mut stream, &config, &sessions, expected_peer).await?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return Ok(()), // Hello rechazado, no se registró nada
|
||||
};
|
||||
|
||||
let result = run_post_handshake(
|
||||
stream,
|
||||
session_id,
|
||||
sessions.clone(),
|
||||
push_table.clone(),
|
||||
last_matches.clone(),
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
cleanup(
|
||||
session_id,
|
||||
&sessions,
|
||||
&push_table,
|
||||
&last_matches,
|
||||
&config,
|
||||
)
|
||||
.await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Free functions (post-refactor): la lógica del post-handshake corre sobre
|
||||
// halves del stream; no necesita más `&mut Session<S>` y por eso vive afuera.
|
||||
// ============================================================================
|
||||
|
||||
async fn run_post_handshake<S>(
|
||||
stream: S,
|
||||
session_id: SessionId,
|
||||
sessions: SessionRegistry,
|
||||
push_table: SessionTxTable,
|
||||
last_matches: LastMatches,
|
||||
config: ServerConfig,
|
||||
) -> std::io::Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
// Canal por donde el server inyecta frames push (MatchEvent, etc.).
|
||||
let (tx, mut rx) = mpsc::channel::<Frame>(PUSH_CHANNEL_CAPACITY);
|
||||
push_table.lock().await.insert(session_id, tx);
|
||||
|
||||
// Tras registrar el canal, recomputar matches y emitir diffs.
|
||||
broadcast_match_diffs(&push_table, &last_matches, &config).await;
|
||||
|
||||
// Split: reader en el hilo principal, writer compartido bajo Mutex
|
||||
// entre la writer task (push channel) y el handler de inbound
|
||||
// (que escribe Pong/Error). Mutex serializa writes; es OK porque
|
||||
// la frecuencia de writes por sesión es baja.
|
||||
let (mut reader, writer) = split(stream);
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
|
||||
// Writer task: drena el push channel.
|
||||
let writer_for_push = writer.clone();
|
||||
let writer_task = tokio::spawn(async move {
|
||||
while let Some(frame) = rx.recv().await {
|
||||
let mut w = writer_for_push.lock().await;
|
||||
if write_frame(&mut *w, &frame).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reader loop principal.
|
||||
let broker_for_loop = config.broker.clone();
|
||||
let result: std::io::Result<()> = loop {
|
||||
match read_frame(&mut reader).await {
|
||||
Ok(frame) => {
|
||||
match handle_inbound_frame(
|
||||
session_id,
|
||||
frame,
|
||||
&writer,
|
||||
&sessions,
|
||||
broker_for_loop.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => continue,
|
||||
Ok(false) => break Ok(()),
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
debug!(session = %session_id, "cliente cerró sin Farewell");
|
||||
break Ok(());
|
||||
}
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
};
|
||||
|
||||
// Cerrar writer: drop nuestro Arc y abortar la task. La task
|
||||
// saldrá igual cuando rx se cierre por drop del último Sender,
|
||||
// pero abortarla es más rápido que esperar a que próximo recv()
|
||||
// observe el cierre.
|
||||
drop(writer);
|
||||
writer_task.abort();
|
||||
let _ = writer_task.await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn handle_inbound_frame<S>(
|
||||
session_id: SessionId,
|
||||
frame: Frame,
|
||||
writer: &Arc<Mutex<WriteHalf<S>>>,
|
||||
sessions: &SessionRegistry,
|
||||
broker_for_match: Option<&SharedBroker>,
|
||||
) -> std::io::Result<bool>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
match frame {
|
||||
Frame::Ping(Ping { session }) if session == session_id => {
|
||||
let pong = Pong {
|
||||
timestamp_ms: now_ms(),
|
||||
};
|
||||
let mut w = writer.lock().await;
|
||||
write_frame(&mut *w, &Frame::Pong(pong)).await?;
|
||||
Ok(true)
|
||||
}
|
||||
Frame::Ping(_) => {
|
||||
let mut w = writer.lock().await;
|
||||
write_frame(
|
||||
&mut *w,
|
||||
&Frame::Error(HandshakeError::Unauthorized(
|
||||
"session-id no coincide".into(),
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
Frame::Farewell(Farewell { session }) if session == session_id => Ok(false),
|
||||
Frame::Farewell(_) => {
|
||||
let mut w = writer.lock().await;
|
||||
write_frame(
|
||||
&mut *w,
|
||||
&Frame::Error(HandshakeError::Unauthorized(
|
||||
"session-id no coincide".into(),
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
Frame::ListSessions(crate::messages::ListSessions { session })
|
||||
if session == session_id =>
|
||||
{
|
||||
let list = build_session_list(sessions).await;
|
||||
let mut w = writer.lock().await;
|
||||
write_frame(&mut *w, &Frame::SessionList(list)).await?;
|
||||
Ok(true)
|
||||
}
|
||||
Frame::ListSessions(_) => {
|
||||
let mut w = writer.lock().await;
|
||||
write_frame(
|
||||
&mut *w,
|
||||
&Frame::Error(HandshakeError::Unauthorized(
|
||||
"session-id no coincide".into(),
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
Frame::ListMatches(crate::messages::ListMatches { session })
|
||||
if session == session_id =>
|
||||
{
|
||||
let matches = match &broker_for_match {
|
||||
Some(b) => b.lock().await.all_matches(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut w = writer.lock().await;
|
||||
write_frame(
|
||||
&mut *w,
|
||||
&Frame::MatchList(crate::messages::MatchList { matches }),
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
Frame::ListMatches(_) => {
|
||||
let mut w = writer.lock().await;
|
||||
write_frame(
|
||||
&mut *w,
|
||||
&Frame::Error(HandshakeError::Unauthorized(
|
||||
"session-id no coincide".into(),
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
_ => {
|
||||
let mut w = writer.lock().await;
|
||||
write_frame(
|
||||
&mut *w,
|
||||
&Frame::Error(HandshakeError::Rejected(
|
||||
"frame inesperado tras handshake".into(),
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot read-only de la `SessionRegistry` proyectado a la forma
|
||||
/// de wire para el frame `SessionList`. Suelta el lock antes de
|
||||
/// retornar para que el writer del frame no contenga el mutex.
|
||||
async fn build_session_list(sessions: &SessionRegistry) -> crate::messages::SessionList {
|
||||
let table = sessions.lock().await;
|
||||
let entries = table
|
||||
.iter()
|
||||
.map(|(id, resolved)| crate::messages::SessionEntry {
|
||||
session: *id,
|
||||
label: resolved.card.label.clone(),
|
||||
schema_version: resolved.card.schema_version,
|
||||
outputs: resolved
|
||||
.card
|
||||
.flow
|
||||
.output
|
||||
.iter()
|
||||
.map(|f| f.name.clone())
|
||||
.collect(),
|
||||
inputs: resolved
|
||||
.card
|
||||
.flow
|
||||
.input
|
||||
.iter()
|
||||
.map(|f| f.name.clone())
|
||||
.collect(),
|
||||
conscious: resolved.wit.is_some(),
|
||||
})
|
||||
.collect();
|
||||
crate::messages::SessionList { entries }
|
||||
}
|
||||
|
||||
/// Limpieza atómica de las vistas registradas + (si net activo) retiro
|
||||
/// de anuncios DHT de los outputs de la Card. Se ejecuta tanto si la
|
||||
/// sesión cierra por Farewell, EOF, o error. Tras desregistrar, emite
|
||||
/// diffs a las sesiones que perdieron el match contra ésta.
|
||||
async fn cleanup(
|
||||
session_id: SessionId,
|
||||
sessions: &SessionRegistry,
|
||||
push_table: &SessionTxTable,
|
||||
last_matches: &LastMatches,
|
||||
config: &ServerConfig,
|
||||
) {
|
||||
// Tomamos la Card ANTES de borrarla — si net está configurado
|
||||
// necesitamos sus outputs para llamar withdraw_outputs. `remove`
|
||||
// devuelve el valor extraído.
|
||||
let removed_card = sessions.lock().await.remove(&session_id);
|
||||
push_table.lock().await.remove(&session_id);
|
||||
last_matches.lock().await.remove(&session_id);
|
||||
if let Some(broker) = &config.broker {
|
||||
broker.lock().await.unregister(session_id);
|
||||
}
|
||||
if let (Some(net), Some(resolved)) = (&config.net, removed_card) {
|
||||
crate::network::withdraw_outputs(net, &resolved.card);
|
||||
}
|
||||
broadcast_match_diffs(push_table, last_matches, config).await;
|
||||
}
|
||||
|
||||
/// Recomputa los matches para todas las sesiones registradas y empuja
|
||||
/// `MatchEvent::Available` / `MatchEvent::Lost` por las que cambiaron
|
||||
/// respecto al último estado conocido.
|
||||
async fn broadcast_match_diffs(
|
||||
push_table: &SessionTxTable,
|
||||
last_matches: &LastMatches,
|
||||
config: &ServerConfig,
|
||||
) {
|
||||
let broker = match &config.broker {
|
||||
Some(b) => b,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let b = broker.lock().await;
|
||||
let push_table = push_table.lock().await;
|
||||
let mut last = last_matches.lock().await;
|
||||
|
||||
debug!(
|
||||
target: "brahman_handshake::broadcast",
|
||||
cards = b.len(),
|
||||
push_subscribers = push_table.len(),
|
||||
"broadcast_match_diffs"
|
||||
);
|
||||
|
||||
let cards: Vec<_> = b.cards().cloned().collect();
|
||||
|
||||
for cons in &cards {
|
||||
let cons_session = cons.session;
|
||||
let tx = match push_table.get(&cons_session) {
|
||||
Some(tx) => tx,
|
||||
None => continue,
|
||||
};
|
||||
let cons_last = last.entry(cons_session).or_default();
|
||||
|
||||
for input in &cons.inputs {
|
||||
let new_match = b.find_producer_for(cons_session, &input.name);
|
||||
let new_endpoint = new_match.as_ref().map(|m| m.producer.clone());
|
||||
let old_endpoint = cons_last.get(&input.name).cloned();
|
||||
|
||||
if old_endpoint == new_endpoint {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(m) = &new_match {
|
||||
let producer_service_socket = b
|
||||
.cards()
|
||||
.find(|c| c.session == m.producer.session)
|
||||
.and_then(|c| c.service_socket.clone());
|
||||
let event = MatchEvent {
|
||||
kind: MatchEventKind::Available,
|
||||
consumer_flow: input.name.clone(),
|
||||
producer_session: m.producer.session,
|
||||
producer_label: m.producer_label.clone(),
|
||||
producer_flow: m.producer.flow_name.clone(),
|
||||
ty: m.ty.clone(),
|
||||
via: m.via,
|
||||
pinned: m.pinned,
|
||||
producer_service_socket,
|
||||
};
|
||||
let send_res = tx.try_send(Frame::MatchEvent(event));
|
||||
debug!(
|
||||
target: "brahman_handshake::broadcast",
|
||||
consumer = %cons_session,
|
||||
flow = %input.name,
|
||||
producer = %m.producer_label,
|
||||
result = ?send_res.as_ref().map(|_| "ok").unwrap_or_else(|e| match e {
|
||||
tokio::sync::mpsc::error::TrySendError::Full(_) => "full",
|
||||
tokio::sync::mpsc::error::TrySendError::Closed(_) => "closed",
|
||||
}),
|
||||
"Available pushed"
|
||||
);
|
||||
} else {
|
||||
let event = MatchEvent {
|
||||
kind: MatchEventKind::Lost,
|
||||
consumer_flow: input.name.clone(),
|
||||
producer_session: Ulid::nil(),
|
||||
producer_label: String::new(),
|
||||
producer_flow: String::new(),
|
||||
ty: input.ty.clone(),
|
||||
via: brahman_broker::MatchStrategy::Exact,
|
||||
pinned: false,
|
||||
producer_service_socket: None,
|
||||
};
|
||||
let _ = tx.try_send(Frame::MatchEvent(event));
|
||||
}
|
||||
|
||||
if let Some(ep) = new_endpoint {
|
||||
cons_last.insert(input.name.clone(), ep);
|
||||
} else {
|
||||
cons_last.remove(&input.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lee el Hello, valida (incluyendo firma cuando aplica), registra la
|
||||
/// sesión y emite HelloAck.
|
||||
async fn do_handshake<S>(
|
||||
stream: &mut S,
|
||||
config: &ServerConfig,
|
||||
sessions: &SessionRegistry,
|
||||
expected_peer: Option<PeerId>,
|
||||
) -> std::io::Result<Option<SessionId>>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let frame = read_frame(stream).await?;
|
||||
let hello = match frame {
|
||||
Frame::Hello(h) => h,
|
||||
_ => {
|
||||
write_frame(
|
||||
stream,
|
||||
&Frame::Error(HandshakeError::Rejected(
|
||||
"primer frame debe ser Hello".into(),
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(err) = validate_hello(&hello) {
|
||||
write_frame(stream, &Frame::Error(err)).await?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Identity cert (multi-key identity, opcional): si el cliente
|
||||
// adjuntó cert, la "identidad lógica" del peer es el master
|
||||
// derivado del cert (estable across rotaciones), no el session
|
||||
// peer_id (efímero). Sin cert, fallback al modelo de Fase 3
|
||||
// (logical = session). Esto permite migración gradual y
|
||||
// backwards compatibility con clientes que no usan identity.
|
||||
let logical_peer = if let (Some(session_peer), Some(cert)) =
|
||||
(expected_peer, &hello.identity_cert)
|
||||
{
|
||||
let session_pk_bytes: &[u8] = match &hello.signature {
|
||||
Some(sig) => &sig.public_key,
|
||||
None => {
|
||||
write_frame(
|
||||
stream,
|
||||
&Frame::Error(HandshakeError::Unauthorized(
|
||||
"Hello con identity_cert requiere también signature".into(),
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
match cert.verify_against_session(session_pk_bytes) {
|
||||
Ok(master_peer) => {
|
||||
debug!(
|
||||
session = %session_peer,
|
||||
master = %master_peer,
|
||||
"identity cert válido — policy se evalúa contra master_peer"
|
||||
);
|
||||
Some(master_peer)
|
||||
}
|
||||
Err(e) => {
|
||||
write_frame(
|
||||
stream,
|
||||
&Frame::Error(HandshakeError::Unauthorized(format!(
|
||||
"identity cert inválido: {e}"
|
||||
))),
|
||||
)
|
||||
.await?;
|
||||
debug!(peer = %session_peer, error = %e, "cert rechazado");
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
expected_peer
|
||||
};
|
||||
|
||||
// Policy gate (path libp2p): si está configurada, el peer
|
||||
// autenticado debe pasar la política (deny first, luego allow).
|
||||
// El peer evaluado es `logical_peer`: master si hay cert,
|
||||
// session si no. Se chequea ANTES de la firma porque es
|
||||
// comparación O(log n) sin crypto. La política no se aplica
|
||||
// al path Unix (autenticación por SO_PEERCRED, no por PeerId).
|
||||
if let (Some(peer), Some(policy)) = (logical_peer, &config.policy) {
|
||||
let decision = policy.evaluate(&peer);
|
||||
if !decision.is_admitted() {
|
||||
write_frame(
|
||||
stream,
|
||||
&Frame::Error(HandshakeError::Unauthorized(format!(
|
||||
"peer {peer}: {}",
|
||||
decision.reason()
|
||||
))),
|
||||
)
|
||||
.await?;
|
||||
debug!(peer = %peer, reason = decision.reason(), "rechazado por policy");
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Trust gate: en path libp2p (expected_peer = Some), exigir
|
||||
// firma cuya public key derive al peer autenticado por Noise.
|
||||
// En path Unix (expected_peer = None), si la firma viene se
|
||||
// verifica anyway por defensa en profundidad — no es un error
|
||||
// que esté ahí, pero si está debe ser válida.
|
||||
if let Some(peer) = expected_peer {
|
||||
let sig = match &hello.signature {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
write_frame(
|
||||
stream,
|
||||
&Frame::Error(HandshakeError::Unauthorized(
|
||||
"Hello sin firma en conexión remota libp2p".into(),
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if let Err(e) = crate::signature::verify_hello(sig, &hello.card, &hello.wit, peer) {
|
||||
write_frame(
|
||||
stream,
|
||||
&Frame::Error(HandshakeError::Unauthorized(format!("firma inválida: {e}"))),
|
||||
)
|
||||
.await?;
|
||||
debug!(peer = %peer, error = %e, "firma rechazada");
|
||||
return Ok(None);
|
||||
}
|
||||
} else if let Some(sig) = &hello.signature {
|
||||
// Firma presente en path local: no exigida pero verificada.
|
||||
// Si está y no valida, es un signo de Hello mal-construido y
|
||||
// rechazamos por seguridad.
|
||||
// Para Unix no tenemos peer_id contra el cual comparar; se
|
||||
// verifica sólo la consistencia interna (firma sobre payload
|
||||
// con la public_key declarada).
|
||||
match brahman_net::PublicKey::try_decode_protobuf(&sig.public_key) {
|
||||
Ok(pk) => {
|
||||
let payload = match postcard::to_allocvec(&(
|
||||
crate::signature::SIGNATURE_VERSION,
|
||||
&hello.card,
|
||||
&hello.wit,
|
||||
)) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
write_frame(
|
||||
stream,
|
||||
&Frame::Error(HandshakeError::Internal(
|
||||
"no pude codificar payload para verificar firma".into(),
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if !pk.verify(&payload, &sig.signature) {
|
||||
write_frame(
|
||||
stream,
|
||||
&Frame::Error(HandshakeError::Unauthorized(
|
||||
"firma del Hello presente pero inválida".into(),
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
write_frame(
|
||||
stream,
|
||||
&Frame::Error(HandshakeError::Unauthorized(format!(
|
||||
"public_key inválida en firma: {e}"
|
||||
))),
|
||||
)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let session_id = Ulid::new();
|
||||
let card: Card = hello.card.into();
|
||||
register_session(session_id, card, hello.wit, config, sessions).await;
|
||||
|
||||
let ack = HelloAck {
|
||||
server_version: crate::HANDSHAKE_VERSION.to_string(),
|
||||
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
|
||||
session: session_id,
|
||||
init_attached: config.init_attached,
|
||||
};
|
||||
write_frame(stream, &Frame::HelloAck(ack)).await?;
|
||||
debug!(session = %session_id, "handshake completado");
|
||||
Ok(Some(session_id))
|
||||
}
|
||||
|
||||
async fn register_session(
|
||||
session_id: SessionId,
|
||||
card: Card,
|
||||
wit: Option<WitInterface>,
|
||||
config: &ServerConfig,
|
||||
sessions: &SessionRegistry,
|
||||
) {
|
||||
if let Some(broker) = &config.broker {
|
||||
broker
|
||||
.lock()
|
||||
.await
|
||||
.register(session_id, &card, wit.clone());
|
||||
}
|
||||
// Si el server tiene net configurado, anunciar los outputs al
|
||||
// DHT para que peers remotos puedan descubrirlos. Idempotente
|
||||
// y best-effort — fallos de Kad no propagan al handshake.
|
||||
if let Some(net) = &config.net {
|
||||
crate::network::announce_outputs(net, &card);
|
||||
}
|
||||
let resolved = match wit {
|
||||
Some(w) => ResolvedCard::from_conscious(card, w),
|
||||
None => ResolvedCard::from_agnostic(card),
|
||||
};
|
||||
sessions.lock().await.insert(session_id, resolved);
|
||||
}
|
||||
|
||||
fn validate_hello(hello: &Hello) -> Option<HandshakeError> {
|
||||
if hello.schema_version != CARD_SCHEMA_VERSION {
|
||||
return Some(HandshakeError::SchemaMismatch {
|
||||
client: hello.schema_version,
|
||||
server: CARD_SCHEMA_VERSION,
|
||||
});
|
||||
}
|
||||
if hello.protocol_version != brahman_card::PROTOCOL_VERSION {
|
||||
return Some(HandshakeError::ProtocolMismatch(format!(
|
||||
"cliente={}, servidor={}",
|
||||
hello.protocol_version,
|
||||
brahman_card::PROTOCOL_VERSION
|
||||
)));
|
||||
}
|
||||
let as_card: Card = Card::from(hello.card.clone());
|
||||
if let Err(e) = as_card.validate() {
|
||||
return Some(HandshakeError::InvalidCard(e.to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Firma y verificación del payload del `Hello` para trust remoto.
|
||||
//!
|
||||
//! Usa la identidad Ed25519 de libp2p (la misma keypair que el peer
|
||||
//! presenta al swarm vía Noise). Esto ancla la identidad criptográfica
|
||||
//! del Ente a la identidad de transporte: si Noise autenticó al
|
||||
//! `peer_id` X, sólo X puede firmar Cards válidas para esa conexión.
|
||||
//!
|
||||
//! ## Payload firmado
|
||||
//!
|
||||
//! Bytes postcard de la tupla `(WireCard, Option<WitInterface>)`. Se
|
||||
//! eligió postcard porque ya es el wire format del resto del protocolo:
|
||||
//! mismo determinismo, sin convertir a otro formato sólo para firmar.
|
||||
//!
|
||||
//! Cualquier campo que entre al payload firmado en el futuro debe
|
||||
//! añadirse al final de la tupla (postcard es position-dependent), o
|
||||
//! bumpearse el [`SIGNATURE_VERSION`] para distinguir esquemas.
|
||||
|
||||
use brahman_card::{WireCard, WitInterface};
|
||||
use brahman_net::{Keypair, PeerId, PublicKey};
|
||||
|
||||
use crate::messages::HelloSignature;
|
||||
|
||||
/// Versión del esquema de payload firmado. Si cambia el shape de
|
||||
/// `(WireCard, Option<WitInterface>)` o cómo se serializa, bump este
|
||||
/// número y el verificador rechaza firmas antiguas.
|
||||
pub const SIGNATURE_VERSION: u8 = 1;
|
||||
|
||||
/// Errores de verificación de firma.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SignatureError {
|
||||
#[error("public_key inválida (libp2p decode protobuf): {0}")]
|
||||
DecodeKey(String),
|
||||
#[error("encode del payload falló: {0}")]
|
||||
EncodePayload(String),
|
||||
#[error("firma rechazada: bytes inválidos para la public_key")]
|
||||
Invalid,
|
||||
#[error("peer_id de la firma ({signer}) no coincide con el peer libp2p autenticado ({expected})")]
|
||||
PeerMismatch { signer: PeerId, expected: PeerId },
|
||||
#[error("firma del Hello faltante (requerida para conexión remota libp2p)")]
|
||||
Missing,
|
||||
#[error("firma del Hello inesperada en path local sin trust remoto")]
|
||||
Unexpected,
|
||||
}
|
||||
|
||||
/// Construye los bytes canónicos a firmar/verificar para un Hello.
|
||||
/// Postcard determinístico de `(version, WireCard, Option<WitInterface>)`.
|
||||
fn payload_bytes(card: &WireCard, wit: &Option<WitInterface>) -> Result<Vec<u8>, SignatureError> {
|
||||
let tup = (SIGNATURE_VERSION, card, wit);
|
||||
postcard::to_allocvec(&tup).map_err(|e| SignatureError::EncodePayload(e.to_string()))
|
||||
}
|
||||
|
||||
/// Firma `(card, wit)` con la `keypair`. La public key derivada de
|
||||
/// `keypair` debe coincidir con la identidad libp2p del peer cuando
|
||||
/// el verificador la chequee.
|
||||
pub fn sign_hello(
|
||||
keypair: &Keypair,
|
||||
card: &WireCard,
|
||||
wit: &Option<WitInterface>,
|
||||
) -> Result<HelloSignature, SignatureError> {
|
||||
let bytes = payload_bytes(card, wit)?;
|
||||
let signature_bytes = keypair
|
||||
.sign(&bytes)
|
||||
.map_err(|e| SignatureError::EncodePayload(e.to_string()))?;
|
||||
Ok(HelloSignature {
|
||||
public_key: keypair.public().encode_protobuf(),
|
||||
signature: signature_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifica que `sig` es una firma válida sobre `(card, wit)` y que
|
||||
/// la public key declarada coincide con `expected_peer` (la identidad
|
||||
/// libp2p autenticada por Noise).
|
||||
///
|
||||
/// Devuelve `Ok(())` si todo cuadra; si no, el error concreto.
|
||||
pub fn verify_hello(
|
||||
sig: &HelloSignature,
|
||||
card: &WireCard,
|
||||
wit: &Option<WitInterface>,
|
||||
expected_peer: PeerId,
|
||||
) -> Result<(), SignatureError> {
|
||||
let public_key = PublicKey::try_decode_protobuf(&sig.public_key)
|
||||
.map_err(|e| SignatureError::DecodeKey(e.to_string()))?;
|
||||
let signer_peer = public_key.to_peer_id();
|
||||
if signer_peer != expected_peer {
|
||||
return Err(SignatureError::PeerMismatch {
|
||||
signer: signer_peer,
|
||||
expected: expected_peer,
|
||||
});
|
||||
}
|
||||
let bytes = payload_bytes(card, wit)?;
|
||||
if !public_key.verify(&bytes, &sig.signature) {
|
||||
return Err(SignatureError::Invalid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use brahman_card::Card;
|
||||
|
||||
fn sample_card() -> WireCard {
|
||||
Card::new("test.signed").into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_then_verify_roundtrip() {
|
||||
let kp = Keypair::generate_ed25519();
|
||||
let peer = kp.public().to_peer_id();
|
||||
let card = sample_card();
|
||||
let wit = None;
|
||||
let sig = sign_hello(&kp, &card, &wit).unwrap();
|
||||
verify_hello(&sig, &card, &wit, peer).expect("firma propia debe verificar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_peer() {
|
||||
let kp = Keypair::generate_ed25519();
|
||||
let other = Keypair::generate_ed25519().public().to_peer_id();
|
||||
let card = sample_card();
|
||||
let wit = None;
|
||||
let sig = sign_hello(&kp, &card, &wit).unwrap();
|
||||
let err = verify_hello(&sig, &card, &wit, other).unwrap_err();
|
||||
assert!(matches!(err, SignatureError::PeerMismatch { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_tampered_card() {
|
||||
let kp = Keypair::generate_ed25519();
|
||||
let peer = kp.public().to_peer_id();
|
||||
let original = sample_card();
|
||||
let wit = None;
|
||||
let sig = sign_hello(&kp, &original, &wit).unwrap();
|
||||
|
||||
// Verificamos contra una Card distinta (mismo shape, distinto label).
|
||||
let tampered: WireCard = Card::new("test.tampered").into();
|
||||
let err = verify_hello(&sig, &tampered, &wit, peer).unwrap_err();
|
||||
assert!(matches!(err, SignatureError::Invalid), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_corrupted_signature() {
|
||||
let kp = Keypair::generate_ed25519();
|
||||
let peer = kp.public().to_peer_id();
|
||||
let card = sample_card();
|
||||
let wit = None;
|
||||
let mut sig = sign_hello(&kp, &card, &wit).unwrap();
|
||||
// Flip un bit de la firma.
|
||||
if let Some(b) = sig.signature.last_mut() {
|
||||
*b ^= 0x01;
|
||||
}
|
||||
let err = verify_hello(&sig, &card, &wit, peer).unwrap_err();
|
||||
assert!(matches!(err, SignatureError::Invalid), "got {err:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Convenciones de transporte: dónde vive el socket del Init.
|
||||
//!
|
||||
//! Resolución del path canónico:
|
||||
//! 1. Variable de entorno [`SOCKET_ENV`] si está definida (override
|
||||
//! explícito, prioridad máxima).
|
||||
//! 2. `$XDG_RUNTIME_DIR/brahman-init.sock` (sesión usuario).
|
||||
//! 3. `$TMPDIR/brahman-init.sock` (fallback portable).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Variable de entorno que sobreescribe la ruta del socket del Init.
|
||||
pub const SOCKET_ENV: &str = "BRAHMAN_INIT_SOCKET";
|
||||
|
||||
/// Nombre del socket dentro del runtime dir.
|
||||
pub const SOCKET_NAME: &str = "brahman-init.sock";
|
||||
|
||||
/// Ruta canónica al socket del Init brahman.
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn env_override_wins() {
|
||||
// Nota: estos tests modifican entorno del proceso. `cargo test`
|
||||
// los corre paralelos por defecto pero usamos un nombre de var
|
||||
// único y restablecemos al final.
|
||||
let key = "BRAHMAN_INIT_SOCKET_TEST_OVERRIDE";
|
||||
// SAFETY: sólo escribimos una variable local al test; sin
|
||||
// contaminar SOCKET_ENV.
|
||||
std::env::set_var(key, "/tmp/explicit.sock");
|
||||
let saved = std::env::var(SOCKET_ENV).ok();
|
||||
std::env::set_var(SOCKET_ENV, "/tmp/explicit.sock");
|
||||
let p = default_socket_path();
|
||||
assert_eq!(p, PathBuf::from("/tmp/explicit.sock"));
|
||||
// Restaurar
|
||||
match saved {
|
||||
Some(v) => std::env::set_var(SOCKET_ENV, v),
|
||||
None => std::env::remove_var(SOCKET_ENV),
|
||||
}
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
//! Tests de integración: levanta server + client en el mismo proceso,
|
||||
//! ejercita el round-trip completo del protocolo.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use brahman_broker::{Broker, BrokerConfig};
|
||||
use brahman_card::{
|
||||
Card, CgroupSpec, Flow, Flows, NamespaceSet, Payload, ResourceLimits, SomaSpec, Supervision,
|
||||
TypeRef, CARD_SCHEMA_VERSION,
|
||||
};
|
||||
use brahman_handshake::{
|
||||
client::{Client, ClientError},
|
||||
codec::{read_frame, write_frame},
|
||||
messages::{Frame, HandshakeError, Hello, Ping},
|
||||
server::{Server, ServerConfig},
|
||||
};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::Mutex;
|
||||
use ulid::Ulid;
|
||||
|
||||
fn sample_card(label: &str) -> Card {
|
||||
Card {
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
lineage: None,
|
||||
label: label.into(),
|
||||
provides: BTreeSet::new(),
|
||||
requires: BTreeSet::new(),
|
||||
soma: SomaSpec {
|
||||
cgroup: CgroupSpec {
|
||||
path: "ente.slice/test".into(),
|
||||
cpu_weight: None,
|
||||
io_weight: None,
|
||||
},
|
||||
namespaces: NamespaceSet::default(),
|
||||
rlimits: ResourceLimits::default(),
|
||||
cpu_affinity: None,
|
||||
},
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::OneShot,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Genera una ruta de socket única bajo TMPDIR. No la creamos —
|
||||
/// el server la creará al hacer bind.
|
||||
fn sock_path(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"brahman-test-{}-{}-{}.sock",
|
||||
name,
|
||||
std::process::id(),
|
||||
Ulid::new()
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_handshake_roundtrip() {
|
||||
let path = sock_path("happy");
|
||||
let server = Server::bind(&path, ServerConfig { init_attached: true, broker: None, net: None, policy: None }).unwrap();
|
||||
|
||||
let session_handle = tokio::spawn({
|
||||
async move {
|
||||
let session = server.accept_one().await.unwrap();
|
||||
session.handle().await.unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
let mut client = Client::connect(&path, sample_card("alpha")).await.unwrap();
|
||||
assert!(client.server_info().init_attached);
|
||||
assert_eq!(
|
||||
client.server_info().protocol_version,
|
||||
brahman_card::PROTOCOL_VERSION
|
||||
);
|
||||
|
||||
let mut last = 0u64;
|
||||
for _ in 0..3 {
|
||||
let ts = client.ping().await.unwrap();
|
||||
assert!(ts >= last);
|
||||
last = ts;
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
client.farewell().await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), session_handle)
|
||||
.await
|
||||
.expect("server hung after farewell")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_returns_currently_registered() {
|
||||
// Levantamos un server con broker (requerido para que el registro
|
||||
// pase por el path real) y conectamos 3 clientes. El último pide
|
||||
// ListSessions y debe ver a los 2 anteriores + a sí mismo.
|
||||
let path = sock_path("listsess");
|
||||
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
|
||||
let server = Server::bind(
|
||||
&path,
|
||||
ServerConfig {
|
||||
init_attached: true,
|
||||
broker: Some(broker),
|
||||
net: None,
|
||||
policy: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Una task accept loop genérica para los 3 clientes.
|
||||
let server_handle = tokio::spawn(async move {
|
||||
for _ in 0..3 {
|
||||
let session = server.accept_one().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = session.handle().await;
|
||||
});
|
||||
}
|
||||
// Mantener el server vivo para que las sesiones puedan
|
||||
// mantenerse abiertas mientras el observer pregunta.
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
|
||||
let mut alpha = Client::connect(&path, sample_card("producer-alpha"))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut beta = Client::connect(&path, sample_card("producer-beta"))
|
||||
.await
|
||||
.unwrap();
|
||||
// observer es el que va a preguntar.
|
||||
let mut observer = Client::connect(&path, sample_card("observer"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list = observer.list_sessions().await.unwrap();
|
||||
assert_eq!(list.entries.len(), 3, "deberían verse 3 sesiones activas");
|
||||
|
||||
let labels: BTreeSet<&str> = list.entries.iter().map(|e| e.label.as_str()).collect();
|
||||
assert!(labels.contains("producer-alpha"));
|
||||
assert!(labels.contains("producer-beta"));
|
||||
assert!(labels.contains("observer"));
|
||||
|
||||
// schema_version + conscious sanity en la propia entry del observer.
|
||||
let me = list
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.label == "observer")
|
||||
.unwrap();
|
||||
assert_eq!(me.schema_version, brahman_card::CARD_SCHEMA_VERSION);
|
||||
assert!(!me.conscious, "observer no envió WIT — debería ser agnostic");
|
||||
|
||||
alpha.farewell().await.unwrap();
|
||||
beta.farewell().await.unwrap();
|
||||
observer.farewell().await.unwrap();
|
||||
server_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_card_client_side() {
|
||||
let path = sock_path("invalid");
|
||||
let server = Server::bind(&path, ServerConfig::default()).unwrap();
|
||||
let session_handle = tokio::spawn(async move {
|
||||
// No esperamos que el server complete: el cliente corta antes.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(1), async move {
|
||||
let session = server.accept_one().await.unwrap();
|
||||
session.handle().await.unwrap();
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
let mut bad = sample_card("placeholder");
|
||||
bad.label = String::new();
|
||||
let err = Client::connect(&path, bad).await.unwrap_err();
|
||||
assert!(matches!(err, ClientError::InvalidCard(_)));
|
||||
|
||||
session_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_rejects_protocol_mismatch() {
|
||||
let path = sock_path("mismatch");
|
||||
let server = Server::bind(&path, ServerConfig::default()).unwrap();
|
||||
let session_handle = tokio::spawn(async move {
|
||||
let session = server.accept_one().await.unwrap();
|
||||
session.handle().await.unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&path).await.unwrap();
|
||||
let hello = Hello {
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
protocol_version: "999.0.0".into(),
|
||||
card: sample_card("future-module").into(),
|
||||
wit: None,
|
||||
signature: None,
|
||||
identity_cert: None,
|
||||
};
|
||||
write_frame(&mut stream, &Frame::Hello(hello)).await.unwrap();
|
||||
|
||||
match read_frame(&mut stream).await.unwrap() {
|
||||
Frame::Error(HandshakeError::ProtocolMismatch(_)) => {}
|
||||
other => panic!("esperado ProtocolMismatch, got {other:?}"),
|
||||
}
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), session_handle)
|
||||
.await
|
||||
.expect("server hung after rejecting")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Integración handshake ↔ broker
|
||||
// =====================================================================
|
||||
|
||||
fn card_with_flows(label: &str, input: Vec<Flow>, output: Vec<Flow>) -> Card {
|
||||
Card {
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
label: label.into(),
|
||||
soma: SomaSpec {
|
||||
cgroup: CgroupSpec {
|
||||
path: "ente.slice/test".into(),
|
||||
cpu_weight: None,
|
||||
io_weight: None,
|
||||
},
|
||||
namespaces: NamespaceSet::default(),
|
||||
rlimits: ResourceLimits::default(),
|
||||
cpu_affinity: None,
|
||||
},
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::OneShot,
|
||||
flow: Flows { input, output },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn flow(name: &str, ty: TypeRef) -> Flow {
|
||||
Flow {
|
||||
name: name.into(),
|
||||
ty,
|
||||
pin_to: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Espera hasta que `broker.len() >= n` o timeout.
|
||||
async fn wait_for_broker_len(broker: &Arc<Mutex<Broker>>, n: usize) {
|
||||
for _ in 0..50 {
|
||||
if broker.lock().await.len() >= n {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("broker no alcanzó {n} entradas en 500ms");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broker_registers_and_unregisters_with_session() {
|
||||
let path = sock_path("broker-lifecycle");
|
||||
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
|
||||
let server = Server::bind(
|
||||
&path,
|
||||
ServerConfig {
|
||||
init_attached: false,
|
||||
broker: Some(broker.clone()),
|
||||
net: None,
|
||||
policy: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let session_handle = tokio::spawn(async move {
|
||||
let session = server.accept_one().await.unwrap();
|
||||
session.handle().await.unwrap();
|
||||
});
|
||||
|
||||
let mut client = Client::connect(&path, sample_card("alpha")).await.unwrap();
|
||||
let session_id = client.session();
|
||||
|
||||
// Tras el handshake, la Card debe estar registrada en el broker.
|
||||
wait_for_broker_len(&broker, 1).await;
|
||||
{
|
||||
let b = broker.lock().await;
|
||||
assert_eq!(b.len(), 1);
|
||||
assert!(b.sessions().any(|s| s == session_id));
|
||||
}
|
||||
|
||||
client.farewell().await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), session_handle)
|
||||
.await
|
||||
.expect("server colgó tras farewell")
|
||||
.unwrap();
|
||||
|
||||
// Tras el cleanup, el broker queda vacío.
|
||||
{
|
||||
let b = broker.lock().await;
|
||||
assert_eq!(b.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broker_matches_two_live_modules() {
|
||||
let path = sock_path("broker-match");
|
||||
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
|
||||
let server = Server::bind(
|
||||
&path,
|
||||
ServerConfig {
|
||||
init_attached: false,
|
||||
broker: Some(broker.clone()),
|
||||
net: None,
|
||||
policy: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Server loop: usa la API run() para manejar accept+spawn.
|
||||
let server_handle = tokio::spawn(async move {
|
||||
let _ = server.run().await;
|
||||
});
|
||||
|
||||
// Productor: emite "out" tipo string.
|
||||
let producer_card = card_with_flows(
|
||||
"dht",
|
||||
vec![],
|
||||
vec![flow(
|
||||
"out",
|
||||
TypeRef::Primitive {
|
||||
name: "string".into(),
|
||||
},
|
||||
)],
|
||||
);
|
||||
let mut producer = Client::connect(&path, producer_card).await.unwrap();
|
||||
wait_for_broker_len(&broker, 1).await;
|
||||
|
||||
// Consumidor: pide "in" tipo string.
|
||||
let consumer_card = card_with_flows(
|
||||
"ui",
|
||||
vec![flow(
|
||||
"in",
|
||||
TypeRef::Primitive {
|
||||
name: "string".into(),
|
||||
},
|
||||
)],
|
||||
vec![],
|
||||
);
|
||||
let mut consumer = Client::connect(&path, consumer_card).await.unwrap();
|
||||
wait_for_broker_len(&broker, 2).await;
|
||||
|
||||
// El broker debe encontrar el match consumer.in ← producer.out.
|
||||
let m = {
|
||||
let b = broker.lock().await;
|
||||
b.find_producer_for(consumer.session(), "in")
|
||||
}
|
||||
.expect("broker no encontró match");
|
||||
assert_eq!(m.consumer_label, "ui");
|
||||
assert_eq!(m.producer_label, "dht");
|
||||
assert_eq!(m.producer.flow_name, "out");
|
||||
|
||||
// Cuando el productor se va, el match desaparece.
|
||||
producer.farewell().await.unwrap();
|
||||
for _ in 0..50 {
|
||||
if broker.lock().await.len() < 2 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
{
|
||||
let b = broker.lock().await;
|
||||
assert!(b.find_producer_for(consumer.session(), "in").is_none());
|
||||
}
|
||||
|
||||
consumer.farewell().await.unwrap();
|
||||
server_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn match_event_pushed_on_producer_arrival() {
|
||||
use brahman_handshake::messages::MatchEventKind;
|
||||
|
||||
let path = sock_path("push-match");
|
||||
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
|
||||
let server = Server::bind(
|
||||
&path,
|
||||
ServerConfig {
|
||||
init_attached: false,
|
||||
broker: Some(broker.clone()),
|
||||
net: None,
|
||||
policy: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let server_handle = tokio::spawn(async move {
|
||||
let _ = server.run().await;
|
||||
});
|
||||
|
||||
// El consumidor llega primero — sin productor, no hay match aún.
|
||||
let consumer_card = card_with_flows(
|
||||
"ui",
|
||||
vec![flow(
|
||||
"in",
|
||||
TypeRef::Primitive {
|
||||
name: "json".into(),
|
||||
},
|
||||
)],
|
||||
vec![],
|
||||
);
|
||||
let mut consumer = Client::connect(&path, consumer_card).await.unwrap();
|
||||
|
||||
// No debería haber evento todavía.
|
||||
let no_event = consumer
|
||||
.await_event(Duration::from_millis(100))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(no_event.is_none(), "evento inesperado: {no_event:?}");
|
||||
|
||||
// Llega el productor → consumer debe recibir Available.
|
||||
let producer_card = card_with_flows(
|
||||
"dht",
|
||||
vec![],
|
||||
vec![flow(
|
||||
"out",
|
||||
TypeRef::Primitive {
|
||||
name: "json".into(),
|
||||
},
|
||||
)],
|
||||
);
|
||||
let mut producer = Client::connect(&path, producer_card).await.unwrap();
|
||||
|
||||
let ev = consumer
|
||||
.await_event(Duration::from_secs(2))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("Available no llegó");
|
||||
assert_eq!(ev.kind, MatchEventKind::Available);
|
||||
assert_eq!(ev.consumer_flow, "in");
|
||||
assert_eq!(ev.producer_label, "dht");
|
||||
assert_eq!(ev.producer_flow, "out");
|
||||
|
||||
// El productor se va → consumer debe recibir Lost.
|
||||
producer.farewell().await.unwrap();
|
||||
let ev = consumer
|
||||
.await_event(Duration::from_secs(2))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("Lost no llegó");
|
||||
assert_eq!(ev.kind, MatchEventKind::Lost);
|
||||
assert_eq!(ev.consumer_flow, "in");
|
||||
|
||||
consumer.farewell().await.unwrap();
|
||||
server_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_before_hello_rejected() {
|
||||
let path = sock_path("ping-no-hello");
|
||||
let server = Server::bind(&path, ServerConfig::default()).unwrap();
|
||||
let session_handle = tokio::spawn(async move {
|
||||
let session = server.accept_one().await.unwrap();
|
||||
session.handle().await.unwrap();
|
||||
});
|
||||
|
||||
// Conectamos y mandamos un Ping sin haber saludado.
|
||||
let mut stream = UnixStream::connect(&path).await.unwrap();
|
||||
write_frame(
|
||||
&mut stream,
|
||||
&Frame::Ping(Ping {
|
||||
session: Ulid::new(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match read_frame(&mut stream).await.unwrap() {
|
||||
Frame::Error(HandshakeError::Rejected(_)) => {}
|
||||
other => panic!("esperado Rejected, got {other:?}"),
|
||||
}
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), session_handle)
|
||||
.await
|
||||
.expect("server hung after rejecting")
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Test E2E de Fase 2: discovery remoto vía DHT.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//! 1. **Provider node (A)**: arma server con `BrahmanNet` configurado;
|
||||
//! listen TCP; un cliente local registra una Card con un output
|
||||
//! flow. El server llama `announce_outputs` automáticamente, lo
|
||||
//! que hace `start_providing` en el DHT bajo la key derivada del
|
||||
//! flow.
|
||||
//! 2. **Consumer node (B)**: arma su propio `BrahmanNet`; dial-ea al
|
||||
//! multiaddr del provider para que ambos se conozcan vía Identify
|
||||
//! (esto popula sus respectivos routing tables de Kademlia).
|
||||
//! 3. **B llama `find_remote_providers(flow_name, type)`**: la query
|
||||
//! DHT propaga vía Kad, y eventually el provider responde con su
|
||||
//! `PeerId`.
|
||||
//! 4. **Verificación**: el `PeerId` que B descubre coincide con el
|
||||
//! de A.
|
||||
//!
|
||||
//! Notas:
|
||||
//! - Kademlia replication factor por defecto es 20; con 2 nodos no
|
||||
//! hay propagación material — A es el único provider, B llega a A
|
||||
//! vía la conexión directa establecida en step 2 y obtiene el record
|
||||
//! del store local de A.
|
||||
//! - El test usa flow `monad-list:json` por familiaridad (es el flow
|
||||
//! real que `akasha daemon` declara). Sirve también como prueba de
|
||||
//! que el sistema completo (daemon + DHT) funcionaría con cero
|
||||
//! cambios en la Card.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use brahman_broker::{Broker, BrokerConfig};
|
||||
use brahman_card::{
|
||||
ulid::Ulid, Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
|
||||
CARD_SCHEMA_VERSION,
|
||||
};
|
||||
use brahman_handshake::network::{find_remote_providers, run_libp2p_accept_loop};
|
||||
use brahman_handshake::server::{Server, ServerConfig};
|
||||
use brahman_net::{BrahmanNet, Multiaddr, Protocol};
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
fn provider_card(label: &str, flow_name: &str, type_name: &str) -> Card {
|
||||
Card {
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
label: label.into(),
|
||||
provides: BTreeSet::new(),
|
||||
requires: BTreeSet::new(),
|
||||
permissions: Default::default(),
|
||||
soma: Default::default(),
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::Delegate,
|
||||
lifecycle: Lifecycle::Daemon,
|
||||
priority: Priority::Normal,
|
||||
kind: CardKind::Ente,
|
||||
flow: Flows {
|
||||
input: vec![],
|
||||
output: vec![Flow {
|
||||
name: flow_name.into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: type_name.into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn dht_discovery_finds_remote_provider() {
|
||||
// ---- Node A (provider): server + libp2p net + Card con output ----
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let a_unix = tmp.path().join("a.sock");
|
||||
|
||||
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
|
||||
let a_net = Arc::new(BrahmanNet::new().unwrap());
|
||||
let a_peer = a_net.peer_id;
|
||||
|
||||
let a_server = Arc::new(
|
||||
Server::bind(
|
||||
&a_unix,
|
||||
ServerConfig {
|
||||
init_attached: true,
|
||||
broker: Some(a_broker.clone()),
|
||||
net: Some(a_net.clone()), // ← clave Fase 2: anuncia al DHT
|
||||
policy: None,
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
|
||||
let a_addr = a_net.listen(listen_addr).await;
|
||||
let mut a_full_addr = a_addr.clone();
|
||||
a_full_addr.push(Protocol::P2p(a_peer));
|
||||
|
||||
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
|
||||
|
||||
// Unix accept loop: necesario para que Client::connect al socket
|
||||
// local no cuelgue (Server no se auto-accepta; el caller arma el
|
||||
// loop). Cada session entrante corre en su propia task.
|
||||
{
|
||||
let s = a_server.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match s.accept_one().await {
|
||||
Ok(session) => {
|
||||
tokio::spawn(async move {
|
||||
let _ = session.handle().await;
|
||||
});
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Registrar la Card local en A con un flow output.
|
||||
let card = provider_card("test.engine_remote", "monad-list", "json");
|
||||
let mut local_client = brahman_handshake::client::Client::connect(&a_unix, card)
|
||||
.await
|
||||
.expect("registro local en A");
|
||||
|
||||
// ---- Node B (consumer): otro net que dial-a a A ----
|
||||
let b_net = BrahmanNet::new().unwrap();
|
||||
b_net.dial(a_full_addr.clone());
|
||||
|
||||
// Esperar a que la conexión se establezca y Identify popule el
|
||||
// routing table de Kad. En localhost con 2 peers, ~250ms es de
|
||||
// sobra; sumamos margen para CI.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// ---- Discovery: B busca providers de "monad-list:json" ----
|
||||
let providers = find_remote_providers(
|
||||
&b_net,
|
||||
"monad-list",
|
||||
&TypeRef::Primitive {
|
||||
name: "json".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
providers.contains(&a_peer),
|
||||
"B debería descubrir a A vía DHT. Encontrados: {:?}, esperado: {}",
|
||||
providers,
|
||||
a_peer
|
||||
);
|
||||
|
||||
// Sanidad: el cliente local sigue vivo durante todo el test (lo
|
||||
// que mantiene la Card registrada y por tanto el record DHT vivo).
|
||||
local_client.farewell().await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn dht_discovery_negative_unknown_flow() {
|
||||
// Mismo setup que el test happy-path, pero B busca un flow que A
|
||||
// NO ofrece. Debe devolver lista vacía dentro del timeout
|
||||
// razonable (no colgarse).
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let a_unix = tmp.path().join("a.sock");
|
||||
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
|
||||
let a_net = Arc::new(BrahmanNet::new().unwrap());
|
||||
let a_peer = a_net.peer_id;
|
||||
|
||||
let a_server = Arc::new(
|
||||
Server::bind(
|
||||
&a_unix,
|
||||
ServerConfig {
|
||||
init_attached: true,
|
||||
broker: Some(a_broker),
|
||||
net: Some(a_net.clone()),
|
||||
policy: None,
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let a_addr = a_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
|
||||
let mut a_full = a_addr.clone();
|
||||
a_full.push(Protocol::P2p(a_peer));
|
||||
|
||||
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
|
||||
|
||||
// Unix accept loop: necesario para que Client::connect al socket
|
||||
// local no cuelgue (Server no se auto-accepta; el caller arma el
|
||||
// loop). Cada session entrante corre en su propia task.
|
||||
{
|
||||
let s = a_server.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match s.accept_one().await {
|
||||
Ok(session) => {
|
||||
tokio::spawn(async move {
|
||||
let _ = session.handle().await;
|
||||
});
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let card = provider_card("test.engine_other", "monad-list", "json");
|
||||
let mut local = brahman_handshake::client::Client::connect(&a_unix, card)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let b_net = BrahmanNet::new().unwrap();
|
||||
b_net.dial(a_full);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Buscamos un flow que NADIE anunció.
|
||||
let providers = find_remote_providers(
|
||||
&b_net,
|
||||
"flow-que-no-existe",
|
||||
&TypeRef::Primitive {
|
||||
name: "json".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
providers.is_empty(),
|
||||
"no debería haber providers para un flow inexistente, got: {:?}",
|
||||
providers
|
||||
);
|
||||
|
||||
local.farewell().await.ok();
|
||||
}
|
||||
|
||||
/// stop_providing test: A registra Card con flow X, B descubre a A.
|
||||
/// El cliente local de A hace farewell → cleanup llama
|
||||
/// withdraw_outputs → A se quita del provider local store. Una nueva
|
||||
/// query desde B (que rutea por A, único peer en el DHT) ya no debe
|
||||
/// listarlo.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn dht_discovery_withdraws_on_session_cleanup() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let a_unix = tmp.path().join("a.sock");
|
||||
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
|
||||
let a_net = Arc::new(BrahmanNet::new().unwrap());
|
||||
let a_peer = a_net.peer_id;
|
||||
|
||||
let a_server = Arc::new(
|
||||
Server::bind(
|
||||
&a_unix,
|
||||
ServerConfig {
|
||||
init_attached: true,
|
||||
broker: Some(a_broker),
|
||||
net: Some(a_net.clone()),
|
||||
policy: None,
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let sessions = a_server.sessions();
|
||||
|
||||
let a_addr = a_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
|
||||
let mut a_full = a_addr.clone();
|
||||
a_full.push(Protocol::P2p(a_peer));
|
||||
|
||||
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
|
||||
{
|
||||
let s = a_server.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match s.accept_one().await {
|
||||
Ok(session) => {
|
||||
tokio::spawn(async move {
|
||||
let _ = session.handle().await;
|
||||
});
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Card con un flow output anunciable.
|
||||
let card = provider_card("test.withdraws", "monad-list", "json");
|
||||
let local = brahman_handshake::client::Client::connect(&a_unix, card)
|
||||
.await
|
||||
.expect("registro local en A");
|
||||
|
||||
let b_net = BrahmanNet::new().unwrap();
|
||||
b_net.dial(a_full);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Confirmación previa: A es discoverable.
|
||||
let before = find_remote_providers(
|
||||
&b_net,
|
||||
"monad-list",
|
||||
&TypeRef::Primitive {
|
||||
name: "json".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
before.contains(&a_peer),
|
||||
"antes del farewell A debería ser discoverable. got: {:?}",
|
||||
before
|
||||
);
|
||||
|
||||
// Farewell del cliente local → server.cleanup → withdraw_outputs.
|
||||
local.farewell().await.ok();
|
||||
|
||||
// Esperamos a que la sesión salga del registro de A (señal de
|
||||
// que cleanup completó).
|
||||
let mut waited = 0;
|
||||
while !sessions.lock().await.is_empty() && waited < 50 {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
waited += 1;
|
||||
}
|
||||
assert!(
|
||||
sessions.lock().await.is_empty(),
|
||||
"sesión debería estar removida tras farewell"
|
||||
);
|
||||
|
||||
// Pequeño margen extra para que el Command::StopProviding lo
|
||||
// procese el swarm task (no es await-able desde fuera).
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Nueva query: A ya no debería listarse como provider.
|
||||
let after = find_remote_providers(
|
||||
&b_net,
|
||||
"monad-list",
|
||||
&TypeRef::Primitive {
|
||||
name: "json".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!after.contains(&a_peer),
|
||||
"tras farewell + withdraw_outputs, A NO debería ser discoverable. got: {:?}",
|
||||
after
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
//! Test E2E: handshake brahman remoto sobre libp2p stream.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//! 1. Server: bind Unix socket (necesario aunque no lo use el cliente);
|
||||
//! crear `BrahmanNet` y escuchar en `/ip4/127.0.0.1/tcp/0`;
|
||||
//! montar `run_libp2p_accept_loop`.
|
||||
//! 2. Client: crear su propio `BrahmanNet`; dial al multiaddr del
|
||||
//! server; `connect_libp2p` con su Card; `ping`; `farewell`.
|
||||
//! 3. Verificar: el server registró la sesión; sessions.len() == 1
|
||||
//! durante la sesión, == 0 después del farewell.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use brahman_broker::{Broker, BrokerConfig};
|
||||
use brahman_card::{
|
||||
ulid::Ulid, Card, CardKind, Lifecycle, Payload, Priority, Supervision,
|
||||
CARD_SCHEMA_VERSION,
|
||||
};
|
||||
use brahman_handshake::identity::{Identity, DEFAULT_SESSION_TTL};
|
||||
use brahman_handshake::network::{connect_libp2p, connect_libp2p_with_cert, run_libp2p_accept_loop};
|
||||
use brahman_handshake::peer_policy::PeerPolicy;
|
||||
use brahman_handshake::server::{Server, ServerConfig};
|
||||
use brahman_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol};
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
fn sample_card(label: &str) -> Card {
|
||||
Card {
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
label: label.into(),
|
||||
provides: BTreeSet::new(),
|
||||
requires: BTreeSet::new(),
|
||||
permissions: Default::default(),
|
||||
soma: Default::default(),
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::OneShot,
|
||||
lifecycle: Lifecycle::default(),
|
||||
priority: Priority::default(),
|
||||
kind: CardKind::Ente,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn libp2p_handshake_roundtrip() {
|
||||
// ---- Server side ----
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let unix_socket = tmp.path().join("brahman-init.sock");
|
||||
|
||||
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
|
||||
let server = Arc::new(
|
||||
Server::bind(
|
||||
&unix_socket,
|
||||
ServerConfig {
|
||||
init_attached: true,
|
||||
broker: Some(broker.clone()),
|
||||
net: None,
|
||||
policy: None,
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let sessions = server.sessions();
|
||||
|
||||
let server_net = Arc::new(BrahmanNet::new().unwrap());
|
||||
let server_peer_id = server_net.peer_id;
|
||||
|
||||
// Listen on a random TCP port.
|
||||
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
|
||||
let actual_addr = server_net.listen(listen_addr).await;
|
||||
// Inject the libp2p PeerId into the multiaddr so the client knows
|
||||
// who to dial.
|
||||
let mut full_addr = actual_addr.clone();
|
||||
full_addr.push(Protocol::P2p(server_peer_id));
|
||||
|
||||
// Spawn the libp2p accept loop.
|
||||
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
|
||||
|
||||
// ---- Client side ----
|
||||
let client_net = BrahmanNet::new().unwrap();
|
||||
client_net.dial(full_addr.clone());
|
||||
|
||||
// Pequeña espera para que el dial conecte. En un entorno real el
|
||||
// caller usaría un mecanismo de barrier, pero para tests un sleep
|
||||
// corto es suficiente y deterministic en localhost.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let card = sample_card("test.remote_ente");
|
||||
let client_kp = client_net.keypair();
|
||||
let mut client = connect_libp2p(&client_net, server_peer_id, card, None, &client_kp)
|
||||
.await
|
||||
.expect("handshake remoto debería completar");
|
||||
|
||||
// Verificación: el server vio la sesión.
|
||||
{
|
||||
let s = sessions.lock().await;
|
||||
assert_eq!(s.len(), 1, "una sesión registrada");
|
||||
let resolved = s.values().next().unwrap();
|
||||
assert_eq!(resolved.card.label, "test.remote_ente");
|
||||
}
|
||||
|
||||
// Ping roundtrip.
|
||||
let ts = client.ping().await.expect("ping debería responder");
|
||||
assert!(ts > 0, "timestamp del Pong > 0");
|
||||
|
||||
// Farewell limpio.
|
||||
client.farewell().await.expect("farewell debería completar");
|
||||
|
||||
// Tras el farewell, el cleanup remueve la sesión.
|
||||
// Damos un tick para que el handler procese el frame.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
{
|
||||
let s = sessions.lock().await;
|
||||
assert_eq!(s.len(), 0, "sesión removida tras farewell");
|
||||
}
|
||||
|
||||
// peer_id no usado aquí, pero validamos que la API existe.
|
||||
let _ = PeerId::random();
|
||||
}
|
||||
|
||||
/// Fase 3 negativo: el cliente intenta firmar el Hello con una keypair
|
||||
/// distinta a la del peer libp2p. El server (que verifica que la
|
||||
/// public key del Hello derive al peer_id autenticado por Noise) debe
|
||||
/// rechazar con `Unauthorized`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn libp2p_handshake_rejects_mismatched_signing_key() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let unix_socket = tmp.path().join("brahman-init.sock");
|
||||
|
||||
let server = Arc::new(
|
||||
Server::bind(
|
||||
&unix_socket,
|
||||
ServerConfig {
|
||||
init_attached: true,
|
||||
broker: None,
|
||||
net: None,
|
||||
policy: None,
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let sessions = server.sessions();
|
||||
|
||||
let server_net = Arc::new(BrahmanNet::new().unwrap());
|
||||
let server_peer = server_net.peer_id;
|
||||
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
|
||||
let actual = server_net.listen(listen_addr).await;
|
||||
let mut full = actual.clone();
|
||||
full.push(Protocol::P2p(server_peer));
|
||||
|
||||
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
|
||||
|
||||
let client_net = BrahmanNet::new().unwrap();
|
||||
client_net.dial(full);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Keypair fraudulenta: NO es la del client_net.
|
||||
let evil_keypair = Keypair::generate_ed25519();
|
||||
|
||||
let card = sample_card("test.evil");
|
||||
let result = connect_libp2p(&client_net, server_peer, card, None, &evil_keypair).await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"handshake con keypair fraudulenta debe fallar"
|
||||
);
|
||||
|
||||
// Sanidad: ninguna sesión registrada.
|
||||
let s = sessions.lock().await;
|
||||
assert_eq!(s.len(), 0, "no debería haber sesión registrada");
|
||||
}
|
||||
|
||||
/// Allowlist gate: A configura `allowlist = [client_authorized_peer]`.
|
||||
/// Un cliente con peer_id en la lista pasa el handshake; otro con
|
||||
/// peer_id distinto es rechazado con `Unauthorized` ANTES de la
|
||||
/// verificación de firma (la allowlist se chequea primero, es más
|
||||
/// barata).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn libp2p_handshake_allowlist_admits_listed_rejects_others() {
|
||||
// Pre-generamos las dos identidades cliente para que A pueda
|
||||
// construir la allowlist conociendo cuál es la "permitida".
|
||||
let allowed_kp = Keypair::generate_ed25519();
|
||||
let allowed_peer = allowed_kp.public().to_peer_id();
|
||||
let denied_kp = Keypair::generate_ed25519();
|
||||
// (denied_peer no se necesita para la lista — sólo para clarity)
|
||||
let _ = denied_kp.public().to_peer_id();
|
||||
|
||||
// ---- Server con allowlist activa ----
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let unix_socket = tmp.path().join("brahman-init.sock");
|
||||
let server = Arc::new(
|
||||
Server::bind(
|
||||
&unix_socket,
|
||||
ServerConfig {
|
||||
init_attached: true,
|
||||
broker: None,
|
||||
net: None,
|
||||
policy: Some(PeerPolicy::from_sets(
|
||||
Some([allowed_peer].into_iter().collect()),
|
||||
std::collections::BTreeSet::new(),
|
||||
)),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let sessions = server.sessions();
|
||||
|
||||
let server_net = Arc::new(BrahmanNet::new().unwrap());
|
||||
let server_peer = server_net.peer_id;
|
||||
let actual = server_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
|
||||
let mut full = actual.clone();
|
||||
full.push(Protocol::P2p(server_peer));
|
||||
|
||||
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
|
||||
|
||||
// ---- Cliente PERMITIDO ----
|
||||
let allowed_net = BrahmanNet::with_keypair(allowed_kp.clone()).unwrap();
|
||||
allowed_net.dial(full.clone());
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let card_ok = sample_card("test.allowed");
|
||||
let mut allowed_client = connect_libp2p(&allowed_net, server_peer, card_ok, None, &allowed_kp)
|
||||
.await
|
||||
.expect("peer en allowlist debe pasar");
|
||||
|
||||
{
|
||||
let s = sessions.lock().await;
|
||||
assert_eq!(s.len(), 1, "sesión del peer permitido registrada");
|
||||
}
|
||||
allowed_client.farewell().await.ok();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// ---- Cliente DENEGADO ----
|
||||
let denied_net = BrahmanNet::with_keypair(denied_kp.clone()).unwrap();
|
||||
denied_net.dial(full.clone());
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let card_no = sample_card("test.denied");
|
||||
let result = connect_libp2p(&denied_net, server_peer, card_no, None, &denied_kp).await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"peer fuera de allowlist debe ser rechazado, got: {:?}",
|
||||
result.is_ok()
|
||||
);
|
||||
{
|
||||
let s = sessions.lock().await;
|
||||
assert_eq!(s.len(), 0, "ninguna sesión adicional registrada tras intento denegado");
|
||||
}
|
||||
}
|
||||
|
||||
/// Denylist gate: A configura `policy` con un peer en la denylist.
|
||||
/// Modo abierto para todo lo demás (sin allowlist), pero el peer
|
||||
/// baneado es rechazado aún teniendo Ed25519 válida y peer_id que
|
||||
/// derivaría limpio del Noise handshake.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn libp2p_handshake_denylist_blocks_listed_peer() {
|
||||
let banned_kp = Keypair::generate_ed25519();
|
||||
let banned_peer = banned_kp.public().to_peer_id();
|
||||
let other_kp = Keypair::generate_ed25519();
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let unix_socket = tmp.path().join("brahman-init.sock");
|
||||
let server = Arc::new(
|
||||
Server::bind(
|
||||
&unix_socket,
|
||||
ServerConfig {
|
||||
init_attached: true,
|
||||
broker: None,
|
||||
net: None,
|
||||
policy: Some(PeerPolicy::from_sets(
|
||||
None, // sin allowlist (abierto)
|
||||
[banned_peer].into_iter().collect(),
|
||||
)),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let sessions = server.sessions();
|
||||
|
||||
let server_net = Arc::new(BrahmanNet::new().unwrap());
|
||||
let server_peer = server_net.peer_id;
|
||||
let actual = server_net
|
||||
.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
|
||||
.await;
|
||||
let mut full = actual.clone();
|
||||
full.push(Protocol::P2p(server_peer));
|
||||
|
||||
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
|
||||
|
||||
// Cliente baneado: connect debe fallar.
|
||||
let banned_net = BrahmanNet::with_keypair(banned_kp.clone()).unwrap();
|
||||
banned_net.dial(full.clone());
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let card_x = sample_card("test.banned");
|
||||
let result = connect_libp2p(&banned_net, server_peer, card_x, None, &banned_kp).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"peer en denylist debe ser rechazado, got Ok"
|
||||
);
|
||||
{
|
||||
let s = sessions.lock().await;
|
||||
assert_eq!(s.len(), 0, "el peer baneado no debería tener sesión");
|
||||
}
|
||||
|
||||
// Cliente no-baneado pasa.
|
||||
let other_net = BrahmanNet::with_keypair(other_kp.clone()).unwrap();
|
||||
other_net.dial(full.clone());
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let card_ok = sample_card("test.other");
|
||||
let mut other_client = connect_libp2p(&other_net, server_peer, card_ok, None, &other_kp)
|
||||
.await
|
||||
.expect("peer fuera de denylist debe pasar");
|
||||
{
|
||||
let s = sessions.lock().await;
|
||||
assert_eq!(s.len(), 1, "sesión del peer no-baneado registrada");
|
||||
}
|
||||
other_client.farewell().await.ok();
|
||||
}
|
||||
|
||||
/// Swarm-level deny via `PeerPolicy::attach_to_net`: cuando la deny
|
||||
/// se aplica al swarm vía `block_list`, el peer baneado es rechazado
|
||||
/// en el dial — la conexión TCP/Noise nunca completa, así que el
|
||||
/// cliente nunca llega siquiera a mandar el Hello. Más eficiente que
|
||||
/// el handshake-level deny.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn swarm_level_deny_blocks_before_noise() {
|
||||
let banned_kp = Keypair::generate_ed25519();
|
||||
let banned_peer = banned_kp.public().to_peer_id();
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let unix_socket = tmp.path().join("brahman-init.sock");
|
||||
let policy = brahman_handshake::peer_policy::PeerPolicy::from_sets(
|
||||
None,
|
||||
[banned_peer].into_iter().collect(),
|
||||
);
|
||||
let server = Arc::new(
|
||||
Server::bind(
|
||||
&unix_socket,
|
||||
ServerConfig {
|
||||
init_attached: true,
|
||||
broker: None,
|
||||
net: None,
|
||||
policy: Some(policy.clone()),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let server_net = Arc::new(BrahmanNet::new().unwrap());
|
||||
let server_peer = server_net.peer_id;
|
||||
|
||||
// ATTACH: la deny se proyecta al swarm. Es lo nuevo de este
|
||||
// commit — sin esta llamada, el deny seguiría aplicando sólo
|
||||
// al nivel de handshake brahman (lo que también funciona pero
|
||||
// gasta un round-trip Noise).
|
||||
policy.attach_to_net(server_net.clone());
|
||||
|
||||
let actual = server_net
|
||||
.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
|
||||
.await;
|
||||
let mut full = actual.clone();
|
||||
full.push(Protocol::P2p(server_peer));
|
||||
|
||||
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
|
||||
|
||||
// Cliente baneado intenta dial + handshake. Con swarm-level
|
||||
// deny, la conexión libp2p ni siquiera completa: `connect_libp2p`
|
||||
// falla con error de open_stream (peer inalcanzable / connection
|
||||
// refused) en lugar del Unauthorized del handshake-level path.
|
||||
let banned_net = BrahmanNet::with_keypair(banned_kp.clone()).unwrap();
|
||||
banned_net.dial(full.clone());
|
||||
|
||||
let card = sample_card("test.swarm_banned");
|
||||
// Timeout corto: si el block falla, el handshake completaría
|
||||
// rápido en localhost. Si funciona, debería fallar el dial casi
|
||||
// instantáneo o colgarse hasta el timeout.
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
connect_libp2p(&banned_net, server_peer, card, None, &banned_kp),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(_)) => panic!("peer baneado a nivel swarm NO debería completar handshake"),
|
||||
Ok(Err(e)) => {
|
||||
// Esperado: error de transporte/stream, no de handshake.
|
||||
tracing::info!(error = %e, "swarm-level deny rechazó como esperado");
|
||||
}
|
||||
Err(_) => {
|
||||
// También aceptable: timeout porque el dial nunca completa.
|
||||
tracing::info!("swarm-level deny → connect timeout (también OK)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-key identity: la propiedad fundamental que cierra el
|
||||
/// proyecto. El cliente B tiene una identity master estable; el
|
||||
/// server A le permite el master_peer en allowlist. B se conecta con
|
||||
/// **session1**; pasa. B "rota": genera **session2** distinta, emite
|
||||
/// un nuevo cert con la misma identity, se conecta de nuevo. Pasa
|
||||
/// también — sin que A toque su allowlist.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn identity_cert_allows_session_rotation_without_policy_change() {
|
||||
// Master de B (estable, persistente).
|
||||
let master_kp = Keypair::generate_ed25519();
|
||||
let master_peer = master_kp.public().to_peer_id();
|
||||
let identity = Identity::from_keypair(master_kp);
|
||||
|
||||
// A configura policy: allowlist con master_peer (NO sessions).
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let unix_socket = tmp.path().join("brahman-init.sock");
|
||||
let server = Arc::new(
|
||||
Server::bind(
|
||||
&unix_socket,
|
||||
ServerConfig {
|
||||
init_attached: true,
|
||||
broker: None,
|
||||
net: None,
|
||||
policy: Some(PeerPolicy::from_sets(
|
||||
Some([master_peer].into_iter().collect()),
|
||||
std::collections::BTreeSet::new(),
|
||||
)),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let sessions = server.sessions();
|
||||
|
||||
let server_net = Arc::new(BrahmanNet::new().unwrap());
|
||||
let server_peer = server_net.peer_id;
|
||||
let actual = server_net
|
||||
.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
|
||||
.await;
|
||||
let mut full = actual.clone();
|
||||
full.push(Protocol::P2p(server_peer));
|
||||
|
||||
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
|
||||
|
||||
// ---- Conexión 1: session1 ----
|
||||
let session1_kp = Keypair::generate_ed25519();
|
||||
let cert1 = identity
|
||||
.issue_session_cert(&session1_kp, DEFAULT_SESSION_TTL)
|
||||
.unwrap();
|
||||
let net1 = BrahmanNet::with_keypair(session1_kp.clone()).unwrap();
|
||||
net1.dial(full.clone());
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let mut client1 = connect_libp2p_with_cert(
|
||||
&net1,
|
||||
server_peer,
|
||||
sample_card("test.session1"),
|
||||
None,
|
||||
&session1_kp,
|
||||
cert1,
|
||||
)
|
||||
.await
|
||||
.expect("session1 con cert válido del master allowlisted debe pasar");
|
||||
|
||||
{
|
||||
let s = sessions.lock().await;
|
||||
assert_eq!(s.len(), 1, "session1 registrada");
|
||||
}
|
||||
client1.farewell().await.ok();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// ---- ROTACIÓN: session2 distinta, mismo master ----
|
||||
let session2_kp = Keypair::generate_ed25519();
|
||||
assert_ne!(
|
||||
session1_kp.public().to_peer_id(),
|
||||
session2_kp.public().to_peer_id(),
|
||||
"test inválido si las sessions son iguales"
|
||||
);
|
||||
let cert2 = identity
|
||||
.issue_session_cert(&session2_kp, DEFAULT_SESSION_TTL)
|
||||
.unwrap();
|
||||
|
||||
let net2 = BrahmanNet::with_keypair(session2_kp.clone()).unwrap();
|
||||
net2.dial(full.clone());
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let mut client2 = connect_libp2p_with_cert(
|
||||
&net2,
|
||||
server_peer,
|
||||
sample_card("test.session2"),
|
||||
None,
|
||||
&session2_kp,
|
||||
cert2,
|
||||
)
|
||||
.await
|
||||
.expect(
|
||||
"session2 (rotada) con cert del MISMO master debe pasar sin tocar allowlist",
|
||||
);
|
||||
|
||||
{
|
||||
let s = sessions.lock().await;
|
||||
assert_eq!(s.len(), 1, "session2 registrada");
|
||||
}
|
||||
client2.farewell().await.ok();
|
||||
|
||||
// Sanity: una session sin cert (path Fase 3) cuyo session_peer_id
|
||||
// NO está en la allowlist (porque la allowlist tiene master, no
|
||||
// sessions) DEBE ser rechazada.
|
||||
let session_other = Keypair::generate_ed25519();
|
||||
let net_other = BrahmanNet::with_keypair(session_other.clone()).unwrap();
|
||||
net_other.dial(full.clone());
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let result = connect_libp2p(
|
||||
&net_other,
|
||||
server_peer,
|
||||
sample_card("test.no_cert"),
|
||||
None,
|
||||
&session_other,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"sin cert, session_peer_id (no listado) debe ser rechazado"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "brahman-net"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Brahman — capa de transporte P2P compartida (libp2p TCP+Noise+Yamux+Kad+Identify+Stream). Cualquier protocolo (handshake brahman, sync minga, futuros) puede registrar su StreamProtocol y abrir/aceptar streams sobre la malla común."
|
||||
|
||||
[dependencies]
|
||||
futures = { workspace = true }
|
||||
libp2p = { workspace = true }
|
||||
libp2p-stream = { workspace = true }
|
||||
libp2p-allow-block-list = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
@@ -0,0 +1,423 @@
|
||||
//! `brahman-net` — capa P2P compartida de la red Brahman.
|
||||
//!
|
||||
//! Provee un nodo libp2p genérico que cualquier protocolo de la
|
||||
//! familia (handshake brahman remoto, sync minga, futuros) puede
|
||||
//! reusar. La idea: una sola malla, múltiples sub-protocolos
|
||||
//! multiplexados por `StreamProtocol`.
|
||||
//!
|
||||
//! ## Stack
|
||||
//!
|
||||
//! - **TCP + Noise + Yamux**: transporte autenticado y multiplexado.
|
||||
//! - **`stream::Behaviour`**: streams bidireccionales por
|
||||
//! `StreamProtocol`. Cada protocolo (`/brahman/handshake/1.0.0`,
|
||||
//! `/minga/sync/1.0.0`, …) se registra independientemente vía el
|
||||
//! `stream::Control` que `BrahmanNet` expone.
|
||||
//! - **`kad::Behaviour<MemoryStore>`**: Kademlia DHT en modo Server
|
||||
//! para discovery (peers cercanos + content providers).
|
||||
//! - **`identify::Behaviour`**: cada peer anuncia sus listen-addrs
|
||||
//! reales; las inyectamos automáticamente al routing table de Kad.
|
||||
//!
|
||||
//! ## Modelo
|
||||
//!
|
||||
//! El swarm corre en una task tokio dedicada. La interfaz pública son:
|
||||
//! 1. **Comandos** (canal mpsc): `dial`, `listen`, `add_dht_peer`,
|
||||
//! `find_closest_peers`, `start_providing`, `find_providers`.
|
||||
//! 2. **`stream::Control`** (acceso directo): para abrir/aceptar
|
||||
//! streams de un protocolo concreto. Cada protocolo se ocupa de
|
||||
//! su propia lógica sobre el stream resultante.
|
||||
//!
|
||||
//! La separación entre comandos y control permite que la lógica de
|
||||
//! red (DHT, dial, listen) y la lógica de protocolos (handshake/sync)
|
||||
//! evolucionen independientes — el protocolo no necesita conocer al
|
||||
//! swarm, sólo pide streams.
|
||||
//!
|
||||
//! ## Identidad
|
||||
//!
|
||||
//! Por defecto se genera una keypair Ed25519 efímera. Para identidad
|
||||
//! persistente (la misma `peer_id` across reboots), pasar la keypair
|
||||
//! con [`BrahmanNet::with_keypair`]. Esa misma keypair puede ser la
|
||||
//! base para firmas de Cards (cuando se implemente trust remoto).
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::StreamExt;
|
||||
use libp2p::{
|
||||
identify, identity, kad, noise,
|
||||
swarm::{NetworkBehaviour, SwarmEvent},
|
||||
tcp, yamux, Swarm, SwarmBuilder,
|
||||
};
|
||||
use libp2p_allow_block_list::{self as allow_block_list, BlockedPeers};
|
||||
use libp2p_stream as stream;
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
|
||||
pub use libp2p::{
|
||||
identity::{Keypair, PublicKey},
|
||||
multiaddr::Protocol,
|
||||
Multiaddr, PeerId, Stream, StreamProtocol,
|
||||
};
|
||||
pub use libp2p_stream::OpenStreamError;
|
||||
|
||||
const IDENTIFY_PROTOCOL: &str = "/brahman-net/0.1.0";
|
||||
const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(NetworkBehaviour)]
|
||||
struct BrahmanBehaviour {
|
||||
/// Block-list a nivel de swarm: peers en este behaviour son
|
||||
/// rechazados ANTES del handshake Noise. Más eficiente que
|
||||
/// rechazar al nivel del handshake brahman (ahorra round-trip
|
||||
/// TCP+Noise por intento denegado). Sincronizado con la
|
||||
/// `PeerPolicy.deny` vía `block_peer`/`unblock_peer` exposed
|
||||
/// en `BrahmanNet`.
|
||||
block_list: allow_block_list::Behaviour<BlockedPeers>,
|
||||
stream: stream::Behaviour,
|
||||
kad: kad::Behaviour<kad::store::MemoryStore>,
|
||||
identify: identify::Behaviour,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NodeError {
|
||||
#[error("transport build failed: {0}")]
|
||||
Build(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Command {
|
||||
Dial(Multiaddr),
|
||||
Listen(Multiaddr),
|
||||
AddDhtPeer(PeerId, Multiaddr),
|
||||
FindClosestPeers(PeerId, oneshot::Sender<Vec<DiscoveredPeer>>),
|
||||
StartProviding(Vec<u8>),
|
||||
StopProviding(Vec<u8>),
|
||||
GetProviders(Vec<u8>, oneshot::Sender<Vec<PeerId>>),
|
||||
BlockPeer(PeerId),
|
||||
UnblockPeer(PeerId),
|
||||
}
|
||||
|
||||
/// Peer descubierto vía DHT: identidad + direcciones conocidas.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredPeer {
|
||||
pub peer_id: PeerId,
|
||||
pub addrs: Vec<Multiaddr>,
|
||||
}
|
||||
|
||||
/// Nodo Brahman en la malla P2P. Maneja el swarm libp2p y expone
|
||||
/// API uniforme para listen/dial/DHT/streams.
|
||||
pub struct BrahmanNet {
|
||||
/// Identidad libp2p de este nodo. Estable mientras viva la
|
||||
/// keypair (efímera por default; persistente si pasaste una
|
||||
/// vía [`with_keypair`]).
|
||||
pub peer_id: PeerId,
|
||||
/// Keypair compartida (Arc para compartir con consumers que
|
||||
/// necesitan firmar mensajes con la misma identidad — p. ej.
|
||||
/// `brahman_handshake::network::connect_libp2p` que firma el
|
||||
/// Hello). NO se expone públicamente; usar [`Self::keypair`].
|
||||
keypair: Arc<Keypair>,
|
||||
cmd_tx: mpsc::UnboundedSender<Command>,
|
||||
listen_rx: Mutex<mpsc::UnboundedReceiver<Multiaddr>>,
|
||||
/// Control para abrir y aceptar streams. Cada protocolo
|
||||
/// (handshake brahman, sync minga, etc.) llama
|
||||
/// `control.accept(StreamProtocol::new("/foo/1.0.0"))` para
|
||||
/// recibir streams entrantes, o `control.open_stream(peer, proto)`
|
||||
/// para abrirlos. Multiplexado y demultiplexado lo hace libp2p.
|
||||
pub control: stream::Control,
|
||||
}
|
||||
|
||||
impl BrahmanNet {
|
||||
/// Crea un nodo con keypair Ed25519 generada al vuelo (peer_id
|
||||
/// efímero — cambia en cada arranque).
|
||||
pub fn new() -> Result<Self, NodeError> {
|
||||
Self::with_keypair(identity::Keypair::generate_ed25519())
|
||||
}
|
||||
|
||||
/// Crea un nodo con una keypair libp2p específica. Usá esto para
|
||||
/// `peer_id` estable (por ejemplo si tu identidad se persiste a
|
||||
/// disco, o si la derivás de la identidad criptográfica del
|
||||
/// módulo).
|
||||
///
|
||||
/// Sólo Ed25519 se soporta — la `keypair` se duplica internamente
|
||||
/// vía clone del `ed25519::Keypair` para que tanto el swarm
|
||||
/// (Noise auth) como el caller (firma de Cards) compartan la
|
||||
/// misma identidad sin la fricción de que `identity::Keypair` no
|
||||
/// implemente `Clone`.
|
||||
pub fn with_keypair(keypair: identity::Keypair) -> Result<Self, NodeError> {
|
||||
let ed_kp = keypair
|
||||
.try_into_ed25519()
|
||||
.map_err(|_| NodeError::Build("brahman-net sólo soporta keypairs Ed25519".into()))?;
|
||||
let kp_for_swarm = identity::Keypair::from(ed_kp.clone());
|
||||
let kp_for_storage = Arc::new(identity::Keypair::from(ed_kp));
|
||||
let peer_id = kp_for_swarm.public().to_peer_id();
|
||||
|
||||
let mut swarm: Swarm<BrahmanBehaviour> = SwarmBuilder::with_existing_identity(kp_for_swarm)
|
||||
.with_tokio()
|
||||
.with_tcp(
|
||||
tcp::Config::default(),
|
||||
noise::Config::new,
|
||||
yamux::Config::default,
|
||||
)
|
||||
.map_err(|e| NodeError::Build(format!("{e}")))?
|
||||
.with_behaviour(|key| {
|
||||
let local = key.public().to_peer_id();
|
||||
let mut kad =
|
||||
kad::Behaviour::new(local, kad::store::MemoryStore::new(local));
|
||||
// Modo Server: respondemos a queries del DHT. Auto
|
||||
// requiere detectar reachability; para entornos
|
||||
// controlados (localhost, redes privadas) Server es
|
||||
// lo correcto.
|
||||
kad.set_mode(Some(kad::Mode::Server));
|
||||
let identify = identify::Behaviour::new(
|
||||
identify::Config::new(IDENTIFY_PROTOCOL.to_string(), key.public())
|
||||
.with_agent_version(format!("brahman-net/{}", env!("CARGO_PKG_VERSION"))),
|
||||
);
|
||||
BrahmanBehaviour {
|
||||
block_list: allow_block_list::Behaviour::default(),
|
||||
stream: stream::Behaviour::new(),
|
||||
kad,
|
||||
identify,
|
||||
}
|
||||
})
|
||||
.map_err(|e| NodeError::Build(format!("{e}")))?
|
||||
.with_swarm_config(|c| c.with_idle_connection_timeout(IDLE_CONNECTION_TIMEOUT))
|
||||
.build();
|
||||
|
||||
let control = swarm.behaviour().stream.new_control();
|
||||
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (listen_tx, listen_rx) = mpsc::unbounded_channel::<Multiaddr>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut pending_finds: HashMap<
|
||||
kad::QueryId,
|
||||
oneshot::Sender<Vec<DiscoveredPeer>>,
|
||||
> = HashMap::new();
|
||||
let mut pending_providers: HashMap<
|
||||
kad::QueryId,
|
||||
(Vec<PeerId>, oneshot::Sender<Vec<PeerId>>),
|
||||
> = HashMap::new();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(cmd) = cmd_rx.recv() => {
|
||||
match cmd {
|
||||
Command::Dial(addr) => {
|
||||
let _ = swarm.dial(addr);
|
||||
}
|
||||
Command::Listen(addr) => {
|
||||
let _ = swarm.listen_on(addr);
|
||||
}
|
||||
Command::AddDhtPeer(peer, addr) => {
|
||||
swarm.behaviour_mut().kad.add_address(&peer, addr);
|
||||
}
|
||||
Command::FindClosestPeers(target, tx) => {
|
||||
let qid = swarm.behaviour_mut().kad.get_closest_peers(target);
|
||||
pending_finds.insert(qid, tx);
|
||||
}
|
||||
Command::StartProviding(key) => {
|
||||
// Best-effort: si falla (sin peers cercanos para
|
||||
// replicar), seguirá viviendo en el local store
|
||||
// y se servirá vía get_providers de quien tenga
|
||||
// conexión con nosotros.
|
||||
let _ = swarm.behaviour_mut().kad.start_providing(key.into());
|
||||
}
|
||||
Command::StopProviding(key) => {
|
||||
// Quitamos el record local del provider store.
|
||||
// Los peers cercanos eventualmente expiran su
|
||||
// copia replicada por TTL natural (~24h en
|
||||
// libp2p kad default); para retiro inmediato
|
||||
// habría que enviar un republish con sentinel,
|
||||
// pero kad no expone esa primitiva. Aceptable
|
||||
// para el caso "el provider local desapareció":
|
||||
// queries que pasen por nosotros dejan de
|
||||
// listarnos al instante.
|
||||
swarm.behaviour_mut().kad.stop_providing(&key.into());
|
||||
}
|
||||
Command::GetProviders(key, tx) => {
|
||||
let qid = swarm.behaviour_mut().kad.get_providers(key.into());
|
||||
pending_providers.insert(qid, (Vec::new(), tx));
|
||||
}
|
||||
Command::BlockPeer(peer) => {
|
||||
swarm.behaviour_mut().block_list.block_peer(peer);
|
||||
}
|
||||
Command::UnblockPeer(peer) => {
|
||||
swarm.behaviour_mut().block_list.unblock_peer(peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
event = swarm.select_next_some() => {
|
||||
match event {
|
||||
SwarmEvent::NewListenAddr { address, .. } => {
|
||||
let _ = listen_tx.send(address);
|
||||
}
|
||||
// Identify nos dice las listen-addrs reales del
|
||||
// peer. Las inyectamos a Kad para poblar el
|
||||
// routing table sin necesidad de add_dht_peer
|
||||
// manual — la propagación pasa a ser automática.
|
||||
SwarmEvent::Behaviour(BrahmanBehaviourEvent::Identify(
|
||||
identify::Event::Received { peer_id, info, .. }
|
||||
)) => {
|
||||
for addr in info.listen_addrs {
|
||||
swarm.behaviour_mut().kad.add_address(&peer_id, addr);
|
||||
}
|
||||
}
|
||||
SwarmEvent::Behaviour(BrahmanBehaviourEvent::Kad(
|
||||
kad::Event::OutboundQueryProgressed { id, result, step, .. }
|
||||
)) => {
|
||||
match result {
|
||||
kad::QueryResult::GetClosestPeers(Ok(ok)) if step.last => {
|
||||
if let Some(tx) = pending_finds.remove(&id) {
|
||||
let infos = ok.peers.into_iter()
|
||||
.map(|p| DiscoveredPeer {
|
||||
peer_id: p.peer_id,
|
||||
addrs: p.addrs,
|
||||
})
|
||||
.collect();
|
||||
let _ = tx.send(infos);
|
||||
}
|
||||
}
|
||||
kad::QueryResult::GetClosestPeers(Err(_)) if step.last => {
|
||||
if let Some(tx) = pending_finds.remove(&id) {
|
||||
let _ = tx.send(Vec::new());
|
||||
}
|
||||
}
|
||||
kad::QueryResult::GetProviders(Ok(ok)) => {
|
||||
if let Some((collected, _)) =
|
||||
pending_providers.get_mut(&id)
|
||||
{
|
||||
if let kad::GetProvidersOk::FoundProviders {
|
||||
providers, ..
|
||||
} = ok
|
||||
{
|
||||
for p in providers {
|
||||
if !collected.contains(&p) {
|
||||
collected.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if step.last {
|
||||
if let Some((providers, tx)) =
|
||||
pending_providers.remove(&id)
|
||||
{
|
||||
let _ = tx.send(providers);
|
||||
}
|
||||
}
|
||||
}
|
||||
kad::QueryResult::GetProviders(Err(_)) if step.last => {
|
||||
if let Some((providers, tx)) =
|
||||
pending_providers.remove(&id)
|
||||
{
|
||||
let _ = tx.send(providers);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
peer_id,
|
||||
keypair: kp_for_storage,
|
||||
cmd_tx,
|
||||
listen_rx: Mutex::new(listen_rx),
|
||||
control,
|
||||
})
|
||||
}
|
||||
|
||||
/// Acceso a la keypair de identidad del nodo. Usar para firmar
|
||||
/// payloads que viajan asociados al `peer_id` (handshake brahman
|
||||
/// firmado, futuros sub-protocolos con autenticación). El `Arc`
|
||||
/// permite compartir sin copia — la keypair libp2p no es `Clone`.
|
||||
pub fn keypair(&self) -> Arc<Keypair> {
|
||||
self.keypair.clone()
|
||||
}
|
||||
|
||||
/// Bloquea conexiones desde/hacia `peer` a nivel del swarm.
|
||||
/// Conexiones existentes se cierran y nuevos intentos son
|
||||
/// rechazados ANTES del Noise handshake — más eficiente que
|
||||
/// rechazar al nivel del handshake brahman (ahorra round-trip
|
||||
/// TCP+Noise por intento). Idempotente.
|
||||
pub fn block_peer(&self, peer: PeerId) {
|
||||
let _ = self.cmd_tx.send(Command::BlockPeer(peer));
|
||||
}
|
||||
|
||||
/// Quita a `peer` de la block-list del swarm. Conexiones futuras
|
||||
/// son aceptadas con normalidad. Idempotente.
|
||||
pub fn unblock_peer(&self, peer: PeerId) {
|
||||
let _ = self.cmd_tx.send(Command::UnblockPeer(peer));
|
||||
}
|
||||
|
||||
/// Empieza a escuchar en `addr`. Bloquea hasta que el listener
|
||||
/// publique su dirección real (Multiaddr resuelta — útil cuando
|
||||
/// pediste `/ip4/0.0.0.0/tcp/0` y querés saber qué puerto te tocó).
|
||||
pub async fn listen(&self, addr: Multiaddr) -> Multiaddr {
|
||||
self.cmd_tx
|
||||
.send(Command::Listen(addr))
|
||||
.expect("swarm task alive");
|
||||
let mut rx = self.listen_rx.lock().await;
|
||||
rx.recv().await.expect("listen address arrives")
|
||||
}
|
||||
|
||||
/// Inicia conexión con un peer en `addr`. No-op si ya hay
|
||||
/// conexión. Best-effort — fallos se loggean al swarm pero no se
|
||||
/// propagan al caller (consistente con libp2p).
|
||||
pub fn dial(&self, addr: Multiaddr) {
|
||||
let _ = self.cmd_tx.send(Command::Dial(addr));
|
||||
}
|
||||
|
||||
/// Añade un peer al routing table de Kademlia. Punto de entrada
|
||||
/// para bootstrap: tras esto, el nodo puede dirigir queries DHT
|
||||
/// a través de este peer.
|
||||
pub fn add_dht_peer(&self, peer: PeerId, addr: Multiaddr) {
|
||||
let _ = self.cmd_tx.send(Command::AddDhtPeer(peer, addr));
|
||||
}
|
||||
|
||||
/// Consulta el DHT por los peers más cercanos al `target` PeerId.
|
||||
/// Devuelve la lista resuelta (vacía si la query falla o si no
|
||||
/// hay peers conocidos). Bloquea hasta que la query completa.
|
||||
pub async fn find_closest_peers(&self, target: PeerId) -> Vec<DiscoveredPeer> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = self
|
||||
.cmd_tx
|
||||
.send(Command::FindClosestPeers(target, tx));
|
||||
rx.await.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Anuncia en el DHT que este peer tiene el contenido identificado
|
||||
/// por `key`. Otros peers pueden luego descubrirlo vía
|
||||
/// [`find_providers`](Self::find_providers). Best-effort: si la
|
||||
/// replicación falla inicialmente, el record vive en el store
|
||||
/// local hasta que llegue conexión.
|
||||
pub fn start_providing(&self, key: &[u8]) {
|
||||
let _ = self.cmd_tx.send(Command::StartProviding(key.to_vec()));
|
||||
}
|
||||
|
||||
/// Retira el anuncio previo de [`start_providing`] para `key`.
|
||||
/// El record local se borra al instante (queries que lleguen a
|
||||
/// nosotros dejan de listarnos). Los records replicados en peers
|
||||
/// remotos viven hasta su TTL — kad no expone primitiva para
|
||||
/// retracción inmediata cross-peer. Aceptable: simétrico al
|
||||
/// caso "el provider apareció" (también propagación eventual).
|
||||
pub fn stop_providing(&self, key: &[u8]) {
|
||||
let _ = self.cmd_tx.send(Command::StopProviding(key.to_vec()));
|
||||
}
|
||||
|
||||
/// Consulta el DHT por peers que han anunciado proveer `key`.
|
||||
/// Devuelve la lista de `PeerId`s que se reportan como providers.
|
||||
/// Lista vacía si nadie anuncia.
|
||||
pub async fn find_providers(&self, key: &[u8]) -> Vec<PeerId> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = self
|
||||
.cmd_tx
|
||||
.send(Command::GetProviders(key.to_vec(), tx));
|
||||
rx.await.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "brahman-sidecar"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Brahman — sidecar reusable: thread + tokio runtime que mantiene viva la sesión de un módulo contra el Init."
|
||||
|
||||
[dependencies]
|
||||
brahman-card = { path = "../brahman-card" }
|
||||
brahman-handshake = { path = "../brahman-handshake" }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber = { workspace = true }
|
||||
brahman-card-wit = { path = "../brahman-card-wit" }
|
||||
|
||||
[[example]]
|
||||
name = "presence"
|
||||
path = "examples/presence.rs"
|
||||
|
||||
[[example]]
|
||||
name = "presence-conscious"
|
||||
path = "examples/presence-conscious.rs"
|
||||
@@ -0,0 +1,99 @@
|
||||
//! `presence-conscious` — módulo brahman que se presenta con su WIT.
|
||||
//!
|
||||
//! Variante de [`presence`] que toma un path a un archivo `.wit` (default
|
||||
//! `shared_wit/protocol.wit` resuelto desde el cwd) y lo parsea con
|
||||
//! `brahman-card-wit` antes de spawnear el sidecar. Demuestra el flujo
|
||||
//! "módulo consciente": Hello incluye `WitInterface`, el server lo
|
||||
//! registra como `ResolvedCard::from_conscious`, y aparece con marker
|
||||
//! 🧠 en `brahman-status`.
|
||||
//!
|
||||
//! Uso:
|
||||
//! ```sh
|
||||
//! cargo run -p brahman-sidecar --example presence-conscious -- mi-modulo [path/al.wit]
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use brahman_card::{
|
||||
ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
|
||||
CARD_SCHEMA_VERSION,
|
||||
};
|
||||
use brahman_sidecar::{spawn_with_handle, SidecarConfig};
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
let label = args.next().unwrap_or_else(|| "conscious-default".into());
|
||||
let wit_path: PathBuf = args
|
||||
.next()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("shared_wit/protocol.wit"));
|
||||
|
||||
let wit = match brahman_card_wit::parse_wit_file(&wit_path) {
|
||||
Ok(worlds) => match worlds.into_iter().next() {
|
||||
Some(w) => {
|
||||
eprintln!(
|
||||
"[{label}] cargado wit: {} / {}",
|
||||
w.package, w.world
|
||||
);
|
||||
Some(w)
|
||||
}
|
||||
None => {
|
||||
eprintln!("[{label}] {} no declara worlds", wit_path.display());
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("[{label}] falló parse de {}: {e}", wit_path.display());
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let card = Card {
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
label: label.clone(),
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::OneShot,
|
||||
lifecycle: Lifecycle::Daemon,
|
||||
priority: Priority::Normal,
|
||||
provides: BTreeSet::new(),
|
||||
requires: BTreeSet::new(),
|
||||
flow: Flows {
|
||||
input: vec![Flow {
|
||||
name: "in".into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: "json".into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
output: vec![Flow {
|
||||
name: "out".into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: "json".into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = SidecarConfig {
|
||||
card,
|
||||
wit,
|
||||
ping_interval: Duration::from_secs(5),
|
||||
};
|
||||
|
||||
let _handle = spawn_with_handle(config);
|
||||
|
||||
eprintln!("[{label}] sidecar lanzado, durmiendo (Ctrl-C para salir)");
|
||||
std::thread::park();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! `presence` — módulo brahman dummy para pruebas y demos.
|
||||
//!
|
||||
//! Declara una Card mínima con label tomado del primer argumento (default
|
||||
//! `presence-default`) y mantiene la sesión viva hasta SIGTERM/SIGINT.
|
||||
//! Útil para poblar el broker con sesiones de prueba.
|
||||
//!
|
||||
//! Uso:
|
||||
//! ```sh
|
||||
//! cargo run -p brahman-sidecar --example presence -- mi-modulo
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use brahman_card::{
|
||||
ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
|
||||
CARD_SCHEMA_VERSION,
|
||||
};
|
||||
use brahman_sidecar::{spawn_with_handle, SidecarConfig};
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let label = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "presence-default".into());
|
||||
|
||||
let card = Card {
|
||||
schema_version: CARD_SCHEMA_VERSION,
|
||||
id: Ulid::new(),
|
||||
label: label.clone(),
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::OneShot,
|
||||
lifecycle: Lifecycle::Daemon,
|
||||
priority: Priority::Normal,
|
||||
provides: BTreeSet::new(),
|
||||
requires: BTreeSet::new(),
|
||||
flow: Flows {
|
||||
input: vec![Flow {
|
||||
name: "in".into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: "json".into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
output: vec![Flow {
|
||||
name: "out".into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: "json".into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let _handle = spawn_with_handle(SidecarConfig {
|
||||
card,
|
||||
wit: None,
|
||||
ping_interval: Duration::from_secs(5),
|
||||
});
|
||||
|
||||
eprintln!("presence({label}): sidecar lanzado, durmiendo (Ctrl-C para salir)");
|
||||
std::thread::park();
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! `brahman-sidecar::discovery` — API reusable para que un módulo
|
||||
//! consumer encuentre proveedores vivos vía broker, sin hardcodear
|
||||
//! sockets ni reimplementar el patrón a mano.
|
||||
//!
|
||||
//! Es la generalización de `discover_producer_socket` del CLI
|
||||
//! `akasha attract --remote`: declarás el `TypeRef` que querés
|
||||
//! consumir y el broker te empuja un `MatchEvent::Available` con el
|
||||
//! `producer_service_socket` del primer proveedor matched.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//! 1. `build_consumer_card(label, flow_name, type_name)` arma una
|
||||
//! Card mínima (Ente, Oneshot, Virtual) con un input flow.
|
||||
//! 2. `await_provider(card, timeout)` se conecta al brahman-init,
|
||||
//! espera hasta `timeout` por `MatchEvent::Available`, devuelve
|
||||
//! el socket del proveedor electo, y envía Farewell.
|
||||
//! 3. Para mundos blocking (CLIs, tests, std-thread loops) hay
|
||||
//! `await_provider_blocking` que arma su propio runtime
|
||||
//! `current_thread`.
|
||||
//!
|
||||
//! Quién elige al proveedor es el broker, no este módulo. Si el
|
||||
//! broker tiene `priority_contexts` activo, podés cambiar de
|
||||
//! proveedor sin tocar el consumer; el matching dinámico se respeta.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use brahman_card::{
|
||||
Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
|
||||
};
|
||||
use brahman_handshake::client::{Client, ClientError};
|
||||
use brahman_handshake::messages::MatchEventKind;
|
||||
use brahman_handshake::transport;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConsumerError {
|
||||
#[error("no se pudo conectar al init en {socket}: {source}")]
|
||||
Connect {
|
||||
socket: PathBuf,
|
||||
#[source]
|
||||
source: ClientError,
|
||||
},
|
||||
#[error("error en cliente brahman: {0}")]
|
||||
Client(#[from] ClientError),
|
||||
#[error("timeout {timeout:?} sin proveedor disponible para flow '{flow}' (type '{type_ref}')")]
|
||||
NoProvider {
|
||||
flow: String,
|
||||
type_ref: String,
|
||||
timeout: Duration,
|
||||
},
|
||||
#[error("no se pudo crear runtime tokio: {0}")]
|
||||
Runtime(String),
|
||||
}
|
||||
|
||||
/// Construye una Card mínima de consumer que declara un input flow
|
||||
/// con el `TypeRef::Primitive { name }` solicitado. Usá esto para
|
||||
/// el caso común; si necesitás algo más rico (output flows,
|
||||
/// permissions, references) construí la Card a mano y pasala a
|
||||
/// [`await_provider`] directamente.
|
||||
pub fn build_consumer_card(
|
||||
consumer_label: impl Into<String>,
|
||||
flow_name: impl Into<String>,
|
||||
type_name: impl Into<String>,
|
||||
) -> Card {
|
||||
Card {
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::OneShot,
|
||||
lifecycle: Lifecycle::Oneshot,
|
||||
priority: Priority::Normal,
|
||||
kind: CardKind::Ente,
|
||||
flow: Flows {
|
||||
input: vec![Flow {
|
||||
name: flow_name.into(),
|
||||
ty: TypeRef::Primitive {
|
||||
name: type_name.into(),
|
||||
},
|
||||
pin_to: None,
|
||||
}],
|
||||
output: vec![],
|
||||
},
|
||||
..Card::new(consumer_label)
|
||||
}
|
||||
}
|
||||
|
||||
/// Conecta al brahman-init, registra `consumer_card`, espera el
|
||||
/// primer `MatchEvent::Available` y devuelve el `producer_service_socket`
|
||||
/// que el broker emitió. Cierra la sesión con Farewell antes de
|
||||
/// retornar (best-effort).
|
||||
///
|
||||
/// La `consumer_card` debe declarar al menos un `flow.input`; si no,
|
||||
/// el broker no puede hacer matching y el await siempre dará timeout.
|
||||
pub async fn await_provider(
|
||||
consumer_card: Card,
|
||||
timeout: Duration,
|
||||
) -> Result<PathBuf, ConsumerError> {
|
||||
let init_path = transport::default_socket_path();
|
||||
|
||||
// Capturamos descriptor para el mensaje de error antes de mover
|
||||
// la card al cliente.
|
||||
let (flow_name, type_ref_name) = describe_first_input(&consumer_card);
|
||||
|
||||
let mut client = Client::connect(&init_path, consumer_card)
|
||||
.await
|
||||
.map_err(|source| ConsumerError::Connect {
|
||||
socket: init_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
let socket = loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break None;
|
||||
}
|
||||
match client.await_event(remaining).await? {
|
||||
Some(ev) if ev.kind == MatchEventKind::Available => {
|
||||
break ev.producer_service_socket;
|
||||
}
|
||||
Some(_) => continue, // Lost u otros: seguir esperando hasta el deadline
|
||||
None => break None,
|
||||
}
|
||||
};
|
||||
|
||||
let _ = client.farewell().await; // best-effort cleanup
|
||||
|
||||
socket.ok_or(ConsumerError::NoProvider {
|
||||
flow: flow_name,
|
||||
type_ref: type_ref_name,
|
||||
timeout,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wrapper bloqueante de [`await_provider`]. Crea un runtime tokio
|
||||
/// `current_thread` efímero y bloquea el thread llamador. Útil para
|
||||
/// CLIs, tests y módulos std-thread (p. ej. el frontend GPUI antes
|
||||
/// de tener su propio runtime async).
|
||||
pub fn await_provider_blocking(
|
||||
consumer_card: Card,
|
||||
timeout: Duration,
|
||||
) -> Result<PathBuf, ConsumerError> {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|e| ConsumerError::Runtime(e.to_string()))?;
|
||||
|
||||
rt.block_on(await_provider(consumer_card, timeout))
|
||||
}
|
||||
|
||||
/// Conecta al brahman-init con una Card observer (sin inputs ni
|
||||
/// outputs) y pide la lista de sesiones activas. Útil para
|
||||
/// herramientas de observabilidad (broker-explorer, CLIs).
|
||||
///
|
||||
/// El observer se identifica con `observer_label`. La sesión se
|
||||
/// cierra con Farewell antes de retornar (best-effort).
|
||||
pub async fn list_sessions(
|
||||
observer_label: impl Into<String>,
|
||||
) -> Result<brahman_handshake::messages::SessionList, ConsumerError> {
|
||||
let init_path = transport::default_socket_path();
|
||||
// Card mínima sin flow.input/output: el observer no participa en
|
||||
// matching, sólo establece sesión para poder consultar.
|
||||
let card = Card {
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::OneShot,
|
||||
lifecycle: Lifecycle::Oneshot,
|
||||
priority: Priority::Normal,
|
||||
kind: CardKind::Ente,
|
||||
flow: Flows {
|
||||
input: vec![],
|
||||
output: vec![],
|
||||
},
|
||||
..Card::new(observer_label)
|
||||
};
|
||||
|
||||
let mut client = Client::connect(&init_path, card)
|
||||
.await
|
||||
.map_err(|source| ConsumerError::Connect {
|
||||
socket: init_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let list = client.list_sessions().await?;
|
||||
let _ = client.farewell().await;
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Wrapper bloqueante de [`list_sessions`]. Idéntico patrón a
|
||||
/// `await_provider_blocking`: runtime current_thread efímero.
|
||||
pub fn list_sessions_blocking(
|
||||
observer_label: impl Into<String>,
|
||||
) -> Result<brahman_handshake::messages::SessionList, ConsumerError> {
|
||||
let label = observer_label.into();
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|e| ConsumerError::Runtime(e.to_string()))?;
|
||||
rt.block_on(list_sessions(label))
|
||||
}
|
||||
|
||||
/// Análogo a `list_sessions` pero pide los matches activos del
|
||||
/// broker. La Card observer es la misma forma minimalista (sin
|
||||
/// flow.input/output) — el endpoint no requiere participar en
|
||||
/// matching.
|
||||
pub async fn list_matches(
|
||||
observer_label: impl Into<String>,
|
||||
) -> Result<brahman_handshake::messages::MatchList, ConsumerError> {
|
||||
let init_path = transport::default_socket_path();
|
||||
let card = Card {
|
||||
payload: Payload::Virtual,
|
||||
supervision: Supervision::OneShot,
|
||||
lifecycle: Lifecycle::Oneshot,
|
||||
priority: Priority::Normal,
|
||||
kind: CardKind::Ente,
|
||||
flow: Flows {
|
||||
input: vec![],
|
||||
output: vec![],
|
||||
},
|
||||
..Card::new(observer_label)
|
||||
};
|
||||
|
||||
let mut client = Client::connect(&init_path, card)
|
||||
.await
|
||||
.map_err(|source| ConsumerError::Connect {
|
||||
socket: init_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let list = client.list_matches().await?;
|
||||
let _ = client.farewell().await;
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Wrapper bloqueante de [`list_matches`].
|
||||
pub fn list_matches_blocking(
|
||||
observer_label: impl Into<String>,
|
||||
) -> Result<brahman_handshake::messages::MatchList, ConsumerError> {
|
||||
let label = observer_label.into();
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|e| ConsumerError::Runtime(e.to_string()))?;
|
||||
rt.block_on(list_matches(label))
|
||||
}
|
||||
|
||||
fn describe_first_input(card: &Card) -> (String, String) {
|
||||
match card.flow.input.first() {
|
||||
Some(flow) => {
|
||||
let type_name = match &flow.ty {
|
||||
TypeRef::Primitive { name } => name.clone(),
|
||||
TypeRef::Wit { package, name, .. } => format!("{package}#{name}"),
|
||||
};
|
||||
(flow.name.clone(), type_name)
|
||||
}
|
||||
None => ("(sin input)".into(), "(sin tipo)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use brahman_card::ulid::Ulid;
|
||||
|
||||
#[test]
|
||||
fn builder_sets_input_flow_with_primitive_type() {
|
||||
let c = build_consumer_card("akasha.cli", "embed-result", "json");
|
||||
assert_eq!(c.label, "akasha.cli");
|
||||
assert_eq!(c.kind, CardKind::Ente);
|
||||
assert!(matches!(c.lifecycle, Lifecycle::Oneshot));
|
||||
assert!(matches!(c.supervision, Supervision::OneShot));
|
||||
assert_eq!(c.flow.input.len(), 1);
|
||||
let f = &c.flow.input[0];
|
||||
assert_eq!(f.name, "embed-result");
|
||||
match &f.ty {
|
||||
TypeRef::Primitive { name } => assert_eq!(name, "json"),
|
||||
_ => panic!("expected primitive type"),
|
||||
}
|
||||
assert!(c.flow.output.is_empty());
|
||||
// El builder asigna un id real (no nil) — fundamental para que
|
||||
// el broker no colisione con otros consumers.
|
||||
assert!(c.id != Ulid::nil(), "consumer card id no debe ser nil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_assigns_distinct_ids_per_call() {
|
||||
let a = build_consumer_card("a", "f", "t");
|
||||
let b = build_consumer_card("a", "f", "t");
|
||||
assert_ne!(a.id, b.id, "cada Card debería tener id propio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_falls_back_when_no_input_flow() {
|
||||
let mut c = build_consumer_card("x", "f", "t");
|
||||
c.flow.input.clear();
|
||||
let (flow, ty) = describe_first_input(&c);
|
||||
assert_eq!(flow, "(sin input)");
|
||||
assert_eq!(ty, "(sin tipo)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_formats_wit_type() {
|
||||
let mut c = build_consumer_card("x", "f", "t");
|
||||
c.flow.input[0].ty = TypeRef::Wit {
|
||||
package: "brahman:dht".into(),
|
||||
interface: None,
|
||||
name: "entity-result".into(),
|
||||
};
|
||||
let (_, ty) = describe_first_input(&c);
|
||||
assert_eq!(ty, "brahman:dht#entity-result");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! `brahman-sidecar` — boilerplate del cliente brahman extraído.
|
||||
//!
|
||||
//! Cualquier módulo que quiera presentarse al Init brahman pero que tenga
|
||||
//! su propio runtime (GPUI, current_thread tokio, std-thread loop, etc.)
|
||||
//! puede llamar [`spawn`] con su [`brahman_card::Card`]. Eso arma un
|
||||
//! thread aparte con un runtime tokio current_thread, conecta al Init,
|
||||
//! y mantiene la sesión viva con pings periódicos.
|
||||
//!
|
||||
//! Si el Init no está disponible, el thread loggea y termina — el módulo
|
||||
//! sigue funcionando standalone.
|
||||
//!
|
||||
//! Errores de conexión / ping se loggean vía `tracing::warn!`. Si querés
|
||||
//! capturar la salida del thread (por ejemplo para test), usá
|
||||
//! [`spawn_with_handle`] que devuelve un `JoinHandle`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
pub mod discovery;
|
||||
pub use discovery::{
|
||||
await_provider, await_provider_blocking, build_consumer_card, list_matches,
|
||||
list_matches_blocking, list_sessions, list_sessions_blocking, ConsumerError,
|
||||
};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use brahman_card::{ulid::Ulid, Card, WitInterface};
|
||||
use brahman_handshake::{client::Client, transport};
|
||||
use tokio::task::AbortHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Período entre pings al Init.
|
||||
pub const DEFAULT_PING_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Configuración del sidecar.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SidecarConfig {
|
||||
/// Card que se presenta al Init.
|
||||
pub card: Card,
|
||||
/// WIT interface opcional. Si es `Some`, el módulo se registra como
|
||||
/// "consciente" (`ResolvedCard::from_conscious`).
|
||||
pub wit: Option<WitInterface>,
|
||||
/// Período entre pings.
|
||||
pub ping_interval: Duration,
|
||||
}
|
||||
|
||||
impl SidecarConfig {
|
||||
/// Configuración agnóstica con defaults razonables (sin WIT, ping 30s).
|
||||
pub fn new(card: Card) -> Self {
|
||||
Self {
|
||||
card,
|
||||
wit: None,
|
||||
ping_interval: DEFAULT_PING_INTERVAL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuración consciente con WIT extraída.
|
||||
pub fn with_wit(mut self, wit: WitInterface) -> Self {
|
||||
self.wit = Some(wit);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn fire-and-forget agnóstico. Para módulos conscientes usá
|
||||
/// [`spawn_conscious`] o [`spawn_with_handle`] con un `SidecarConfig`
|
||||
/// personalizado.
|
||||
pub fn spawn(card: Card) {
|
||||
if let Err(e) = spawn_with_handle(SidecarConfig::new(card)) {
|
||||
warn!(error = %e, "no se pudo spawnear el sidecar brahman");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn fire-and-forget con WIT. Idéntico a [`spawn`] pero el módulo se
|
||||
/// registra como consciente en el broker.
|
||||
pub fn spawn_conscious(card: Card, wit: WitInterface) {
|
||||
if let Err(e) = spawn_with_handle(SidecarConfig::new(card).with_wit(wit)) {
|
||||
warn!(error = %e, "no se pudo spawnear el sidecar brahman");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn devolviendo el `JoinHandle` para tests o cleanup explícito.
|
||||
pub fn spawn_with_handle(config: SidecarConfig) -> std::io::Result<JoinHandle<()>> {
|
||||
std::thread::Builder::new()
|
||||
.name("brahman-sidecar".into())
|
||||
.spawn(move || run_thread(config))
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// SidecarPool — un solo runtime tokio compartido por N sesiones
|
||||
// =====================================================================
|
||||
|
||||
/// Pool consolidado: un único thread con un runtime tokio
|
||||
/// `current_thread` que hostea N tasks de sidecar simultáneas.
|
||||
///
|
||||
/// Para módulos con muchas sesiones (p. ej. `akasha daemon` publicando
|
||||
/// 50+ Mónadas), evita el costo de tener un thread+runtime por cada
|
||||
/// sesión.
|
||||
///
|
||||
/// **API**:
|
||||
/// - `SidecarPool::new()` crea el pool (spawn del thread runtime).
|
||||
/// - `pool.spawn(card)` añade una sesión sin WIT.
|
||||
/// - `pool.spawn_conscious(card, wit)` añade una sesión con WIT.
|
||||
/// - `pool.spawn_with_config(config)` para configuración custom.
|
||||
///
|
||||
/// El pool se mantiene vivo mientras exista. Si el `SidecarPool`
|
||||
/// se dropea, el thread interno termina y todas las sesiones cierran.
|
||||
pub struct SidecarPool {
|
||||
handle: tokio::runtime::Handle,
|
||||
/// Sesiones vivas indexadas por `Card.id`. Permite que un nuevo
|
||||
/// `spawn` con el mismo id aborte la sesión previa — útil cuando
|
||||
/// un módulo (p. ej. `akasha daemon`) re-publica una Mónada cuya
|
||||
/// composición cambió.
|
||||
sessions: Arc<Mutex<HashMap<Ulid, AbortHandle>>>,
|
||||
_thread: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl SidecarPool {
|
||||
/// Crea un pool nuevo. Bloquea hasta que el runtime esté listo.
|
||||
pub fn new() -> std::io::Result<Self> {
|
||||
let (handle_tx, handle_rx) = mpsc::sync_channel::<tokio::runtime::Handle>(0);
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("brahman-sidecar-pool".into())
|
||||
.spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "tokio runtime falló — pool muerto");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if handle_tx.send(rt.handle().clone()).is_err() {
|
||||
return;
|
||||
}
|
||||
// Mantenemos el runtime vivo mientras existan tasks.
|
||||
rt.block_on(std::future::pending::<()>());
|
||||
})?;
|
||||
let handle = handle_rx
|
||||
.recv()
|
||||
.map_err(|_| std::io::Error::other("pool runtime no respondió"))?;
|
||||
Ok(Self {
|
||||
handle,
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
_thread: thread,
|
||||
})
|
||||
}
|
||||
|
||||
/// Añade una sesión agnóstica al pool (sin WIT).
|
||||
pub fn spawn(&self, card: Card) {
|
||||
self.spawn_with_config(SidecarConfig::new(card));
|
||||
}
|
||||
|
||||
/// Añade una sesión consciente (con WIT) al pool.
|
||||
pub fn spawn_conscious(&self, card: Card, wit: WitInterface) {
|
||||
self.spawn_with_config(SidecarConfig::new(card).with_wit(wit));
|
||||
}
|
||||
|
||||
/// Añade una sesión con configuración custom.
|
||||
///
|
||||
/// Si ya existía una sesión para el mismo `Card.id`, la previa
|
||||
/// se aborta antes de spawnear la nueva. Esto hace `spawn`
|
||||
/// idempotente respecto al id: re-publicar una Mónada cuya
|
||||
/// composición cambió "refresca" la sesión en el broker.
|
||||
pub fn spawn_with_config(&self, config: SidecarConfig) {
|
||||
let card_id = config.card.id;
|
||||
let join = self.handle.spawn(run_client(config));
|
||||
let abort = join.abort_handle();
|
||||
if let Ok(mut sessions) = self.sessions.lock() {
|
||||
if let Some(prev) = sessions.insert(card_id, abort) {
|
||||
prev.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cierra explícitamente la sesión asociada a `card_id`. No-op si
|
||||
/// no había sesión registrada.
|
||||
pub fn drop_session(&self, card_id: Ulid) {
|
||||
if let Ok(mut sessions) = self.sessions.lock() {
|
||||
if let Some(abort) = sessions.remove(&card_id) {
|
||||
abort.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cantidad actual de sesiones vivas (estimada — puede haber
|
||||
/// drift transitorio entre abort y limpieza).
|
||||
pub fn live_sessions(&self) -> usize {
|
||||
self.sessions.lock().map(|s| s.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SidecarPool {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("falló SidecarPool::new")
|
||||
}
|
||||
}
|
||||
|
||||
fn run_thread(config: SidecarConfig) {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "tokio runtime falló");
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(run_client(config));
|
||||
}
|
||||
|
||||
/// Bucle async del sidecar. Público para que `SidecarPool` lo use vía
|
||||
/// `handle.spawn(run_client(...))` desde código externo al runtime.
|
||||
pub async fn run_client(config: SidecarConfig) {
|
||||
let path = transport::default_socket_path();
|
||||
let conscious = config.wit.is_some();
|
||||
let mut client = match Client::connect_with(&path, config.card, config.wit).await {
|
||||
Ok(c) => {
|
||||
info!(
|
||||
target: "brahman_sidecar",
|
||||
session = %c.session(),
|
||||
init_attached = c.server_info().init_attached,
|
||||
server = %c.server_info().server_version,
|
||||
conscious,
|
||||
"attached"
|
||||
);
|
||||
c
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "brahman_sidecar",
|
||||
error = %e,
|
||||
socket = %path.display(),
|
||||
"no conectado"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(config.ping_interval).await;
|
||||
if let Err(e) = client.ping().await {
|
||||
warn!(target: "brahman_sidecar", error = %e, "ping falló — terminando sidecar");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "ente-card"
|
||||
version = "0.0.1"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Alias histórico de brahman-card. Re-exporta tipos legacy (EntityCard ≡ Card)."
|
||||
|
||||
[dependencies]
|
||||
brahman-card = { path = "../brahman-card" }
|
||||
@@ -0,0 +1,30 @@
|
||||
//! `ente-card` — alias histórico de [`brahman_card`].
|
||||
//!
|
||||
//! Mantenido como compatibilidad para los crates `ente-*` del Init que
|
||||
//! importan `EntityCard`, `Capability`, `Payload`, etc. La fuente de verdad
|
||||
//! del schema vive en [`brahman_card`]; este crate sólo re-exporta los tipos
|
||||
//! bajo sus nombres legacy:
|
||||
//!
|
||||
//! - `EntityCard` ≡ [`brahman_card::Card`]
|
||||
//! - El resto de tipos conservan el mismo nombre.
|
||||
//!
|
||||
//! Toda lógica nueva debe consumir directamente `brahman_card`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub use brahman_card::{
|
||||
Capability,
|
||||
CardError,
|
||||
Card as EntityCard,
|
||||
CgroupSpec,
|
||||
DeviceClass,
|
||||
InterfaceId,
|
||||
LegacyFacade,
|
||||
NamespaceSet,
|
||||
NetlinkFamily,
|
||||
Payload,
|
||||
ResourceLimits,
|
||||
SomaSpec,
|
||||
Supervision,
|
||||
CARD_SCHEMA_VERSION,
|
||||
};
|
||||
Reference in New Issue
Block a user