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>
This commit is contained in:
sergio
2026-05-20 15:37:11 +00:00
parent 0e13c35f3e
commit 05886022e0
9 changed files with 178 additions and 2 deletions
Generated
+13
View File
@@ -10580,6 +10580,7 @@ dependencies = [
"sandokan-daemon", "sandokan-daemon",
"sandokan-lifecycle", "sandokan-lifecycle",
"sandokan-local", "sandokan-local",
"sandokan-remote",
"tempfile", "tempfile",
"tokio", "tokio",
] ]
@@ -10645,6 +10646,18 @@ dependencies = [
"ulid", "ulid",
] ]
[[package]]
name = "sandokan-remote"
version = "0.1.0"
dependencies = [
"async-trait",
"brahman-ssh-multiplex",
"sandokan-core",
"sandokan-daemon",
"sandokan-lifecycle",
"ulid",
]
[[package]] [[package]]
name = "saphyr-parser" name = "saphyr-parser"
version = "0.0.6" version = "0.0.6"
+1
View File
@@ -41,6 +41,7 @@ members = [
"crates/runtime/sandokan-core", "crates/runtime/sandokan-core",
"crates/runtime/sandokan-local", "crates/runtime/sandokan-local",
"crates/runtime/sandokan-daemon", "crates/runtime/sandokan-daemon",
"crates/runtime/sandokan-remote",
"crates/runtime/sandokan", "crates/runtime/sandokan",
# ============================================================ # ============================================================
@@ -141,6 +141,24 @@ impl SshSession {
Ok(Self { handle: Arc::new(handle) }) 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. /// Ejecuta `command` en un canal nuevo y junta su salida completa.
/// Canales concurrentes se multiplexan sobre la misma conexión. /// Canales concurrentes se multiplexan sobre la misma conexión.
pub async fn exec(&self, command: &str) -> Result<ExecOutput, SshError> { pub async fn exec(&self, command: &str) -> Result<ExecOutput, SshError> {
+2 -2
View File
@@ -11,9 +11,9 @@
//! demás se le suman como `DaemonEngine`. //! demás se le suman como `DaemonEngine`.
mod client; mod client;
mod protocol; pub mod protocol;
mod server; mod server;
pub use client::DaemonEngine; pub use client::DaemonEngine;
pub use protocol::{DaemonRequest, DaemonResponse}; pub use protocol::{read_frame, write_frame, DaemonRequest, DaemonResponse};
pub use server::serve; pub use server::serve;
+16
View File
@@ -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 }
+112
View File
@@ -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()),
}
}
}
+1
View File
@@ -12,6 +12,7 @@ sandokan-core = { path = "../sandokan-core" }
sandokan-lifecycle = { path = "../sandokan-lifecycle" } sandokan-lifecycle = { path = "../sandokan-lifecycle" }
sandokan-local = { path = "../sandokan-local" } sandokan-local = { path = "../sandokan-local" }
sandokan-daemon = { path = "../sandokan-daemon" } sandokan-daemon = { path = "../sandokan-daemon" }
sandokan-remote = { path = "../sandokan-remote" }
tokio = { workspace = true } tokio = { workspace = true }
[dev-dependencies] [dev-dependencies]
+1
View File
@@ -17,6 +17,7 @@ pub use sandokan_core::{
}; };
pub use sandokan_daemon::{serve, DaemonEngine, DaemonRequest, DaemonResponse}; pub use sandokan_daemon::{serve, DaemonEngine, DaemonRequest, DaemonResponse};
pub use sandokan_local::LocalEngine; pub use sandokan_local::LocalEngine;
pub use sandokan_remote::RemoteEngine;
/// Re-export de las primitivas de lifecycle. /// Re-export de las primitivas de lifecycle.
pub use sandokan_lifecycle as lifecycle; pub use sandokan_lifecycle as lifecycle;
+14
View File
@@ -806,3 +806,17 @@
FASE E · yachay (integrador, sem 12-18) FASE E · yachay (integrador, sem 12-18)
FASE F · Cobertura tests + cerrar stubs cosmo/pineal (continuo) FASE F · Cobertura tests + cerrar stubs cosmo/pineal (continuo)
FASE G · Backlog (rimay, yuyay, apu, tinkuy, nutu, ...) 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>