gioser

tawasuyu/brahman

Rust 496TOML 195Markdown 55JSON 197001 Definiciones

feat(sandokan-remote): B1.4 — RemoteEngine vía SSH socket-forward

Opción B: RemoteEngine orquesta en un host remoto tunelando el wire
del daemon sobre un canal SSH direct-streamlocal hacia el sandokan.sock
remoto. El protocolo es idéntico al de DaemonEngine (postcard
length-prefixed) — sólo cambia el transporte, así que read_frame/
write_frame se reusan tal cual.

- brahman-ssh-multiplex: + SshSession::forward_unix — abre un canal
  direct-streamlocal y devuelve su ChannelStream (AsyncRead+AsyncWrite).
- sandokan-daemon: protocol ahora pub, exporta read_frame/write_frame.
- sandokan-remote: RemoteEngine { SshSession + remote_socket }.
  connect() o with_session(); cada operación abre un canal nuevo
  (multiplexado sobre la conexión maestra).
- sandokan umbrella re-exporta RemoteEngine.

Completa Fase B: sandokan tiene Local + Daemon + Remote + auto().
cargo check --workspace verde. RemoteEngine necesita un host remoto
con `sandokan daemon` para validación runtime (sin unit test).

Opción A (text-parse del CLI por compat) queda pendiente por decisión
del usuario.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

05886022e0a43dd1273ceefe927922df419a2661 · por sergio · (hace 5 meses) · padre 0e13c35f3 · ver el árbol en este commit

Delta semántico · 11

crates/protocol/brahman-ssh-multiplex/src/lib.rs

crates/runtime/sandokan-remote/src/lib.rs

9 archivos · +178 −2

diff --git a/Cargo.lock b/Cargo.lock
index b650a57..5e4d3d0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10580,6 +10580,7 @@ dependencies = [
  "sandokan-daemon",
  "sandokan-lifecycle",
  "sandokan-local",
+ "sandokan-remote",
  "tempfile",
  "tokio",
 ]
@@ -10645,6 +10646,18 @@ dependencies = [
  "ulid",
 ]
 
+[[package]]
+name = "sandokan-remote"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "brahman-ssh-multiplex",
+ "sandokan-core",
+ "sandokan-daemon",
+ "sandokan-lifecycle",
+ "ulid",
+]
+
 [[package]]
 name = "saphyr-parser"
 version = "0.0.6"
