feat(shipote): data plane + DAG fan-in/out + stats + lifecycle (fases F-I)
Pipeline runtime:
- Fan-out 1→N (splitter task replica al N consumers) y fan-in N→1 (merger
task con mpsc + reader-per-input). DAGs no lineales soportados.
- Flow channels: Unix socket + tokio broadcast con replay buffer
configurable por pipeline (DiscernPolicy.replay_chunks). Subscribers
externos vía `shipote flow tail <socket>`.
- Templating en specs con `${KEY}` (CLI `--var KEY=VALUE`). Walk
recursivo sobre serde_json::Value, soporta todos los strings del schema.
- Pipelines guardados (`pipeline save/saved-list/drop/run-saved`)
persisten con el snapshot.
Lifecycle de comandos:
- Log capture per-stream (stdout/stderr separados) via pipe O_CLOEXEC +
AsyncFd. CLI `shipote logs <ws> <cmd> --stream {stdout,stderr,both}`.
- Stop graceful con tiempo configurable: SIGTERM → grace → SIGKILL.
Tanto a nivel workspace como pipeline individual.
- TTL auto-stop ya existente (Fase C) sigue funcionando.
ente-incarnate:
- ChildStdio declarativo (Fase C) + ChildPreExec declarativo nuevo:
NoNewPrivs, ParentDeathSig, Dumpable, NewSession, Chdir, Umask.
- Aplicación pre-execve async-signal-safe en ambos paths (plain via
Command::pre_exec, namespaced via callback del clone(2)).
Observabilidad:
- WorkspaceStats: RSS + RSS peak (VmHWM o memory.peak cgroup) + CPU usec
+ uptime. Fuente per-proc o cgroup según delegation.
- shipote-shell con sparkline ASCII por workspace (history cap 24),
card de flow channels activos, vista de comandos + saved pipelines.
- Tap → broker: cada edge enriquecido con TypeRef se anuncia como Card
efímera vía SidecarPool (graceful si broker no corre).
Discern:
- Integrado en yahweh-provider-fs (mime_type en EntityNode).
- Integrado en nouser-core::cluster::pick_lens como fallback cuando la
extensión cae a Lens::Grid.
79 tests pasan: ente-incarnate (16), nouser-core (27), shipote-card (8),
shipote-core (20), shipote-discern (5), yahweh-provider-fs (3).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -63,11 +63,31 @@ enum Cmd {
|
|||||||
/// Bytes desde el final (0 = todo).
|
/// Bytes desde el final (0 = todo).
|
||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
tail: usize,
|
tail: usize,
|
||||||
|
/// Stream a leer: stdout | stderr | both.
|
||||||
|
#[arg(long, default_value = "both")]
|
||||||
|
stream: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Pipeline DAG con flujo tipado.
|
/// Pipeline DAG con flujo tipado.
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Pipeline(PipeCmd),
|
Pipeline(PipeCmd),
|
||||||
|
|
||||||
|
/// Flow data plane (subscribirse a streams enriquecidos).
|
||||||
|
#[command(subcommand)]
|
||||||
|
Flow(FlowCmd),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum FlowCmd {
|
||||||
|
/// Listar pipelines activos con sus sockets de flow.
|
||||||
|
List,
|
||||||
|
/// Cerrar el data plane de un pipeline (drop de todos sus sockets).
|
||||||
|
Drop { pipeline: String },
|
||||||
|
/// Suscribirse a un flow socket y volcar bytes a stdout.
|
||||||
|
Tail {
|
||||||
|
/// Path al Unix socket del flow.
|
||||||
|
socket: PathBuf,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
@@ -80,6 +100,9 @@ enum PipeCmd {
|
|||||||
/// discernir el TypeRef del flujo.
|
/// discernir el TypeRef del flujo.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
tap: bool,
|
tap: bool,
|
||||||
|
/// Variables `KEY=VALUE` para sustitución `${KEY}` en el spec.
|
||||||
|
#[arg(long = "var", value_parser = parse_kv)]
|
||||||
|
vars: Vec<(String, String)>,
|
||||||
},
|
},
|
||||||
/// Guardar un pipeline bajo un nombre (persiste con el snapshot).
|
/// Guardar un pipeline bajo un nombre (persiste con el snapshot).
|
||||||
Save {
|
Save {
|
||||||
@@ -90,16 +113,31 @@ enum PipeCmd {
|
|||||||
},
|
},
|
||||||
/// Listar nombres de pipelines guardados.
|
/// Listar nombres de pipelines guardados.
|
||||||
SavedList,
|
SavedList,
|
||||||
/// Eliminar un pipeline guardado.
|
/// Eliminar un pipeline guardado (no afecta runs en curso).
|
||||||
Drop { name: String },
|
Drop { name: String },
|
||||||
|
/// Detener un pipeline en curso por ID (SIGTERM → grace → SIGKILL
|
||||||
|
/// sólo a sus comandos).
|
||||||
|
Stop {
|
||||||
|
/// ULID del pipeline (devuelto por `pipeline run`).
|
||||||
|
pipeline: String,
|
||||||
|
#[arg(long, default_value_t = 1000)]
|
||||||
|
grace_ms: u64,
|
||||||
|
},
|
||||||
/// Ejecutar un pipeline guardado por nombre.
|
/// Ejecutar un pipeline guardado por nombre.
|
||||||
RunSaved {
|
RunSaved {
|
||||||
name: String,
|
name: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
tap: bool,
|
tap: bool,
|
||||||
|
#[arg(long = "var", value_parser = parse_kv)]
|
||||||
|
vars: Vec<(String, String)>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_kv(s: &str) -> Result<(String, String), String> {
|
||||||
|
let (k, v) = s.split_once('=').ok_or_else(|| format!("expected KEY=VALUE, got `{s}`"))?;
|
||||||
|
Ok((k.to_string(), v.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum WsCmd {
|
enum WsCmd {
|
||||||
/// Crear un workspace desde un spec TOML/JSON.
|
/// Crear un workspace desde un spec TOML/JSON.
|
||||||
@@ -112,6 +150,13 @@ enum WsCmd {
|
|||||||
/// Detener un workspace por ID.
|
/// Detener un workspace por ID.
|
||||||
Stop {
|
Stop {
|
||||||
id: String,
|
id: String,
|
||||||
|
/// Milisegundos de gracia tras SIGTERM antes de SIGKILL.
|
||||||
|
#[arg(long, default_value_t = 1000)]
|
||||||
|
grace_ms: u64,
|
||||||
|
},
|
||||||
|
/// Resource accounting (RSS, CPU, comandos vivos).
|
||||||
|
Stats {
|
||||||
|
id: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +230,33 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Cmd::Workspace(WsCmd::Stop { id }) => {
|
Cmd::Workspace(WsCmd::Stats { id }) => {
|
||||||
let id = parse_ws_id(&id)?;
|
let id = parse_ws_id(&id)?;
|
||||||
let resp = round_trip(&mut stream, Request::WorkspaceStop { id }).await?;
|
let resp = round_trip(&mut stream, Request::WorkspaceStats { workspace: id }).await?;
|
||||||
|
match resp {
|
||||||
|
Response::WorkspaceStats { info } => {
|
||||||
|
println!("commands: {} alive / {} total", info.commands_alive, info.commands_total);
|
||||||
|
let fmt_mib = |b: u64| format!("{:.2} MiB", b as f64 / 1024.0 / 1024.0);
|
||||||
|
let rss = info.rss_bytes.map(fmt_mib).unwrap_or_else(|| "—".into());
|
||||||
|
let peak = info.rss_peak_bytes.map(fmt_mib).unwrap_or_else(|| "—".into());
|
||||||
|
let cpu = info
|
||||||
|
.cpu_usec
|
||||||
|
.map(|u| format!("{:.3} s", u as f64 / 1_000_000.0))
|
||||||
|
.unwrap_or_else(|| "—".into());
|
||||||
|
println!("rss: {rss}");
|
||||||
|
println!("rss_peak: {peak}");
|
||||||
|
println!("cpu: {cpu}");
|
||||||
|
println!("source: {}", info.source);
|
||||||
|
println!("uptime: {} ms", info.uptime_ms);
|
||||||
|
}
|
||||||
|
Response::Error { message } => return Err(anyhow!(message)),
|
||||||
|
other => print_unexpected(&other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cmd::Workspace(WsCmd::Stop { id, grace_ms }) => {
|
||||||
|
let id = parse_ws_id(&id)?;
|
||||||
|
let resp = round_trip(&mut stream, Request::WorkspaceStop { id, grace_ms }).await?;
|
||||||
match resp {
|
match resp {
|
||||||
Response::WorkspaceStopped { id, reaped } => {
|
Response::WorkspaceStopped { id, reaped } => {
|
||||||
println!("stopped {id} (reaped {reaped})");
|
println!("stopped {id} (reaped {reaped})");
|
||||||
@@ -218,9 +287,17 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Cmd::Pipeline(PipeCmd::Run { spec, tap }) => {
|
Cmd::Pipeline(PipeCmd::Run { spec, tap, vars }) => {
|
||||||
let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
|
let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
|
||||||
let resp = round_trip(&mut stream, Request::PipelineRun { spec: p, tap }).await?;
|
let resp = round_trip(
|
||||||
|
&mut stream,
|
||||||
|
Request::PipelineRun {
|
||||||
|
spec: p,
|
||||||
|
tap,
|
||||||
|
vars: vars.into_iter().collect(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
print_pipeline_started(resp)?;
|
print_pipeline_started(resp)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +326,17 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Cmd::Pipeline(PipeCmd::Stop { pipeline, grace_ms }) => {
|
||||||
|
let pid = Ulid::from_string(&pipeline).map_err(|e| anyhow!("invalid pipeline id: {e}"))?;
|
||||||
|
let resp = round_trip(&mut stream, Request::PipelineStop { pipeline: pid, grace_ms }).await?;
|
||||||
|
match resp {
|
||||||
|
Response::PipelineStopped { pipeline, reaped } => {
|
||||||
|
println!("stopped pipeline {pipeline} (reaped {reaped})");
|
||||||
|
}
|
||||||
|
other => print_unexpected(&other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Cmd::Pipeline(PipeCmd::Drop { name }) => {
|
Cmd::Pipeline(PipeCmd::Drop { name }) => {
|
||||||
let resp = round_trip(&mut stream, Request::PipelineDrop { name }).await?;
|
let resp = round_trip(&mut stream, Request::PipelineDrop { name }).await?;
|
||||||
match resp {
|
match resp {
|
||||||
@@ -263,8 +351,16 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Cmd::Pipeline(PipeCmd::RunSaved { name, tap }) => {
|
Cmd::Pipeline(PipeCmd::RunSaved { name, tap, vars }) => {
|
||||||
let resp = round_trip(&mut stream, Request::PipelineRunSaved { name, tap }).await?;
|
let resp = round_trip(
|
||||||
|
&mut stream,
|
||||||
|
Request::PipelineRunSaved {
|
||||||
|
name,
|
||||||
|
tap,
|
||||||
|
vars: vars.into_iter().collect(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
print_pipeline_started(resp)?;
|
print_pipeline_started(resp)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +388,7 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Cmd::Logs { workspace, command, tail } => {
|
Cmd::Logs { workspace, command, tail, stream: which_stream } => {
|
||||||
let ws = parse_ws_id(&workspace)?;
|
let ws = parse_ws_id(&workspace)?;
|
||||||
let cmd_id = Ulid::from_string(&command).map_err(|e| anyhow!("invalid command id: {e}"))?;
|
let cmd_id = Ulid::from_string(&command).map_err(|e| anyhow!("invalid command id: {e}"))?;
|
||||||
let resp = round_trip(
|
let resp = round_trip(
|
||||||
@@ -301,6 +397,7 @@ async fn main() -> Result<()> {
|
|||||||
workspace: ws,
|
workspace: ws,
|
||||||
command: cmd_id,
|
command: cmd_id,
|
||||||
tail_bytes: tail,
|
tail_bytes: tail,
|
||||||
|
stream: which_stream,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -316,6 +413,57 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Cmd::Flow(FlowCmd::List) => {
|
||||||
|
let resp = round_trip(&mut stream, Request::FlowList).await?;
|
||||||
|
match resp {
|
||||||
|
Response::FlowList { items } => {
|
||||||
|
if items.is_empty() {
|
||||||
|
println!("(no active flows)");
|
||||||
|
}
|
||||||
|
for it in items {
|
||||||
|
println!("{}", it.pipeline);
|
||||||
|
for s in it.sockets {
|
||||||
|
println!(" {}", s.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => print_unexpected(&other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cmd::Flow(FlowCmd::Drop { pipeline }) => {
|
||||||
|
let pid = Ulid::from_string(&pipeline).map_err(|e| anyhow!("invalid pipeline id: {e}"))?;
|
||||||
|
let resp = round_trip(&mut stream, Request::FlowDrop { pipeline: pid }).await?;
|
||||||
|
match resp {
|
||||||
|
Response::FlowDropped { pipeline, existed } => {
|
||||||
|
if existed {
|
||||||
|
println!("dropped {pipeline}");
|
||||||
|
} else {
|
||||||
|
eprintln!("no existía: {pipeline}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => print_unexpected(&other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cmd::Flow(FlowCmd::Tail { socket }) => {
|
||||||
|
// Subscribirse directo al socket — no pasamos por el daemon.
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
let mut s = UnixStream::connect(&socket)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("connect {}", socket.display()))?;
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
let n = s.read(&mut buf).await?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
use std::io::Write;
|
||||||
|
let _ = std::io::stdout().write_all(&buf[..n]);
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Cmd::Discern { path } => {
|
Cmd::Discern { path } => {
|
||||||
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
||||||
// Sample: hasta 4 KiB.
|
// Sample: hasta 4 KiB.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use shipote_core::WorkspaceManager;
|
|||||||
use shipote_discern::{DiscernPipeline, Hint};
|
use shipote_discern::{DiscernPipeline, Hint};
|
||||||
use shipote_protocol::{
|
use shipote_protocol::{
|
||||||
default_socket_path, read_frame, write_frame, CommandInfo as ProtoCommandInfo,
|
default_socket_path, read_frame, write_frame, CommandInfo as ProtoCommandInfo,
|
||||||
EdgeDiscernmentInfo, Request, Response, WorkspaceSummary,
|
EdgeDiscernmentInfo, FlowInfo, Request, Response, WorkspaceStatsInfo, WorkspaceSummary,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::net::{UnixListener, UnixStream};
|
use tokio::net::{UnixListener, UnixStream};
|
||||||
@@ -38,10 +38,18 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
|
let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
|
||||||
info!(socket = %sock.display(), "shipote-daemon listening");
|
info!(socket = %sock.display(), "shipote-daemon listening");
|
||||||
|
|
||||||
// Sidecar al broker: shipote se anuncia como sesión. Si el Init no
|
// Sidecar pool: una sesión global del daemon + N sesiones efímeras
|
||||||
// está corriendo, el sidecar loguea y termina; el daemon sigue
|
// por edge enriquecido tras cada pipeline tap.
|
||||||
// standalone (UX de v1: ningún feature requiere broker).
|
let sidecar_pool = match brahman_sidecar::SidecarPool::new() {
|
||||||
brahman_sidecar::spawn(build_daemon_card(&sock));
|
Ok(p) => Some(Arc::new(p)),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(?e, "SidecarPool falló — broker integration disabled");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(pool) = &sidecar_pool {
|
||||||
|
pool.spawn(build_daemon_card(&sock));
|
||||||
|
}
|
||||||
|
|
||||||
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig {
|
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig {
|
||||||
// El daemon aún no se conecta al broker; cuando lo haga, este path
|
// El daemon aún no se conecta al broker; cuando lo haga, este path
|
||||||
@@ -101,8 +109,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok((stream, _)) => {
|
Ok((stream, _)) => {
|
||||||
let mgr = mgr.clone();
|
let mgr = mgr.clone();
|
||||||
let disc = discerner.clone();
|
let disc = discerner.clone();
|
||||||
|
let pool = sidecar_pool.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = handle_client(stream, mgr, disc).await {
|
if let Err(e) = handle_client(stream, mgr, disc, pool).await {
|
||||||
warn!(?e, "client handler error");
|
warn!(?e, "client handler error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -119,6 +128,7 @@ async fn handle_client(
|
|||||||
mut stream: UnixStream,
|
mut stream: UnixStream,
|
||||||
mgr: Arc<WorkspaceManager>,
|
mgr: Arc<WorkspaceManager>,
|
||||||
disc: Arc<DiscernPipeline>,
|
disc: Arc<DiscernPipeline>,
|
||||||
|
pool: Option<Arc<brahman_sidecar::SidecarPool>>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
loop {
|
loop {
|
||||||
let req: Request = match read_frame(&mut stream).await {
|
let req: Request = match read_frame(&mut stream).await {
|
||||||
@@ -126,12 +136,17 @@ async fn handle_client(
|
|||||||
Err(shipote_protocol::ProtocolError::Closed) => return Ok(()),
|
Err(shipote_protocol::ProtocolError::Closed) => return Ok(()),
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
let resp = dispatch(&mgr, &disc, req).await;
|
let resp = dispatch(&mgr, &disc, &pool, req).await;
|
||||||
write_frame(&mut stream, &resp).await?;
|
write_frame(&mut stream, &resp).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Request) -> Response {
|
async fn dispatch(
|
||||||
|
mgr: &Arc<WorkspaceManager>,
|
||||||
|
disc: &DiscernPipeline,
|
||||||
|
pool: &Option<Arc<brahman_sidecar::SidecarPool>>,
|
||||||
|
req: Request,
|
||||||
|
) -> Response {
|
||||||
match req {
|
match req {
|
||||||
Request::Ping => Response::Pong,
|
Request::Ping => Response::Pong,
|
||||||
|
|
||||||
@@ -155,10 +170,15 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
|||||||
Response::WorkspaceList { items }
|
Response::WorkspaceList { items }
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::WorkspaceStop { id } => match mgr.stop(id).await {
|
Request::WorkspaceStop { id, grace_ms } => {
|
||||||
|
match mgr
|
||||||
|
.stop_with_grace(id, std::time::Duration::from_millis(grace_ms))
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(reaped) => Response::WorkspaceStopped { id, reaped },
|
Ok(reaped) => Response::WorkspaceStopped { id, reaped },
|
||||||
Err(e) => Response::Error { message: format!("{e}") },
|
Err(e) => Response::Error { message: format!("{e}") },
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Request::Run { workspace, exec, argv, envp } => {
|
Request::Run { workspace, exec, argv, envp } => {
|
||||||
match mgr.run(workspace, exec, argv, envp).await {
|
match mgr.run(workspace, exec, argv, envp).await {
|
||||||
@@ -171,7 +191,12 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::PipelineRun { spec, tap } => {
|
Request::PipelineRun { spec, tap, vars } => {
|
||||||
|
let vars_map: std::collections::HashMap<String, String> = vars.into_iter().collect();
|
||||||
|
let spec = match shipote_card::substitute_vars(&spec, &vars_map) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => return Response::Error { message: format!("template: {e}") },
|
||||||
|
};
|
||||||
let disc = DiscernPipeline::default_pipeline();
|
let disc = DiscernPipeline::default_pipeline();
|
||||||
let inc = mgr.incarnator_handle();
|
let inc = mgr.incarnator_handle();
|
||||||
let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default();
|
let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default();
|
||||||
@@ -181,27 +206,22 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
|||||||
tap,
|
tap,
|
||||||
std::sync::Arc::new(disc),
|
std::sync::Arc::new(disc),
|
||||||
inc,
|
inc,
|
||||||
|
Some(mgr.clone()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(launch) => Response::PipelineStarted {
|
Ok(launch) => {
|
||||||
pipeline: launch.pipeline,
|
let pipeline_id = launch.pipeline;
|
||||||
command_pids: launch.command_pids,
|
announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments);
|
||||||
edges: launch
|
let cmds = launch.command_pids;
|
||||||
.edge_discernments
|
mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await;
|
||||||
.into_iter()
|
let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect();
|
||||||
.map(|e| EdgeDiscernmentInfo {
|
Response::PipelineStarted {
|
||||||
from_label: e.from_label,
|
pipeline: pipeline_id,
|
||||||
from_output: e.from_output,
|
command_pids: cmds,
|
||||||
to_label: e.to_label,
|
edges,
|
||||||
to_input: e.to_input,
|
}
|
||||||
ty: e.discernment.as_ref().map(|d| format!("{:?}", d.ty)),
|
}
|
||||||
mime: e.discernment.as_ref().and_then(|d| d.mime.clone()),
|
|
||||||
lens: e.discernment.as_ref().and_then(|d| d.lens.clone()),
|
|
||||||
confidence: e.discernment.as_ref().map(|d| d.confidence).unwrap_or(0.0),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
},
|
|
||||||
Err(e) => Response::Error { message: format!("{e}") },
|
Err(e) => Response::Error { message: format!("{e}") },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,8 +260,13 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
|||||||
Response::CommandList { items }
|
Response::CommandList { items }
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::CommandLogs { workspace, command, tail_bytes } => {
|
Request::CommandLogs { workspace, command, tail_bytes, stream } => {
|
||||||
match mgr.get_command_logs(workspace, command, tail_bytes).await {
|
let s = match stream.as_str() {
|
||||||
|
"stdout" => shipote_core::LogStream::Stdout,
|
||||||
|
"stderr" => shipote_core::LogStream::Stderr,
|
||||||
|
_ => shipote_core::LogStream::Both,
|
||||||
|
};
|
||||||
|
match mgr.get_command_logs(workspace, command, tail_bytes, s).await {
|
||||||
Some(bytes) => Response::CommandLogs { bytes },
|
Some(bytes) => Response::CommandLogs { bytes },
|
||||||
None => Response::Error {
|
None => Response::Error {
|
||||||
message: format!("no logs for command {command} in workspace {workspace}"),
|
message: format!("no logs for command {command} in workspace {workspace}"),
|
||||||
@@ -264,8 +289,13 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
|||||||
Response::PipelineDropped { name, existed }
|
Response::PipelineDropped { name, existed }
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::PipelineRunSaved { name, tap } => match mgr.get_saved_pipeline(&name).await {
|
Request::PipelineRunSaved { name, tap, vars } => match mgr.get_saved_pipeline(&name).await {
|
||||||
Some(spec) => {
|
Some(spec) => {
|
||||||
|
let vars_map: std::collections::HashMap<String, String> = vars.into_iter().collect();
|
||||||
|
let spec = match shipote_card::substitute_vars(&spec, &vars_map) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => return Response::Error { message: format!("template: {e}") },
|
||||||
|
};
|
||||||
let disc = DiscernPipeline::default_pipeline();
|
let disc = DiscernPipeline::default_pipeline();
|
||||||
let inc = mgr.incarnator_handle();
|
let inc = mgr.incarnator_handle();
|
||||||
let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default();
|
let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default();
|
||||||
@@ -275,27 +305,22 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
|||||||
tap,
|
tap,
|
||||||
std::sync::Arc::new(disc),
|
std::sync::Arc::new(disc),
|
||||||
inc,
|
inc,
|
||||||
|
Some(mgr.clone()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(launch) => Response::PipelineStarted {
|
Ok(launch) => {
|
||||||
pipeline: launch.pipeline,
|
let pipeline_id = launch.pipeline;
|
||||||
command_pids: launch.command_pids,
|
announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments);
|
||||||
edges: launch
|
let cmds = launch.command_pids;
|
||||||
.edge_discernments
|
mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await;
|
||||||
.into_iter()
|
let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect();
|
||||||
.map(|e| EdgeDiscernmentInfo {
|
Response::PipelineStarted {
|
||||||
from_label: e.from_label,
|
pipeline: pipeline_id,
|
||||||
from_output: e.from_output,
|
command_pids: cmds,
|
||||||
to_label: e.to_label,
|
edges,
|
||||||
to_input: e.to_input,
|
}
|
||||||
ty: e.discernment.as_ref().map(|d| format!("{:?}", d.ty)),
|
}
|
||||||
mime: e.discernment.as_ref().and_then(|d| d.mime.clone()),
|
|
||||||
lens: e.discernment.as_ref().and_then(|d| d.lens.clone()),
|
|
||||||
confidence: e.discernment.as_ref().map(|d| d.confidence).unwrap_or(0.0),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
},
|
|
||||||
Err(e) => Response::Error { message: format!("{e}") },
|
Err(e) => Response::Error { message: format!("{e}") },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,6 +329,45 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Request::PipelineStop { pipeline, grace_ms } => {
|
||||||
|
let reaped = mgr
|
||||||
|
.stop_pipeline(pipeline, std::time::Duration::from_millis(grace_ms))
|
||||||
|
.await;
|
||||||
|
Response::PipelineStopped { pipeline, reaped }
|
||||||
|
}
|
||||||
|
|
||||||
|
Request::WorkspaceStats { workspace } => match mgr.workspace_stats(workspace).await {
|
||||||
|
Some(s) => Response::WorkspaceStats {
|
||||||
|
info: WorkspaceStatsInfo {
|
||||||
|
commands_alive: s.commands_alive,
|
||||||
|
commands_total: s.commands_total,
|
||||||
|
rss_bytes: s.rss_bytes,
|
||||||
|
rss_peak_bytes: s.rss_peak_bytes,
|
||||||
|
cpu_usec: s.cpu_usec,
|
||||||
|
source: s.source,
|
||||||
|
uptime_ms: s.uptime_ms,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
None => Response::Error {
|
||||||
|
message: format!("workspace {workspace} not found"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Request::FlowList => {
|
||||||
|
let items = mgr
|
||||||
|
.list_flow_pipelines()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|(pipeline, sockets)| FlowInfo { pipeline, sockets })
|
||||||
|
.collect();
|
||||||
|
Response::FlowList { items }
|
||||||
|
}
|
||||||
|
|
||||||
|
Request::FlowDrop { pipeline } => {
|
||||||
|
let existed = mgr.drop_pipeline_flows(pipeline).await;
|
||||||
|
Response::FlowDropped { pipeline, existed }
|
||||||
|
}
|
||||||
|
|
||||||
Request::Capabilities => {
|
Request::Capabilities => {
|
||||||
let c = mgr.incarnator().capabilities();
|
let c = mgr.incarnator().capabilities();
|
||||||
Response::Capabilities {
|
Response::Capabilities {
|
||||||
@@ -317,6 +381,69 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_edge_to_info(e: shipote_core::pipeline::EdgeDiscernment) -> EdgeDiscernmentInfo {
|
||||||
|
EdgeDiscernmentInfo {
|
||||||
|
from_label: e.from_label,
|
||||||
|
from_output: e.from_output,
|
||||||
|
to_label: e.to_label,
|
||||||
|
to_input: e.to_input,
|
||||||
|
ty: e.discernment.as_ref().map(|d| format!("{:?}", d.ty)),
|
||||||
|
mime: e.discernment.as_ref().and_then(|d| d.mime.clone()),
|
||||||
|
lens: e.discernment.as_ref().and_then(|d| d.lens.clone()),
|
||||||
|
confidence: e.discernment.as_ref().map(|d| d.confidence).unwrap_or(0.0),
|
||||||
|
flow_socket: e.flow_socket,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Por cada edge con TypeRef detectado, spawneamos una Card efímera en el
|
||||||
|
/// SidecarPool que se anuncia al broker como producer del TypeRef
|
||||||
|
/// enriquecido. Esto permite a otros explorers (broker-explorer, etc.)
|
||||||
|
/// ver que shipote vio JSON/text/wasm/etc. saliendo de un pipeline.
|
||||||
|
fn announce_edges_to_broker(
|
||||||
|
pool: Option<&brahman_sidecar::SidecarPool>,
|
||||||
|
pipeline: &ulid::Ulid,
|
||||||
|
edges: &[shipote_core::pipeline::EdgeDiscernment],
|
||||||
|
) {
|
||||||
|
let Some(pool) = pool else { return };
|
||||||
|
for e in edges {
|
||||||
|
let Some(d) = &e.discernment else { continue };
|
||||||
|
let label = format!(
|
||||||
|
"shipote.flow.{}.{}.{}.{}",
|
||||||
|
short_ulid(pipeline),
|
||||||
|
e.from_label,
|
||||||
|
e.from_output,
|
||||||
|
type_label(&d.ty)
|
||||||
|
);
|
||||||
|
let mut card = Card::new(label);
|
||||||
|
card.kind = CardKind::Data;
|
||||||
|
card.lifecycle = Lifecycle::Oneshot;
|
||||||
|
card.payload = Payload::Virtual;
|
||||||
|
card.supervision = Supervision::OneShot;
|
||||||
|
card.flow = Flows {
|
||||||
|
input: Vec::new(),
|
||||||
|
output: vec![Flow {
|
||||||
|
name: e.from_output.clone(),
|
||||||
|
ty: d.ty.clone(),
|
||||||
|
pin_to: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
pool.spawn(card);
|
||||||
|
info!(pipeline = %pipeline, from = %e.from_label, ty = ?d.ty, "edge announced to broker");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn short_ulid(u: &ulid::Ulid) -> String {
|
||||||
|
let s = u.to_string();
|
||||||
|
s[s.len() - 6..].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_label(t: &TypeRef) -> String {
|
||||||
|
match t {
|
||||||
|
TypeRef::Primitive { name } => name.clone(),
|
||||||
|
TypeRef::Wit { package, name, .. } => format!("{package}.{name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Card del daemon. La presentamos al broker así otras sesiones pueden
|
/// Card del daemon. La presentamos al broker así otras sesiones pueden
|
||||||
/// descubrir que shipote está corriendo y, eventualmente, conectarse
|
/// descubrir que shipote está corriendo y, eventualmente, conectarse
|
||||||
/// como consumidoras del flow `workspaces` (futuro: que la GUI o el
|
/// como consumidoras del flow `workspaces` (futuro: que la GUI o el
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window};
|
use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window};
|
||||||
use shipote_protocol::{
|
use shipote_protocol::{
|
||||||
default_socket_path, read_frame, write_frame, CommandInfo, Request, Response, WorkspaceSummary,
|
default_socket_path, read_frame, write_frame, CommandInfo, FlowInfo, Request, Response,
|
||||||
|
WorkspaceStatsInfo, WorkspaceSummary,
|
||||||
};
|
};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -42,10 +43,16 @@ struct Shell {
|
|||||||
/// Comandos por workspace, indexados por workspace id.toString().
|
/// Comandos por workspace, indexados por workspace id.toString().
|
||||||
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
|
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
|
||||||
saved_pipelines: Vec<String>,
|
saved_pipelines: Vec<String>,
|
||||||
|
flows: Vec<FlowInfo>,
|
||||||
|
/// History de RSS por workspace (últimas N samples).
|
||||||
|
stats_history: std::collections::BTreeMap<String, std::collections::VecDeque<WorkspaceStatsInfo>>,
|
||||||
caps: Option<CapsSummary>,
|
caps: Option<CapsSummary>,
|
||||||
last_probe_ms: u64,
|
last_probe_ms: u64,
|
||||||
|
recent_log: Option<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATS_HISTORY_LEN: usize = 24;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
launch_app("Shipote — Shell", (820., 560.), Shell::new);
|
launch_app("Shipote — Shell", (820., 560.), Shell::new);
|
||||||
}
|
}
|
||||||
@@ -71,14 +78,36 @@ impl Shell {
|
|||||||
me.workspaces = snap.workspaces;
|
me.workspaces = snap.workspaces;
|
||||||
me.commands = snap.commands;
|
me.commands = snap.commands;
|
||||||
me.saved_pipelines = snap.saved_pipelines;
|
me.saved_pipelines = snap.saved_pipelines;
|
||||||
|
me.flows = snap.flows;
|
||||||
|
// Append a la history por workspace.
|
||||||
|
for (ws_id, fresh) in &snap.fresh_stats {
|
||||||
|
let h = me
|
||||||
|
.stats_history
|
||||||
|
.entry(ws_id.clone())
|
||||||
|
.or_default();
|
||||||
|
if h.len() >= STATS_HISTORY_LEN {
|
||||||
|
h.pop_front();
|
||||||
|
}
|
||||||
|
h.push_back(fresh.clone());
|
||||||
|
}
|
||||||
|
// Limpiar history de workspaces que ya no existen.
|
||||||
|
let alive: std::collections::HashSet<String> = me
|
||||||
|
.workspaces
|
||||||
|
.iter()
|
||||||
|
.map(|w| w.id.to_string())
|
||||||
|
.collect();
|
||||||
|
me.stats_history.retain(|k, _| alive.contains(k));
|
||||||
me.caps = Some(snap.caps);
|
me.caps = Some(snap.caps);
|
||||||
|
me.recent_log = snap.recent_log;
|
||||||
}
|
}
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
me.state = DaemonState::Down { reason };
|
me.state = DaemonState::Down { reason };
|
||||||
me.workspaces.clear();
|
me.workspaces.clear();
|
||||||
me.commands.clear();
|
me.commands.clear();
|
||||||
me.saved_pipelines.clear();
|
me.saved_pipelines.clear();
|
||||||
|
me.flows.clear();
|
||||||
me.caps = None;
|
me.caps = None;
|
||||||
|
me.recent_log = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
me.last_probe_ms = elapsed;
|
me.last_probe_ms = elapsed;
|
||||||
@@ -95,8 +124,11 @@ impl Shell {
|
|||||||
workspaces: Vec::new(),
|
workspaces: Vec::new(),
|
||||||
commands: std::collections::BTreeMap::new(),
|
commands: std::collections::BTreeMap::new(),
|
||||||
saved_pipelines: Vec::new(),
|
saved_pipelines: Vec::new(),
|
||||||
|
flows: Vec::new(),
|
||||||
|
stats_history: std::collections::BTreeMap::new(),
|
||||||
caps: None,
|
caps: None,
|
||||||
last_probe_ms: 0,
|
last_probe_ms: 0,
|
||||||
|
recent_log: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +138,12 @@ struct Snapshot {
|
|||||||
workspaces: Vec<WorkspaceSummary>,
|
workspaces: Vec<WorkspaceSummary>,
|
||||||
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
|
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
|
||||||
saved_pipelines: Vec<String>,
|
saved_pipelines: Vec<String>,
|
||||||
|
flows: Vec<FlowInfo>,
|
||||||
|
/// Stats fresco por workspace (id.toString → stats).
|
||||||
|
fresh_stats: std::collections::BTreeMap<String, WorkspaceStatsInfo>,
|
||||||
caps: CapsSummary,
|
caps: CapsSummary,
|
||||||
|
/// tail del log del comando más reciente (label + bytes). None si no hay.
|
||||||
|
recent_log: Option<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
||||||
@@ -132,6 +169,7 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
|||||||
|
|
||||||
// Commands por workspace.
|
// Commands por workspace.
|
||||||
let mut commands_map = std::collections::BTreeMap::new();
|
let mut commands_map = std::collections::BTreeMap::new();
|
||||||
|
let mut fresh_stats = std::collections::BTreeMap::new();
|
||||||
for w in &workspaces {
|
for w in &workspaces {
|
||||||
write_frame(&mut stream, &Request::CommandList { workspace: w.id })
|
write_frame(&mut stream, &Request::CommandList { workspace: w.id })
|
||||||
.await
|
.await
|
||||||
@@ -144,6 +182,16 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
|||||||
commands_map.insert(w.id.to_string(), items);
|
commands_map.insert(w.id.to_string(), items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Stats por workspace.
|
||||||
|
write_frame(&mut stream, &Request::WorkspaceStats { workspace: w.id })
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("write stats: {e}"))?;
|
||||||
|
let resp: Response = read_frame(&mut stream)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("read stats: {e}"))?;
|
||||||
|
if let Response::WorkspaceStats { info } = resp {
|
||||||
|
fresh_stats.insert(w.id.to_string(), info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saved pipelines.
|
// Saved pipelines.
|
||||||
@@ -158,6 +206,68 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
|||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Flow channels activos (data plane).
|
||||||
|
write_frame(&mut stream, &Request::FlowList)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("write flows: {e}"))?;
|
||||||
|
let resp: Response = read_frame(&mut stream)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("read flows: {e}"))?;
|
||||||
|
let flows = match resp {
|
||||||
|
Response::FlowList { items } => items,
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Live tail: log del comando más reciente con bytes>0.
|
||||||
|
let recent_log = {
|
||||||
|
// Pick: comando con id más alto que tiene log_bytes>0, en cualquier workspace.
|
||||||
|
let mut best: Option<(&str, &CommandInfo)> = None;
|
||||||
|
for (ws, cmds) in &commands_map {
|
||||||
|
for c in cmds {
|
||||||
|
if c.log_bytes == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let take = match &best {
|
||||||
|
Some((_, prev)) => c.id > prev.id,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if take {
|
||||||
|
best = Some((ws.as_str(), c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match best {
|
||||||
|
Some((ws_str, cmd)) => {
|
||||||
|
let ws_id: shipote_card::WorkspaceId = ws_str
|
||||||
|
.parse::<ulid::Ulid>()
|
||||||
|
.map(shipote_card::WorkspaceId)
|
||||||
|
.map_err(|e| format!("ulid parse: {e}"))?;
|
||||||
|
write_frame(
|
||||||
|
&mut stream,
|
||||||
|
&Request::CommandLogs {
|
||||||
|
workspace: ws_id,
|
||||||
|
command: cmd.id,
|
||||||
|
tail_bytes: 512,
|
||||||
|
stream: "both".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("write logs: {e}"))?;
|
||||||
|
let resp: Response = read_frame(&mut stream)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("read logs: {e}"))?;
|
||||||
|
match resp {
|
||||||
|
Response::CommandLogs { bytes } => {
|
||||||
|
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||||
|
Some((cmd.label.clone(), text))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
write_frame(&mut stream, &Request::Capabilities)
|
write_frame(&mut stream, &Request::Capabilities)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("write caps: {e}"))?;
|
.map_err(|e| format!("write caps: {e}"))?;
|
||||||
@@ -182,11 +292,36 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
|||||||
workspaces,
|
workspaces,
|
||||||
commands: commands_map,
|
commands: commands_map,
|
||||||
saved_pipelines,
|
saved_pipelines,
|
||||||
|
flows,
|
||||||
|
fresh_stats,
|
||||||
caps,
|
caps,
|
||||||
|
recent_log,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render ASCII de sparkline para una serie de valores. `chars` define los
|
||||||
|
/// glifos (orden ascendente). Auto-scales al máximo de la serie.
|
||||||
|
fn sparkline(values: &[u64], width: usize) -> String {
|
||||||
|
if values.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
const CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||||
|
let take = values.len().min(width);
|
||||||
|
let series = &values[values.len() - take..];
|
||||||
|
let max = *series.iter().max().unwrap_or(&1);
|
||||||
|
if max == 0 {
|
||||||
|
return "▁".repeat(take);
|
||||||
|
}
|
||||||
|
series
|
||||||
|
.iter()
|
||||||
|
.map(|v| {
|
||||||
|
let idx = ((*v as f64 / max as f64) * (CHARS.len() as f64 - 1.0)).round() as usize;
|
||||||
|
CHARS[idx.min(CHARS.len() - 1)]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
impl Render for Shell {
|
impl Render for Shell {
|
||||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let theme = Theme::global(cx).clone();
|
let theme = Theme::global(cx).clone();
|
||||||
@@ -247,7 +382,28 @@ impl Render for Shell {
|
|||||||
let ws_items: Vec<String> = self
|
let ws_items: Vec<String> = self
|
||||||
.workspaces
|
.workspaces
|
||||||
.iter()
|
.iter()
|
||||||
.map(|w| format!("{} {:<20} cmds={} uptime={}ms", w.id, w.label, w.commands, w.uptime_ms))
|
.map(|w| {
|
||||||
|
let key = w.id.to_string();
|
||||||
|
let history = self.stats_history.get(&key);
|
||||||
|
let rss_series: Vec<u64> = history
|
||||||
|
.map(|h| h.iter().map(|s| s.rss_bytes.unwrap_or(0)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let spark = sparkline(&rss_series, STATS_HISTORY_LEN);
|
||||||
|
let (rss_now, peak) = history
|
||||||
|
.and_then(|h| h.back())
|
||||||
|
.map(|s| (s.rss_bytes.unwrap_or(0), s.rss_peak_bytes.unwrap_or(0)))
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
let rss_mb = rss_now as f64 / 1024.0 / 1024.0;
|
||||||
|
let peak_mb = peak as f64 / 1024.0 / 1024.0;
|
||||||
|
format!(
|
||||||
|
"{:<14} {:<14} {} {:>6.1}M peak {:>6.1}M",
|
||||||
|
&w.id.to_string()[20..],
|
||||||
|
w.label,
|
||||||
|
spark,
|
||||||
|
rss_mb,
|
||||||
|
peak_mb,
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let ws_count = self.workspaces.len().to_string();
|
let ws_count = self.workspaces.len().to_string();
|
||||||
let ws_descr = if self.workspaces.is_empty() {
|
let ws_descr = if self.workspaces.is_empty() {
|
||||||
@@ -294,6 +450,33 @@ impl Render for Shell {
|
|||||||
"definiciones reusables vía run-saved".to_string()
|
"definiciones reusables vía run-saved".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Flow channels (data plane).
|
||||||
|
let flow_count: usize = self.flows.iter().map(|f| f.sockets.len()).sum();
|
||||||
|
let flow_items: Vec<String> = self
|
||||||
|
.flows
|
||||||
|
.iter()
|
||||||
|
.flat_map(|f| {
|
||||||
|
let pipe = f.pipeline.to_string();
|
||||||
|
let short = &pipe[pipe.len() - 6..];
|
||||||
|
f.sockets
|
||||||
|
.iter()
|
||||||
|
.map(move |s| {
|
||||||
|
format!(
|
||||||
|
"{short} {}",
|
||||||
|
s.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| s.display().to_string())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let flow_descr = if flow_count == 0 {
|
||||||
|
"pipelines con --tap exponen sockets aquí".to_string()
|
||||||
|
} else {
|
||||||
|
"shipote flow tail <socket> para suscribirse".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let body = div()
|
let body = div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
@@ -349,8 +532,44 @@ impl Render for Shell {
|
|||||||
text,
|
text,
|
||||||
text_dim,
|
text_dim,
|
||||||
&saved_items,
|
&saved_items,
|
||||||
|
))
|
||||||
|
.child(stat_card(
|
||||||
|
cx,
|
||||||
|
"Flow channels",
|
||||||
|
flow_count.to_string(),
|
||||||
|
&flow_descr,
|
||||||
|
accent_up,
|
||||||
|
text,
|
||||||
|
text_dim,
|
||||||
|
&flow_items,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Live tail del comando más reciente con output.
|
||||||
|
let live_card = self.recent_log.as_ref().map(|(label, content)| {
|
||||||
|
// Cortamos a las últimas ~12 líneas para no inflar el panel.
|
||||||
|
let lines: Vec<String> = content
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.take(12)
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect();
|
||||||
|
stat_card(
|
||||||
|
cx,
|
||||||
|
"Live tail",
|
||||||
|
label.clone(),
|
||||||
|
"últimas líneas del comando más reciente",
|
||||||
|
accent_up,
|
||||||
|
text,
|
||||||
|
text_dim,
|
||||||
|
&lines,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let body = body.when_some(live_card, |d, c| d.child(c));
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ pub struct FlowEdge {
|
|||||||
pub to_input: String,
|
pub to_input: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DiscernPolicy {
|
pub struct DiscernPolicy {
|
||||||
/// Bytes a samplear por flow para el discernidor. Default 4 KiB.
|
/// Bytes a samplear por flow para el discernidor. Default 4 KiB.
|
||||||
#[serde(default = "default_sample_bytes")]
|
#[serde(default = "default_sample_bytes")]
|
||||||
@@ -211,6 +211,21 @@ pub struct DiscernPolicy {
|
|||||||
/// Si `true`, enriquece la Card del producer con el TypeRef detectado.
|
/// Si `true`, enriquece la Card del producer con el TypeRef detectado.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub enrich_producer: bool,
|
pub enrich_producer: bool,
|
||||||
|
/// Chunks que el FlowChannel guarda en replay buffer para subscribers
|
||||||
|
/// tarde. Default 32. Subir si los productores escriben en ráfagas y
|
||||||
|
/// querés que los consumidores tardíos vean toda la salida.
|
||||||
|
#[serde(default = "default_replay_chunks")]
|
||||||
|
pub replay_chunks: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DiscernPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sample_bytes: default_sample_bytes(),
|
||||||
|
enrich_producer: default_true(),
|
||||||
|
replay_chunks: default_replay_chunks(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sample_bytes() -> usize {
|
fn default_sample_bytes() -> usize {
|
||||||
@@ -219,6 +234,9 @@ fn default_sample_bytes() -> usize {
|
|||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
fn default_replay_chunks() -> usize {
|
||||||
|
32
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Compilación a Card
|
// Compilación a Card
|
||||||
@@ -358,6 +376,116 @@ pub fn load_pipeline_spec(path: &std::path::Path) -> Result<PipelineSpec, LoadEr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sustituye `${KEY}` en todos los strings del spec por el valor de
|
||||||
|
/// `vars["KEY"]`. Variables sin match quedan intactas (no se borra el
|
||||||
|
/// placeholder — útil para detectar olvidos).
|
||||||
|
///
|
||||||
|
/// Walk recursivo sobre la representación JSON intermedia para cubrir
|
||||||
|
/// labels, argv, envp, paths y cualquier String del schema.
|
||||||
|
pub fn substitute_vars(
|
||||||
|
spec: &PipelineSpec,
|
||||||
|
vars: &std::collections::HashMap<String, String>,
|
||||||
|
) -> Result<PipelineSpec, serde_json::Error> {
|
||||||
|
if vars.is_empty() {
|
||||||
|
return Ok(spec.clone());
|
||||||
|
}
|
||||||
|
let mut v = serde_json::to_value(spec)?;
|
||||||
|
walk_subst(&mut v, vars);
|
||||||
|
serde_json::from_value(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_subst(v: &mut serde_json::Value, vars: &std::collections::HashMap<String, String>) {
|
||||||
|
match v {
|
||||||
|
serde_json::Value::String(s) => {
|
||||||
|
*s = subst_str(s, vars);
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(arr) => {
|
||||||
|
for item in arr {
|
||||||
|
walk_subst(item, vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(obj) => {
|
||||||
|
for (_, val) in obj.iter_mut() {
|
||||||
|
walk_subst(val, vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subst_str(s: &str, vars: &std::collections::HashMap<String, String>) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
|
||||||
|
// Buscar el cierre `}`.
|
||||||
|
if let Some(close) = bytes[i + 2..].iter().position(|&b| b == b'}') {
|
||||||
|
let key = std::str::from_utf8(&bytes[i + 2..i + 2 + close]).unwrap_or("");
|
||||||
|
if let Some(val) = vars.get(key) {
|
||||||
|
out.push_str(val);
|
||||||
|
i += 2 + close + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[i] as char);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod subst_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn substitute_in_argv_and_label() {
|
||||||
|
let mut vars = HashMap::new();
|
||||||
|
vars.insert("MSG".into(), "hola-mundo".into());
|
||||||
|
vars.insert("LABEL".into(), "renamed".into());
|
||||||
|
let spec = PipelineSpec {
|
||||||
|
label: "p-${LABEL}".into(),
|
||||||
|
workspace: WorkspaceId::new(),
|
||||||
|
nodes: vec![CommandRef {
|
||||||
|
label: "node-${LABEL}".into(),
|
||||||
|
payload: Payload::Native {
|
||||||
|
exec: "/bin/echo".into(),
|
||||||
|
argv: vec!["${MSG}".into()],
|
||||||
|
envp: vec![],
|
||||||
|
},
|
||||||
|
soma: Default::default(),
|
||||||
|
flows: Default::default(),
|
||||||
|
supervision: Supervision::OneShot,
|
||||||
|
}],
|
||||||
|
edges: vec![],
|
||||||
|
discern: DiscernPolicy::default(),
|
||||||
|
};
|
||||||
|
let out = substitute_vars(&spec, &vars).unwrap();
|
||||||
|
assert_eq!(out.label, "p-renamed");
|
||||||
|
assert_eq!(out.nodes[0].label, "node-renamed");
|
||||||
|
match &out.nodes[0].payload {
|
||||||
|
Payload::Native { argv, .. } => assert_eq!(argv[0], "hola-mundo"),
|
||||||
|
_ => panic!("wrong payload"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_var_left_intact() {
|
||||||
|
let vars = HashMap::new();
|
||||||
|
let spec = PipelineSpec {
|
||||||
|
label: "p-${UNDEFINED}".into(),
|
||||||
|
workspace: WorkspaceId::new(),
|
||||||
|
nodes: vec![],
|
||||||
|
edges: vec![],
|
||||||
|
discern: DiscernPolicy::default(),
|
||||||
|
};
|
||||||
|
let out = substitute_vars(&spec, &vars).unwrap();
|
||||||
|
assert_eq!(out.label, "p-${UNDEFINED}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
//! Flow channels: data plane sobre Unix socket por edge enriquecido.
|
||||||
|
//!
|
||||||
|
//! Cuando un splitter detecta el TypeRef de un edge, además de replicar a
|
||||||
|
//! los consumers internos del pipeline, se levanta un FlowChannel que
|
||||||
|
//! expone los bytes a subscribers externos (otros módulos del fractal).
|
||||||
|
//!
|
||||||
|
//! ## Diseño
|
||||||
|
//!
|
||||||
|
//! - `tokio::sync::broadcast::channel` para fan-out lock-less entre el
|
||||||
|
//! splitter (sender) y los N subscribers conectados.
|
||||||
|
//! - `UnixListener` accept-loop: por cada cliente nuevo, spawn una task
|
||||||
|
//! que drena el receiver y escribe al socket.
|
||||||
|
//! - Subscribers lentos pueden perder mensajes (broadcast::Receiver::Lagged)
|
||||||
|
//! — se loguea warn y se sigue. Esto es deliberado para no bloquear el
|
||||||
|
//! splitter en consumers lentos.
|
||||||
|
//!
|
||||||
|
//! ## Lifetime
|
||||||
|
//!
|
||||||
|
//! `FlowChannel` se construye con `new(path)`. Cuando se drop:
|
||||||
|
//! - El `accept_task` se cancela (vía drop del `tokio::task::JoinHandle`
|
||||||
|
//! que tenemos abort-on-drop).
|
||||||
|
//! - El socket file se borra del FS (`Drop` impl).
|
||||||
|
//!
|
||||||
|
//! Sender clones son baratos; los subscribers conectados se enteran del
|
||||||
|
//! cierre cuando todos los senders se dropean.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::net::UnixListener;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tokio::task::AbortHandle;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
/// Capacidad del broadcast channel. Si un subscriber está más de N chunks
|
||||||
|
/// atrasado, queda `Lagged` y empieza a perder mensajes.
|
||||||
|
const BROADCAST_CAP: usize = 64;
|
||||||
|
|
||||||
|
/// Chunks default del replay buffer. Cuando un cliente nuevo se conecta,
|
||||||
|
/// recibe hasta estos N chunks antes de iniciar el broadcast live.
|
||||||
|
/// Override via `FlowChannel::with_replay_cap`.
|
||||||
|
pub const DEFAULT_REPLAY_CHUNKS: usize = 32;
|
||||||
|
|
||||||
|
pub struct FlowChannel {
|
||||||
|
sender: broadcast::Sender<Arc<Vec<u8>>>,
|
||||||
|
replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
|
||||||
|
replay_cap: usize,
|
||||||
|
socket_path: PathBuf,
|
||||||
|
_accept_handle: AbortOnDrop,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FlowSender {
|
||||||
|
sender: broadcast::Sender<Arc<Vec<u8>>>,
|
||||||
|
replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
|
||||||
|
replay_cap: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlowSender {
|
||||||
|
/// Pushea al broadcast y al replay buffer. Si no hay subscribers,
|
||||||
|
/// el broadcast::send retorna Err pero igual guardamos en replay
|
||||||
|
/// (subscribers tarde verán los chunks pasados).
|
||||||
|
pub fn send(&self, data: Arc<Vec<u8>>) {
|
||||||
|
let cap = self.replay_cap;
|
||||||
|
if let Ok(mut g) = self.replay.lock() {
|
||||||
|
if g.len() >= cap {
|
||||||
|
g.pop_front();
|
||||||
|
}
|
||||||
|
g.push_back(data.clone());
|
||||||
|
}
|
||||||
|
let _ = self.sender.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for FlowChannel {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("FlowChannel")
|
||||||
|
.field("socket_path", &self.socket_path)
|
||||||
|
.field("subscribers", &self.sender.receiver_count())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlowChannel {
|
||||||
|
/// Crea un FlowChannel atado al path `socket_path`. Si el path ya
|
||||||
|
/// existe, lo borra antes de bind (asume restart limpio).
|
||||||
|
pub fn new(socket_path: PathBuf) -> std::io::Result<Self> {
|
||||||
|
Self::with_replay_cap(socket_path, DEFAULT_REPLAY_CHUNKS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_replay_cap(socket_path: PathBuf, replay_cap: usize) -> std::io::Result<Self> {
|
||||||
|
let cap = replay_cap.max(1);
|
||||||
|
if socket_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&socket_path);
|
||||||
|
}
|
||||||
|
if let Some(parent) = socket_path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let listener = UnixListener::bind(&socket_path)?;
|
||||||
|
let (tx, _rx_unused) = broadcast::channel::<Arc<Vec<u8>>>(BROADCAST_CAP);
|
||||||
|
let replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>> =
|
||||||
|
Arc::new(Mutex::new(VecDeque::with_capacity(cap)));
|
||||||
|
let tx_for_accept = tx.clone();
|
||||||
|
let replay_for_accept = replay.clone();
|
||||||
|
let path_for_log = socket_path.clone();
|
||||||
|
|
||||||
|
let join = tokio::spawn(async move {
|
||||||
|
debug!(path = %path_for_log.display(), "flow channel listening");
|
||||||
|
loop {
|
||||||
|
let (mut stream, _addr) = match listener.accept().await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(?e, "flow channel accept failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Snapshot del replay buffer Y subscribe al broadcast.
|
||||||
|
// El orden es crítico: subscribe ANTES de drenar el replay
|
||||||
|
// para no perder chunks que llegan justo en el medio.
|
||||||
|
let mut rx = tx_for_accept.subscribe();
|
||||||
|
let snapshot: Vec<Arc<Vec<u8>>> = {
|
||||||
|
let g = replay_for_accept.lock().expect("replay lock");
|
||||||
|
g.iter().cloned().collect()
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Fase 1: drenar replay snapshot al subscriber.
|
||||||
|
for chunk in &snapshot {
|
||||||
|
if let Err(e) = stream.write_all(chunk).await {
|
||||||
|
debug!(?e, "flow subscriber dropped during replay");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fase 2: live broadcast.
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(chunk) => {
|
||||||
|
if let Err(e) = stream.write_all(&chunk).await {
|
||||||
|
debug!(?e, "flow subscriber dropped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => return,
|
||||||
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
warn!(skipped = n, "flow subscriber lagged");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
sender: tx,
|
||||||
|
replay,
|
||||||
|
replay_cap: cap,
|
||||||
|
socket_path,
|
||||||
|
_accept_handle: AbortOnDrop(join.abort_handle()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push un chunk al channel. Si no hay subscribers, drop silencioso.
|
||||||
|
/// Siempre se guarda en el replay buffer (con cap rotation).
|
||||||
|
pub fn send(&self, data: Vec<u8>) {
|
||||||
|
let arc = Arc::new(data);
|
||||||
|
let cap = self.replay_cap;
|
||||||
|
if let Ok(mut g) = self.replay.lock() {
|
||||||
|
if g.len() >= cap {
|
||||||
|
g.pop_front();
|
||||||
|
}
|
||||||
|
g.push_back(arc.clone());
|
||||||
|
}
|
||||||
|
let _ = self.sender.send(arc);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn socket_path(&self) -> &Path {
|
||||||
|
&self.socket_path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle clone-able para que tasks externas (splitter) pushen al
|
||||||
|
/// channel sin tener ownership del FlowChannel. Cada push se guarda
|
||||||
|
/// también en el replay buffer.
|
||||||
|
pub fn sender_handle(&self) -> FlowSender {
|
||||||
|
FlowSender {
|
||||||
|
sender: self.sender.clone(),
|
||||||
|
replay: self.replay.clone(),
|
||||||
|
replay_cap: self.replay_cap,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscriber_count(&self) -> usize {
|
||||||
|
self.sender.receiver_count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for FlowChannel {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// El AbortOnDrop cancela el accept loop; sólo nos queda limpiar el
|
||||||
|
// socket file.
|
||||||
|
let _ = std::fs::remove_file(&self.socket_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AbortOnDrop(AbortHandle);
|
||||||
|
impl Drop for AbortOnDrop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path canónico para un flow channel: `$XDG_RUNTIME_DIR/shipote-flow-<id>.sock`.
|
||||||
|
pub fn default_flow_socket_path(id: &str) -> PathBuf {
|
||||||
|
let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
|
||||||
|
let uid = nix::unistd::getuid().as_raw();
|
||||||
|
let p = format!("/run/user/{uid}");
|
||||||
|
if std::path::Path::new(&p).exists() {
|
||||||
|
p
|
||||||
|
} else {
|
||||||
|
"/tmp".into()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
PathBuf::from(base).join(format!("shipote-flow-{id}.sock"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn channel_delivers_to_subscriber() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("flow.sock");
|
||||||
|
let ch = FlowChannel::new(path.clone()).unwrap();
|
||||||
|
|
||||||
|
// Subscriber se conecta.
|
||||||
|
let path_clone = path.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let mut stream = UnixStream::connect(&path_clone).await.unwrap();
|
||||||
|
let mut buf = vec![0u8; 64];
|
||||||
|
let n = stream.read(&mut buf).await.unwrap();
|
||||||
|
buf.truncate(n);
|
||||||
|
buf
|
||||||
|
});
|
||||||
|
|
||||||
|
// Damos tiempo al accept.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
// Hasta que haya 1 receiver_count, el send no llega.
|
||||||
|
for _ in 0..50 {
|
||||||
|
if ch.subscriber_count() >= 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
ch.send(b"hello-flow".to_vec());
|
||||||
|
|
||||||
|
let received = tokio::time::timeout(std::time::Duration::from_secs(2), task)
|
||||||
|
.await
|
||||||
|
.expect("timeout")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(received, b"hello-flow");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replay_buffer_serves_late_subscriber() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("flow.sock");
|
||||||
|
let ch = FlowChannel::new(path.clone()).unwrap();
|
||||||
|
|
||||||
|
// Pushes ANTES de cualquier subscriber: van solo al replay.
|
||||||
|
ch.send(b"chunk-1".to_vec());
|
||||||
|
ch.send(b"chunk-2".to_vec());
|
||||||
|
ch.send(b"chunk-3".to_vec());
|
||||||
|
|
||||||
|
// Subscriber LATE — debe recibir los 3 chunks del replay.
|
||||||
|
let path_clone = path.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let mut stream = UnixStream::connect(&path_clone).await.unwrap();
|
||||||
|
let mut buf = vec![0u8; 256];
|
||||||
|
// Leemos hasta recibir los 3 chunks (21 bytes esperados).
|
||||||
|
let mut total = Vec::new();
|
||||||
|
for _ in 0..20 {
|
||||||
|
let n = stream.read(&mut buf).await.unwrap();
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
total.extend_from_slice(&buf[..n]);
|
||||||
|
if total.len() >= 21 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total
|
||||||
|
});
|
||||||
|
|
||||||
|
let received = tokio::time::timeout(std::time::Duration::from_secs(2), task)
|
||||||
|
.await
|
||||||
|
.expect("timeout")
|
||||||
|
.unwrap();
|
||||||
|
let s = String::from_utf8_lossy(&received);
|
||||||
|
assert!(s.contains("chunk-1"), "got: {s:?}");
|
||||||
|
assert!(s.contains("chunk-2"), "got: {s:?}");
|
||||||
|
assert!(s.contains("chunk-3"), "got: {s:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn drop_removes_socket() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("flow.sock");
|
||||||
|
{
|
||||||
|
let _ch = FlowChannel::new(path.clone()).unwrap();
|
||||||
|
assert!(path.exists());
|
||||||
|
}
|
||||||
|
// Después del drop, el socket file no debe quedar.
|
||||||
|
// Damos un pelín de tiempo al runtime para que el drop corra
|
||||||
|
// mientras estamos en task.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
assert!(!path.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,9 +11,11 @@
|
|||||||
// el módulo concreto.
|
// el módulo concreto.
|
||||||
#![deny(unsafe_op_in_unsafe_fn)]
|
#![deny(unsafe_op_in_unsafe_fn)]
|
||||||
|
|
||||||
|
pub mod flow_channel;
|
||||||
pub mod logbuf;
|
pub mod logbuf;
|
||||||
pub mod persist;
|
pub mod persist;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
pub mod stats;
|
||||||
|
|
||||||
use brahman_card::{Card, Payload, Supervision};
|
use brahman_card::{Card, Payload, Supervision};
|
||||||
use ente_incarnate::{Incarnator, IncarnatorConfig};
|
use ente_incarnate::{Incarnator, IncarnatorConfig};
|
||||||
@@ -55,10 +57,22 @@ pub struct CommandState {
|
|||||||
pub pid: Pid,
|
pub pid: Pid,
|
||||||
pub alive: bool,
|
pub alive: bool,
|
||||||
pub exit_status: Option<i32>,
|
pub exit_status: Option<i32>,
|
||||||
/// Ring buffer compartido con la tokio task que drena stdout+stderr
|
/// Ring buffer del stdout. `None` para comandos sin captura.
|
||||||
/// del comando. `None` para comandos que no capturan output (futuro:
|
pub stdout: Option<logbuf::LogBuf>,
|
||||||
/// comandos con stdout=inherit).
|
/// Ring buffer del stderr. Separado de `stdout` para que el CLI
|
||||||
pub logs: Option<logbuf::LogBuf>,
|
/// pueda filtrarlos. `None` para comandos sin captura.
|
||||||
|
pub stderr: Option<logbuf::LogBuf>,
|
||||||
|
/// Si el comando fue lanzado como parte de un Pipeline, su ULID.
|
||||||
|
pub pipeline_id: Option<Ulid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream a leer en `get_command_logs`. `Both` concatena stderr-después-stdout
|
||||||
|
/// para una vista combinada (orden temporal aproximado).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LogStream {
|
||||||
|
Stdout,
|
||||||
|
Stderr,
|
||||||
|
Both,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct WorkspaceManager {
|
pub struct WorkspaceManager {
|
||||||
@@ -72,6 +86,11 @@ struct Inner {
|
|||||||
/// que "pipelines vivos" — son specs guardados para reusar con
|
/// que "pipelines vivos" — son specs guardados para reusar con
|
||||||
/// `run-saved`. Sobreviven restart vía snapshot.
|
/// `run-saved`. Sobreviven restart vía snapshot.
|
||||||
saved_pipelines: HashMap<String, PipelineSpec>,
|
saved_pipelines: HashMap<String, PipelineSpec>,
|
||||||
|
/// Flow channels vivos por pipeline. Se retienen hasta que el
|
||||||
|
/// pipeline termine — cuando todos los hijos del pipeline murieron,
|
||||||
|
/// el reaper los borra (futuro). v1: viven hasta `stop_pipeline_flows`
|
||||||
|
/// explícito o hasta shutdown.
|
||||||
|
pipeline_flows: HashMap<Ulid, Vec<crate::flow_channel::FlowChannel>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -156,11 +175,134 @@ impl WorkspaceManager {
|
|||||||
inner: Arc::new(Mutex::new(Inner {
|
inner: Arc::new(Mutex::new(Inner {
|
||||||
workspaces: HashMap::new(),
|
workspaces: HashMap::new(),
|
||||||
saved_pipelines: HashMap::new(),
|
saved_pipelines: HashMap::new(),
|
||||||
|
pipeline_flows: HashMap::new(),
|
||||||
})),
|
})),
|
||||||
incarnator: Arc::new(Incarnator::new(cfg)),
|
incarnator: Arc::new(Incarnator::new(cfg)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registra los comandos lanzados por un pipeline en el workspace.
|
||||||
|
/// Esto permite `pipeline_stop` (matar selectivamente sólo los pids
|
||||||
|
/// de un pipeline). `pipeline_id` se setea en cada CommandState.
|
||||||
|
pub async fn register_pipeline_commands(
|
||||||
|
&self,
|
||||||
|
workspace: WorkspaceId,
|
||||||
|
pipeline_id: Ulid,
|
||||||
|
commands: Vec<(String, i32)>,
|
||||||
|
) {
|
||||||
|
let mut g = self.inner.lock().await;
|
||||||
|
let Some(ws) = g.workspaces.get_mut(&workspace) else { return };
|
||||||
|
for (label, pid) in commands {
|
||||||
|
let cmd_id = Ulid::new();
|
||||||
|
ws.commands.insert(
|
||||||
|
cmd_id,
|
||||||
|
CommandState {
|
||||||
|
id: cmd_id,
|
||||||
|
label,
|
||||||
|
pid: Pid::from_raw(pid),
|
||||||
|
alive: true,
|
||||||
|
exit_status: None,
|
||||||
|
stdout: None,
|
||||||
|
stderr: None,
|
||||||
|
pipeline_id: Some(pipeline_id),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detiene selectivamente los comandos de un pipeline. SIGTERM →
|
||||||
|
/// `grace` → SIGKILL. Devuelve cantidad reapeada. Si no hay comandos
|
||||||
|
/// del pipeline en ningún workspace, retorna 0.
|
||||||
|
pub async fn stop_pipeline(
|
||||||
|
&self,
|
||||||
|
pipeline_id: Ulid,
|
||||||
|
grace: std::time::Duration,
|
||||||
|
) -> u32 {
|
||||||
|
// 1) Recolectamos pids de ese pipeline en todos los workspaces.
|
||||||
|
let mut targets: Vec<Pid> = Vec::new();
|
||||||
|
{
|
||||||
|
let g = self.inner.lock().await;
|
||||||
|
for ws in g.workspaces.values() {
|
||||||
|
for cmd in ws.commands.values() {
|
||||||
|
if cmd.alive && cmd.pipeline_id == Some(pipeline_id) {
|
||||||
|
targets.push(cmd.pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targets.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let initial = if grace.is_zero() { Signal::SIGKILL } else { Signal::SIGTERM };
|
||||||
|
for pid in &targets {
|
||||||
|
let _ = kill(*pid, initial);
|
||||||
|
}
|
||||||
|
let mut reaped = 0u32;
|
||||||
|
let mut still = targets.clone();
|
||||||
|
let deadline = std::time::Instant::now() + grace;
|
||||||
|
let poll = std::time::Duration::from_millis(20);
|
||||||
|
while !still.is_empty() && std::time::Instant::now() < deadline {
|
||||||
|
still.retain(|pid| match waitpid(*pid, Some(WaitPidFlag::WNOHANG)) {
|
||||||
|
Ok(WaitStatus::StillAlive) => true,
|
||||||
|
Ok(_) => {
|
||||||
|
reaped += 1;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
});
|
||||||
|
if !still.is_empty() {
|
||||||
|
tokio::time::sleep(poll).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pid in &still {
|
||||||
|
let _ = kill(*pid, Signal::SIGKILL);
|
||||||
|
let _ = waitpid(*pid, None);
|
||||||
|
reaped += 1;
|
||||||
|
}
|
||||||
|
// Marcar como dead en estado in-memory.
|
||||||
|
let mut g = self.inner.lock().await;
|
||||||
|
for ws in g.workspaces.values_mut() {
|
||||||
|
for cmd in ws.commands.values_mut() {
|
||||||
|
if cmd.pipeline_id == Some(pipeline_id) && cmd.alive {
|
||||||
|
cmd.alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop flows del pipeline.
|
||||||
|
g.pipeline_flows.remove(&pipeline_id);
|
||||||
|
info!(%pipeline_id, reaped, "pipeline stopped");
|
||||||
|
reaped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retiene los FlowChannels de un pipeline para que sobrevivan al
|
||||||
|
/// fin del request. Drop = cierre del data plane.
|
||||||
|
pub async fn retain_pipeline_flows(
|
||||||
|
&self,
|
||||||
|
pipeline: Ulid,
|
||||||
|
flows: Vec<crate::flow_channel::FlowChannel>,
|
||||||
|
) {
|
||||||
|
self.inner.lock().await.pipeline_flows.insert(pipeline, flows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lista pipelines vivos con sus sockets activos.
|
||||||
|
pub async fn list_flow_pipelines(&self) -> Vec<(Ulid, Vec<std::path::PathBuf>)> {
|
||||||
|
let g = self.inner.lock().await;
|
||||||
|
g.pipeline_flows
|
||||||
|
.iter()
|
||||||
|
.map(|(id, flows)| {
|
||||||
|
(
|
||||||
|
*id,
|
||||||
|
flows.iter().map(|f| f.socket_path().to_path_buf()).collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cierra el data plane de un pipeline (drop = remove_file de sockets).
|
||||||
|
pub async fn drop_pipeline_flows(&self, pipeline: Ulid) -> bool {
|
||||||
|
self.inner.lock().await.pipeline_flows.remove(&pipeline).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn incarnator(&self) -> &Incarnator {
|
pub fn incarnator(&self) -> &Incarnator {
|
||||||
&self.incarnator
|
&self.incarnator
|
||||||
}
|
}
|
||||||
@@ -208,6 +350,35 @@ impl WorkspaceManager {
|
|||||||
.map(|w| w.spec.label.clone())
|
.map(|w| w.spec.label.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Estadísticas de recursos del workspace: RSS + CPU agregado de sus
|
||||||
|
/// comandos vivos. Lee `/proc/<pid>/` directamente; si el spec declara
|
||||||
|
/// `soma.cgroup.path`, también intenta el cgroup (más preciso, incluye
|
||||||
|
/// descendants).
|
||||||
|
pub async fn workspace_stats(&self, id: WorkspaceId) -> Option<stats::WorkspaceStats> {
|
||||||
|
let g = self.inner.lock().await;
|
||||||
|
let ws = g.workspaces.get(&id)?;
|
||||||
|
let alive: Vec<i32> = ws
|
||||||
|
.commands
|
||||||
|
.values()
|
||||||
|
.filter(|c| c.alive)
|
||||||
|
.map(|c| c.pid.as_raw())
|
||||||
|
.collect();
|
||||||
|
let total = ws.commands.len() as u32;
|
||||||
|
let cgroup_path = if ws.spec.soma.cgroup.path.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
// resolve_cgroup_path está en ente_incarnate, pero acá basta
|
||||||
|
// con el path absoluto bajo /sys/fs/cgroup. Resolución gruesa.
|
||||||
|
Some(std::path::PathBuf::from(format!(
|
||||||
|
"/sys/fs/cgroup{}",
|
||||||
|
ws.spec.soma.cgroup.path
|
||||||
|
)))
|
||||||
|
};
|
||||||
|
let mut s = stats::measure(&alive, cgroup_path.as_deref(), ws.started);
|
||||||
|
s.commands_total = total;
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create(
|
pub async fn create(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
spec: WorkspaceSpec,
|
spec: WorkspaceSpec,
|
||||||
@@ -269,31 +440,66 @@ impl WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop(&self, id: WorkspaceId) -> Result<u32, CoreError> {
|
pub async fn stop(&self, id: WorkspaceId) -> Result<u32, CoreError> {
|
||||||
|
self.stop_with_grace(id, std::time::Duration::from_millis(1000)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variante con tiempo de gracia configurable. SIGTERM → espera `grace`
|
||||||
|
/// → SIGKILL si quedan vivos. `grace=0` = SIGKILL inmediato.
|
||||||
|
pub async fn stop_with_grace(
|
||||||
|
&self,
|
||||||
|
id: WorkspaceId,
|
||||||
|
grace: std::time::Duration,
|
||||||
|
) -> Result<u32, CoreError> {
|
||||||
let mut g = self.inner.lock().await;
|
let mut g = self.inner.lock().await;
|
||||||
let ws = g.workspaces.remove(&id).ok_or(CoreError::WorkspaceNotFound(id))?;
|
let ws = g.workspaces.remove(&id).ok_or(CoreError::WorkspaceNotFound(id))?;
|
||||||
let mut reaped = 0u32;
|
// También limpiamos flow_channels del workspace si los hubiera —
|
||||||
for (_cid, cmd) in ws.commands {
|
// por workspace lo retenemos por pipeline, no por workspace.
|
||||||
if cmd.alive {
|
drop(g);
|
||||||
let _ = kill(cmd.pid, Signal::SIGTERM);
|
|
||||||
// Cosecha sin bloquear infinito: WNOHANG en loop con un par de intentos.
|
// 1) SIGTERM (o SIGKILL si grace=0) a todos vivos.
|
||||||
for _ in 0..50 {
|
let initial_signal = if grace.is_zero() { Signal::SIGKILL } else { Signal::SIGTERM };
|
||||||
match waitpid(cmd.pid, Some(WaitPidFlag::WNOHANG)) {
|
let alive_pids: Vec<Pid> = ws
|
||||||
Ok(WaitStatus::StillAlive) => {
|
.commands
|
||||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
.values()
|
||||||
|
.filter(|c| c.alive)
|
||||||
|
.map(|c| c.pid)
|
||||||
|
.collect();
|
||||||
|
for pid in &alive_pids {
|
||||||
|
let _ = kill(*pid, initial_signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2) Esperar hasta `grace` haciendo polling WNOHANG.
|
||||||
|
let mut reaped = 0u32;
|
||||||
|
let mut still_alive: Vec<Pid> = alive_pids.clone();
|
||||||
|
let deadline = std::time::Instant::now() + grace;
|
||||||
|
let poll_interval = std::time::Duration::from_millis(20);
|
||||||
|
while !still_alive.is_empty() && std::time::Instant::now() < deadline {
|
||||||
|
still_alive.retain(|pid| match waitpid(*pid, Some(WaitPidFlag::WNOHANG)) {
|
||||||
|
Ok(WaitStatus::StillAlive) => true,
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
reaped += 1;
|
reaped += 1;
|
||||||
break;
|
false
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => false,
|
||||||
|
});
|
||||||
|
if !still_alive.is_empty() {
|
||||||
|
tokio::time::sleep(poll_interval).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Último recurso: SIGKILL.
|
|
||||||
let _ = kill(cmd.pid, Signal::SIGKILL);
|
// 3) SIGKILL forzoso a los que quedan, y wait blocking.
|
||||||
let _ = waitpid(cmd.pid, None);
|
for pid in &still_alive {
|
||||||
|
let _ = kill(*pid, Signal::SIGKILL);
|
||||||
|
let _ = waitpid(*pid, None);
|
||||||
|
reaped += 1;
|
||||||
}
|
}
|
||||||
}
|
info!(
|
||||||
info!(%id, reaped, "workspace stopped");
|
%id,
|
||||||
|
reaped,
|
||||||
|
grace_ms = grace.as_millis() as u64,
|
||||||
|
sigkilled = still_alive.len(),
|
||||||
|
"workspace stopped"
|
||||||
|
);
|
||||||
Ok(reaped)
|
Ok(reaped)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,31 +527,36 @@ impl WorkspaceManager {
|
|||||||
};
|
};
|
||||||
let card = cmd_ref.to_card(0, &workspace_label)?;
|
let card = cmd_ref.to_card(0, &workspace_label)?;
|
||||||
|
|
||||||
// Pipe para capturar stdout. O_CLOEXEC para que hijos del hijo
|
// Dos pipes O_CLOEXEC: uno para stdout, otro para stderr.
|
||||||
// no hereden la copia. v1: stderr=inherit (simplicidad; tail útil
|
use std::os::fd::IntoRawFd;
|
||||||
// para stdout solo). Futuro: stderr separado en el ring.
|
let (sout_r, sout_w) =
|
||||||
let (capture_r, capture_w) =
|
|
||||||
nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(|e| {
|
nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(|e| {
|
||||||
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
|
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
|
||||||
})?;
|
})?;
|
||||||
use std::os::fd::IntoRawFd;
|
let (serr_r, serr_w) =
|
||||||
let capture_r_fd = capture_r.into_raw_fd();
|
nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(|e| {
|
||||||
let capture_w_fd = capture_w.into_raw_fd();
|
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
|
||||||
|
})?;
|
||||||
|
let sout_r_fd = sout_r.into_raw_fd();
|
||||||
|
let sout_w_fd = sout_w.into_raw_fd();
|
||||||
|
let serr_r_fd = serr_r.into_raw_fd();
|
||||||
|
let serr_w_fd = serr_w.into_raw_fd();
|
||||||
|
|
||||||
let logs = logbuf::LogBuf::new();
|
let stdout_buf = logbuf::LogBuf::new();
|
||||||
|
let stderr_buf = logbuf::LogBuf::new();
|
||||||
|
|
||||||
let stdio = ente_incarnate::ChildStdio {
|
let stdio = ente_incarnate::ChildStdio {
|
||||||
stdin_fd: None,
|
stdin_fd: None,
|
||||||
stdout_fd: Some(capture_w_fd),
|
stdout_fd: Some(sout_w_fd),
|
||||||
stderr_fd: None,
|
stderr_fd: Some(serr_w_fd),
|
||||||
};
|
};
|
||||||
let out = self.incarnator.incarnate_with(&card, stdio)?;
|
let out = self.incarnator.incarnate_with(&card, stdio)?;
|
||||||
let cmd_id = card.id;
|
let cmd_id = card.id;
|
||||||
let cmd_label = cmd_ref.label.clone();
|
let cmd_label = cmd_ref.label.clone();
|
||||||
let pid = out.pid;
|
let pid = out.pid;
|
||||||
|
|
||||||
// Drainer: tokio task que lee capture_r_fd y appendea al ring.
|
spawn_log_drainer(sout_r_fd, stdout_buf.clone());
|
||||||
spawn_log_drainer(capture_r_fd, logs.clone());
|
spawn_log_drainer(serr_r_fd, stderr_buf.clone());
|
||||||
|
|
||||||
let mut g = self.inner.lock().await;
|
let mut g = self.inner.lock().await;
|
||||||
if let Some(ws) = g.workspaces.get_mut(&id) {
|
if let Some(ws) = g.workspaces.get_mut(&id) {
|
||||||
@@ -357,7 +568,9 @@ impl WorkspaceManager {
|
|||||||
pid,
|
pid,
|
||||||
alive: true,
|
alive: true,
|
||||||
exit_status: None,
|
exit_status: None,
|
||||||
logs: Some(logs),
|
stdout: Some(stdout_buf),
|
||||||
|
stderr: Some(stderr_buf),
|
||||||
|
pipeline_id: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -372,16 +585,28 @@ impl WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Devuelve el tail del log capturado para `(workspace, command)`.
|
/// Devuelve el tail del log capturado para `(workspace, command)`.
|
||||||
|
/// `stream` selecciona stdout/stderr/both.
|
||||||
pub async fn get_command_logs(
|
pub async fn get_command_logs(
|
||||||
&self,
|
&self,
|
||||||
workspace: WorkspaceId,
|
workspace: WorkspaceId,
|
||||||
command: Ulid,
|
command: Ulid,
|
||||||
tail_bytes: usize,
|
tail_bytes: usize,
|
||||||
|
stream: LogStream,
|
||||||
) -> Option<Vec<u8>> {
|
) -> Option<Vec<u8>> {
|
||||||
let g = self.inner.lock().await;
|
let g = self.inner.lock().await;
|
||||||
let ws = g.workspaces.get(&workspace)?;
|
let ws = g.workspaces.get(&workspace)?;
|
||||||
let cmd = ws.commands.get(&command)?;
|
let cmd = ws.commands.get(&command)?;
|
||||||
cmd.logs.as_ref().map(|lb| lb.tail(tail_bytes))
|
match stream {
|
||||||
|
LogStream::Stdout => cmd.stdout.as_ref().map(|lb| lb.tail(tail_bytes)),
|
||||||
|
LogStream::Stderr => cmd.stderr.as_ref().map(|lb| lb.tail(tail_bytes)),
|
||||||
|
LogStream::Both => {
|
||||||
|
let so = cmd.stdout.as_ref().map(|lb| lb.tail(tail_bytes)).unwrap_or_default();
|
||||||
|
let se = cmd.stderr.as_ref().map(|lb| lb.tail(tail_bytes)).unwrap_or_default();
|
||||||
|
let mut out = so;
|
||||||
|
out.extend_from_slice(&se);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lista comandos de un workspace.
|
/// Lista comandos de un workspace.
|
||||||
@@ -397,7 +622,8 @@ impl WorkspaceManager {
|
|||||||
pid: c.pid.as_raw(),
|
pid: c.pid.as_raw(),
|
||||||
alive: c.alive,
|
alive: c.alive,
|
||||||
exit_status: c.exit_status,
|
exit_status: c.exit_status,
|
||||||
log_bytes: c.logs.as_ref().map(|l| l.written_total()).unwrap_or(0),
|
log_bytes: c.stdout.as_ref().map(|l| l.written_total()).unwrap_or(0)
|
||||||
|
+ c.stderr.as_ref().map(|l| l.written_total()).unwrap_or(0),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
// Orden estable por ULID (temporal).
|
// Orden estable por ULID (temporal).
|
||||||
@@ -435,7 +661,9 @@ impl WorkspaceManager {
|
|||||||
pid: out.pid,
|
pid: out.pid,
|
||||||
alive: true,
|
alive: true,
|
||||||
exit_status: None,
|
exit_status: None,
|
||||||
logs: None, // run_pipeline NO captura logs (los conecta por pipes).
|
stdout: None, // run_pipeline NO captura (conecta por pipes).
|
||||||
|
stderr: None,
|
||||||
|
pipeline_id: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -538,19 +766,16 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let (id, _) = mgr.create(spec).await.unwrap();
|
let (id, _) = mgr.create(spec).await.unwrap();
|
||||||
let summary = mgr
|
let summary = mgr
|
||||||
.run(
|
.run(id, "/bin/echo".into(), vec!["captured-output".into()], vec![])
|
||||||
id,
|
|
||||||
"/bin/echo".into(),
|
|
||||||
vec!["captured-output".into()],
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Esperamos a que el comando termine y el drainer drene.
|
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||||
mgr.reap_dead().await;
|
mgr.reap_dead().await;
|
||||||
let logs = mgr.get_command_logs(id, summary.id, 0).await.unwrap_or_default();
|
let logs = mgr
|
||||||
|
.get_command_logs(id, summary.id, 0, LogStream::Stdout)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
if !logs.is_empty() {
|
if !logs.is_empty() {
|
||||||
let s = String::from_utf8_lossy(&logs);
|
let s = String::from_utf8_lossy(&logs);
|
||||||
assert!(s.contains("captured-output"), "got: {s:?}");
|
assert!(s.contains("captured-output"), "got: {s:?}");
|
||||||
@@ -560,6 +785,52 @@ mod tests {
|
|||||||
panic!("logs never captured");
|
panic!("logs never captured");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_captures_stderr_separately() {
|
||||||
|
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
||||||
|
let spec = WorkspaceSpec {
|
||||||
|
label: "stderr".into(),
|
||||||
|
soma: Default::default(),
|
||||||
|
permissions: Default::default(),
|
||||||
|
ttl: None,
|
||||||
|
flow_dirs: vec![],
|
||||||
|
on_exit: shipote_card::ExitPolicy::Reap,
|
||||||
|
};
|
||||||
|
let (id, _) = mgr.create(spec).await.unwrap();
|
||||||
|
// sh -c "echo OUT; echo ERR >&2"
|
||||||
|
let summary = mgr
|
||||||
|
.run(
|
||||||
|
id,
|
||||||
|
"/bin/sh".into(),
|
||||||
|
vec!["-c".into(), "echo OUT; echo ERR >&2".into()],
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for _ in 0..50 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||||
|
mgr.reap_dead().await;
|
||||||
|
let so = mgr
|
||||||
|
.get_command_logs(id, summary.id, 0, LogStream::Stdout)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let se = mgr
|
||||||
|
.get_command_logs(id, summary.id, 0, LogStream::Stderr)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !so.is_empty() && !se.is_empty() {
|
||||||
|
let so_s = String::from_utf8_lossy(&so);
|
||||||
|
let se_s = String::from_utf8_lossy(&se);
|
||||||
|
assert!(so_s.contains("OUT"), "stdout: {so_s:?}");
|
||||||
|
assert!(se_s.contains("ERR"), "stderr: {se_s:?}");
|
||||||
|
assert!(!so_s.contains("ERR"), "stdout no debería tener ERR");
|
||||||
|
assert!(!se_s.contains("OUT"), "stderr no debería tener OUT");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("logs never captured on both streams");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_true_in_workspace() {
|
async fn run_true_in_workspace() {
|
||||||
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use brahman_card::Payload;
|
|||||||
use ente_incarnate::{ChildStdio, Incarnator};
|
use ente_incarnate::{ChildStdio, Incarnator};
|
||||||
use nix::fcntl::OFlag;
|
use nix::fcntl::OFlag;
|
||||||
use nix::unistd::pipe2;
|
use nix::unistd::pipe2;
|
||||||
use shipote_card::{FlowEdge, PipelineSpec};
|
use shipote_card::PipelineSpec;
|
||||||
use shipote_discern::{DiscernPipeline, Discernment, Hint};
|
use shipote_discern::{DiscernPipeline, Discernment, Hint};
|
||||||
use std::os::fd::{AsRawFd, IntoRawFd, RawFd};
|
use std::os::fd::{AsRawFd, IntoRawFd, RawFd};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -22,7 +22,7 @@ use tracing::{debug, info, warn};
|
|||||||
use ulid::Ulid;
|
use ulid::Ulid;
|
||||||
|
|
||||||
/// Resultado de lanzar un pipeline.
|
/// Resultado de lanzar un pipeline.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug)]
|
||||||
pub struct PipelineLaunch {
|
pub struct PipelineLaunch {
|
||||||
pub pipeline: Ulid,
|
pub pipeline: Ulid,
|
||||||
pub command_pids: Vec<(String, i32)>,
|
pub command_pids: Vec<(String, i32)>,
|
||||||
@@ -37,19 +37,29 @@ pub struct EdgeDiscernment {
|
|||||||
pub to_label: String,
|
pub to_label: String,
|
||||||
pub to_input: String,
|
pub to_input: String,
|
||||||
pub discernment: Option<Discernment>,
|
pub discernment: Option<Discernment>,
|
||||||
|
/// Path del Unix socket donde otros módulos pueden suscribirse al
|
||||||
|
/// stream replicado por este edge. `None` cuando tap=false (no hay
|
||||||
|
/// data plane porque no hay sampling).
|
||||||
|
pub flow_socket: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lanza un pipeline conectando nodos por stdin/stdout. Cada nodo se
|
/// Lanza un pipeline conectando nodos por stdin/stdout. Cada nodo se
|
||||||
/// encarna via `Incarnator` (con o sin namespacing según su SomaSpec).
|
/// encarna via `Incarnator` (con o sin namespacing según su SomaSpec).
|
||||||
///
|
///
|
||||||
/// v1: pipeline lineal (un edge entrante por nodo). Múltiples edges
|
/// Soporta:
|
||||||
/// entrantes generan warning y sólo el primero se honra.
|
/// - Pipeline lineal (1 producer → 1 consumer).
|
||||||
|
/// - **Fan-out** (1 producer → N consumers): shipote interpone un
|
||||||
|
/// splitter que duplica bytes a cada destino. Cuando `tap=true`, el
|
||||||
|
/// splitter además samplea para discernir.
|
||||||
|
/// - Múltiples predecessors por nodo NO se soporta aún (fan-in): sólo se
|
||||||
|
/// honra el primer edge entrante.
|
||||||
pub async fn run_pipeline(
|
pub async fn run_pipeline(
|
||||||
spec: &PipelineSpec,
|
spec: &PipelineSpec,
|
||||||
workspace_label: &str,
|
workspace_label: &str,
|
||||||
tap: bool,
|
tap: bool,
|
||||||
discerner: Arc<DiscernPipeline>,
|
discerner: Arc<DiscernPipeline>,
|
||||||
incarnator: Arc<Incarnator>,
|
incarnator: Arc<Incarnator>,
|
||||||
|
manager: Option<Arc<crate::WorkspaceManager>>,
|
||||||
) -> Result<PipelineLaunch, CoreError> {
|
) -> Result<PipelineLaunch, CoreError> {
|
||||||
spec.validate()?;
|
spec.validate()?;
|
||||||
let n = spec.nodes.len();
|
let n = spec.nodes.len();
|
||||||
@@ -60,30 +70,100 @@ pub async fn run_pipeline(
|
|||||||
"launching pipeline (incarnated)"
|
"launching pipeline (incarnated)"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Predecessor: para cada nodo, su edge entrante (si tiene).
|
// Pre-compute grafo:
|
||||||
let mut predecessor: Vec<Option<&FlowEdge>> = vec![None; n];
|
// - `consumers[i]` = índices de edges salientes de `i`.
|
||||||
for e in &spec.edges {
|
// - `predecessors[j]` = índices de edges entrantes a `j`.
|
||||||
if predecessor[e.to].is_some() {
|
let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||||
warn!(node = e.to, "v1 pipeline: nodo con múltiples predecessors — sólo se honra el primero");
|
let mut predecessors: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||||
|
for (idx, e) in spec.edges.iter().enumerate() {
|
||||||
|
consumers[e.from].push(idx);
|
||||||
|
predecessors[e.to].push(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Por cada edge: par (r_to_consumer, w_from_producer_side).
|
||||||
|
// El consumer recibe r_to_consumer; el producer escribe a w_from_producer_side
|
||||||
|
// (directa o vía splitter).
|
||||||
|
let mut edge_r: Vec<RawFd> = vec![-1; spec.edges.len()];
|
||||||
|
let mut edge_w: Vec<RawFd> = vec![-1; spec.edges.len()];
|
||||||
|
for i in 0..spec.edges.len() {
|
||||||
|
let (r, w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
|
||||||
|
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
|
||||||
|
})?;
|
||||||
|
edge_r[i] = r.into_raw_fd();
|
||||||
|
edge_w[i] = w.into_raw_fd();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut consumer_stdin_fd: Vec<Option<RawFd>> = vec![None; n];
|
||||||
|
let mut producer_stdout_fd: Vec<Option<RawFd>> = vec![None; n];
|
||||||
|
let mut splitter_specs: Vec<SplitterSpec> = Vec::new();
|
||||||
|
let mut merger_specs: Vec<MergerSpec> = Vec::new();
|
||||||
|
|
||||||
|
// Stdout del producer: directo a edge_w[único] si tiene 1 consumer y NO tap;
|
||||||
|
// sino, pipe propio que va al splitter task.
|
||||||
|
for i in 0..n {
|
||||||
|
if consumers[i].is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
predecessor[e.to] = Some(e);
|
if consumers[i].len() == 1 && !tap {
|
||||||
|
producer_stdout_fd[i] = Some(edge_w[consumers[i][0]]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Splitter: pipe propio para el productor → splitter lee y replica a edge_w[*].
|
||||||
|
let (prod_r, prod_w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
|
||||||
|
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
|
||||||
|
})?;
|
||||||
|
producer_stdout_fd[i] = Some(prod_w.into_raw_fd());
|
||||||
|
let prod_r_fd = prod_r.into_raw_fd();
|
||||||
|
let mut consumer_writes: Vec<RawFd> = Vec::with_capacity(consumers[i].len());
|
||||||
|
let mut edge_meta: Vec<EdgeMeta> = Vec::with_capacity(consumers[i].len());
|
||||||
|
for edge_idx in &consumers[i] {
|
||||||
|
let edge = &spec.edges[*edge_idx];
|
||||||
|
consumer_writes.push(edge_w[*edge_idx]);
|
||||||
|
edge_meta.push(EdgeMeta {
|
||||||
|
from_label: spec.nodes[edge.from].label.clone(),
|
||||||
|
from_output: edge.from_output.clone(),
|
||||||
|
to_label: spec.nodes[edge.to].label.clone(),
|
||||||
|
to_input: edge.to_input.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
splitter_specs.push(SplitterSpec {
|
||||||
|
producer_r_fd: prod_r_fd,
|
||||||
|
consumer_w_fds: consumer_writes,
|
||||||
|
edges: edge_meta,
|
||||||
|
tap,
|
||||||
|
sample_bytes: spec.discern.sample_bytes,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut pids = Vec::with_capacity(n);
|
// Stdin del consumer: edge_r[único] si tiene 1 predecessor; sino, merger.
|
||||||
let mut taps: Vec<TapHandle> = Vec::new();
|
for j in 0..n {
|
||||||
// Para cada nodo i que produce, guardamos el FD de read del pipe
|
match predecessors[j].len() {
|
||||||
// del productor → al armar el consumidor lo consume.
|
0 => {}
|
||||||
// Pero como puede haber tap intermedio, llevamos un esquema:
|
1 => {
|
||||||
// - Sin tap: read FD del pipe productor → stdin del consumidor.
|
consumer_stdin_fd[j] = Some(edge_r[predecessors[j][0]]);
|
||||||
// - Con tap: read FD del pipe productor → tokio proxy → write FD
|
}
|
||||||
// del pipe consumidor → stdin del consumidor.
|
_ => {
|
||||||
// Para simplicidad lineal, `pending_stdin_for_next` guarda el FD que
|
// Merger: lee de N edge_r y escribe a un nuevo pipe cuyo
|
||||||
// el siguiente consumidor debe usar como stdin.
|
// read end es el stdin del consumer.
|
||||||
let mut pending_stdin_for_next: Option<RawFd> = None;
|
let (cons_r, cons_w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
|
||||||
|
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
|
||||||
|
})?;
|
||||||
|
consumer_stdin_fd[j] = Some(cons_r.into_raw_fd());
|
||||||
|
let inputs: Vec<RawFd> = predecessors[j]
|
||||||
|
.iter()
|
||||||
|
.map(|eidx| edge_r[*eidx])
|
||||||
|
.collect();
|
||||||
|
merger_specs.push(MergerSpec {
|
||||||
|
producer_r_fds: inputs,
|
||||||
|
consumer_w_fd: cons_w.into_raw_fd(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encarnamos cada nodo con su stdin/stdout fd asignado.
|
||||||
|
let mut pids = Vec::with_capacity(n);
|
||||||
for (i, node) in spec.nodes.iter().enumerate() {
|
for (i, node) in spec.nodes.iter().enumerate() {
|
||||||
// Validar payload ejecutable.
|
|
||||||
match &node.payload {
|
match &node.payload {
|
||||||
Payload::Native { .. } | Payload::Legacy { .. } => {}
|
Payload::Native { .. } | Payload::Legacy { .. } => {}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -92,91 +172,98 @@ pub async fn run_pipeline(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compilamos a Card.
|
|
||||||
let card = node.to_card(i, workspace_label)?;
|
let card = node.to_card(i, workspace_label)?;
|
||||||
|
|
||||||
// ¿Soy productor? Necesito stdout_fd hacia un pipe nuevo.
|
|
||||||
let i_is_producer = spec.edges.iter().any(|e| e.from == i);
|
|
||||||
let stdin_fd: Option<RawFd> = pending_stdin_for_next.take();
|
|
||||||
let mut stdout_fd: Option<RawFd> = None;
|
|
||||||
let mut next_pending: Option<RawFd> = None;
|
|
||||||
|
|
||||||
// FDs que el PADRE debe cerrar tras spawn (son nuestra copia del
|
|
||||||
// extremo que pasamos al hijo).
|
|
||||||
let mut parent_closes: Vec<RawFd> = Vec::new();
|
|
||||||
|
|
||||||
if i_is_producer {
|
|
||||||
let (r, w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
|
|
||||||
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
|
|
||||||
})?;
|
|
||||||
let r_raw = r.into_raw_fd();
|
|
||||||
let w_raw = w.into_raw_fd();
|
|
||||||
stdout_fd = Some(w_raw);
|
|
||||||
parent_closes.push(w_raw);
|
|
||||||
|
|
||||||
if tap {
|
|
||||||
// Necesitamos un segundo pipe entre tap y consumidor.
|
|
||||||
let (r2, w2) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
|
|
||||||
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
|
|
||||||
})?;
|
|
||||||
let r2_raw = r2.into_raw_fd();
|
|
||||||
let w2_raw = w2.into_raw_fd();
|
|
||||||
next_pending = Some(r2_raw);
|
|
||||||
// El tap lee de r_raw y escribe a w2_raw.
|
|
||||||
let edge = predecessor
|
|
||||||
.iter()
|
|
||||||
.find_map(|p| *p)
|
|
||||||
.and_then(|e| if e.from == i { Some(e) } else { None })
|
|
||||||
// Edge donde i es from:
|
|
||||||
.or_else(|| spec.edges.iter().find(|e| e.from == i));
|
|
||||||
let from_label = node.label.clone();
|
|
||||||
let to_label = edge
|
|
||||||
.map(|e| spec.nodes[e.to].label.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let from_output = edge.map(|e| e.from_output.clone()).unwrap_or_default();
|
|
||||||
let to_input = edge.map(|e| e.to_input.clone()).unwrap_or_default();
|
|
||||||
let sample_bytes = spec.discern.sample_bytes;
|
|
||||||
let disc = discerner.clone();
|
|
||||||
let h = spawn_tap(
|
|
||||||
r_raw, w2_raw, sample_bytes, disc, from_label, from_output, to_label, to_input,
|
|
||||||
);
|
|
||||||
taps.push(h);
|
|
||||||
// r_raw y w2_raw pasaron a manos del tokio task. No los
|
|
||||||
// cerramos en el padre.
|
|
||||||
} else {
|
|
||||||
// Sin tap, el read del productor va directo al stdin del
|
|
||||||
// siguiente consumidor.
|
|
||||||
next_pending = Some(r_raw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let stdio = ChildStdio {
|
let stdio = ChildStdio {
|
||||||
stdin_fd,
|
stdin_fd: consumer_stdin_fd[i],
|
||||||
stdout_fd,
|
stdout_fd: producer_stdout_fd[i],
|
||||||
stderr_fd: None,
|
stderr_fd: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Incarnator absorbe los fds de `stdio` — no los cerramos acá.
|
|
||||||
// `parent_closes` queda obsoleto.
|
|
||||||
let _ = parent_closes;
|
|
||||||
let outcome = incarnator
|
let outcome = incarnator
|
||||||
.incarnate_with(&card, stdio)
|
.incarnate_with(&card, stdio)
|
||||||
.map_err(CoreError::Incarnate)?;
|
.map_err(CoreError::Incarnate)?;
|
||||||
let pid = outcome.pid;
|
let pid = outcome.pid;
|
||||||
pids.push((node.label.clone(), pid.as_raw()));
|
pids.push((node.label.clone(), pid.as_raw()));
|
||||||
debug!(label = %node.label, pid = pid.as_raw(), "node incarnated");
|
debug!(label = %node.label, pid = pid.as_raw(), "node incarnated");
|
||||||
|
|
||||||
pending_stdin_for_next = next_pending;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let pipeline_id = Ulid::new();
|
let pipeline_id_for_flows = Ulid::new();
|
||||||
|
// Si tap=true, creamos un FlowChannel por edge para el data plane.
|
||||||
|
// Cada splitter pushea al sender del channel correspondiente.
|
||||||
|
let pipeline_id = pipeline_id_for_flows;
|
||||||
|
let mut flow_channels: Vec<crate::flow_channel::FlowChannel> = Vec::new();
|
||||||
|
let mut splitter_channels: Vec<Vec<Option<crate::flow_channel::FlowSender>>> =
|
||||||
|
Vec::with_capacity(splitter_specs.len());
|
||||||
|
let mut edge_socket_for_splitter: Vec<Vec<Option<std::path::PathBuf>>> = Vec::new();
|
||||||
|
for s in &splitter_specs {
|
||||||
|
let mut senders_per_edge = Vec::with_capacity(s.edges.len());
|
||||||
|
let mut paths_per_edge = Vec::with_capacity(s.edges.len());
|
||||||
|
for (i, em) in s.edges.iter().enumerate() {
|
||||||
|
if !s.tap {
|
||||||
|
senders_per_edge.push(None);
|
||||||
|
paths_per_edge.push(None);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = format!(
|
||||||
|
"{}-{}-{}-{}",
|
||||||
|
short_ulid(&pipeline_id),
|
||||||
|
em.from_label,
|
||||||
|
em.from_output,
|
||||||
|
i
|
||||||
|
);
|
||||||
|
let socket = crate::flow_channel::default_flow_socket_path(&id);
|
||||||
|
match crate::flow_channel::FlowChannel::with_replay_cap(socket.clone(), spec.discern.replay_chunks) {
|
||||||
|
Ok(fc) => {
|
||||||
|
senders_per_edge.push(Some(fc.sender_handle()));
|
||||||
|
paths_per_edge.push(Some(socket));
|
||||||
|
flow_channels.push(fc);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(?e, "flow channel new failed");
|
||||||
|
senders_per_edge.push(None);
|
||||||
|
paths_per_edge.push(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
splitter_channels.push(senders_per_edge);
|
||||||
|
edge_socket_for_splitter.push(paths_per_edge);
|
||||||
|
}
|
||||||
|
|
||||||
let mut edge_discernments = Vec::with_capacity(taps.len());
|
// Registramos los flow_channels en el manager AHORA, antes de await
|
||||||
for t in taps {
|
// las tasks. Esto permite que clientes externos hagan `flow list` y
|
||||||
match t.handle.await {
|
// se suscriban mientras el pipeline aún produce data.
|
||||||
Ok(d) => edge_discernments.push(d),
|
if let Some(mgr) = &manager {
|
||||||
Err(e) => warn!(?e, "tap handle joined with error"),
|
if !flow_channels.is_empty() {
|
||||||
|
let drained: Vec<crate::flow_channel::FlowChannel> = flow_channels.drain(..).collect();
|
||||||
|
mgr.retain_pipeline_flows(pipeline_id, drained).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn mergers + splitters después del incarnate. Cada task posee
|
||||||
|
// sus fds y los cierra al terminar (via Drop de OwnedFd).
|
||||||
|
let mut merger_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
|
||||||
|
for m in merger_specs {
|
||||||
|
merger_handles.push(spawn_merger(m));
|
||||||
|
}
|
||||||
|
let mut tap_handles: Vec<SplitterHandle> = Vec::new();
|
||||||
|
for (s, senders) in splitter_specs.into_iter().zip(splitter_channels.into_iter()) {
|
||||||
|
tap_handles.push(spawn_splitter(s, discerner.clone(), senders));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut edge_discernments = Vec::new();
|
||||||
|
for (h, paths) in tap_handles.into_iter().zip(edge_socket_for_splitter.into_iter()) {
|
||||||
|
match h.handle.await {
|
||||||
|
Ok(eds) => {
|
||||||
|
for (mut ed, path) in eds.into_iter().zip(paths.into_iter()) {
|
||||||
|
ed.flow_socket = path;
|
||||||
|
edge_discernments.push(ed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!(?e, "splitter handle joined with error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for h in merger_handles {
|
||||||
|
if let Err(e) = h.await {
|
||||||
|
warn!(?e, "merger handle joined with error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,57 +274,156 @@ pub async fn run_pipeline(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TapHandle {
|
fn short_ulid(u: &Ulid) -> String {
|
||||||
handle: tokio::task::JoinHandle<EdgeDiscernment>,
|
let s = u.to_string();
|
||||||
|
s[s.len() - 6..].to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[derive(Debug, Clone)]
|
||||||
fn spawn_tap(
|
struct EdgeMeta {
|
||||||
producer_r_fd: RawFd,
|
|
||||||
consumer_w_fd: RawFd,
|
|
||||||
sample_bytes: usize,
|
|
||||||
discerner: Arc<DiscernPipeline>,
|
|
||||||
from_label: String,
|
from_label: String,
|
||||||
from_output: String,
|
from_output: String,
|
||||||
to_label: String,
|
to_label: String,
|
||||||
to_input: String,
|
to_input: String,
|
||||||
) -> TapHandle {
|
}
|
||||||
// Marcar non-blocking ANTES de envolverlos en AsyncFd. Sino tokio
|
|
||||||
// bloquea el reactor en operaciones lentas.
|
struct SplitterSpec {
|
||||||
set_nonblocking(producer_r_fd);
|
producer_r_fd: RawFd,
|
||||||
set_nonblocking(consumer_w_fd);
|
consumer_w_fds: Vec<RawFd>,
|
||||||
|
edges: Vec<EdgeMeta>,
|
||||||
|
tap: bool,
|
||||||
|
sample_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SplitterHandle {
|
||||||
|
handle: tokio::task::JoinHandle<Vec<EdgeDiscernment>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MergerSpec {
|
||||||
|
producer_r_fds: Vec<RawFd>,
|
||||||
|
consumer_w_fd: RawFd,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_merger(spec: MergerSpec) -> tokio::task::JoinHandle<()> {
|
||||||
|
for fd in &spec.producer_r_fds {
|
||||||
|
set_nonblocking(*fd);
|
||||||
|
}
|
||||||
|
set_nonblocking(spec.consumer_w_fd);
|
||||||
|
// Patrón: una task lectora por cada producer reenvía bytes a un mpsc.
|
||||||
|
// El merger principal consume del mpsc y escribe al consumer.
|
||||||
|
// Esto evita el "block en reader idle" del enfoque round-robin sobre
|
||||||
|
// AsyncFd::ready() (los readers idle nunca dejan turno).
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
|
||||||
|
let nr = spec.producer_r_fds.len();
|
||||||
|
for fd in spec.producer_r_fds {
|
||||||
|
let tx = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// SAFETY: ownership transferida.
|
||||||
|
let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(fd) };
|
||||||
|
let r = match AsyncFd::with_interest(owned, Interest::READABLE) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(?e, "merger reader AsyncFd");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
match async_read(&r, &mut buf).await {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
if tx.send(buf[..n].to_vec()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop de tx → cuando todos los readers cerraron, el rx
|
||||||
|
// recibe None y el merger termina.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
drop(tx); // sólo los reader tasks tienen sus clones ahora.
|
||||||
|
|
||||||
|
// SAFETY: ownership transferida al task.
|
||||||
|
let w_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(spec.consumer_w_fd) };
|
||||||
|
let w = match AsyncFd::with_interest(w_owned, Interest::WRITABLE) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(?e, "merger AsyncFd w");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
while let Some(chunk) = rx.recv().await {
|
||||||
|
if async_write_all(&w, &chunk).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total += chunk.len() as u64;
|
||||||
|
}
|
||||||
|
debug!(bytes = total, readers = nr, "merger finished");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_splitter(
|
||||||
|
spec: SplitterSpec,
|
||||||
|
discerner: Arc<DiscernPipeline>,
|
||||||
|
edge_senders: Vec<Option<crate::flow_channel::FlowSender>>,
|
||||||
|
) -> SplitterHandle {
|
||||||
|
set_nonblocking(spec.producer_r_fd);
|
||||||
|
for fd in &spec.consumer_w_fds {
|
||||||
|
set_nonblocking(*fd);
|
||||||
|
}
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
// SAFETY: el caller transfiere ownership de los fds al task.
|
// SAFETY: ownership transferida al task.
|
||||||
let r_std = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(producer_r_fd) };
|
let r_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(spec.producer_r_fd) };
|
||||||
let w_std = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(consumer_w_fd) };
|
let r = match AsyncFd::with_interest(r_owned, Interest::READABLE) {
|
||||||
let r = AsyncFd::with_interest(r_std, Interest::READABLE).expect("AsyncFd r");
|
Ok(a) => a,
|
||||||
let w = AsyncFd::with_interest(w_std, Interest::WRITABLE).expect("AsyncFd w");
|
Err(e) => {
|
||||||
|
warn!(?e, "splitter AsyncFd r");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut writers: Vec<AsyncFd<std::os::fd::OwnedFd>> = Vec::with_capacity(spec.consumer_w_fds.len());
|
||||||
|
for fd in spec.consumer_w_fds {
|
||||||
|
let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(fd) };
|
||||||
|
match AsyncFd::with_interest(owned, Interest::WRITABLE) {
|
||||||
|
Ok(a) => writers.push(a),
|
||||||
|
Err(e) => warn!(?e, "splitter AsyncFd w"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut sample: Vec<u8> = Vec::with_capacity(sample_bytes);
|
let mut sample: Vec<u8> = Vec::with_capacity(spec.sample_bytes);
|
||||||
let mut buf = [0u8; 4096];
|
let mut buf = [0u8; 4096];
|
||||||
let mut total: u64 = 0;
|
let mut total: u64 = 0;
|
||||||
|
|
||||||
// Fase 1: sampling + pump.
|
|
||||||
let mut eof = false;
|
let mut eof = false;
|
||||||
while !eof && sample.len() < sample_bytes {
|
|
||||||
|
// Fase 1: sampling (sólo si tap=true) + replicación.
|
||||||
|
while !eof && (spec.tap && sample.len() < spec.sample_bytes) {
|
||||||
let n = match async_read(&r, &mut buf).await {
|
let n = match async_read(&r, &mut buf).await {
|
||||||
Ok(0) => { eof = true; 0 }
|
Ok(0) => { eof = true; 0 }
|
||||||
Ok(n) => n,
|
Ok(n) => n,
|
||||||
Err(e) => { warn!(?e, "tap producer read failed"); break; }
|
Err(e) => { warn!(?e, "splitter read"); break; }
|
||||||
};
|
};
|
||||||
if n == 0 { break; }
|
if n == 0 { break; }
|
||||||
let take = n.min(sample_bytes - sample.len());
|
if spec.tap {
|
||||||
|
let take = n.min(spec.sample_bytes - sample.len());
|
||||||
sample.extend_from_slice(&buf[..take]);
|
sample.extend_from_slice(&buf[..take]);
|
||||||
if let Err(e) = async_write_all(&w, &buf[..n]).await {
|
|
||||||
warn!(?e, "tap consumer write failed");
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
broadcast_chunk(&writers, &edge_senders, &buf[..n]).await;
|
||||||
total += n as u64;
|
total += n as u64;
|
||||||
}
|
}
|
||||||
let d = discerner.discern(&sample, &Hint { path: None, size_total: None });
|
|
||||||
|
|
||||||
// Fase 2: pump-only hasta EOF.
|
let d = if spec.tap {
|
||||||
|
discerner.discern(&sample, &Hint { path: None, size_total: None })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fase 2: replicación pura.
|
||||||
while !eof {
|
while !eof {
|
||||||
let n = match async_read(&r, &mut buf).await {
|
let n = match async_read(&r, &mut buf).await {
|
||||||
Ok(0) => { eof = true; 0 }
|
Ok(0) => { eof = true; 0 }
|
||||||
@@ -245,19 +431,50 @@ fn spawn_tap(
|
|||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
};
|
};
|
||||||
if n == 0 { break; }
|
if n == 0 { break; }
|
||||||
if async_write_all(&w, &buf[..n]).await.is_err() { break; }
|
broadcast_chunk(&writers, &edge_senders, &buf[..n]).await;
|
||||||
total += n as u64;
|
total += n as u64;
|
||||||
}
|
}
|
||||||
debug!(bytes = total, "tap finished");
|
debug!(bytes = total, consumers = writers.len(), "splitter finished");
|
||||||
EdgeDiscernment {
|
|
||||||
from_label,
|
// Mismo discernment para todos los edges del splitter (es el mismo
|
||||||
from_output,
|
// stream replicado). Devolvemos N entries (una por edge) para que
|
||||||
to_label,
|
// la UI/CLI los liste todos. flow_socket lo rellena el caller.
|
||||||
to_input,
|
spec.edges
|
||||||
discernment: d,
|
.into_iter()
|
||||||
}
|
.map(|em| EdgeDiscernment {
|
||||||
|
from_label: em.from_label,
|
||||||
|
from_output: em.from_output,
|
||||||
|
to_label: em.to_label,
|
||||||
|
to_input: em.to_input,
|
||||||
|
discernment: d.clone(),
|
||||||
|
flow_socket: None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
});
|
});
|
||||||
TapHandle { handle }
|
SplitterHandle { handle }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_chunk(
|
||||||
|
writers: &[AsyncFd<std::os::fd::OwnedFd>],
|
||||||
|
edge_senders: &[Option<crate::flow_channel::FlowSender>],
|
||||||
|
data: &[u8],
|
||||||
|
) {
|
||||||
|
// Internal pipes a los consumers del pipeline.
|
||||||
|
for w in writers {
|
||||||
|
let _ = async_write_all(w, data).await;
|
||||||
|
}
|
||||||
|
// Externos: broadcast a subscribers vía FlowChannel.
|
||||||
|
// Cada edge tiene su propio sender (mismo data — el sample/discernment
|
||||||
|
// viaja por broadcast separados para que un subscriber por edge vea su
|
||||||
|
// stream específico).
|
||||||
|
if edge_senders.iter().any(|s| s.is_some()) {
|
||||||
|
let shared = std::sync::Arc::new(data.to_vec());
|
||||||
|
for s in edge_senders {
|
||||||
|
if let Some(s) = s {
|
||||||
|
let _ = s.send(shared.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn async_read(
|
async fn async_read(
|
||||||
@@ -377,7 +594,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||||
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||||
let launch = run_pipeline(&spec, "ws", false, disc, inc).await.unwrap();
|
let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
|
||||||
assert_eq!(launch.command_pids.len(), 2);
|
assert_eq!(launch.command_pids.len(), 2);
|
||||||
// Cosecha.
|
// Cosecha.
|
||||||
for (_, pid) in &launch.command_pids {
|
for (_, pid) in &launch.command_pids {
|
||||||
@@ -385,6 +602,78 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pipeline_fanin_two_to_one() {
|
||||||
|
// 2 productores → 1 consumer (cat). El merger multiplexa.
|
||||||
|
let spec = PipelineSpec {
|
||||||
|
label: "fanin".into(),
|
||||||
|
workspace: WorkspaceId::new(),
|
||||||
|
nodes: vec![
|
||||||
|
cmd("p1", "/bin/echo", &["from-p1"]),
|
||||||
|
cmd("p2", "/bin/echo", &["from-p2"]),
|
||||||
|
cmd("c", "/bin/cat", &[]),
|
||||||
|
],
|
||||||
|
edges: vec![
|
||||||
|
FlowEdge {
|
||||||
|
from: 0,
|
||||||
|
from_output: "stdout".into(),
|
||||||
|
to: 2,
|
||||||
|
to_input: "stdin".into(),
|
||||||
|
},
|
||||||
|
FlowEdge {
|
||||||
|
from: 1,
|
||||||
|
from_output: "stdout".into(),
|
||||||
|
to: 2,
|
||||||
|
to_input: "stdin".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
discern: DiscernPolicy::default(),
|
||||||
|
};
|
||||||
|
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||||
|
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||||
|
let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
|
||||||
|
assert_eq!(launch.command_pids.len(), 3);
|
||||||
|
for (_, pid) in &launch.command_pids {
|
||||||
|
let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pipeline_fanout_one_to_two() {
|
||||||
|
// 1 productor (echo) → 2 consumers (wc -c). Splitter replica.
|
||||||
|
let spec = PipelineSpec {
|
||||||
|
label: "fanout".into(),
|
||||||
|
workspace: WorkspaceId::new(),
|
||||||
|
nodes: vec![
|
||||||
|
cmd("p", "/bin/echo", &["fanout-test"]),
|
||||||
|
cmd("c1", "/bin/cat", &[]),
|
||||||
|
cmd("c2", "/bin/cat", &[]),
|
||||||
|
],
|
||||||
|
edges: vec![
|
||||||
|
FlowEdge {
|
||||||
|
from: 0,
|
||||||
|
from_output: "stdout".into(),
|
||||||
|
to: 1,
|
||||||
|
to_input: "stdin".into(),
|
||||||
|
},
|
||||||
|
FlowEdge {
|
||||||
|
from: 0,
|
||||||
|
from_output: "stdout".into(),
|
||||||
|
to: 2,
|
||||||
|
to_input: "stdin".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
discern: DiscernPolicy::default(),
|
||||||
|
};
|
||||||
|
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||||
|
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||||
|
let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
|
||||||
|
assert_eq!(launch.command_pids.len(), 3);
|
||||||
|
for (_, pid) in &launch.command_pids {
|
||||||
|
let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pipeline_isolated_with_tap_captures_discernment() {
|
async fn pipeline_isolated_with_tap_captures_discernment() {
|
||||||
let spec = PipelineSpec {
|
let spec = PipelineSpec {
|
||||||
@@ -403,11 +692,12 @@ mod tests {
|
|||||||
discern: DiscernPolicy {
|
discern: DiscernPolicy {
|
||||||
sample_bytes: 4096,
|
sample_bytes: 4096,
|
||||||
enrich_producer: true,
|
enrich_producer: true,
|
||||||
|
replay_chunks: 32,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||||
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||||
let launch = run_pipeline(&spec, "ws", true, disc, inc).await.unwrap();
|
let launch = run_pipeline(&spec, "ws", true, disc, inc, None).await.unwrap();
|
||||||
assert_eq!(launch.edge_discernments.len(), 1);
|
assert_eq!(launch.edge_discernments.len(), 1);
|
||||||
let d = &launch.edge_discernments[0];
|
let d = &launch.edge_discernments[0];
|
||||||
let dis = d.discernment.as_ref().expect("discernment present");
|
let dis = d.discernment.as_ref().expect("discernment present");
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
//! Resource accounting por workspace.
|
||||||
|
//!
|
||||||
|
//! Dos fuentes:
|
||||||
|
//! - **Per-proc** (`/proc/<pid>/status` + `stat`): suma RSS y CPU ticks de
|
||||||
|
//! los comandos vivos del workspace. Siempre disponible. Costo: O(N pids).
|
||||||
|
//! - **Cgroup v2** (`memory.current`, `cpu.stat`): un read por workspace si
|
||||||
|
//! `SomaSpec.cgroup.path` está y es leíble. Más preciso (incluye descendants).
|
||||||
|
//!
|
||||||
|
//! Si ambos están disponibles, devolvemos el cgroup (más preciso) y dejamos
|
||||||
|
//! el per-proc como `sample_via_proc`.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct WorkspaceStats {
|
||||||
|
pub commands_alive: u32,
|
||||||
|
pub commands_total: u32,
|
||||||
|
/// RSS sumado en bytes. `None` si no se pudo medir.
|
||||||
|
pub rss_bytes: Option<u64>,
|
||||||
|
/// High-water mark de RSS (peak alguna vez observado). Cgroup v2:
|
||||||
|
/// `memory.peak` (≥6.5). Per-proc: suma de `VmHWM` de cada pid.
|
||||||
|
pub rss_peak_bytes: Option<u64>,
|
||||||
|
/// Tiempo CPU acumulado en microsegundos. `None` si no se pudo medir.
|
||||||
|
pub cpu_usec: Option<u64>,
|
||||||
|
/// Fuente del dato: "proc" | "cgroup" | "mixed".
|
||||||
|
pub source: String,
|
||||||
|
/// Wall-clock uptime del workspace en milisegundos.
|
||||||
|
pub uptime_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mide stats para un set de PIDs vivos + un path de cgroup opcional.
|
||||||
|
pub fn measure(
|
||||||
|
alive_pids: &[i32],
|
||||||
|
cgroup_path: Option<&Path>,
|
||||||
|
workspace_started: Instant,
|
||||||
|
) -> WorkspaceStats {
|
||||||
|
let mut rss_proc: u64 = 0;
|
||||||
|
let mut rss_peak_proc: u64 = 0;
|
||||||
|
let mut cpu_proc: u64 = 0;
|
||||||
|
let mut proc_ok = false;
|
||||||
|
for &pid in alive_pids {
|
||||||
|
if let Some((rss, peak, cpu)) = read_proc_pid(pid) {
|
||||||
|
rss_proc += rss;
|
||||||
|
rss_peak_proc += peak;
|
||||||
|
cpu_proc += cpu;
|
||||||
|
proc_ok = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cgroup = cgroup_path.and_then(read_cgroup_stats);
|
||||||
|
|
||||||
|
let (rss, rss_peak, cpu, source) = match (cgroup, proc_ok) {
|
||||||
|
(Some(cg), _) => (Some(cg.rss), cg.rss_peak, Some(cg.cpu_usec), "cgroup".to_string()),
|
||||||
|
(None, true) => (
|
||||||
|
Some(rss_proc),
|
||||||
|
Some(rss_peak_proc),
|
||||||
|
Some(cpu_proc),
|
||||||
|
"proc".to_string(),
|
||||||
|
),
|
||||||
|
(None, false) => (None, None, None, "none".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
WorkspaceStats {
|
||||||
|
commands_alive: alive_pids.len() as u32,
|
||||||
|
commands_total: 0,
|
||||||
|
rss_bytes: rss,
|
||||||
|
rss_peak_bytes: rss_peak,
|
||||||
|
cpu_usec: cpu,
|
||||||
|
source,
|
||||||
|
uptime_ms: workspace_started.elapsed().as_millis() as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CgroupStats {
|
||||||
|
rss: u64,
|
||||||
|
rss_peak: Option<u64>,
|
||||||
|
cpu_usec: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lee `(rss_bytes, rss_peak_bytes, cpu_usec)` de `/proc/<pid>/`. None si el proc desapareció.
|
||||||
|
fn read_proc_pid(pid: i32) -> Option<(u64, u64, u64)> {
|
||||||
|
let (rss_kb, hwm_kb) = {
|
||||||
|
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||||
|
let mut rss = 0u64;
|
||||||
|
let mut hwm = 0u64;
|
||||||
|
for l in status.lines() {
|
||||||
|
if let Some(rest) = l.strip_prefix("VmRSS:") {
|
||||||
|
rss = rest
|
||||||
|
.trim()
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
} else if let Some(rest) = l.strip_prefix("VmHWM:") {
|
||||||
|
hwm = rest
|
||||||
|
.trim()
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(rss, hwm)
|
||||||
|
};
|
||||||
|
let cpu_usec = {
|
||||||
|
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||||
|
// formato: pid (comm) state ppid pgrp ... utime stime cutime cstime
|
||||||
|
// Cuidado: comm puede tener espacios y paréntesis. Buscamos la última `)`.
|
||||||
|
let end_comm = stat.rfind(')')?;
|
||||||
|
let after = &stat[end_comm + 1..];
|
||||||
|
let fields: Vec<&str> = after.split_whitespace().collect();
|
||||||
|
// Tras `)`, índice 0 = state, índice 11 = utime, 12 = stime.
|
||||||
|
let utime = fields.get(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||||
|
let stime = fields.get(12).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||||
|
let ticks = utime + stime;
|
||||||
|
// Convertimos ticks → microsegundos. SC_CLK_TCK típicamente 100.
|
||||||
|
let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }.max(1) as u64;
|
||||||
|
ticks * 1_000_000 / clk_tck
|
||||||
|
};
|
||||||
|
Some((rss_kb * 1024, hwm_kb * 1024, cpu_usec))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lee `CgroupStats` del cgroup. None si no existe o no es leíble.
|
||||||
|
/// `memory.peak` requiere kernel ≥6.5; si falta, `rss_peak` queda None.
|
||||||
|
fn read_cgroup_stats(cgroup_path: &Path) -> Option<CgroupStats> {
|
||||||
|
let mem = std::fs::read_to_string(cgroup_path.join("memory.current"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse::<u64>().ok())?;
|
||||||
|
let cpu_stat = std::fs::read_to_string(cgroup_path.join("cpu.stat")).ok()?;
|
||||||
|
let cpu_usec = cpu_stat
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("usage_usec"))
|
||||||
|
.and_then(|s| s.split_whitespace().next())
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let peak = std::fs::read_to_string(cgroup_path.join("memory.peak"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse::<u64>().ok());
|
||||||
|
Some(CgroupStats {
|
||||||
|
rss: mem,
|
||||||
|
rss_peak: peak,
|
||||||
|
cpu_usec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn measure_with_no_pids_returns_zero() {
|
||||||
|
let stats = measure(&[], None, Instant::now());
|
||||||
|
assert_eq!(stats.commands_alive, 0);
|
||||||
|
assert_eq!(stats.rss_bytes, None);
|
||||||
|
assert_eq!(stats.source, "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn measure_self_pid_returns_data() {
|
||||||
|
let me = std::process::id() as i32;
|
||||||
|
let stats = measure(&[me], None, Instant::now());
|
||||||
|
assert_eq!(stats.commands_alive, 1);
|
||||||
|
// Nuestro propio RSS debería ser > 0.
|
||||||
|
assert!(stats.rss_bytes.unwrap_or(0) > 0);
|
||||||
|
assert_eq!(stats.source, "proc");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ use ulid::Ulid;
|
|||||||
pub const DEFAULT_SOCK_NAME: &str = "shipote.sock";
|
pub const DEFAULT_SOCK_NAME: &str = "shipote.sock";
|
||||||
pub const MAX_FRAME: usize = 1 << 20;
|
pub const MAX_FRAME: usize = 1 << 20;
|
||||||
|
|
||||||
|
fn default_grace_ms() -> u64 {
|
||||||
|
1000
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Mensajes
|
// Mensajes
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -32,8 +36,13 @@ pub enum Request {
|
|||||||
/// Listar todos los workspaces vivos.
|
/// Listar todos los workspaces vivos.
|
||||||
WorkspaceList,
|
WorkspaceList,
|
||||||
|
|
||||||
/// Detener un workspace y reapear sus comandos.
|
/// Detener un workspace y reapear sus comandos. `grace_ms`: tiempo
|
||||||
WorkspaceStop { id: WorkspaceId },
|
/// que se espera tras SIGTERM antes de SIGKILL. 0 = SIGKILL inmediato.
|
||||||
|
WorkspaceStop {
|
||||||
|
id: WorkspaceId,
|
||||||
|
#[serde(default = "default_grace_ms")]
|
||||||
|
grace_ms: u64,
|
||||||
|
},
|
||||||
|
|
||||||
/// Ejecutar un comando one-shot dentro de un workspace existente.
|
/// Ejecutar un comando one-shot dentro de un workspace existente.
|
||||||
Run {
|
Run {
|
||||||
@@ -50,6 +59,10 @@ pub enum Request {
|
|||||||
/// consumidor de cada FlowEdge, sampleando los primeros bytes
|
/// consumidor de cada FlowEdge, sampleando los primeros bytes
|
||||||
/// y discerniendo el TypeRef.
|
/// y discerniendo el TypeRef.
|
||||||
tap: bool,
|
tap: bool,
|
||||||
|
/// Variables para sustitución `${KEY}` en strings del spec
|
||||||
|
/// antes de spawn (templating).
|
||||||
|
#[serde(default)]
|
||||||
|
vars: std::collections::BTreeMap<String, String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Discernir un buffer ad-hoc (sin workspace). Útil para `shipote discern <file>`.
|
/// Discernir un buffer ad-hoc (sin workspace). Útil para `shipote discern <file>`.
|
||||||
@@ -66,6 +79,8 @@ pub enum Request {
|
|||||||
workspace: shipote_card::WorkspaceId,
|
workspace: shipote_card::WorkspaceId,
|
||||||
command: Ulid,
|
command: Ulid,
|
||||||
tail_bytes: usize,
|
tail_bytes: usize,
|
||||||
|
/// "stdout" | "stderr" | "both" (default "both" si vacío).
|
||||||
|
stream: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Guardar (o reemplazar) un PipelineSpec bajo un nombre.
|
/// Guardar (o reemplazar) un PipelineSpec bajo un nombre.
|
||||||
@@ -78,7 +93,29 @@ pub enum Request {
|
|||||||
PipelineDrop { name: String },
|
PipelineDrop { name: String },
|
||||||
|
|
||||||
/// Ejecutar un pipeline guardado.
|
/// Ejecutar un pipeline guardado.
|
||||||
PipelineRunSaved { name: String, tap: bool },
|
PipelineRunSaved {
|
||||||
|
name: String,
|
||||||
|
tap: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
vars: std::collections::BTreeMap<String, String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Resource accounting de un workspace.
|
||||||
|
WorkspaceStats { workspace: shipote_card::WorkspaceId },
|
||||||
|
|
||||||
|
/// Detener selectivamente los comandos de un pipeline (no el workspace
|
||||||
|
/// entero). `grace_ms`: SIGTERM → wait → SIGKILL.
|
||||||
|
PipelineStop {
|
||||||
|
pipeline: Ulid,
|
||||||
|
#[serde(default = "default_grace_ms")]
|
||||||
|
grace_ms: u64,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Listar pipelines activos con sus flow channels (data plane).
|
||||||
|
FlowList,
|
||||||
|
|
||||||
|
/// Cerrar el data plane de un pipeline (drop sockets + canales).
|
||||||
|
FlowDrop { pipeline: Ulid },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -148,11 +185,47 @@ pub enum Response {
|
|||||||
existed: bool,
|
existed: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
PipelineStopped {
|
||||||
|
pipeline: Ulid,
|
||||||
|
reaped: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
WorkspaceStats {
|
||||||
|
info: WorkspaceStatsInfo,
|
||||||
|
},
|
||||||
|
|
||||||
|
FlowList {
|
||||||
|
items: Vec<FlowInfo>,
|
||||||
|
},
|
||||||
|
|
||||||
|
FlowDropped {
|
||||||
|
pipeline: Ulid,
|
||||||
|
existed: bool,
|
||||||
|
},
|
||||||
|
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WorkspaceStatsInfo {
|
||||||
|
pub commands_alive: u32,
|
||||||
|
pub commands_total: u32,
|
||||||
|
pub rss_bytes: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rss_peak_bytes: Option<u64>,
|
||||||
|
pub cpu_usec: Option<u64>,
|
||||||
|
pub source: String,
|
||||||
|
pub uptime_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FlowInfo {
|
||||||
|
pub pipeline: Ulid,
|
||||||
|
pub sockets: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CommandInfo {
|
pub struct CommandInfo {
|
||||||
pub id: Ulid,
|
pub id: Ulid,
|
||||||
@@ -175,6 +248,9 @@ pub struct EdgeDiscernmentInfo {
|
|||||||
pub mime: Option<String>,
|
pub mime: Option<String>,
|
||||||
pub lens: Option<String>,
|
pub lens: Option<String>,
|
||||||
pub confidence: f32,
|
pub confidence: f32,
|
||||||
|
/// Path del Unix socket donde otros módulos pueden suscribirse a los
|
||||||
|
/// bytes replicados de este edge (data plane). `None` si tap=false.
|
||||||
|
pub flow_socket: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ pub mod env;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod namespaced;
|
pub mod namespaced;
|
||||||
pub mod plain;
|
pub mod plain;
|
||||||
|
pub mod pre_exec;
|
||||||
|
|
||||||
pub use brahman_card::Card;
|
pub use brahman_card::Card;
|
||||||
pub use caps::{CapabilitySet, CgroupStatus, NsKind, UserNsStatus};
|
pub use caps::{CapabilitySet, CgroupStatus, NsKind, UserNsStatus};
|
||||||
pub use env::{EnvSpec, ENV_BUS_SOCK, ENV_ENTE_ID};
|
pub use env::{EnvSpec, ENV_BUS_SOCK, ENV_ENTE_ID};
|
||||||
pub use error::{Degradation, IncarnateError};
|
pub use error::{Degradation, IncarnateError};
|
||||||
|
pub use pre_exec::{ChildPreExec, ChildSetup};
|
||||||
|
|
||||||
use std::os::fd::RawFd;
|
use std::os::fd::RawFd;
|
||||||
|
|
||||||
@@ -201,6 +203,16 @@ impl Incarnator {
|
|||||||
&self,
|
&self,
|
||||||
card: &Card,
|
card: &Card,
|
||||||
stdio: ChildStdio,
|
stdio: ChildStdio,
|
||||||
|
) -> Result<IncarnateOutcome, IncarnateError> {
|
||||||
|
self.incarnate_full(card, stdio, ChildSetup::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variante full: stdio + setup pre-execve.
|
||||||
|
pub fn incarnate_full(
|
||||||
|
&self,
|
||||||
|
card: &Card,
|
||||||
|
stdio: ChildStdio,
|
||||||
|
setup: ChildSetup,
|
||||||
) -> Result<IncarnateOutcome, IncarnateError> {
|
) -> Result<IncarnateOutcome, IncarnateError> {
|
||||||
if self.cfg.strict_caps {
|
if self.cfg.strict_caps {
|
||||||
let v = self.dry_run(card);
|
let v = self.dry_run(card);
|
||||||
@@ -231,9 +243,9 @@ impl Incarnator {
|
|||||||
|
|
||||||
let mut degradations = Vec::new();
|
let mut degradations = Vec::new();
|
||||||
let pid = if namespaced::needs_namespacing(&card.soma.namespaces) {
|
let pid = if namespaced::needs_namespacing(&card.soma.namespaces) {
|
||||||
namespaced::incarnate_namespaced(card, &env_spec, &stdio, &mut degradations)?
|
namespaced::incarnate_namespaced(card, &env_spec, &stdio, &setup, &mut degradations)?
|
||||||
} else {
|
} else {
|
||||||
plain::incarnate_plain(card, &env_spec, &stdio)?
|
plain::incarnate_plain(card, &env_spec, &stdio, &setup)?
|
||||||
};
|
};
|
||||||
Ok(IncarnateOutcome { pid, degradations })
|
Ok(IncarnateOutcome { pid, degradations })
|
||||||
}
|
}
|
||||||
@@ -345,6 +357,48 @@ mod tests {
|
|||||||
// r se cierra al drop del OwnedFd.
|
// r se cierra al drop del OwnedFd.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// child_pre_exec aplica chdir + NoNewPrivs en path plain.
|
||||||
|
#[test]
|
||||||
|
fn child_pre_exec_chdir_changes_pwd() {
|
||||||
|
use crate::{ChildPreExec, ChildSetup};
|
||||||
|
use nix::fcntl::OFlag;
|
||||||
|
use nix::unistd::{pipe2, read};
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::fd::{AsRawFd, IntoRawFd};
|
||||||
|
|
||||||
|
let inc = Incarnator::new(IncarnatorConfig::default());
|
||||||
|
// Comando: /bin/pwd. Si chdir funciona, output = /tmp.
|
||||||
|
let card = make_card(
|
||||||
|
Payload::Native {
|
||||||
|
exec: "/bin/pwd".into(),
|
||||||
|
argv: vec![],
|
||||||
|
envp: vec![],
|
||||||
|
},
|
||||||
|
NamespaceSet::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (r, w) = pipe2(OFlag::empty()).expect("pipe");
|
||||||
|
let w_raw = w.into_raw_fd();
|
||||||
|
let r_raw = r.as_raw_fd();
|
||||||
|
|
||||||
|
let stdio = ChildStdio {
|
||||||
|
stdin_fd: None,
|
||||||
|
stdout_fd: Some(w_raw),
|
||||||
|
stderr_fd: None,
|
||||||
|
};
|
||||||
|
let setup = ChildSetup::new()
|
||||||
|
.with(ChildPreExec::Chdir(CString::new("/tmp").unwrap()))
|
||||||
|
.with(ChildPreExec::NoNewPrivs);
|
||||||
|
let out = inc.incarnate_full(&card, stdio, setup).expect("incarnate");
|
||||||
|
|
||||||
|
let _ = nix::sys::wait::waitpid(out.pid, None);
|
||||||
|
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
let n = read(r_raw, &mut buf).expect("read");
|
||||||
|
let s = std::str::from_utf8(&buf[..n]).unwrap();
|
||||||
|
assert!(s.starts_with("/tmp"), "pwd output was: {s:?}");
|
||||||
|
}
|
||||||
|
|
||||||
/// Smoke: encarnar /bin/true sin ns. No requiere root.
|
/// Smoke: encarnar /bin/true sin ns. No requiere root.
|
||||||
#[test]
|
#[test]
|
||||||
fn incarnate_plain_true_succeeds() {
|
fn incarnate_plain_true_succeeds() {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use crate::child::{apply_rlimits, make_root_private};
|
|||||||
use crate::cgroup::{ensure_cgroup, move_to_cgroup};
|
use crate::cgroup::{ensure_cgroup, move_to_cgroup};
|
||||||
use crate::env::{build_env, EnvSpec};
|
use crate::env::{build_env, EnvSpec};
|
||||||
use crate::error::{Degradation, IncarnateError};
|
use crate::error::{Degradation, IncarnateError};
|
||||||
|
use crate::pre_exec::{apply_unchecked, ChildSetup};
|
||||||
use crate::ChildStdio;
|
use crate::ChildStdio;
|
||||||
use brahman_card::{Card, NamespaceSet, Payload};
|
use brahman_card::{Card, NamespaceSet, Payload};
|
||||||
use nix::fcntl::OFlag;
|
use nix::fcntl::OFlag;
|
||||||
@@ -53,6 +54,7 @@ pub fn incarnate_namespaced(
|
|||||||
card: &Card,
|
card: &Card,
|
||||||
env_spec: &EnvSpec,
|
env_spec: &EnvSpec,
|
||||||
stdio: &ChildStdio,
|
stdio: &ChildStdio,
|
||||||
|
setup: &ChildSetup,
|
||||||
degradations: &mut Vec<Degradation>,
|
degradations: &mut Vec<Degradation>,
|
||||||
) -> Result<Pid, IncarnateError> {
|
) -> Result<Pid, IncarnateError> {
|
||||||
let flags = build_clone_flags(&card.soma.namespaces);
|
let flags = build_clone_flags(&card.soma.namespaces);
|
||||||
@@ -96,6 +98,7 @@ pub fn incarnate_namespaced(
|
|||||||
let stdin_fd = stdio.stdin_fd;
|
let stdin_fd = stdio.stdin_fd;
|
||||||
let stdout_fd = stdio.stdout_fd;
|
let stdout_fd = stdio.stdout_fd;
|
||||||
let stderr_fd = stdio.stderr_fd;
|
let stderr_fd = stdio.stderr_fd;
|
||||||
|
let setup_ops = setup.ops.clone();
|
||||||
|
|
||||||
// SAFETY: la clausura corre en stack nuevo dentro de un proceso recién
|
// SAFETY: la clausura corre en stack nuevo dentro de un proceso recién
|
||||||
// clonado, COW del padre. Sólo syscalls async-signal-safe; sin allocator,
|
// clonado, COW del padre. Sólo syscalls async-signal-safe; sin allocator,
|
||||||
@@ -142,6 +145,14 @@ pub fn incarnate_namespaced(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aplica las ops declarativas pre-execve (NoNewPrivs, chdir, etc.).
|
||||||
|
if !setup_ops.is_empty() {
|
||||||
|
let r = unsafe { apply_unchecked(&setup_ops) };
|
||||||
|
if r != 0 {
|
||||||
|
unsafe { libc::_exit(r) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::execve(exec_c.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr());
|
libc::execve(exec_c.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr());
|
||||||
libc::_exit(102);
|
libc::_exit(102);
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
|
|
||||||
use crate::env::{build_env, EnvSpec};
|
use crate::env::{build_env, EnvSpec};
|
||||||
use crate::error::IncarnateError;
|
use crate::error::IncarnateError;
|
||||||
|
use crate::pre_exec::{apply_unchecked, ChildSetup};
|
||||||
use crate::ChildStdio;
|
use crate::ChildStdio;
|
||||||
use brahman_card::{Card, Payload};
|
use brahman_card::{Card, Payload};
|
||||||
use nix::unistd::Pid;
|
use nix::unistd::Pid;
|
||||||
use std::os::fd::FromRawFd;
|
use std::os::fd::FromRawFd;
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
pub fn incarnate_plain(
|
pub fn incarnate_plain(
|
||||||
card: &Card,
|
card: &Card,
|
||||||
env_spec: &EnvSpec,
|
env_spec: &EnvSpec,
|
||||||
stdio: &ChildStdio,
|
stdio: &ChildStdio,
|
||||||
|
setup: &ChildSetup,
|
||||||
) -> Result<Pid, IncarnateError> {
|
) -> Result<Pid, IncarnateError> {
|
||||||
let (exec, argv, base_envp) = match &card.payload {
|
let (exec, argv, base_envp) = match &card.payload {
|
||||||
Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()),
|
Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()),
|
||||||
@@ -36,6 +39,22 @@ pub fn incarnate_plain(
|
|||||||
if let Some(fd) = stdio.stderr_fd {
|
if let Some(fd) = stdio.stderr_fd {
|
||||||
cmd.stderr(unsafe { Stdio::from_raw_fd(fd) });
|
cmd.stderr(unsafe { Stdio::from_raw_fd(fd) });
|
||||||
}
|
}
|
||||||
|
if !setup.is_empty() {
|
||||||
|
// Clone para que la closure sea 'static (Command::pre_exec lo exige).
|
||||||
|
let ops = setup.ops.clone();
|
||||||
|
// SAFETY: pre_exec corre post-fork pre-exec. apply_unchecked sólo
|
||||||
|
// hace syscalls async-signal-safe.
|
||||||
|
unsafe {
|
||||||
|
cmd.pre_exec(move || {
|
||||||
|
let r = apply_unchecked(&ops);
|
||||||
|
if r != 0 {
|
||||||
|
Err(std::io::Error::from_raw_os_error(libc::EINVAL))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
let child = cmd.spawn()?;
|
let child = cmd.spawn()?;
|
||||||
Ok(Pid::from_raw(child.id() as i32))
|
Ok(Pid::from_raw(child.id() as i32))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! Hook declarativo pre-execve para el hijo.
|
||||||
|
//!
|
||||||
|
//! Las ops corren EN EL HIJO, post-fork/clone, pre-execve. Reglas:
|
||||||
|
//! - sólo syscalls async-signal-safe.
|
||||||
|
//! - sin allocator (los CStrings ya están construidos por el padre).
|
||||||
|
//! - sin Drop con efectos.
|
||||||
|
|
||||||
|
use std::ffi::CString;
|
||||||
|
|
||||||
|
/// Operaciones declarativas aplicables pre-execve.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ChildPreExec {
|
||||||
|
/// `PR_SET_NO_NEW_PRIVS = 1` — bloquea escaladas futuras
|
||||||
|
/// (suid bits, file caps, AT_SECURE). Recomendado en sandboxes.
|
||||||
|
NoNewPrivs,
|
||||||
|
/// `PR_SET_PDEATHSIG = sig` — el child recibe esta señal cuando su
|
||||||
|
/// padre (PID 1 del namespace, o el que sea) muere. Útil para
|
||||||
|
/// auto-cleanup de procesos huérfanos.
|
||||||
|
ParentDeathSig(i32),
|
||||||
|
/// `PR_SET_DUMPABLE` — controla si el proceso permite core dump.
|
||||||
|
Dumpable(bool),
|
||||||
|
/// `setsid()` — nuevo session/group leader (desconecta del controlling tty).
|
||||||
|
NewSession,
|
||||||
|
/// `chdir(path)` — cambiar working dir. Path pre-allocado.
|
||||||
|
Chdir(CString),
|
||||||
|
/// `umask(mode)` — fijar umask (octal, e.g. 0o022).
|
||||||
|
Umask(libc::mode_t),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setup completo del hijo. Default = sin ops.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ChildSetup {
|
||||||
|
pub ops: Vec<ChildPreExec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChildSetup {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, op: ChildPreExec) -> &mut Self {
|
||||||
|
self.ops.push(op);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with(mut self, op: ChildPreExec) -> Self {
|
||||||
|
self.ops.push(op);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.ops.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aplica las ops en orden. SAFETY: ejecuta en el hijo, post-fork,
|
||||||
|
/// pre-execve. Sólo libc, sin allocator, sin Drop.
|
||||||
|
///
|
||||||
|
/// En caso de error, retorna el código de exit que el caller usará para
|
||||||
|
/// abortar el child (igual semántica que el resto de la closure de clone).
|
||||||
|
/// 0 = todo OK.
|
||||||
|
pub unsafe fn apply_unchecked(ops: &[ChildPreExec]) -> i32 {
|
||||||
|
for op in ops {
|
||||||
|
match op {
|
||||||
|
ChildPreExec::NoNewPrivs => {
|
||||||
|
// PR_SET_NO_NEW_PRIVS = 38 en Linux.
|
||||||
|
let r = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1u64, 0u64, 0u64, 0u64) };
|
||||||
|
if r != 0 {
|
||||||
|
return 110;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChildPreExec::ParentDeathSig(sig) => {
|
||||||
|
let r = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, *sig as u64, 0u64, 0u64, 0u64) };
|
||||||
|
if r != 0 {
|
||||||
|
return 111;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChildPreExec::Dumpable(yes) => {
|
||||||
|
let v: u64 = if *yes { 1 } else { 0 };
|
||||||
|
let r = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, v, 0u64, 0u64, 0u64) };
|
||||||
|
if r != 0 {
|
||||||
|
return 112;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChildPreExec::NewSession => {
|
||||||
|
let r = unsafe { libc::setsid() };
|
||||||
|
if r < 0 {
|
||||||
|
return 113;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChildPreExec::Chdir(path) => {
|
||||||
|
let r = unsafe { libc::chdir(path.as_ptr()) };
|
||||||
|
if r != 0 {
|
||||||
|
return 114;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChildPreExec::Umask(mode) => {
|
||||||
|
unsafe { libc::umask(*mode) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user