From 05886022e0a43dd1273ceefe927922df419a2661 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 15:37:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(sandokan-remote):=20B1.4=20=E2=80=94=20Rem?= =?UTF-8?q?oteEngine=20v=C3=ADa=20SSH=20socket-forward?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 13 ++ Cargo.toml | 1 + .../protocol/brahman-ssh-multiplex/src/lib.rs | 18 +++ crates/runtime/sandokan-daemon/src/lib.rs | 4 +- crates/runtime/sandokan-remote/Cargo.toml | 16 +++ crates/runtime/sandokan-remote/src/lib.rs | 112 ++++++++++++++++++ crates/runtime/sandokan/Cargo.toml | 1 + crates/runtime/sandokan/src/lib.rs | 1 + vamos.txt | 14 +++ 9 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 crates/runtime/sandokan-remote/Cargo.toml create mode 100644 crates/runtime/sandokan-remote/src/lib.rs 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, 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 { 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, + ) -> Result { + 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) -> Self { + Self { session, remote_socket: remote_socket.into() } + } + + async fn roundtrip(&self, req: DaemonRequest) -> Result { + 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 { + 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, 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 { + 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 { + 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 + ./target/debug/sandokan stop + +