diff --git a/Cargo.toml b/Cargo.toml
index e69cfa5..c3cf1ac 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,6 +41,7 @@ members = [
     "crates/runtime/sandokan-core",
     "crates/runtime/sandokan-local",
     "crates/runtime/sandokan-daemon",
+    "crates/runtime/sandokan-remote",
     "crates/runtime/sandokan",
 
     # ============================================================
diff --git a/crates/protocol/brahman-ssh-multiplex/src/lib.rs b/crates/protocol/brahman-ssh-multiplex/src/lib.rs
index eaa7d3a..a486f23 100644
--- a/crates/protocol/brahman-ssh-multiplex/src/lib.rs
+++ b/crates/protocol/brahman-ssh-multiplex/src/lib.rs
@@ -141,6 +141,24 @@ impl SshSession {
         Ok(Self { handle: Arc::new(handle) })
     }
 
+    /// Abre un canal `direct-streamlocal` hacia un Unix socket del host
+    /// remoto y devuelve su stream bidireccional (`AsyncRead + AsyncWrite`).
+    ///
+    /// Permite tunelar un protocolo arbitrario (p. ej. el wire postcard
+    /// de `sandokan-daemon`) contra un socket remoto, reusando el código
+    /// de cliente sin cambios — sólo cambia el transporte.
+    pub async fn forward_unix(
+        &self,
+        remote_socket: &str,
+    ) -> Result<russh::ChannelStream<russh::client::Msg>, SshError> {
+        let channel = self
+            .handle
+            .channel_open_direct_streamlocal(remote_socket)
+            .await
+            .map_err(|e| SshError::Channel(e.to_string()))?;
+        Ok(channel.into_stream())
+    }
+
     /// Ejecuta `command` en un canal nuevo y junta su salida completa.
     /// Canales concurrentes se multiplexan sobre la misma conexión.
     pub async fn exec(&self, command: &str) -> Result<ExecOutput, SshError> {
diff --git a/crates/runtime/sandokan-daemon/src/lib.rs b/crates/runtime/sandokan-daemon/src/lib.rs
index 4e0d903..d6f72b7 100644
--- a/crates/runtime/sandokan-daemon/src/lib.rs
+++ b/crates/runtime/sandokan-daemon/src/lib.rs
@@ -11,9 +11,9 @@
 //! demás se le suman como `DaemonEngine`.
 
 mod client;
-mod protocol;
+pub mod protocol;
 mod server;
 
 pub use client::DaemonEngine;
-pub use protocol::{DaemonRequest, DaemonResponse};
+pub use protocol::{read_frame, write_frame, DaemonRequest, DaemonResponse};
 pub use server::serve;
diff --git a/crates/runtime/sandokan-remote/Cargo.toml b/crates/runtime/sandokan-remote/Cargo.toml
new file mode 100644
index 0000000..13ed81f
--- /dev/null
+++ b/crates/runtime/sandokan-remote/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "sandokan-remote"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "sandokan RemoteEngine — orquesta en un host remoto tunelando el wire del daemon sobre un canal SSH direct-streamlocal."
+
+[dependencies]
+sandokan-core = { path = "../sandokan-core" }
+sandokan-daemon = { path = "../sandokan-daemon" }
+sandokan-lifecycle = { path = "../sandokan-lifecycle" }
+brahman-ssh-multiplex = { path = "../../protocol/brahman-ssh-multiplex" }
+async-trait = { workspace = true }
+ulid = { workspace = true }
diff --git a/crates/runtime/sandokan-remote/src/lib.rs b/crates/runtime/sandokan-remote/src/lib.rs
new file mode 100644
index 0000000..5071b69
--- /dev/null
+++ b/crates/runtime/sandokan-remote/src/lib.rs
@@ -0,0 +1,112 @@
+//! `sandokan-remote` — `RemoteEngine`: orquesta en un host remoto.
+//!
+//! Misma técnica que `DaemonEngine` pero el transporte es un canal SSH
+//! `direct-streamlocal` hacia el `sandokan.sock` del host remoto. El wire
+//! es idéntico (postcard length-prefixed) — sólo cambia el túnel, así
+//! que el código de protocolo se reusa tal cual.
+//!
+//! Requiere que el host remoto corra `sandokan daemon` escuchando en
+//! `remote_socket`.
+
+#![forbid(unsafe_code)]
+
+use async_trait::async_trait;
+use brahman_ssh_multiplex::{SshConfig, SshSession};
+use sandokan_core::{Engine, EngineError, ExecHandle, Intent, TelemetryFrame};
+use sandokan_daemon::{read_frame, write_frame, DaemonRequest, DaemonResponse};
+use sandokan_lifecycle::LifecycleState;
+use std::time::Duration;
+use ulid::Ulid;
+
+/// Engine que delega a un daemon sandokan en un host remoto, tunelando
+/// el wire sobre SSH. La sesión SSH maestra se mantiene; cada operación
+/// abre un canal `direct-streamlocal` nuevo (multiplexado, barato).
+pub struct RemoteEngine {
+    session: SshSession,
+    remote_socket: String,
+}
+
+impl RemoteEngine {
+    /// Conecta por SSH al host y prepara el túnel al socket del daemon.
+    pub async fn connect(
+        ssh: &SshConfig,
+        remote_socket: impl Into<String>,
+    ) -> Result<Self, EngineError> {
+        let session = SshSession::connect(ssh)
+            .await
+            .map_err(|e| EngineError::Transport(format!("ssh connect: {e}")))?;
+        Ok(Self { session, remote_socket: remote_socket.into() })
+    }
+
+    /// Construye un `RemoteEngine` sobre una `SshSession` ya establecida
+    /// (permite compartir la conexión maestra con otros consumidores).
+    pub fn with_session(session: SshSession, remote_socket: impl Into<String>) -> Self {
+        Self { session, remote_socket: remote_socket.into() }
+    }
+
+    async fn roundtrip(&self, req: DaemonRequest) -> Result<DaemonResponse, EngineError> {
+        let mut stream = self
+            .session
+            .forward_unix(&self.remote_socket)
+            .await
+            .map_err(|e| EngineError::Transport(format!("ssh forward: {e}")))?;
+        write_frame(&mut stream, &req)
+            .await
+            .map_err(|e| EngineError::Transport(format!("send: {e}")))?;
+        read_frame::<_, DaemonResponse>(&mut stream)
+            .await
+            .map_err(|e| EngineError::Transport(format!("recv: {e}")))
+    }
+}
+
+/// Un response que no corresponde al request enviado.
+fn mismatch() -> EngineError {
+    EngineError::Transport("respuesta remota no coincide con el request".into())
+}
+
+#[async_trait]
+impl Engine for RemoteEngine {
+    async fn run(&self, intent: Intent) -> Result<ExecHandle, EngineError> {
+        match self.roundtrip(DaemonRequest::Run(intent)).await? {
+            DaemonResponse::Ran(h) => Ok(h),
+            DaemonResponse::Err(e) => Err(e),
+            _ => Err(mismatch()),
+        }
+    }
+
+    async fn stop(&self, card_id: Ulid, grace: Duration) -> Result<(), EngineError> {
+        let req = DaemonRequest::Stop {
+            card_id,
+            grace_ms: grace.as_millis() as u64,
+        };
+        match self.roundtrip(req).await? {
+            DaemonResponse::Stopped => Ok(()),
+            DaemonResponse::Err(e) => Err(e),
+            _ => Err(mismatch()),
+        }
+    }
+
+    async fn list(&self) -> Result<Vec<ExecHandle>, EngineError> {
+        match self.roundtrip(DaemonRequest::List).await? {
+            DaemonResponse::Listed(v) => Ok(v),
+            DaemonResponse::Err(e) => Err(e),
+            _ => Err(mismatch()),
+        }
+    }
+
+    async fn status(&self, card_id: Ulid) -> Result<LifecycleState, EngineError> {
+        match self.roundtrip(DaemonRequest::Status { card_id }).await? {
+            DaemonResponse::Status(s) => Ok(s),
+            DaemonResponse::Err(e) => Err(e),
+            _ => Err(mismatch()),
+        }
+    }
+
+    async fn telemetry(&self, card_id: Ulid) -> Result<TelemetryFrame, EngineError> {
+        match self.roundtrip(DaemonRequest::Telemetry { card_id }).await? {
+            DaemonResponse::Telemetry(t) => Ok(t),
+            DaemonResponse::Err(e) => Err(e),
+            _ => Err(mismatch()),
+        }
+    }
+}
diff --git a/crates/runtime/sandokan/Cargo.toml b/crates/runtime/sandokan/Cargo.toml
index 66786bd..500ec73 100644
--- a/crates/runtime/sandokan/Cargo.toml
+++ b/crates/runtime/sandokan/Cargo.toml
@@ -12,6 +12,7 @@ sandokan-core = { path = "../sandokan-core" }
 sandokan-lifecycle = { path = "../sandokan-lifecycle" }
 sandokan-local = { path = "../sandokan-local" }
 sandokan-daemon = { path = "../sandokan-daemon" }
+sandokan-remote = { path = "../sandokan-remote" }
 tokio = { workspace = true }
 
 [dev-dependencies]
diff --git a/crates/runtime/sandokan/src/lib.rs b/crates/runtime/sandokan/src/lib.rs
index afd72f5..103d5d8 100644
--- a/crates/runtime/sandokan/src/lib.rs
+++ b/crates/runtime/sandokan/src/lib.rs
@@ -17,6 +17,7 @@ pub use sandokan_core::{
 };
 pub use sandokan_daemon::{serve, DaemonEngine, DaemonRequest, DaemonResponse};
 pub use sandokan_local::LocalEngine;
+pub use sandokan_remote::RemoteEngine;
 
 /// Re-export de las primitivas de lifecycle.
 pub use sandokan_lifecycle as lifecycle;
diff --git a/vamos.txt b/vamos.txt
index 7f4f6ee..09e4d9b 100644
--- a/vamos.txt
+++ b/vamos.txt
@@ -806,3 +806,17 @@
   FASE E · yachay (integrador, sem 12-18)
   FASE F · Cobertura tests + cerrar stubs cosmo/pineal (continuo)
   FASE G · Backlog (rimay, yuyay, apu, tinkuy, nutu, ...)
+
+
+
+
+
+
+  cargo build -p sandokan-cli
+  ./target/debug/sandokan daemon &              # terminal/background
+  ./target/debug/sandokan run /bin/sleep 300    # → card_id
+  ./target/debug/sandokan list
+  ./target/debug/sandokan status <card-id>
+  ./target/debug/sandokan stop <card-id>
+
+