From af5d4a1f229ee8964b1afa3532da79aef9234739 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 00:38:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(sandokan-core):=20B1.1=20=E2=80=94=20contr?= =?UTF-8?q?ato=20del=20orquestador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primer crate de la Fase B. Define SOLO el contrato del orquestador sandokan (library horizontal embebible, no daemon supremo): - Intent / ExecContext / IsolationLevel — qué orquestar - ExecHandle — referencia a una entidad encarnada - LifecycleEvent / TelemetryFrame — observabilidad (wire types) - EngineError — taxonomía de fallas - trait Engine — run/stop/list/status/telemetry (poll-based, sin streams sobre trait objects, para que las 3 impls lo cumplan uniformemente) Las impls concretas (LocalEngine, DaemonEngine, RemoteEngine) vendrán en crates separados (sandokan-local, sandokan-daemon, sandokan-remote). 3 tests verdes. cargo check --workspace verde. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 12 +++ Cargo.toml | 1 + crates/runtime/sandokan-core/Cargo.toml | 16 ++++ crates/runtime/sandokan-core/src/engine.rs | 39 +++++++++ crates/runtime/sandokan-core/src/error.rs | 28 +++++++ crates/runtime/sandokan-core/src/event.rs | 57 +++++++++++++ crates/runtime/sandokan-core/src/intent.rs | 93 ++++++++++++++++++++++ crates/runtime/sandokan-core/src/lib.rs | 26 ++++++ 8 files changed, 272 insertions(+) create mode 100644 crates/runtime/sandokan-core/Cargo.toml create mode 100644 crates/runtime/sandokan-core/src/engine.rs create mode 100644 crates/runtime/sandokan-core/src/error.rs create mode 100644 crates/runtime/sandokan-core/src/event.rs create mode 100644 crates/runtime/sandokan-core/src/intent.rs create mode 100644 crates/runtime/sandokan-core/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 97513ea..ebba677 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10127,6 +10127,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sandokan-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "brahman-card", + "sandokan-lifecycle", + "serde", + "thiserror 2.0.18", + "ulid", +] + [[package]] name = "sandokan-lifecycle" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index da692e3..ac5cb00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ members = [ "crates/runtime/arje-brain", "crates/runtime/arje-echo", "crates/runtime/sandokan-lifecycle", + "crates/runtime/sandokan-core", # ============================================================ # compat/ — Shims D-Bus para correr software systemd-aware diff --git a/crates/runtime/sandokan-core/Cargo.toml b/crates/runtime/sandokan-core/Cargo.toml new file mode 100644 index 0000000..a4b1310 --- /dev/null +++ b/crates/runtime/sandokan-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sandokan-core" +description = "Contrato del orquestador sandokan: trait Engine + tipos (Intent, ExecHandle, eventos, telemetría). Sin transporte ni impl concreta." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +brahman-card = { path = "../../protocol/brahman-card" } +sandokan-lifecycle = { path = "../sandokan-lifecycle" } +serde = { workspace = true } +ulid = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } diff --git a/crates/runtime/sandokan-core/src/engine.rs b/crates/runtime/sandokan-core/src/engine.rs new file mode 100644 index 0000000..9d5270b --- /dev/null +++ b/crates/runtime/sandokan-core/src/engine.rs @@ -0,0 +1,39 @@ +//! El trait `Engine` — contrato uniforme del orquestador. + +use crate::error::EngineError; +use crate::event::TelemetryFrame; +use crate::intent::{ExecHandle, Intent}; +use async_trait::async_trait; +use sandokan_lifecycle::LifecycleState; +use std::time::Duration; +use ulid::Ulid; + +/// El orquestador. Tres implementaciones lo cumplen de forma intercambiable: +/// +/// - `LocalEngine` — encarna in-process (`arje-incarnate` + `arje-brain-rules`). +/// - `DaemonEngine` — delega a otro proceso vía Unix socket (postcard). +/// - `RemoteEngine` — delega a otro host vía `brahman-ssh-multiplex`. +/// +/// Un helper `Engine::auto()` (en `sandokan-local`) prueba si hay un +/// daemon escuchando y elige `DaemonEngine`; si no, `LocalEngine`. +/// +/// El contrato es poll-based (sin streams) para que las tres impls lo +/// cumplan uniformemente sin complejidad de `Stream` sobre trait objects. +#[async_trait] +pub trait Engine: Send + Sync { + /// Encarna una intención. Devuelve un handle a la entidad corriendo. + async fn run(&self, intent: Intent) -> Result; + + /// Detiene una entidad con período de gracia (SIGTERM → espera → + /// SIGKILL). `grace == 0` = kill inmediato. + async fn stop(&self, card_id: Ulid, grace: Duration) -> Result<(), EngineError>; + + /// Lista las entidades actualmente activas. + async fn list(&self) -> Result, EngineError>; + + /// Estado actual de una entidad. + async fn status(&self, card_id: Ulid) -> Result; + + /// Telemetría puntual de una entidad. + async fn telemetry(&self, card_id: Ulid) -> Result; +} diff --git a/crates/runtime/sandokan-core/src/error.rs b/crates/runtime/sandokan-core/src/error.rs new file mode 100644 index 0000000..67a51ce --- /dev/null +++ b/crates/runtime/sandokan-core/src/error.rs @@ -0,0 +1,28 @@ +//! Errores del orquestador. + +use ulid::Ulid; + +/// Falla de una operación del `Engine`. Las impls concretas mapean sus +/// errores internos (encarnación, IPC, SSH) a estas variantes. +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + /// No existe ninguna entidad activa con ese `card_id`. + #[error("card `{0}` no encontrada")] + NotFound(Ulid), + + /// La encarnación falló (clone/namespaces/exec). + #[error("encarnación falló: {0}")] + Incarnate(String), + + /// Falla de transporte (Unix socket del daemon, túnel SSH). + #[error("transporte: {0}")] + Transport(String), + + /// La intención es inconsistente (Card inválida, contexto imposible). + #[error("intención inválida: {0}")] + InvalidIntent(String), + + /// La operación excedió su deadline. + #[error("timeout")] + Timeout, +} diff --git a/crates/runtime/sandokan-core/src/event.rs b/crates/runtime/sandokan-core/src/event.rs new file mode 100644 index 0000000..bf0854e --- /dev/null +++ b/crates/runtime/sandokan-core/src/event.rs @@ -0,0 +1,57 @@ +//! Observabilidad: eventos de ciclo de vida y frames de telemetría. + +use sandokan_lifecycle::LifecycleState; +use serde::{Deserialize, Serialize}; +use std::time::SystemTime; +use ulid::Ulid; + +/// Un evento en la vida de una entidad encarnada. El orquestador los +/// emite; los consumidores (shells, paneles) reaccionan. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum LifecycleEvent { + /// La entidad fue encarnada. `pid` es `None` si no aplica (Wasm, virtual). + Spawned { card_id: Ulid, pid: Option }, + /// El estado de la entidad cambió. + StateChanged { card_id: Ulid, state: LifecycleState }, + /// La entidad terminó (estado terminal). + Exited { card_id: Ulid, state: LifecycleState }, +} + +impl LifecycleEvent { + /// El `card_id` al que refiere el evento. + pub fn card_id(&self) -> Ulid { + match self { + LifecycleEvent::Spawned { card_id, .. } + | LifecycleEvent::StateChanged { card_id, .. } + | LifecycleEvent::Exited { card_id, .. } => *card_id, + } + } +} + +/// Una medición puntual de recursos de una entidad. Los campos se +/// inlinean (en vez de reusar `sandokan_lifecycle::ResourceUsage`) para +/// que el frame sea un wire type estable e independiente. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelemetryFrame { + pub card_id: Ulid, + pub at: SystemTime, + pub mem_bytes: u64, + pub nproc: u32, + /// Porcentaje de CPU (100.0 = 1 core saturado). + pub cpu_pct: f64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_card_id_accessor() { + let id = Ulid::new(); + let ev = LifecycleEvent::Exited { + card_id: id, + state: LifecycleState::Exited { code: 0 }, + }; + assert_eq!(ev.card_id(), id); + } +} diff --git a/crates/runtime/sandokan-core/src/intent.rs b/crates/runtime/sandokan-core/src/intent.rs new file mode 100644 index 0000000..7151af0 --- /dev/null +++ b/crates/runtime/sandokan-core/src/intent.rs @@ -0,0 +1,93 @@ +//! Qué orquestar: la intención de ejecución y su contexto. + +use brahman_card::Card; +use serde::{Deserialize, Serialize}; +use std::time::{Duration, SystemTime}; +use ulid::Ulid; + +/// Nivel de aislamiento pedido para una encarnación. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum IsolationLevel { + /// Sin sandbox — mismo namespace que el orquestador. + None, + /// Namespaces estándar (pid/mount/net/...) según `Card.soma`. + #[default] + Standard, + /// Namespaces + rootfs aislado (`pivot_root` + OverlayFS). + Sealed, +} + +/// Contexto de ejecución: ajustes sobre cómo encarnar la Card. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ExecContext { + /// Aislamiento pedido. `None` = derivar de `Card.soma`. + pub isolation: Option, + /// Variables de entorno adicionales (sobre las del Card). + pub env: Vec<(String, String)>, + /// Time-to-live opcional: si se setea, el orquestador detiene la + /// entidad al vencer. + pub ttl: Option, +} + +/// Una intención de ejecución: la `Card` a encarnar + su contexto. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Intent { + pub card: Card, + #[serde(default)] + pub context: ExecContext, +} + +impl Intent { + /// Intención mínima: encarnar una Card con el contexto por defecto. + pub fn new(card: Card) -> Self { + Self { card, context: ExecContext::default() } + } + + /// Builder: fija el nivel de aislamiento. + pub fn with_isolation(mut self, level: IsolationLevel) -> Self { + self.context.isolation = Some(level); + self + } + + /// Builder: fija un TTL. + pub fn with_ttl(mut self, ttl: Duration) -> Self { + self.context.ttl = Some(ttl); + self + } + + /// El id de la Card que esta intención encarna. + pub fn card_id(&self) -> Ulid { + self.card.id + } +} + +/// Referencia a una entidad encarnada por el orquestador. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecHandle { + /// Id de la Card encarnada (identidad estable en el fractal). + pub card_id: Ulid, + /// Label humano-legible (copiado de `Card.label`). + pub label: String, + /// Cuándo arrancó. + pub started_at: SystemTime, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn isolation_default_is_standard() { + assert_eq!(IsolationLevel::default(), IsolationLevel::Standard); + } + + #[test] + fn intent_builders_compose() { + let card = Card::new("demo"); + let intent = Intent::new(card) + .with_isolation(IsolationLevel::Sealed) + .with_ttl(Duration::from_secs(30)); + assert_eq!(intent.context.isolation, Some(IsolationLevel::Sealed)); + assert_eq!(intent.context.ttl, Some(Duration::from_secs(30))); + } +} diff --git a/crates/runtime/sandokan-core/src/lib.rs b/crates/runtime/sandokan-core/src/lib.rs new file mode 100644 index 0000000..bb7c315 --- /dev/null +++ b/crates/runtime/sandokan-core/src/lib.rs @@ -0,0 +1,26 @@ +//! sandokan-core — el contrato del orquestador. +//! +//! `sandokan` es el orquestador del ecosistema brahman, diseñado como +//! **library horizontal embebible**, no como daemon supremo. Cualquier +//! binario (shuma, nahual-shell, matilda, un agente SSH) embebe un +//! `Engine` y decide si lo corre in-process o delega a otro. +//! +//! Este crate define SOLO el contrato: +//! - [`Intent`] — qué orquestar (una Card + contexto de ejecución). +//! - [`ExecHandle`] — referencia a una entidad encarnada. +//! - [`LifecycleEvent`] / [`TelemetryFrame`] — observabilidad. +//! - [`Engine`] — el trait que `LocalEngine`/`DaemonEngine`/`RemoteEngine` +//! implementan. +//! +//! Las implementaciones concretas viven en crates separados +//! (`sandokan-local`, `sandokan-daemon`, `sandokan-remote`). + +pub mod engine; +pub mod error; +pub mod event; +pub mod intent; + +pub use engine::Engine; +pub use error::EngineError; +pub use event::{LifecycleEvent, TelemetryFrame}; +pub use intent::{ExecContext, ExecHandle, Intent, IsolationLevel};