diff --git a/Cargo.lock b/Cargo.lock index d35c30c..9185e17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2884,6 +2884,19 @@ dependencies = [ "zbus 4.4.0", ] +[[package]] +name = "ente-incarnate" +version = "0.1.0" +dependencies = [ + "anyhow", + "brahman-card", + "libc", + "nix 0.29.0", + "tempfile", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "ente-journald-compat" version = "0.0.1" @@ -3023,7 +3036,7 @@ dependencies = [ "anyhow", "ente-bus", "ente-card", - "libc", + "ente-incarnate", "nix 0.29.0", "tracing", ] @@ -6886,6 +6899,7 @@ dependencies = [ "nouser-nous", "serde", "serde_json", + "shipote-discern", "sled", "tempfile", "thiserror 2.0.18", @@ -9571,6 +9585,111 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shipote-card" +version = "0.1.0" +dependencies = [ + "brahman-card", + "serde", + "serde_json", + "thiserror 2.0.18", + "toml 0.8.23", + "ulid", +] + +[[package]] +name = "shipote-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "brahman-card", + "clap", + "serde_json", + "shipote-card", + "shipote-protocol", + "tokio", + "ulid", +] + +[[package]] +name = "shipote-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "brahman-card", + "ente-incarnate", + "libc", + "nix 0.29.0", + "serde", + "serde_json", + "shipote-card", + "shipote-discern", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "ulid", +] + +[[package]] +name = "shipote-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "brahman-card", + "brahman-sidecar", + "ente-incarnate", + "nix 0.29.0", + "shipote-card", + "shipote-core", + "shipote-discern", + "shipote-protocol", + "tokio", + "tracing", + "tracing-subscriber", + "ulid", +] + +[[package]] +name = "shipote-discern" +version = "0.1.0" +dependencies = [ + "brahman-card", + "serde", + "serde_json", + "toml 0.8.23", +] + +[[package]] +name = "shipote-protocol" +version = "0.1.0" +dependencies = [ + "brahman-card", + "nix 0.29.0", + "postcard", + "serde", + "shipote-card", + "thiserror 2.0.18", + "tokio", + "ulid", +] + +[[package]] +name = "shipote-shell" +version = "0.1.0" +dependencies = [ + "gpui", + "shipote-card", + "shipote-protocol", + "tokio", + "ulid", + "yahweh-launcher", + "yahweh-theme", + "yahweh-widget-app-header", + "yahweh-widget-banner", + "yahweh-widget-stat-card", +] + [[package]] name = "shlex" version = "1.3.0" @@ -12999,6 +13118,7 @@ version = "0.1.0" dependencies = [ "async-trait", "notify", + "shipote-discern", "tokio", "yahweh-core", ] diff --git a/Cargo.toml b/Cargo.toml index ab92a77..11e2a1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/core/brahman-admin", "crates/shared/brahman-sidecar", "crates/shared/brahman-net", + "crates/shared/ente-incarnate", "crates/core/ente-card", "crates/core/ente-bus", "crates/core/ente-cas", @@ -84,6 +85,14 @@ members = [ "crates/modules/nouser/nous-mock", "crates/modules/nouser/nous-real", + # ============================================================ + # modules/shipote/ — runtime de espacios aislados con flujo tipado + # ============================================================ + "crates/modules/shipote/shipote-card", + "crates/modules/shipote/shipote-protocol", + "crates/modules/shipote/shipote-discern", + "crates/modules/shipote/shipote-core", + # ============================================================ # apps/ — apps que consumen el protocolo (yahweh modules+shell) # ============================================================ @@ -98,6 +107,9 @@ members = [ "crates/apps/minga-explorer", "crates/apps/brahman-broker-explorer", "crates/apps/brahman-demo", + "crates/apps/shipote-daemon", + "crates/apps/shipote-cli", + "crates/apps/shipote-shell", ] [workspace.package] diff --git a/crates/apps/file_explorer/src/lib.rs b/crates/apps/file_explorer/src/lib.rs index 4b96cc3..d7eacb9 100644 --- a/crates/apps/file_explorer/src/lib.rs +++ b/crates/apps/file_explorer/src/lib.rs @@ -111,7 +111,7 @@ impl FileExplorer { let mut me = Self { tree_view, - provider: Arc::new(FileDataProvider), + provider: Arc::new(FileDataProvider::new()), root: root.clone(), expanded, children: HashMap::new(), diff --git a/crates/apps/shipote-cli/Cargo.toml b/crates/apps/shipote-cli/Cargo.toml new file mode 100644 index 0000000..d4f4b58 --- /dev/null +++ b/crates/apps/shipote-cli/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "shipote-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "CLI de administración de shipote-daemon." + +[[bin]] +name = "shipote" +path = "src/main.rs" + +[dependencies] +shipote-card = { path = "../../modules/shipote/shipote-card" } +shipote-protocol = { path = "../../modules/shipote/shipote-protocol" } +brahman-card = { path = "../../core/brahman-card" } +anyhow = { workspace = true } +clap = { workspace = true } +tokio = { workspace = true } +serde_json = { workspace = true } +ulid = { workspace = true } diff --git a/crates/apps/shipote-cli/src/main.rs b/crates/apps/shipote-cli/src/main.rs new file mode 100644 index 0000000..eceb3d1 --- /dev/null +++ b/crates/apps/shipote-cli/src/main.rs @@ -0,0 +1,391 @@ +//! `shipote` — CLI de administración del daemon. + +use anyhow::{anyhow, Context, Result}; +use clap::{Parser, Subcommand}; +use shipote_card::{load_pipeline_spec, load_workspace_spec, WorkspaceId}; +use shipote_protocol::{default_socket_path, read_frame, write_frame, Request, Response}; +use std::path::PathBuf; +use tokio::net::UnixStream; +use ulid::Ulid; + +#[derive(Parser, Debug)] +#[command(name = "shipote", version, about = "Administración de shipote-daemon")] +struct Cli { + /// Path al socket del daemon. Default: $XDG_RUNTIME_DIR/shipote.sock. + #[arg(long, global = true)] + socket: Option, + + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Health-check del daemon. + Ping, + + /// Capacidades runtime detectadas por el daemon. + Caps, + + /// Operaciones sobre Workspaces. + #[command(subcommand)] + Workspace(WsCmd), + + /// Ejecutar un comando one-shot dentro de un workspace. + Run { + /// ULID del workspace destino. + #[arg(short = 'w', long)] + workspace: String, + /// Path del ejecutable. + exec: String, + /// Argumentos del comando. + argv: Vec, + }, + + /// Discernir el tipo de un archivo (ad-hoc, sin workspace). + Discern { + /// Path al archivo a discernir. + path: PathBuf, + }, + + /// Listar comandos de un workspace. + Commands { + /// ULID del workspace. + workspace: String, + }, + + /// Mostrar tail del log capturado de un comando. + Logs { + /// ULID del workspace. + workspace: String, + /// ULID del comando. + command: String, + /// Bytes desde el final (0 = todo). + #[arg(long, default_value_t = 0)] + tail: usize, + }, + + /// Pipeline DAG con flujo tipado. + #[command(subcommand)] + Pipeline(PipeCmd), +} + +#[derive(Subcommand, Debug)] +enum PipeCmd { + /// Lanzar un Pipeline desde un spec TOML/JSON. + Run { + /// Path al spec del pipeline. + spec: PathBuf, + /// Interponer un tap entre productor↔consumidor de cada edge para + /// discernir el TypeRef del flujo. + #[arg(long)] + tap: bool, + }, + /// Guardar un pipeline bajo un nombre (persiste con el snapshot). + Save { + /// Nombre simbólico. + name: String, + /// Path al spec. + spec: PathBuf, + }, + /// Listar nombres de pipelines guardados. + SavedList, + /// Eliminar un pipeline guardado. + Drop { name: String }, + /// Ejecutar un pipeline guardado por nombre. + RunSaved { + name: String, + #[arg(long)] + tap: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum WsCmd { + /// Crear un workspace desde un spec TOML/JSON. + Create { + /// Path al spec del workspace. + spec: PathBuf, + }, + /// Listar workspaces vivos. + List, + /// Detener un workspace por ID. + Stop { + id: String, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + let socket = cli.socket.unwrap_or_else(default_socket_path); + let mut stream = UnixStream::connect(&socket) + .await + .with_context(|| format!("connect {}", socket.display()))?; + + match cli.cmd { + Cmd::Ping => { + let resp = round_trip(&mut stream, Request::Ping).await?; + match resp { + Response::Pong => println!("pong"), + other => print_unexpected(&other), + } + } + + Cmd::Caps => { + let resp = round_trip(&mut stream, Request::Capabilities).await?; + match resp { + Response::Capabilities { + kernel_version, + user_ns, + cgroup_v2, + cgroup_delegated, + has_cap_sys_admin, + } => { + println!("kernel: {}.{}.{}", kernel_version.0, kernel_version.1, kernel_version.2); + println!("user_ns: {user_ns}"); + println!("cgroup_v2: {cgroup_v2}"); + println!("cgroup_delegated: {cgroup_delegated}"); + println!("cap_sys_admin: {has_cap_sys_admin}"); + } + other => print_unexpected(&other), + } + } + + Cmd::Workspace(WsCmd::Create { spec }) => { + let ws = load_workspace_spec(&spec).with_context(|| format!("load {}", spec.display()))?; + let resp = round_trip(&mut stream, Request::WorkspaceCreate { spec: ws }).await?; + match resp { + Response::WorkspaceCreated { id, warnings } => { + println!("{id}"); + for w in warnings { + eprintln!("warning: {w}"); + } + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Workspace(WsCmd::List) => { + let resp = round_trip(&mut stream, Request::WorkspaceList).await?; + match resp { + Response::WorkspaceList { items } => { + if items.is_empty() { + println!("(no workspaces)"); + } + for it in items { + println!( + "{} {:<20} cmds={} uptime={}ms", + it.id, it.label, it.commands, it.uptime_ms + ); + } + } + other => print_unexpected(&other), + } + } + + Cmd::Workspace(WsCmd::Stop { id }) => { + let id = parse_ws_id(&id)?; + let resp = round_trip(&mut stream, Request::WorkspaceStop { id }).await?; + match resp { + Response::WorkspaceStopped { id, reaped } => { + println!("stopped {id} (reaped {reaped})"); + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Run { workspace, exec, argv } => { + let id = parse_ws_id(&workspace)?; + let resp = round_trip( + &mut stream, + Request::Run { + workspace: id, + exec, + argv, + envp: vec![], + }, + ) + .await?; + match resp { + Response::RunStarted { command_id, pid, .. } => { + println!("{command_id} pid={pid}"); + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Pipeline(PipeCmd::Run { spec, tap }) => { + let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?; + let resp = round_trip(&mut stream, Request::PipelineRun { spec: p, tap }).await?; + print_pipeline_started(resp)?; + } + + Cmd::Pipeline(PipeCmd::Save { name, spec }) => { + let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?; + let resp = round_trip(&mut stream, Request::PipelineSave { name: name.clone(), spec: p }).await?; + match resp { + Response::PipelineSaved { name } => println!("saved {name}"), + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Pipeline(PipeCmd::SavedList) => { + let resp = round_trip(&mut stream, Request::PipelineSavedList).await?; + match resp { + Response::PipelineSavedList { names } => { + if names.is_empty() { + println!("(no saved pipelines)"); + } + for n in names { + println!("{n}"); + } + } + other => print_unexpected(&other), + } + } + + Cmd::Pipeline(PipeCmd::Drop { name }) => { + let resp = round_trip(&mut stream, Request::PipelineDrop { name }).await?; + match resp { + Response::PipelineDropped { name, existed } => { + if existed { + println!("dropped {name}"); + } else { + eprintln!("no existía: {name}"); + } + } + other => print_unexpected(&other), + } + } + + Cmd::Pipeline(PipeCmd::RunSaved { name, tap }) => { + let resp = round_trip(&mut stream, Request::PipelineRunSaved { name, tap }).await?; + print_pipeline_started(resp)?; + } + + Cmd::Commands { workspace } => { + let ws = parse_ws_id(&workspace)?; + let resp = round_trip(&mut stream, Request::CommandList { workspace: ws }).await?; + match resp { + Response::CommandList { items } => { + if items.is_empty() { + println!("(no commands)"); + } + for c in items { + let alive = if c.alive { "alive" } else { "exited" }; + let exit = c + .exit_status + .map(|s| format!("exit={s}")) + .unwrap_or_default(); + println!( + "{} {:<24} pid={:<7} {:<8} logs={} {}", + c.id, c.label, c.pid, alive, c.log_bytes, exit + ); + } + } + other => print_unexpected(&other), + } + } + + Cmd::Logs { workspace, command, tail } => { + let ws = parse_ws_id(&workspace)?; + let cmd_id = Ulid::from_string(&command).map_err(|e| anyhow!("invalid command id: {e}"))?; + let resp = round_trip( + &mut stream, + Request::CommandLogs { + workspace: ws, + command: cmd_id, + tail_bytes: tail, + }, + ) + .await?; + match resp { + Response::CommandLogs { bytes } => { + // stdout raw, sin decoding — el log puede tener bytes binarios. + use std::io::Write; + let _ = std::io::stdout().write_all(&bytes); + let _ = std::io::stdout().flush(); + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Discern { path } => { + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + // Sample: hasta 4 KiB. + let sample = bytes.into_iter().take(4096).collect(); + let resp = round_trip( + &mut stream, + Request::Discern { + sample, + hint_path: Some(path), + }, + ) + .await?; + match resp { + Response::Discernment { ty, confidence, mime, lens } => { + println!("type: {ty}"); + println!("confidence: {confidence:.2}"); + if let Some(m) = mime { + println!("mime: {m}"); + } + if let Some(l) = lens { + println!("lens: {l}"); + } + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + } + + Ok(()) +} + +async fn round_trip(stream: &mut UnixStream, req: Request) -> Result { + write_frame(stream, &req).await?; + let resp: Response = read_frame(stream).await?; + Ok(resp) +} + +fn parse_ws_id(s: &str) -> Result { + let u = Ulid::from_string(s).map_err(|e| anyhow!("invalid workspace id: {e}"))?; + Ok(WorkspaceId(u)) +} + +fn print_unexpected(r: &Response) { + eprintln!("unexpected response: {r:?}"); +} + +fn print_pipeline_started(resp: Response) -> Result<()> { + match resp { + Response::PipelineStarted { pipeline, command_pids, edges } => { + println!("pipeline {pipeline}"); + for (label, pid) in command_pids { + println!(" {:<20} pid={pid}", label); + } + if !edges.is_empty() { + println!("edges:"); + for e in edges { + println!( + " {}.{} → {}.{} ty={:?} mime={:?} conf={:.2}", + e.from_label, e.from_output, e.to_label, e.to_input, + e.ty, e.mime, e.confidence, + ); + } + } + Ok(()) + } + Response::Error { message } => Err(anyhow!(message)), + other => { + print_unexpected(&other); + Ok(()) + } + } +} diff --git a/crates/apps/shipote-daemon/Cargo.toml b/crates/apps/shipote-daemon/Cargo.toml new file mode 100644 index 0000000..1f08310 --- /dev/null +++ b/crates/apps/shipote-daemon/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "shipote-daemon" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Daemon de shipote: dueño de los Workspaces, expone admin socket para shipote-cli." + +[[bin]] +name = "shipote-daemon" +path = "src/main.rs" + +[dependencies] +shipote-card = { path = "../../modules/shipote/shipote-card" } +shipote-protocol = { path = "../../modules/shipote/shipote-protocol" } +shipote-discern = { path = "../../modules/shipote/shipote-discern" } +shipote-core = { path = "../../modules/shipote/shipote-core" } +ente-incarnate = { path = "../../shared/ente-incarnate" } +brahman-card = { path = "../../core/brahman-card" } +brahman-sidecar = { path = "../../shared/brahman-sidecar" } +anyhow = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +ulid = { workspace = true } +nix = { workspace = true } diff --git a/crates/apps/shipote-daemon/src/main.rs b/crates/apps/shipote-daemon/src/main.rs new file mode 100644 index 0000000..76512b0 --- /dev/null +++ b/crates/apps/shipote-daemon/src/main.rs @@ -0,0 +1,361 @@ +//! `shipote-daemon` — punto de entrada del runtime de shipote. +//! +//! Responsabilidades: +//! - Escuchar el Unix socket admin (default: `$XDG_RUNTIME_DIR/shipote.sock`). +//! - Despachar mensajes del [`shipote_protocol`] al [`WorkspaceManager`]. +//! - Reapear hijos periódicamente. +//! +//! Lo que NO hace en v1: +//! - Sidecar al broker / handshake con Init (futuro: cuando un workspace +//! exponga `service_socket`, anunciar al broker). +//! - GUI (futuro `shipote-shell` con yahweh_launcher). + +use anyhow::Context; +use brahman_card::{Card, CardKind, Flow, Flows, Lifecycle, Payload, Supervision, TypeRef}; +use ente_incarnate::IncarnatorConfig; +use shipote_core::WorkspaceManager; +use shipote_discern::{DiscernPipeline, Hint}; +use shipote_protocol::{ + default_socket_path, read_frame, write_frame, CommandInfo as ProtoCommandInfo, + EdgeDiscernmentInfo, Request, Response, WorkspaceSummary, +}; +use std::sync::Arc; +use tokio::net::{UnixListener, UnixStream}; +use tracing::{error, info, warn}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_tracing(); + let sock = default_socket_path(); + if sock.exists() { + // Si ya existe, asumimos restart limpio. Si hubiera otro daemon vivo, + // bind fallaría con EADDRINUSE — más adelante: lockfile + check de PID. + let _ = std::fs::remove_file(&sock); + } + if let Some(parent) = sock.parent() { + let _ = std::fs::create_dir_all(parent); + } + let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?; + info!(socket = %sock.display(), "shipote-daemon listening"); + + // Sidecar al broker: shipote se anuncia como sesión. Si el Init no + // está corriendo, el sidecar loguea y termina; el daemon sigue + // standalone (UX de v1: ningún feature requiere broker). + brahman_sidecar::spawn(build_daemon_card(&sock)); + + let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig { + // El daemon aún no se conecta al broker; cuando lo haga, este path + // se llenará desde el handshake. + bus_sock: None, + notify_socket: None, + extra_env: vec![("SHIPOTE_DAEMON".into(), "1".into())], + // strict_caps=false en v1: queremos UX permisiva (correr en non-root + // sin user_ns y avisar via warnings, no abortar). + strict_caps: false, + })); + + // Restaurar snapshot previo si existe. Workspaces se recrean; los + // pids de comandos viejos NO se recuperan (kernel los mató). + let snapshot_path = shipote_core::persist::default_snapshot_path(); + if let Err(e) = mgr.restore_snapshot(&snapshot_path).await { + warn!(?e, "restore_snapshot falló — start fresh"); + } + + // Save-on-shutdown via SIGTERM/SIGINT handler. tokio::signal soporta + // ambos en Linux. + { + let mgr = mgr.clone(); + let path = snapshot_path.clone(); + tokio::spawn(async move { + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("SIGTERM handler"); + let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .expect("SIGINT handler"); + tokio::select! { + _ = term.recv() => info!("SIGTERM — saving snapshot"), + _ = int.recv() => info!("SIGINT — saving snapshot"), + } + if let Err(e) = mgr.save_snapshot(&path).await { + warn!(?e, "save_snapshot falló"); + } + std::process::exit(0); + }); + } + + let discerner = Arc::new(DiscernPipeline::default_pipeline()); + + // Reaper periódico cada 500 ms. + { + let mgr = mgr.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_millis(500)); + loop { + tick.tick().await; + mgr.reap_dead().await; + } + }); + } + + loop { + match listener.accept().await { + Ok((stream, _)) => { + let mgr = mgr.clone(); + let disc = discerner.clone(); + tokio::spawn(async move { + if let Err(e) = handle_client(stream, mgr, disc).await { + warn!(?e, "client handler error"); + } + }); + } + Err(e) => { + error!(?e, "accept failed"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + } +} + +async fn handle_client( + mut stream: UnixStream, + mgr: Arc, + disc: Arc, +) -> anyhow::Result<()> { + loop { + let req: Request = match read_frame(&mut stream).await { + Ok(r) => r, + Err(shipote_protocol::ProtocolError::Closed) => return Ok(()), + Err(e) => return Err(e.into()), + }; + let resp = dispatch(&mgr, &disc, req).await; + write_frame(&mut stream, &resp).await?; + } +} + +async fn dispatch(mgr: &Arc, disc: &DiscernPipeline, req: Request) -> Response { + match req { + Request::Ping => Response::Pong, + + Request::WorkspaceCreate { spec } => match mgr.create(spec).await { + Ok((id, warnings)) => Response::WorkspaceCreated { id, warnings }, + Err(e) => Response::Error { message: format!("{e}") }, + }, + + Request::WorkspaceList => { + let items = mgr + .list() + .await + .into_iter() + .map(|s| WorkspaceSummary { + id: s.id, + label: s.label, + commands: s.commands, + uptime_ms: s.uptime_ms, + }) + .collect(); + Response::WorkspaceList { items } + } + + Request::WorkspaceStop { id } => match mgr.stop(id).await { + Ok(reaped) => Response::WorkspaceStopped { id, reaped }, + Err(e) => Response::Error { message: format!("{e}") }, + }, + + Request::Run { workspace, exec, argv, envp } => { + match mgr.run(workspace, exec, argv, envp).await { + Ok(s) => Response::RunStarted { + workspace, + command_id: s.id, + pid: s.pid, + }, + Err(e) => Response::Error { message: format!("{e}") }, + } + } + + Request::PipelineRun { spec, tap } => { + let disc = DiscernPipeline::default_pipeline(); + let inc = mgr.incarnator_handle(); + let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default(); + match shipote_core::pipeline::run_pipeline( + &spec, + &ws_label, + tap, + std::sync::Arc::new(disc), + inc, + ) + .await + { + Ok(launch) => Response::PipelineStarted { + pipeline: launch.pipeline, + command_pids: launch.command_pids, + edges: launch + .edge_discernments + .into_iter() + .map(|e| 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), + }) + .collect(), + }, + Err(e) => Response::Error { message: format!("{e}") }, + } + } + + Request::Discern { sample, hint_path } => { + let path_str = hint_path.as_ref().and_then(|p| p.to_str()); + let hint = Hint { + path: path_str, + size_total: None, + }; + match disc.discern(&sample, &hint) { + Some(d) => Response::Discernment { + ty: format!("{:?}", d.ty), + confidence: d.confidence, + mime: d.mime, + lens: d.lens, + }, + None => Response::Error { message: "no discernment".into() }, + } + } + + Request::CommandList { workspace } => { + let items: Vec = mgr + .list_commands(workspace) + .await + .into_iter() + .map(|c| ProtoCommandInfo { + id: c.id, + label: c.label, + pid: c.pid, + alive: c.alive, + exit_status: c.exit_status, + log_bytes: c.log_bytes, + }) + .collect(); + Response::CommandList { items } + } + + Request::CommandLogs { workspace, command, tail_bytes } => { + match mgr.get_command_logs(workspace, command, tail_bytes).await { + Some(bytes) => Response::CommandLogs { bytes }, + None => Response::Error { + message: format!("no logs for command {command} in workspace {workspace}"), + }, + } + } + + Request::PipelineSave { name, spec } => { + mgr.save_pipeline(name.clone(), spec).await; + Response::PipelineSaved { name } + } + + Request::PipelineSavedList => { + let names = mgr.list_saved_pipelines().await; + Response::PipelineSavedList { names } + } + + Request::PipelineDrop { name } => { + let existed = mgr.drop_saved_pipeline(&name).await; + Response::PipelineDropped { name, existed } + } + + Request::PipelineRunSaved { name, tap } => match mgr.get_saved_pipeline(&name).await { + Some(spec) => { + let disc = DiscernPipeline::default_pipeline(); + let inc = mgr.incarnator_handle(); + let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default(); + match shipote_core::pipeline::run_pipeline( + &spec, + &ws_label, + tap, + std::sync::Arc::new(disc), + inc, + ) + .await + { + Ok(launch) => Response::PipelineStarted { + pipeline: launch.pipeline, + command_pids: launch.command_pids, + edges: launch + .edge_discernments + .into_iter() + .map(|e| 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), + }) + .collect(), + }, + Err(e) => Response::Error { message: format!("{e}") }, + } + } + None => Response::Error { + message: format!("pipeline `{name}` no encontrado"), + }, + }, + + Request::Capabilities => { + let c = mgr.incarnator().capabilities(); + Response::Capabilities { + kernel_version: c.kernel_version, + user_ns: format!("{:?}", c.user_ns), + cgroup_v2: format!("{:?}", c.cgroup_v2), + cgroup_delegated: c.cgroup_delegated, + has_cap_sys_admin: c.has_cap_sys_admin, + } + } + } +} + +/// Card del daemon. La presentamos al broker así otras sesiones pueden +/// descubrir que shipote está corriendo y, eventualmente, conectarse +/// como consumidoras del flow `workspaces` (futuro: que la GUI o el +/// broker-explorer los listen vía broker en lugar de socket directo). +fn build_daemon_card(service_socket: &std::path::Path) -> Card { + let mut card = Card::new("shipote.daemon"); + card.kind = CardKind::Ente; + card.lifecycle = Lifecycle::Daemon; + card.payload = Payload::Virtual; // el daemon ya está corriendo (no es PID 1 quien lo encarna) + card.supervision = Supervision::Delegate; + card.service_socket = Some(service_socket.to_path_buf()); + card.flow = Flows { + input: Vec::new(), + output: vec![ + Flow { + name: "workspaces".into(), + ty: TypeRef::Wit { + package: "shipote:admin".into(), + interface: None, + name: "workspace-list".into(), + }, + pin_to: None, + }, + Flow { + name: "discern".into(), + ty: TypeRef::Wit { + package: "shipote:admin".into(), + interface: None, + name: "discernment".into(), + }, + pin_to: None, + }, + ], + }; + card +} + +fn init_tracing() { + use tracing_subscriber::{fmt, EnvFilter}; + let filter = EnvFilter::try_from_env("SHIPOTE_LOG").unwrap_or_else(|_| EnvFilter::new("info")); + fmt().with_env_filter(filter).init(); +} diff --git a/crates/apps/shipote-shell/Cargo.toml b/crates/apps/shipote-shell/Cargo.toml new file mode 100644 index 0000000..6eaa0cb --- /dev/null +++ b/crates/apps/shipote-shell/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "shipote-shell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "GUI de shipote: vista de Workspaces+comandos+capabilities. Conecta al daemon vía shipote-protocol." + +[[bin]] +name = "shipote-shell" +path = "src/main.rs" + +[dependencies] +shipote-card = { path = "../../modules/shipote/shipote-card" } +shipote-protocol = { path = "../../modules/shipote/shipote-protocol" } +yahweh-theme = { path = "../../modules/ui_engine/libs/theme" } +yahweh-launcher = { path = "../../modules/ui_engine/libs/launcher" } +yahweh-widget-banner = { path = "../../modules/ui_engine/widgets/banner" } +yahweh-widget-stat-card = { path = "../../modules/ui_engine/widgets/stat-card" } +yahweh-widget-app-header = { path = "../../modules/ui_engine/widgets/app-header" } +tokio = { workspace = true } +gpui = { workspace = true } +ulid = { workspace = true } diff --git a/crates/apps/shipote-shell/src/main.rs b/crates/apps/shipote-shell/src/main.rs new file mode 100644 index 0000000..acd4706 --- /dev/null +++ b/crates/apps/shipote-shell/src/main.rs @@ -0,0 +1,363 @@ +//! `shipote-shell` — GUI dashboard del daemon shipote. +//! +//! Probe-style: conecta al socket del daemon cada 2s, pide +//! capabilities + workspace-list y los muestra en cards. +//! Si el daemon no está corriendo, marca DOWN. + +use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window}; +use shipote_protocol::{ + default_socket_path, read_frame, write_frame, CommandInfo, Request, Response, WorkspaceSummary, +}; +use std::path::PathBuf; +use std::time::Duration; +use tokio::net::UnixStream; +use yahweh_launcher::launch_app; +use yahweh_theme::Theme; +use yahweh_widget_app_header::app_header; +use yahweh_widget_banner::{banner_themed, Banner}; +use yahweh_widget_stat_card::stat_card; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Clone, Debug)] +enum DaemonState { + Pending, + Down { reason: String }, + Up, +} + +#[derive(Clone, Debug, Default)] +struct CapsSummary { + kernel_version: (u32, u32, u32), + user_ns: String, + cgroup_v2: String, + cgroup_delegated: bool, + has_cap_sys_admin: bool, +} + +struct Shell { + socket_path: PathBuf, + state: DaemonState, + workspaces: Vec, + /// Comandos por workspace, indexados por workspace id.toString(). + commands: std::collections::BTreeMap>, + saved_pipelines: Vec, + caps: Option, + last_probe_ms: u64, +} + +fn main() { + launch_app("Shipote — Shell", (820., 560.), Shell::new); +} + +impl Shell { + fn new(cx: &mut Context) -> Self { + let socket_path = default_socket_path(); + let socket_for_loop = socket_path.clone(); + cx.spawn(async move |this, cx| { + let timer = cx.background_executor().clone(); + let bg = cx.background_executor().clone(); + loop { + let path = socket_for_loop.clone(); + let started = std::time::Instant::now(); + let result = bg + .spawn(async move { probe_blocking(&path) }) + .await; + let elapsed = started.elapsed().as_millis() as u64; + let _ = this.update(cx, |me, cx| { + match result { + Ok(snap) => { + me.state = DaemonState::Up; + me.workspaces = snap.workspaces; + me.commands = snap.commands; + me.saved_pipelines = snap.saved_pipelines; + me.caps = Some(snap.caps); + } + Err(reason) => { + me.state = DaemonState::Down { reason }; + me.workspaces.clear(); + me.commands.clear(); + me.saved_pipelines.clear(); + me.caps = None; + } + } + me.last_probe_ms = elapsed; + cx.notify(); + }); + timer.timer(POLL_INTERVAL).await; + } + }) + .detach(); + + Self { + socket_path, + state: DaemonState::Pending, + workspaces: Vec::new(), + commands: std::collections::BTreeMap::new(), + saved_pipelines: Vec::new(), + caps: None, + last_probe_ms: 0, + } + } +} + +#[derive(Debug)] +struct Snapshot { + workspaces: Vec, + commands: std::collections::BTreeMap>, + saved_pipelines: Vec, + caps: CapsSummary, +} + +fn probe_blocking(path: &std::path::Path) -> Result { + // Mini tokio runtime efímero por probe — no compartimos runtime con + // GPUI. Costo aceptable cada 2s: setup ≈ <1 ms. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .map_err(|e| format!("rt: {e}"))?; + rt.block_on(async { + let mut stream = UnixStream::connect(path) + .await + .map_err(|e| format!("connect: {e}"))?; + write_frame(&mut stream, &Request::WorkspaceList) + .await + .map_err(|e| format!("write list: {e}"))?; + let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read list: {e}"))?; + let workspaces = match resp { + Response::WorkspaceList { items } => items, + other => return Err(format!("unexpected list resp: {other:?}")), + }; + + // Commands por workspace. + let mut commands_map = std::collections::BTreeMap::new(); + for w in &workspaces { + write_frame(&mut stream, &Request::CommandList { workspace: w.id }) + .await + .map_err(|e| format!("write commands: {e}"))?; + let resp: Response = read_frame(&mut stream) + .await + .map_err(|e| format!("read commands: {e}"))?; + if let Response::CommandList { items } = resp { + if !items.is_empty() { + commands_map.insert(w.id.to_string(), items); + } + } + } + + // Saved pipelines. + write_frame(&mut stream, &Request::PipelineSavedList) + .await + .map_err(|e| format!("write saved: {e}"))?; + let resp: Response = read_frame(&mut stream) + .await + .map_err(|e| format!("read saved: {e}"))?; + let saved_pipelines = match resp { + Response::PipelineSavedList { names } => names, + _ => Vec::new(), + }; + + write_frame(&mut stream, &Request::Capabilities) + .await + .map_err(|e| format!("write caps: {e}"))?; + let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read caps: {e}"))?; + let caps = match resp { + Response::Capabilities { + kernel_version, + user_ns, + cgroup_v2, + cgroup_delegated, + has_cap_sys_admin, + } => CapsSummary { + kernel_version, + user_ns, + cgroup_v2, + cgroup_delegated, + has_cap_sys_admin, + }, + other => return Err(format!("unexpected caps resp: {other:?}")), + }; + Ok(Snapshot { + workspaces, + commands: commands_map, + saved_pipelines, + caps, + }) + }) +} + +impl Render for Shell { + fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + let bg = theme.bg_app.clone(); + let text = theme.fg_text; + let text_dim = theme.fg_muted; + + let accent_up = gpui::rgb(0xa3be8c); + let accent_down = gpui::rgb(0xbf616a); + let accent_pending = gpui::rgb(0x6a7280); + let accent_info = gpui::rgb(0x88c0d0); + + let header_text = format!( + "Daemon: {} · reload {} ms", + self.socket_path.display(), + self.last_probe_ms + ); + let header = app_header(cx, header_text); + + let status_banner = match &self.state { + DaemonState::Pending => None, + DaemonState::Down { reason } => Some(banner_themed( + cx, + Banner::Error, + SharedString::from(format!("Daemon DOWN — {reason}")), + )), + DaemonState::Up => Some(banner_themed( + cx, + Banner::Success, + SharedString::from("Daemon UP"), + )), + }; + + let (status_value, status_descr, status_accent) = match &self.state { + DaemonState::Pending => ("PENDING".to_string(), "primer probe…".to_string(), accent_pending), + DaemonState::Down { reason } => ("DOWN".to_string(), reason.clone(), accent_down), + DaemonState::Up => ("UP".to_string(), "shipote-daemon respondiendo".to_string(), accent_up), + }; + + let caps_items: Vec = self + .caps + .as_ref() + .map(|c| { + vec![ + format!( + "kernel: {}.{}.{}", + c.kernel_version.0, c.kernel_version.1, c.kernel_version.2 + ), + format!("user_ns: {}", c.user_ns), + format!("cgroup_v2: {}", c.cgroup_v2), + format!("cgroup_delegated: {}", c.cgroup_delegated), + format!("cap_sys_admin: {}", c.has_cap_sys_admin), + ] + }) + .unwrap_or_default(); + let caps_value = if self.caps.is_some() { "OK".to_string() } else { "—".to_string() }; + + let ws_items: Vec = self + .workspaces + .iter() + .map(|w| format!("{} {:<20} cmds={} uptime={}ms", w.id, w.label, w.commands, w.uptime_ms)) + .collect(); + let ws_count = self.workspaces.len().to_string(); + let ws_descr = if self.workspaces.is_empty() { + "no hay workspaces vivos".to_string() + } else { + "id · label · cmds · uptime".to_string() + }; + + // Comandos: aplanamos por workspace, tomamos los más recientes (orden ULID ya temporal). + let mut cmd_items: Vec = Vec::new(); + let mut cmd_total = 0usize; + for (ws_id, cmds) in &self.commands { + cmd_total += cmds.len(); + for c in cmds.iter().rev().take(8) { + let alive = if c.alive { "▶" } else { "✓" }; + let exit = c + .exit_status + .map(|s| format!(" exit={s}")) + .unwrap_or_default(); + cmd_items.push(format!( + "{} {} {:<20} pid={} logs={}B{}", + alive, + &ws_id[..6.min(ws_id.len())], + c.label, + c.pid, + c.log_bytes, + exit + )); + } + } + let cmd_count = cmd_total.to_string(); + let cmd_descr = if cmd_total == 0 { + "no hay comandos lanzados".to_string() + } else { + "▶=alive ✓=exited · ws_prefix · label · pid · logs".to_string() + }; + + // Saved pipelines. + let saved_count = self.saved_pipelines.len().to_string(); + let saved_items: Vec = self.saved_pipelines.clone(); + let saved_descr = if saved_items.is_empty() { + "shipote pipeline save para persistir".to_string() + } else { + "definiciones reusables vía run-saved".to_string() + }; + + let body = div() + .flex() + .flex_col() + .gap(px(8.)) + .px(px(16.)) + .py(px(16.)) + .child(stat_card( + cx, + "Estado", + status_value, + &status_descr, + status_accent, + text, + text_dim, + &[], + )) + .child(stat_card( + cx, + "Capabilities", + caps_value, + "kernel + namespaces + cgroup delegation", + accent_info, + text, + text_dim, + &caps_items, + )) + .child(stat_card( + cx, + "Workspaces", + ws_count, + &ws_descr, + accent_info, + text, + text_dim, + &ws_items, + )) + .child(stat_card( + cx, + "Comandos", + cmd_count, + &cmd_descr, + accent_info, + text, + text_dim, + &cmd_items, + )) + .child(stat_card( + cx, + "Saved pipelines", + saved_count, + &saved_descr, + accent_info, + text, + text_dim, + &saved_items, + )); + + div() + .flex() + .flex_col() + .size_full() + .bg(bg) + .child(header) + .when_some(status_banner, |d, b| d.child(b)) + .child(body) + } +} diff --git a/crates/apps/text_viewer/src/lib.rs b/crates/apps/text_viewer/src/lib.rs index 135b40f..070bb94 100644 --- a/crates/apps/text_viewer/src/lib.rs +++ b/crates/apps/text_viewer/src/lib.rs @@ -114,7 +114,7 @@ impl TextViewer { } fn spawn_load_fs(&self, path: String, gen: u64, cx: &mut Context) { - let provider = Arc::new(FileDataProvider); + let provider = Arc::new(FileDataProvider::new()); cx.spawn(async move |this, cx| { let result = provider.get_data(&path).await; let _ = this.update(cx, |this, cx| this.on_loaded(gen, result, cx)); diff --git a/crates/core/ente-soma/Cargo.toml b/crates/core/ente-soma/Cargo.toml index 63df673..78d24a5 100644 --- a/crates/core/ente-soma/Cargo.toml +++ b/crates/core/ente-soma/Cargo.toml @@ -4,11 +4,12 @@ version = "0.0.1" edition.workspace = true license.workspace = true publish.workspace = true +description = "Wrapper histórico sobre ente-incarnate para mantener la API set_bus_sock+incarnate que usa ente-zero. Toda la lógica vive en ente-incarnate." [dependencies] ente-card = { path = "../ente-card" } ente-bus = { path = "../ente-bus" } +ente-incarnate = { path = "../../shared/ente-incarnate" } nix = { workspace = true } -libc = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } diff --git a/crates/core/ente-soma/src/lib.rs b/crates/core/ente-soma/src/lib.rs index e64ca01..c94f0d3 100644 --- a/crates/core/ente-soma/src/lib.rs +++ b/crates/core/ente-soma/src/lib.rs @@ -1,362 +1,44 @@ -//! Encarnación del Soma: traducción de SomaSpec a syscalls. +//! `ente-soma` — wrapper histórico sobre [`ente_incarnate`]. //! -//! Esta capa es la única parte de PID 1 que toca syscalls de namespacing — -//! todo lo demás opera sobre tipos de alto nivel. La complejidad vive aquí -//! por diseño: encapsulada, auditable, y con un único punto de entrada. +//! La rutina de namespacing fue extraída a `ente-incarnate` para que +//! shipote, exploradores y cualquier supervisor no-PID-1 puedan reusarla. +//! Este crate sobrevive como compat para `ente-zero` y otros que importan +//! `ente_soma::{set_bus_sock, incarnate}`. //! -//! ## Protocolo padre↔hijo en el path namespaced -//! -//! ```text -//! parent child -//! | | -//! |--- clone() ------->| (child empieza dentro de los nuevos NS) -//! | | -//! | |---- read(sync_r, 1) ---- (bloquea) -//! | | -//! | write uid_map | -//! | write gid_map | -//! | cgroup move | -//! | cpu affinity | -//! | | -//! |--- write(sync_w) ->| -//! | |---- setrlimit -//! | |---- mount(/, MS_PRIVATE | MS_REC) -//! | |---- execve() -//! ``` +//! Semántica preservada: +//! - `BUS_SOCK_PATH` global vía `OnceLock` (init lo setea una vez). +//! - `NOTIFY_SOCKET=/run/systemd/notify` se inyecta automáticamente. +//! - `strict_caps = false` (errores no-fatales se loguean, encarnación sigue). -use ente_card::{CgroupSpec, EntityCard, NamespaceSet, Payload, ResourceLimits}; -use nix::fcntl::OFlag; -use nix::sched::CloneFlags; -use nix::unistd::{pipe2, Pid}; -use std::ffi::CString; -use std::os::fd::{AsRawFd, IntoRawFd, RawFd}; -use std::process::Command; +use ente_card::EntityCard; +use ente_incarnate::{Incarnator, IncarnatorConfig}; +use nix::unistd::Pid; +use std::path::PathBuf; use std::sync::OnceLock; -use tracing::{info, warn}; +use tracing::warn; -/// Path del socket del bus interno. Se establece una sola vez al arrancar -/// PID 1 (después de que el listener bind exitoso). Cada hijo encarnado -/// recibe este path en `ENTE_BUS_SOCK`. -static BUS_SOCK_PATH: OnceLock = OnceLock::new(); +static INCARNATOR: OnceLock = OnceLock::new(); +/// Establece el path del socket del bus interno. Se llama una sola vez al +/// arrancar PID 1 (después de que el listener bind exitoso). Cada hijo +/// encarnado recibirá este path en `ENTE_BUS_SOCK`. pub fn set_bus_sock(path: String) { - let _ = BUS_SOCK_PATH.set(path); -} - -fn build_env(card: &EntityCard, base_envp: &[(String, String)]) -> Vec<(String, String)> { - // Heredamos parent env, sobreescribimos con el envp explícito de la Card, - // y al final inyectamos las vars del fractal (no negociables). - let mut env: Vec<(String, String)> = std::env::vars().collect(); - for (k, v) in base_envp { - env.retain(|(ek, _)| ek != k); - env.push((k.clone(), v.clone())); - } - if let Some(p) = BUS_SOCK_PATH.get() { - env.retain(|(k, _)| k != ente_bus::ENV_BUS_SOCK); - env.push((ente_bus::ENV_BUS_SOCK.into(), p.clone())); - } - env.retain(|(k, _)| k != ente_bus::ENV_ENTE_ID); - env.push((ente_bus::ENV_ENTE_ID.into(), card.id.to_string())); - // Apps `Type=notify` (sd_notify) leen NOTIFY_SOCKET. Apuntamos al path - // canónico de systemd; si ente-notify-compat no está corriendo, apps - // sólo verán que sd_notify falla y siguen sin "ready" signal — no es fatal. - env.retain(|(k, _)| k != "NOTIFY_SOCKET"); - env.push(("NOTIFY_SOCKET".into(), "/run/systemd/notify".into())); - env + let cfg = IncarnatorConfig { + bus_sock: Some(PathBuf::from(path)), + notify_socket: Some(PathBuf::from("/run/systemd/notify")), + extra_env: Vec::new(), + strict_caps: false, + }; + let _ = INCARNATOR.set(Incarnator::new(cfg)); } +/// Encarna un EntityCard. Si `set_bus_sock` no fue invocado todavía, +/// usa un Incarnator default (sin bus, sin notify). pub fn incarnate(card: &EntityCard) -> anyhow::Result { - if needs_namespacing(&card.soma.namespaces) { - incarnate_namespaced(card) - } else { - incarnate_plain(card) + let inc = INCARNATOR.get_or_init(|| Incarnator::new(IncarnatorConfig::default())); + let out = inc.incarnate(card)?; + for d in &out.degradations { + warn!(?d, ?out.pid, "incarnation degradation"); } + Ok(out.pid) } - -fn needs_namespacing(ns: &NamespaceSet) -> bool { - ns.mount || ns.pid || ns.net || ns.uts || ns.ipc || ns.user || ns.cgroup -} - -/// Path simple: para Entes que no requieren aislamiento. Útil para Entes-shim -/// que conviven con el host (e.g. compat-logind) y para dev mode. -fn incarnate_plain(card: &EntityCard) -> anyhow::Result { - let (exec, argv, base_envp) = match &card.payload { - Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()), - Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()), - _ => anyhow::bail!("incarnate_plain: payload no ejecutable"), - }; - let env = build_env(card, &base_envp); - let mut cmd = Command::new(&exec); - cmd.args(&argv); - cmd.env_clear(); - for (k, v) in &env { - cmd.env(k, v); - } - let child = cmd.spawn().map_err(|e| anyhow::anyhow!("spawn {exec}: {e}"))?; - Ok(Pid::from_raw(child.id() as i32)) -} - -/// Path namespaced: clone(2) + sync pipe + setup post-clone en padre + finalize en hijo. -fn incarnate_namespaced(card: &EntityCard) -> anyhow::Result { - let flags = build_clone_flags(&card.soma.namespaces); - info!(label = %card.label, ?flags, "namespaced incarnation"); - - let (exec, argv, base_envp) = match &card.payload { - Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()), - Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()), - _ => anyhow::bail!("incarnate_namespaced: payload no ejecutable"), - }; - - // Pipe O_CLOEXEC: el read del lado hijo es lo que hace race-free el setup. - // O_CLOEXEC garantiza que el fd se cierra automáticamente en execve, así - // no contamina el binario final. - let (sync_r, sync_w) = pipe2(OFlag::O_CLOEXEC)?; - let sync_r_raw: RawFd = sync_r.into_raw_fd(); - let sync_w_raw: RawFd = sync_w.into_raw_fd(); - - let exec_c = CString::new(exec.clone())?; - let argv_c: Vec = std::iter::once(exec_c.clone()) - .chain(argv.iter().filter_map(|s| CString::new(s.as_str()).ok())) - .collect(); - let argv_ptrs: Vec<*const libc::c_char> = argv_c.iter() - .map(|c| c.as_ptr()) - .chain(std::iter::once(std::ptr::null())) - .collect(); - - // envp construido pre-clone: padre y hijo comparten el COW. Tras execve - // el kernel reemplaza el address space, así que las CStrings sólo viven - // hasta el syscall. - let env_pairs = build_env(card, &base_envp); - let envp_c: Vec = env_pairs.iter() - .filter_map(|(k, v)| CString::new(format!("{k}={v}")).ok()) - .collect(); - let envp_ptrs: Vec<*const libc::c_char> = envp_c.iter() - .map(|c| c.as_ptr()) - .chain(std::iter::once(std::ptr::null())) - .collect(); - - let rlimits = card.soma.rlimits.clone(); - let mount_ns_enabled = card.soma.namespaces.mount; - - // SAFETY: la clausura corre en stack nuevo dentro de un proceso recién - // clonado, COW del padre. Reglas inviolables: - // - sólo syscalls async-signal-safe - // - no `println!`/`tracing!`/cualquier I/O del runtime - // - no allocator (vec/box/string) - // - no Drop con efectos - // - capturar sólo Copy o datos pre-construidos - let cb = Box::new(move || -> isize { - // 1) Cerrar el extremo de escritura: pertenece al padre. - unsafe { libc::close(sync_w_raw); } - - // 2) Bloquear hasta que el padre termine el setup (uid_map, cgroup, etc). - let mut byte = [0u8; 1]; - let n = unsafe { - libc::read(sync_r_raw, byte.as_mut_ptr() as *mut _, 1) - }; - if n != 1 { unsafe { libc::_exit(101); } } - unsafe { libc::close(sync_r_raw); } - - // 3) Aplicar rlimits dentro del nuevo namespace. - unsafe { apply_rlimits_unchecked(&rlimits); } - - // 4) Si tenemos mount ns, marcar / como privado recursivamente para - // que mounts del Ente no se filtren al host (es la trampa más - // típica al delegar mount ns). - if mount_ns_enabled { - unsafe { - libc::mount( - std::ptr::null(), - b"/\0".as_ptr() as *const _, - std::ptr::null(), - libc::MS_PRIVATE | libc::MS_REC, - std::ptr::null(), - ); - } - } - - // 5) execve. Si retorna, falló. - unsafe { - libc::execve(exec_c.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr()); - libc::_exit(102); - } - }); - - let mut stack = vec![0u8; 1024 * 1024]; - - #[allow(deprecated)] - let pid = unsafe { - nix::sched::clone(cb, &mut stack, flags, Some(libc::SIGCHLD)) - }.map_err(|e| { - unsafe { libc::close(sync_r_raw); libc::close(sync_w_raw); } - anyhow::anyhow!("clone failed: {e}") - })?; - - // Padre: cerrar el extremo de lectura. - unsafe { libc::close(sync_r_raw); } - - // Setup post-clone en padre. Errores aquí no son fatales — registramos y - // continuamos. Si algo crítico falla, el hijo execve seguirá adelante con - // configuración degradada y el supervisor decidirá qué hacer. - if let Err(e) = configure_child(pid, card) { - warn!(?e, ?pid, "configure_child errores no-fatales"); - } - - // Despertar al hijo. - let signal_byte = [b'x']; - let written = unsafe { - libc::write(sync_w_raw, signal_byte.as_ptr() as *const _, 1) - }; - unsafe { libc::close(sync_w_raw); } - if written != 1 { - warn!(?pid, "no se pudo señalizar al hijo (write devolvió {})", written); - } - - if matches!(&card.payload, Payload::Legacy { fakes, .. } if !fakes.is_empty()) { - // TODO: facades viven en un Ente-shim aparte que se inyecta vía - // bind-mount sobre /run/systemd/notify, /run/dbus/system_bus_socket, - // etc. Cuando exista, registrarlas aquí. - warn!("legacy facades declaradas pero shim post-clone no implementado"); - } - - Ok(pid) -} - -/// Setup que requiere capacidades del padre: uid_map, gid_map, cgroup move. -/// Estos archivos en /proc//* tienen reglas de propiedad que sólo el -/// padre puede satisfacer mientras el hijo está suspendido en el sync pipe. -fn configure_child(pid: Pid, card: &EntityCard) -> anyhow::Result<()> { - if card.soma.namespaces.user { - // Desde kernel 3.19 se debe escribir "deny" a setgroups antes de - // poder escribir gid_map sin CAP_SETGID. Ignorar errores: en kernels - // antiguos el archivo no existe y no es problema. - let _ = std::fs::write(format!("/proc/{}/setgroups", pid.as_raw()), "deny"); - - let uid = nix::unistd::getuid().as_raw(); - let gid = nix::unistd::getgid().as_raw(); - std::fs::write( - format!("/proc/{}/uid_map", pid.as_raw()), - format!("0 {uid} 1"), - ).map_err(|e| anyhow::anyhow!("write uid_map: {e}"))?; - std::fs::write( - format!("/proc/{}/gid_map", pid.as_raw()), - format!("0 {gid} 1"), - ).map_err(|e| anyhow::anyhow!("write gid_map: {e}"))?; - } - - if !card.soma.cgroup.path.is_empty() { - match ensure_cgroup(&card.soma.cgroup) { - Ok(abs_path) => { - let procs = format!("{abs_path}/cgroup.procs"); - if let Err(e) = std::fs::write(&procs, format!("{}\n", pid.as_raw())) { - warn!(?e, path = %procs, "cgroup move falló"); - } - } - Err(e) => warn!(?e, path = %card.soma.cgroup.path, "ensure_cgroup falló"), - } - } - - if let Some(cpus) = &card.soma.cpu_affinity { - if let Err(e) = set_cpu_affinity(pid, cpus) { - warn!(?e, ?pid, "sched_setaffinity falló"); - } - } - - Ok(()) -} - -fn set_cpu_affinity(pid: Pid, cpus: &[u32]) -> anyhow::Result<()> { - let mut set: libc::cpu_set_t = unsafe { std::mem::zeroed() }; - unsafe { libc::CPU_ZERO(&mut set); } - for &c in cpus { - unsafe { libc::CPU_SET(c as usize, &mut set); } - } - let r = unsafe { - libc::sched_setaffinity(pid.as_raw(), std::mem::size_of::(), &set) - }; - if r != 0 { - anyhow::bail!("sched_setaffinity: {}", std::io::Error::last_os_error()); - } - Ok(()) -} - -/// SAFETY: invocada en el hijo post-clone, sólo libc, no Rust I/O. -unsafe fn apply_rlimits_unchecked(rl: &ResourceLimits) { - if let Some(mem) = rl.mem_bytes { - let lim = libc::rlimit { rlim_cur: mem, rlim_max: mem }; - libc::setrlimit(libc::RLIMIT_AS, &lim); - } - if let Some(np) = rl.nproc { - let lim = libc::rlimit { rlim_cur: np as u64, rlim_max: np as u64 }; - libc::setrlimit(libc::RLIMIT_NPROC, &lim); - } - if let Some(nf) = rl.nofile { - let lim = libc::rlimit { rlim_cur: nf as u64, rlim_max: nf as u64 }; - libc::setrlimit(libc::RLIMIT_NOFILE, &lim); - } -} - -/// Cgroup actual del proceso PID 1 (o ente-zero en dev). Lo usamos como -/// prefijo para paths declarados relativos en CgroupSpec.path. En prod (PID 1 -/// como child del kernel) será `/`. En dev bajo systemd-user será algo como -/// `/user.slice/user-1001.slice/user@1001.service/...`. -fn current_cgroup() -> Option { - let s = std::fs::read_to_string("/proc/self/cgroup").ok()?; - // Formato unified (cgroup v2): "0::/user.slice/..." - s.lines() - .find_map(|l| l.strip_prefix("0::")) - .map(|s| s.trim().to_string()) -} - -/// Resuelve un path declarado en CgroupSpec contra la jerarquía real. -/// - path absoluto (empieza con `/`): respetar tal cual -/// - path relativo: prefijar con cgroup actual de PID 1 -fn resolve_cgroup_path(spec_path: &str) -> String { - if spec_path.is_empty() { return String::new(); } - if spec_path.starts_with('/') { - return spec_path.to_string(); - } - let trimmed = spec_path.trim_start_matches('/'); - if let Some(cg) = current_cgroup() { - let base = if cg == "/" { String::new() } else { cg.trim_end_matches('/').to_string() }; - format!("{base}/{trimmed}") - } else { - format!("/{trimmed}") - } -} - -/// Crea el cgroup declarado, aplica weights. Devuelve el path absoluto -/// resultante bajo /sys/fs/cgroup. -fn ensure_cgroup(spec: &CgroupSpec) -> anyhow::Result { - let rel = resolve_cgroup_path(&spec.path); - if rel.is_empty() { - anyhow::bail!("cgroup path vacío"); - } - let abs = format!("/sys/fs/cgroup{}", rel); - std::fs::create_dir_all(&abs) - .map_err(|e| anyhow::anyhow!("mkdir {}: {e}", abs))?; - if let Some(w) = spec.cpu_weight { - let _ = std::fs::write(format!("{abs}/cpu.weight"), format!("{w}\n")); - } - if let Some(w) = spec.io_weight { - // io.weight requiere el formato "default " en cgroup v2. - let _ = std::fs::write(format!("{abs}/io.weight"), format!("default {w}\n")); - } - Ok(abs) -} - -fn build_clone_flags(ns: &NamespaceSet) -> CloneFlags { - let mut f = CloneFlags::empty(); - if ns.mount { f |= CloneFlags::CLONE_NEWNS; } - if ns.pid { f |= CloneFlags::CLONE_NEWPID; } - if ns.net { f |= CloneFlags::CLONE_NEWNET; } - if ns.uts { f |= CloneFlags::CLONE_NEWUTS; } - if ns.ipc { f |= CloneFlags::CLONE_NEWIPC; } - if ns.user { f |= CloneFlags::CLONE_NEWUSER; } - if ns.cgroup { f |= CloneFlags::CLONE_NEWCGROUP; } - f -} - -// AsRawFd unused but keep the import alive — soma may grow more fd handling. -#[allow(dead_code)] -fn _keep_imports(_: &dyn AsRawFd) {} diff --git a/crates/modules/nouser/core/Cargo.toml b/crates/modules/nouser/core/Cargo.toml index b752946..fe18c1a 100644 --- a/crates/modules/nouser/core/Cargo.toml +++ b/crates/modules/nouser/core/Cargo.toml @@ -11,6 +11,7 @@ description = "Nouser — explorador de Mónadas: scanner, clustering determinis [dependencies] nouser-card = { path = "../card" } nouser-nous = { path = "../nous" } +shipote-discern = { path = "../../shipote/shipote-discern" } brahman-card = { path = "../../../core/brahman-card" } brahman-handshake = { path = "../../../core/brahman-handshake" } brahman-sidecar = { path = "../../../shared/brahman-sidecar" } diff --git a/crates/modules/nouser/core/src/cluster.rs b/crates/modules/nouser/core/src/cluster.rs index 7091ee3..5ea49c4 100644 --- a/crates/modules/nouser/core/src/cluster.rs +++ b/crates/modules/nouser/core/src/cluster.rs @@ -152,10 +152,12 @@ fn top_extensions(files: &[&FileEntry], n: usize) -> Vec { sorted.into_iter().take(n).map(|(k, _)| k).collect() } -/// Elige el lente dominante según la extensión más frecuente. +/// Elige el lente dominante según la extensión más frecuente, con +/// fallback a `shipote-discern` sobre el head del archivo más +/// representativo cuando la extensión no da hint claro (Lens::Grid). fn pick_lens(files: &[&FileEntry]) -> Lens { let dominant = top_extensions(files, 1).into_iter().next(); - match dominant.as_deref() { + let by_ext = match dominant.as_deref() { Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "kt" | "c" | "cpp" | "cc" | "h" | "hpp" | "rb" | "swift" | "zig") => Lens::Code, Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tiff" | "heic") => { @@ -164,6 +166,42 @@ fn pick_lens(files: &[&FileEntry]) -> Lens { Some("md" | "markdown" | "rst" | "txt" | "org" | "tex") => Lens::Markdown, Some("db" | "sqlite" | "sqlite3" | "csv" | "tsv" | "parquet") => Lens::Database, _ => Lens::Grid, + }; + if by_ext != Lens::Grid { + return by_ext; + } + // Fallback: samplear el primer archivo del grupo con shipote-discern. + // Sólo si tiene path real (FileEntry con path absoluto/relativo). + if let Some(first) = files.first() { + if let Some(lens) = discern_lens(&first.path) { + return lens; + } + } + Lens::Grid +} + +fn discern_lens(path: &std::path::Path) -> Option { + use std::io::Read; + let mut buf = vec![0u8; 4096]; + let mut f = std::fs::File::open(path).ok()?; + let n = f.read(&mut buf).ok()?; + buf.truncate(n); + let pipeline = shipote_discern::DiscernPipeline::default_pipeline(); + let path_str = path.to_str(); + let d = pipeline.discern( + &buf, + &shipote_discern::Hint { + path: path_str, + size_total: None, + }, + )?; + match d.lens.as_deref()? { + "code" => Some(Lens::Code), + "gallery" => Some(Lens::Gallery), + "markdown" => Some(Lens::Markdown), + "database" => Some(Lens::Database), + "tree" => Some(Lens::Tree), + _ => None, } } diff --git a/crates/modules/shipote/shipote-card/Cargo.toml b/crates/modules/shipote/shipote-card/Cargo.toml new file mode 100644 index 0000000..fa123e4 --- /dev/null +++ b/crates/modules/shipote/shipote-card/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "shipote-card" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Tipos de shipote: WorkspaceSpec, PipelineSpec, CommandRef, FlowEdge. Compilan a Cards de brahman-card." + +[dependencies] +brahman-card = { path = "../../../core/brahman-card" } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } +ulid = { workspace = true } diff --git a/crates/modules/shipote/shipote-card/src/lib.rs b/crates/modules/shipote/shipote-card/src/lib.rs new file mode 100644 index 0000000..110c38f --- /dev/null +++ b/crates/modules/shipote/shipote-card/src/lib.rs @@ -0,0 +1,449 @@ +//! `shipote-card` — tipos del runtime shipote. +//! +//! Tres entidades nuevas encima del `brahman-card::Card`: +//! +//! - [`WorkspaceSpec`] — espacio aislado raíz con su propio `SomaSpec`. +//! - [`CommandRef`] — un comando dentro de un workspace. +//! - [`PipelineSpec`] — DAG de `CommandRef` conectados por `FlowEdge`. +//! +//! Cada `WorkspaceSpec`/`CommandRef` se **compila** a una o varias +//! [`brahman_card::Card`] que el daemon entrega al [`Incarnator`] de +//! `ente-incarnate`. Esto preserva el contrato canónico del fractal. + +#![forbid(unsafe_code)] + +use brahman_card::{Card, Payload, Permissions, SomaSpec, Supervision}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use thiserror::Error; +use ulid::Ulid; + +// ===================================================================== +// Identidades +// ===================================================================== + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct WorkspaceId(pub Ulid); + +impl WorkspaceId { + pub fn new() -> Self { + Self(Ulid::new()) + } +} + +impl Default for WorkspaceId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for WorkspaceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PipelineId(pub Ulid); + +impl PipelineId { + pub fn new() -> Self { + Self(Ulid::new()) + } +} + +impl Default for PipelineId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for PipelineId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +// ===================================================================== +// Workspace +// ===================================================================== + +/// Espacio aislado de shipote. Es la raíz de aislamiento — cualquier comando +/// que corre dentro hereda restricciones y no puede aflojarlas. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceSpec { + pub label: String, + + /// Aislamiento del workspace mismo (cuando se materializa como Card raíz). + #[serde(default)] + pub soma: SomaSpec, + + /// Permisos máximos para hijas. Hijas pueden bajar pero no subir. + #[serde(default)] + pub permissions: Permissions, + + /// `None` = vive hasta `stop`. `Some(d)` = el daemon lo termina tras d. + #[serde(default, with = "opt_duration_millis")] + pub ttl: Option, + + /// Slots de flow pre-declarados. Limitan qué consumidores externos al + /// workspace pueden empatar contra los productores internos. + #[serde(default)] + pub flow_dirs: Vec, + + /// Política al terminar el workspace. + #[serde(default)] + pub on_exit: ExitPolicy, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowSlot { + pub name: String, + pub direction: FlowDirection, + /// Si `Workspace`, sólo otros nodos del mismo workspace pueden empatar. + /// Si `Public`, el broker global puede emparejar. + #[serde(default)] + pub scope: FlowScope, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FlowDirection { + Input, + Output, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FlowScope { + #[default] + Workspace, + Public, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExitPolicy { + /// Reapear procesos hijos y descartar estado. + #[default] + Reap, + /// Mantener el workspace en `stopped` para inspección. + Keep, + /// Tomar snapshot del estado (para restart posterior). + Snapshot, +} + +mod opt_duration_millis { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::time::Duration; + + pub fn serialize(d: &Option, s: S) -> Result { + d.map(|x| x.as_millis() as u64).serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let v: Option = Option::deserialize(d)?; + Ok(v.map(Duration::from_millis)) + } +} + +// ===================================================================== +// CommandRef +// ===================================================================== + +/// Un comando que vive dentro de un workspace. Se compila a una `Card` con +/// `pin_to` apuntando al workspace padre (label) y su `SomaSpec` +/// intersectado con el del workspace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandRef { + pub label: String, + pub payload: Payload, + + /// SomaSpec del comando. El compilador lo intersecta con el del workspace. + #[serde(default)] + pub soma: SomaSpec, + + /// Inputs/outputs tipados (mismos `Flow` de brahman-card). + #[serde(default)] + pub flows: brahman_card::Flows, + + /// Política de supervisión. Default `OneShot` (un comando se ejecuta y muere). + #[serde(default = "default_oneshot")] + pub supervision: Supervision, +} + +fn default_oneshot() -> Supervision { + Supervision::OneShot +} + +// ===================================================================== +// Pipeline +// ===================================================================== + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipelineSpec { + pub label: String, + pub workspace: WorkspaceId, + pub nodes: Vec, + #[serde(default)] + pub edges: Vec, + #[serde(default)] + pub discern: DiscernPolicy, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowEdge { + /// Índice en `PipelineSpec.nodes` del productor. + pub from: usize, + /// Nombre del Flow output del productor. + pub from_output: String, + /// Índice en `PipelineSpec.nodes` del consumidor. + pub to: usize, + /// Nombre del Flow input del consumidor. + pub to_input: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DiscernPolicy { + /// Bytes a samplear por flow para el discernidor. Default 4 KiB. + #[serde(default = "default_sample_bytes")] + pub sample_bytes: usize, + /// Si `true`, enriquece la Card del producer con el TypeRef detectado. + #[serde(default = "default_true")] + pub enrich_producer: bool, +} + +fn default_sample_bytes() -> usize { + 4096 +} +fn default_true() -> bool { + true +} + +// ===================================================================== +// Compilación a Card +// ===================================================================== + +#[derive(Debug, Error)] +pub enum CompileError { + #[error("workspace label vacío")] + EmptyWorkspaceLabel, + #[error("comando con label vacío en posición {0}")] + EmptyCommandLabel(usize), + #[error("edge fuera de rango: from={from}, to={to}, nodes={nodes}")] + EdgeOutOfBounds { from: usize, to: usize, nodes: usize }, +} + +impl WorkspaceSpec { + /// Compila el WorkspaceSpec a una Card raíz que el Incarnator puede + /// encarnar. Usa `Payload::Virtual` (el workspace no es un proceso por + /// sí solo; sólo aloja hijos). + pub fn to_card(&self, id: WorkspaceId) -> Result { + if self.label.trim().is_empty() { + return Err(CompileError::EmptyWorkspaceLabel); + } + let mut c = Card::new(format!("shipote.workspace.{}", self.label)); + c.id = id.0; + c.soma = self.soma.clone(); + c.permissions = self.permissions.clone(); + c.payload = Payload::Virtual; + c.supervision = Supervision::OneShot; + Ok(c) + } +} + +impl CommandRef { + /// Compila un CommandRef a Card hija de un workspace. La Card resultante + /// referencia al workspace por label en `pin_to` de cada Flow. + pub fn to_card(&self, idx: usize, workspace_label: &str) -> Result { + if self.label.trim().is_empty() { + return Err(CompileError::EmptyCommandLabel(idx)); + } + let mut c = Card::new(format!("shipote.cmd.{}.{}", workspace_label, self.label)); + c.payload = self.payload.clone(); + c.soma = intersect_soma(&self.soma, /*workspace*/ &SomaSpec::default()); + c.supervision = self.supervision.clone(); + c.flow = self.flows.clone(); + // pin_to del workspace en cada Flow input/output → el broker prefiere + // resolver dentro del mismo workspace cuando hay candidatos múltiples. + let pin = format!("shipote.workspace.{}", workspace_label); + for f in c.flow.input.iter_mut().chain(c.flow.output.iter_mut()) { + if f.pin_to.is_none() { + f.pin_to = Some(pin.clone()); + } + } + Ok(c) + } +} + +/// Intersección conservadora: si el workspace pidió aislamiento, la hija +/// también lo tiene (no puede aflojar). Si la hija pidió aislamiento extra, +/// se respeta. +fn intersect_soma(child: &SomaSpec, ws: &SomaSpec) -> SomaSpec { + let mut out = child.clone(); + out.namespaces.mount |= ws.namespaces.mount; + out.namespaces.pid |= ws.namespaces.pid; + out.namespaces.net |= ws.namespaces.net; + out.namespaces.uts |= ws.namespaces.uts; + out.namespaces.ipc |= ws.namespaces.ipc; + out.namespaces.user |= ws.namespaces.user; + out.namespaces.cgroup |= ws.namespaces.cgroup; + // rlimits: el menor (más restrictivo) gana. + out.rlimits.mem_bytes = min_opt(out.rlimits.mem_bytes, ws.rlimits.mem_bytes); + out.rlimits.nproc = min_opt(out.rlimits.nproc, ws.rlimits.nproc); + out.rlimits.nofile = min_opt(out.rlimits.nofile, ws.rlimits.nofile); + out +} + +fn min_opt(a: Option, b: Option) -> Option { + match (a, b) { + (Some(x), Some(y)) => Some(x.min(y)), + (Some(x), None) | (None, Some(x)) => Some(x), + (None, None) => None, + } +} + +impl PipelineSpec { + pub fn validate(&self) -> Result<(), CompileError> { + let n = self.nodes.len(); + for (i, c) in self.nodes.iter().enumerate() { + if c.label.trim().is_empty() { + return Err(CompileError::EmptyCommandLabel(i)); + } + } + for e in &self.edges { + if e.from >= n || e.to >= n { + return Err(CompileError::EdgeOutOfBounds { + from: e.from, + to: e.to, + nodes: n, + }); + } + } + Ok(()) + } +} + +// ===================================================================== +// I/O conveniencia (TOML + JSON) +// ===================================================================== + +#[derive(Debug, Error)] +pub enum LoadError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("toml: {0}")] + Toml(#[from] toml::de::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("formato desconocido (esperado .toml o .json)")] + UnknownFormat, +} + +pub fn load_workspace_spec(path: &std::path::Path) -> Result { + let raw = std::fs::read_to_string(path)?; + match path.extension().and_then(|s| s.to_str()) { + Some("toml") => Ok(toml::from_str(&raw)?), + Some("json") => Ok(serde_json::from_str(&raw)?), + _ => Err(LoadError::UnknownFormat), + } +} + +pub fn load_pipeline_spec(path: &std::path::Path) -> Result { + let raw = std::fs::read_to_string(path)?; + match path.extension().and_then(|s| s.to_str()) { + Some("toml") => Ok(toml::from_str(&raw)?), + Some("json") => Ok(serde_json::from_str(&raw)?), + _ => Err(LoadError::UnknownFormat), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_workspace() -> WorkspaceSpec { + WorkspaceSpec { + label: "demo".into(), + soma: SomaSpec::default(), + permissions: Permissions::default(), + ttl: Some(Duration::from_secs(60)), + flow_dirs: vec![FlowSlot { + name: "out".into(), + direction: FlowDirection::Output, + scope: FlowScope::Public, + }], + on_exit: ExitPolicy::Reap, + } + } + + #[test] + fn workspace_toml_roundtrip() { + let ws = sample_workspace(); + let s = toml::to_string(&ws).unwrap(); + let back: WorkspaceSpec = toml::from_str(&s).unwrap(); + assert_eq!(back.label, ws.label); + assert_eq!(back.ttl, ws.ttl); + assert_eq!(back.flow_dirs.len(), 1); + } + + #[test] + fn workspace_json_roundtrip() { + let ws = sample_workspace(); + let s = serde_json::to_string(&ws).unwrap(); + let back: WorkspaceSpec = serde_json::from_str(&s).unwrap(); + assert_eq!(back.label, ws.label); + } + + #[test] + fn workspace_compiles_to_card() { + let ws = sample_workspace(); + let id = WorkspaceId::new(); + let c = ws.to_card(id).unwrap(); + assert_eq!(c.id, id.0); + assert!(c.label.starts_with("shipote.workspace.")); + assert!(matches!(c.payload, Payload::Virtual)); + } + + #[test] + fn empty_label_rejected() { + let mut ws = sample_workspace(); + ws.label = String::new(); + assert!(ws.to_card(WorkspaceId::new()).is_err()); + } + + #[test] + fn pipeline_validates_edges() { + let p = PipelineSpec { + label: "p".into(), + workspace: WorkspaceId::new(), + nodes: vec![CommandRef { + label: "a".into(), + payload: Payload::Virtual, + soma: SomaSpec::default(), + flows: brahman_card::Flows::default(), + supervision: Supervision::OneShot, + }], + edges: vec![FlowEdge { + from: 0, + from_output: "x".into(), + to: 5, + to_input: "y".into(), + }], + discern: DiscernPolicy::default(), + }; + assert!(p.validate().is_err()); + } + + #[test] + fn intersect_soma_takes_more_restrictive() { + let mut child = SomaSpec::default(); + child.rlimits.mem_bytes = Some(1_000_000); + let mut ws = SomaSpec::default(); + ws.rlimits.mem_bytes = Some(500_000); + ws.namespaces.user = true; + let r = intersect_soma(&child, &ws); + assert_eq!(r.rlimits.mem_bytes, Some(500_000)); + assert!(r.namespaces.user); + } +} diff --git a/crates/modules/shipote/shipote-core/Cargo.toml b/crates/modules/shipote/shipote-core/Cargo.toml new file mode 100644 index 0000000..5567879 --- /dev/null +++ b/crates/modules/shipote/shipote-core/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "shipote-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Runtime de shipote: WorkspaceManager sobre ente-incarnate. Estado in-memory, lifecycle, reaping." + +[dependencies] +shipote-card = { path = "../shipote-card" } +shipote-discern = { path = "../shipote-discern" } +brahman-card = { path = "../../../core/brahman-card" } +ente-incarnate = { path = "../../../shared/ente-incarnate" } +nix = { workspace = true } +libc = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +ulid = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/modules/shipote/shipote-core/src/lib.rs b/crates/modules/shipote/shipote-core/src/lib.rs new file mode 100644 index 0000000..080ac64 --- /dev/null +++ b/crates/modules/shipote/shipote-core/src/lib.rs @@ -0,0 +1,584 @@ +//! `shipote-core` — runtime in-memory de Workspaces y comandos. +//! +//! Mantiene un estado tokio-friendly (Mutex sobre HashMap) con: +//! - Workspaces vivos (id → state). +//! - PIDs de comandos lanzados, indexados por workspace. +//! - Reaping cooperativo: `reap_dead()` cosecha hijos terminados. + +// `pipeline` necesita `unsafe` puntual para `libc::close` y construir +// `OwnedFd` desde fds que armamos con `pipe2(2)`. El resto del crate +// permanece safe — el cargo lint `unsafe_code` queda permitido sólo en +// el módulo concreto. +#![deny(unsafe_op_in_unsafe_fn)] + +pub mod logbuf; +pub mod persist; +pub mod pipeline; + +use brahman_card::{Card, Payload, Supervision}; +use ente_incarnate::{Incarnator, IncarnatorConfig}; +use nix::sys::signal::{kill, Signal}; +use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus}; +use nix::unistd::Pid; +use shipote_card::{CommandRef, PipelineSpec, WorkspaceId, WorkspaceSpec}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; +use thiserror::Error; +use tokio::sync::Mutex; +use tracing::{info, warn}; +use ulid::Ulid; + +#[derive(Debug, Error)] +pub enum CoreError { + #[error("workspace {0} not found")] + WorkspaceNotFound(WorkspaceId), + #[error("compile: {0}")] + Compile(#[from] shipote_card::CompileError), + #[error("incarnate: {0}")] + Incarnate(#[from] ente_incarnate::IncarnateError), +} + +#[derive(Debug)] +pub struct WorkspaceState { + pub id: WorkspaceId, + pub spec: WorkspaceSpec, + pub root_card: Card, + pub commands: HashMap, + pub started: Instant, +} + +#[derive(Debug, Clone)] +pub struct CommandState { + pub id: Ulid, + pub label: String, + pub pid: Pid, + pub alive: bool, + pub exit_status: Option, + /// Ring buffer compartido con la tokio task que drena stdout+stderr + /// del comando. `None` para comandos que no capturan output (futuro: + /// comandos con stdout=inherit). + pub logs: Option, +} + +pub struct WorkspaceManager { + inner: Arc>, + incarnator: Arc, +} + +struct Inner { + workspaces: HashMap, + /// Definiciones nombradas de pipelines persistidas. NO es lo mismo + /// que "pipelines vivos" — son specs guardados para reusar con + /// `run-saved`. Sobreviven restart vía snapshot. + saved_pipelines: HashMap, +} + +#[derive(Debug, Clone)] +pub struct CommandSummary { + pub id: Ulid, + pub label: String, + pub pid: i32, +} + +#[derive(Debug, Clone)] +pub struct CommandInfo { + pub id: Ulid, + pub label: String, + pub pid: i32, + pub alive: bool, + pub exit_status: Option, + pub log_bytes: u64, +} + +fn spawn_log_drainer(read_fd: std::os::fd::RawFd, logs: logbuf::LogBuf) { + // Marcar non-blocking + envolver en AsyncFd; igual patrón que el tap. + // SAFETY: F_SETFL sobre fd válido. + unsafe { + let flags = libc::fcntl(read_fd, libc::F_GETFL, 0); + if flags >= 0 { + libc::fcntl(read_fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } + tokio::spawn(async move { + // SAFETY: ownership del fd transferido al drainer task. + let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(read_fd) }; + let afd = match tokio::io::unix::AsyncFd::with_interest(owned, tokio::io::Interest::READABLE) { + Ok(a) => a, + Err(e) => { + tracing::warn!(?e, "log drainer AsyncFd failed"); + return; + } + }; + let mut buf = [0u8; 4096]; + loop { + let mut guard = match afd.readable().await { + Ok(g) => g, + Err(_) => break, + }; + use std::os::fd::AsRawFd; + let fd = afd.as_raw_fd(); + // SAFETY: read sobre fd válido. + let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) }; + if r > 0 { + logs.append(&buf[..r as usize]); + continue; + } + if r == 0 { + break; // EOF + } + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + guard.clear_ready(); + continue; + } + tracing::warn!(?err, "log drainer read err"); + break; + } + }); +} + +trait OwnedFdFromRawCompat: Sized { + unsafe fn from_raw_fd_compat(fd: std::os::fd::RawFd) -> Self; +} + +impl OwnedFdFromRawCompat for std::os::fd::OwnedFd { + unsafe fn from_raw_fd_compat(fd: std::os::fd::RawFd) -> Self { + use std::os::fd::FromRawFd; + // SAFETY: el caller transfiere ownership de fd a OwnedFd. + unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) } + } +} + +impl WorkspaceManager { + pub fn new(cfg: IncarnatorConfig) -> Self { + Self { + inner: Arc::new(Mutex::new(Inner { + workspaces: HashMap::new(), + saved_pipelines: HashMap::new(), + })), + incarnator: Arc::new(Incarnator::new(cfg)), + } + } + + pub fn incarnator(&self) -> &Incarnator { + &self.incarnator + } + + /// Handle Arc-clonable del Incarnator, para que el pipeline lo pueda + /// usar fuera del manager. + pub fn incarnator_handle(&self) -> Arc { + self.incarnator.clone() + } + + // ----------------------------------------------------------------- + // Saved pipelines (definiciones nombradas, no runs) + // ----------------------------------------------------------------- + + /// Guarda (o reemplaza) un PipelineSpec bajo `name`. + pub async fn save_pipeline(&self, name: String, spec: PipelineSpec) { + self.inner.lock().await.saved_pipelines.insert(name, spec); + } + + /// Devuelve los nombres de los pipelines guardados. + pub async fn list_saved_pipelines(&self) -> Vec { + let g = self.inner.lock().await; + let mut v: Vec = g.saved_pipelines.keys().cloned().collect(); + v.sort(); + v + } + + /// Recupera el PipelineSpec guardado bajo `name`. + pub async fn get_saved_pipeline(&self, name: &str) -> Option { + self.inner.lock().await.saved_pipelines.get(name).cloned() + } + + /// Elimina un saved pipeline. + pub async fn drop_saved_pipeline(&self, name: &str) -> bool { + self.inner.lock().await.saved_pipelines.remove(name).is_some() + } + + /// Label del workspace, si existe. + pub async fn workspace_label(&self, id: WorkspaceId) -> Option { + self.inner + .lock() + .await + .workspaces + .get(&id) + .map(|w| w.spec.label.clone()) + } + + pub async fn create( + self: &Arc, + spec: WorkspaceSpec, + ) -> Result<(WorkspaceId, Vec), CoreError> { + self.create_with_id(WorkspaceId::new(), spec).await + } + + /// Variante que acepta el ID. Útil para restore_snapshot: preserva + /// ULIDs entre restarts, así clients que tracking workspace_id no se + /// rompen. + pub async fn create_with_id( + self: &Arc, + id: WorkspaceId, + spec: WorkspaceSpec, + ) -> Result<(WorkspaceId, Vec), CoreError> { + let card = spec.to_card(id)?; + let warnings = self.incarnator.dry_run(&card).warnings; + let ttl = spec.ttl; + let state = WorkspaceState { + id, + spec, + root_card: card, + commands: HashMap::new(), + started: Instant::now(), + }; + self.inner.lock().await.workspaces.insert(id, state); + info!(%id, ?ttl, "workspace created"); + + // Si tiene TTL, programar auto-stop. El task captura un weak ref + // al manager para no impedir que se dropée si el daemon termina. + if let Some(duration) = ttl { + let mgr_weak = Arc::downgrade(self); + tokio::spawn(async move { + tokio::time::sleep(duration).await; + if let Some(mgr) = mgr_weak.upgrade() { + let exists = mgr.inner.lock().await.workspaces.contains_key(&id); + if exists { + info!(%id, "workspace TTL expired — auto-stop"); + let _ = mgr.stop(id).await; + } + } + }); + } + + Ok((id, warnings)) + } + + pub async fn list(&self) -> Vec { + let g = self.inner.lock().await; + g.workspaces + .values() + .map(|w| WorkspaceSnapshot { + id: w.id, + label: w.spec.label.clone(), + commands: w.commands.len() as u32, + uptime_ms: w.started.elapsed().as_millis() as u64, + }) + .collect() + } + + pub async fn stop(&self, id: WorkspaceId) -> Result { + let mut g = self.inner.lock().await; + let ws = g.workspaces.remove(&id).ok_or(CoreError::WorkspaceNotFound(id))?; + let mut reaped = 0u32; + for (_cid, cmd) in ws.commands { + if cmd.alive { + let _ = kill(cmd.pid, Signal::SIGTERM); + // Cosecha sin bloquear infinito: WNOHANG en loop con un par de intentos. + for _ in 0..50 { + match waitpid(cmd.pid, Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::StillAlive) => { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + Ok(_) => { + reaped += 1; + break; + } + Err(_) => break, + } + } + // Último recurso: SIGKILL. + let _ = kill(cmd.pid, Signal::SIGKILL); + let _ = waitpid(cmd.pid, None); + } + } + info!(%id, reaped, "workspace stopped"); + Ok(reaped) + } + + /// Ejecuta un comando one-shot dentro de un workspace existente. + /// Captura stdout+stderr en un ring buffer accesible vía + /// [`get_command_logs`](Self::get_command_logs). + pub async fn run( + &self, + id: WorkspaceId, + exec: String, + argv: Vec, + envp: Vec<(String, String)>, + ) -> Result { + let workspace_label = { + let g = self.inner.lock().await; + let ws = g.workspaces.get(&id).ok_or(CoreError::WorkspaceNotFound(id))?; + ws.spec.label.clone() + }; + let cmd_ref = CommandRef { + label: format!("run-{}", short_ulid(&Ulid::new())), + payload: Payload::Native { exec, argv, envp }, + soma: Default::default(), + flows: Default::default(), + supervision: Supervision::OneShot, + }; + let card = cmd_ref.to_card(0, &workspace_label)?; + + // Pipe para capturar stdout. O_CLOEXEC para que hijos del hijo + // no hereden la copia. v1: stderr=inherit (simplicidad; tail útil + // para stdout solo). Futuro: stderr separado en el ring. + let (capture_r, capture_w) = + nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(|e| { + CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e)) + })?; + use std::os::fd::IntoRawFd; + let capture_r_fd = capture_r.into_raw_fd(); + let capture_w_fd = capture_w.into_raw_fd(); + + let logs = logbuf::LogBuf::new(); + + let stdio = ente_incarnate::ChildStdio { + stdin_fd: None, + stdout_fd: Some(capture_w_fd), + stderr_fd: None, + }; + let out = self.incarnator.incarnate_with(&card, stdio)?; + let cmd_id = card.id; + let cmd_label = cmd_ref.label.clone(); + let pid = out.pid; + + // Drainer: tokio task que lee capture_r_fd y appendea al ring. + spawn_log_drainer(capture_r_fd, logs.clone()); + + let mut g = self.inner.lock().await; + if let Some(ws) = g.workspaces.get_mut(&id) { + ws.commands.insert( + cmd_id, + CommandState { + id: cmd_id, + label: cmd_label.clone(), + pid, + alive: true, + exit_status: None, + logs: Some(logs), + }, + ); + } + for d in &out.degradations { + warn!(?d, %id, "command incarnation degradation"); + } + Ok(CommandSummary { + id: cmd_id, + label: cmd_label, + pid: pid.as_raw(), + }) + } + + /// Devuelve el tail del log capturado para `(workspace, command)`. + pub async fn get_command_logs( + &self, + workspace: WorkspaceId, + command: Ulid, + tail_bytes: usize, + ) -> Option> { + let g = self.inner.lock().await; + let ws = g.workspaces.get(&workspace)?; + let cmd = ws.commands.get(&command)?; + cmd.logs.as_ref().map(|lb| lb.tail(tail_bytes)) + } + + /// Lista comandos de un workspace. + pub async fn list_commands(&self, workspace: WorkspaceId) -> Vec { + let g = self.inner.lock().await; + let Some(ws) = g.workspaces.get(&workspace) else { return Vec::new() }; + let mut out: Vec = ws + .commands + .values() + .map(|c| CommandInfo { + id: c.id, + label: c.label.clone(), + pid: c.pid.as_raw(), + alive: c.alive, + exit_status: c.exit_status, + log_bytes: c.logs.as_ref().map(|l| l.written_total()).unwrap_or(0), + }) + .collect(); + // Orden estable por ULID (temporal). + out.sort_by_key(|c| c.id); + out + } + + /// Lanza todas las Cards de un Pipeline. Devuelve (label, pid) por nodo. + /// La conexión via flows queda librada al broker (cuando haya integración + /// completa con sidecar; v1 sólo lanza). + pub async fn run_pipeline( + &self, + spec: &PipelineSpec, + ) -> Result, CoreError> { + spec.validate()?; + let workspace_label = { + let g = self.inner.lock().await; + let ws = g + .workspaces + .get(&spec.workspace) + .ok_or(CoreError::WorkspaceNotFound(spec.workspace))?; + ws.spec.label.clone() + }; + let mut launched = Vec::new(); + for (i, node) in spec.nodes.iter().enumerate() { + let card = node.to_card(i, &workspace_label)?; + let out = self.incarnator.incarnate(&card)?; + let mut g = self.inner.lock().await; + if let Some(ws) = g.workspaces.get_mut(&spec.workspace) { + ws.commands.insert( + card.id, + CommandState { + id: card.id, + label: node.label.clone(), + pid: out.pid, + alive: true, + exit_status: None, + logs: None, // run_pipeline NO captura logs (los conecta por pipes). + }, + ); + } + launched.push((node.label.clone(), out.pid)); + } + Ok(launched) + } + + /// Cosecha hijos terminados (no-bloqueante). Llamar periódicamente desde + /// el daemon o ante SIGCHLD. Marca `alive=false` y guarda exit_status. + pub async fn reap_dead(&self) { + let mut g = self.inner.lock().await; + for ws in g.workspaces.values_mut() { + for cmd in ws.commands.values_mut() { + if !cmd.alive { + continue; + } + match waitpid(cmd.pid, Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::Exited(_, code)) => { + cmd.alive = false; + cmd.exit_status = Some(code); + } + Ok(WaitStatus::Signaled(_, sig, _)) => { + cmd.alive = false; + cmd.exit_status = Some(128 + (sig as i32)); + } + _ => {} + } + } + } + } +} + +#[derive(Debug, Clone)] +pub struct WorkspaceSnapshot { + pub id: WorkspaceId, + pub label: String, + pub commands: u32, + pub uptime_ms: u64, +} + +fn short_ulid(u: &Ulid) -> String { + let s = u.to_string(); + s[s.len() - 6..].to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn ttl_auto_stops_workspace() { + let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); + let spec = WorkspaceSpec { + label: "ttl-test".into(), + soma: Default::default(), + permissions: Default::default(), + ttl: Some(std::time::Duration::from_millis(120)), + flow_dirs: vec![], + on_exit: shipote_card::ExitPolicy::Reap, + }; + let (id, _) = mgr.create(spec).await.unwrap(); + assert_eq!(mgr.list().await.len(), 1); + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + assert_eq!( + mgr.list().await.len(), + 0, + "TTL expirado: workspace debe haber sido removido" + ); + let _ = id; + } + + #[tokio::test] + async fn create_and_list_workspace() { + let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); + let spec = WorkspaceSpec { + label: "test".into(), + soma: Default::default(), + permissions: Default::default(), + ttl: None, + flow_dirs: vec![], + on_exit: shipote_card::ExitPolicy::Reap, + }; + let (id, _w) = mgr.create(spec).await.unwrap(); + let list = mgr.list().await; + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, id); + } + + #[tokio::test] + async fn run_captures_stdout_to_log() { + let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); + let spec = WorkspaceSpec { + label: "logs".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(); + let summary = mgr + .run( + id, + "/bin/echo".into(), + vec!["captured-output".into()], + vec![], + ) + .await + .unwrap(); + // Esperamos a que el comando termine y el drainer drene. + for _ in 0..50 { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + mgr.reap_dead().await; + let logs = mgr.get_command_logs(id, summary.id, 0).await.unwrap_or_default(); + if !logs.is_empty() { + let s = String::from_utf8_lossy(&logs); + assert!(s.contains("captured-output"), "got: {s:?}"); + return; + } + } + panic!("logs never captured"); + } + + #[tokio::test] + async fn run_true_in_workspace() { + let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); + let spec = WorkspaceSpec { + label: "exec".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(); + let summary = mgr + .run(id, "/bin/true".into(), vec![], vec![]) + .await + .unwrap(); + assert!(summary.pid > 0); + // Cosecha. + std::thread::sleep(std::time::Duration::from_millis(100)); + mgr.reap_dead().await; + } +} diff --git a/crates/modules/shipote/shipote-core/src/logbuf.rs b/crates/modules/shipote/shipote-core/src/logbuf.rs new file mode 100644 index 0000000..a5452c0 --- /dev/null +++ b/crates/modules/shipote/shipote-core/src/logbuf.rs @@ -0,0 +1,122 @@ +//! Ring buffer en memoria para capturar stdout/stderr de comandos. +//! +//! Tamaño fijo por comando (config: `MAX_LOG_BYTES`). Cuando se llena, +//! descarta los bytes más viejos. Pensado para diagnostico rápido, no +//! para retención histórica — eso es trabajo de un journald-like aparte. + +use std::sync::{Arc, Mutex}; + +/// Bytes máximos retenidos por comando. 64 KiB cubre logs típicos sin +/// abusar de memoria si el daemon tiene cientos de comandos vivos. +pub const MAX_LOG_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone)] +pub struct LogBuf { + inner: Arc>, +} + +#[derive(Debug)] +struct Inner { + /// Bytes raw. Cuando se acerca al cap, descartamos head para mantener + /// el tail. + buf: Vec, + cap: usize, + /// Total escrito alguna vez (no decrementado al recortar). + written_total: u64, +} + +impl LogBuf { + pub fn new() -> Self { + Self::with_cap(MAX_LOG_BYTES) + } + + pub fn with_cap(cap: usize) -> Self { + Self { + inner: Arc::new(Mutex::new(Inner { + buf: Vec::with_capacity(cap.min(4096)), + cap, + written_total: 0, + })), + } + } + + pub fn append(&self, data: &[u8]) { + let Ok(mut g) = self.inner.lock() else { return }; + g.written_total += data.len() as u64; + g.buf.extend_from_slice(data); + // Recorte cuando excede cap (con un pequeño slack para evitar + // shift en cada append). El usuario ve sólo el tail. + if g.buf.len() > g.cap + 1024 { + let drop = g.buf.len() - g.cap; + g.buf.drain(..drop); + } + } + + /// Devuelve el tail de hasta `n` bytes (o todo si `n=0`). + pub fn tail(&self, n: usize) -> Vec { + let g = match self.inner.lock() { + Ok(g) => g, + Err(_) => return Vec::new(), + }; + if n == 0 || n >= g.buf.len() { + return g.buf.clone(); + } + g.buf[g.buf.len() - n..].to_vec() + } + + /// Cuántos bytes hay actualmente en el buffer. + pub fn len(&self) -> usize { + self.inner.lock().map(|g| g.buf.len()).unwrap_or(0) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn written_total(&self) -> u64 { + self.inner.lock().map(|g| g.written_total).unwrap_or(0) + } +} + +impl Default for LogBuf { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn append_and_tail_basic() { + let lb = LogBuf::with_cap(100); + lb.append(b"hello "); + lb.append(b"world\n"); + let t = lb.tail(0); + assert_eq!(t, b"hello world\n"); + } + + #[test] + fn cap_drops_oldest() { + let lb = LogBuf::with_cap(10); + lb.append(&[b'a'; 8]); + lb.append(&[b'b'; 8]); + // Después del recorte, debe quedar ~10 bytes pero el slack + // permite hasta 10+1024. Como pasamos slack, no se recorta aún + // en este caso (16 bytes < 10+1024). Forzamos un append grande. + lb.append(&[b'c'; 2048]); + assert!(lb.len() <= 10 + 1024); + let t = lb.tail(0); + // El tail debe contener 'c's (los más recientes). + assert!(t.iter().filter(|&&b| b == b'c').count() > 0); + } + + #[test] + fn written_total_tracks_all() { + let lb = LogBuf::with_cap(10); + lb.append(b"abcdef"); + lb.append(b"ghijkl"); + assert_eq!(lb.written_total(), 12); + } +} diff --git a/crates/modules/shipote/shipote-core/src/persist.rs b/crates/modules/shipote/shipote-core/src/persist.rs new file mode 100644 index 0000000..a4e1e44 --- /dev/null +++ b/crates/modules/shipote/shipote-core/src/persist.rs @@ -0,0 +1,228 @@ +//! Persistencia del estado del WorkspaceManager. +//! +//! v1: sólo `WorkspaceSpec`s vivos. Los comandos (PIDs) NO se persisten — +//! el kernel los mata al cerrar el daemon. Sólo la *intención declarada* +//! (Workspaces creados con su spec) sobrevive a un reboot del daemon. + +use crate::WorkspaceManager; +use serde::{Deserialize, Serialize}; +use shipote_card::{PipelineSpec, WorkspaceId, WorkspaceSpec}; +use std::path::{Path, PathBuf}; +use tracing::{info, warn}; + +/// v2 agregó `saved_pipelines`. v1 lee con campo ausente como vacío. +pub const SNAPSHOT_VERSION: u16 = 2; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShipoteSnapshot { + pub version: u16, + pub timestamp_ms: u64, + pub workspaces: Vec, + #[serde(default)] + pub saved_pipelines: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceEntry { + pub id: WorkspaceId, + pub spec: WorkspaceSpec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipelineEntry { + pub name: String, + pub spec: PipelineSpec, +} + +impl ShipoteSnapshot { + pub fn write(&self, path: &Path) -> anyhow::Result<()> { + let bytes = serde_json::to_vec_pretty(self)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, &bytes)?; + std::fs::rename(&tmp, path)?; + Ok(()) + } + + pub fn read(path: &Path) -> anyhow::Result { + let bytes = std::fs::read(path)?; + let snap: ShipoteSnapshot = serde_json::from_slice(&bytes)?; + // v1 y v2 son compatibles forward (v1 sin saved_pipelines lee como vec vacío). + if snap.version > SNAPSHOT_VERSION { + anyhow::bail!( + "snapshot version {} no soportada (esperada ≤ {})", + snap.version, + SNAPSHOT_VERSION + ); + } + Ok(snap) + } +} + +/// Path canónico del snapshot: `$XDG_STATE_HOME/shipote/state.json`, +/// fallback `$HOME/.local/state/shipote/state.json`, +/// fallback `/tmp/shipote-state-$UID.json`. +pub fn default_snapshot_path() -> PathBuf { + if let Ok(state) = std::env::var("XDG_STATE_HOME") { + return PathBuf::from(state).join("shipote/state.json"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".local/state/shipote/state.json"); + } + let uid = nix::unistd::getuid().as_raw(); + PathBuf::from(format!("/tmp/shipote-state-{uid}.json")) +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +impl WorkspaceManager { + /// Toma snapshot del estado actual. + pub async fn snapshot(&self) -> ShipoteSnapshot { + let g = self.inner.lock().await; + let workspaces = g + .workspaces + .iter() + .map(|(id, ws)| WorkspaceEntry { + id: *id, + spec: ws.spec.clone(), + }) + .collect(); + let saved_pipelines = g + .saved_pipelines + .iter() + .map(|(name, spec)| PipelineEntry { + name: name.clone(), + spec: spec.clone(), + }) + .collect(); + ShipoteSnapshot { + version: SNAPSHOT_VERSION, + timestamp_ms: now_ms(), + workspaces, + saved_pipelines, + } + } + + /// Escribe snapshot a disco. + pub async fn save_snapshot(&self, path: &Path) -> anyhow::Result<()> { + let snap = self.snapshot().await; + snap.write(path)?; + info!(path = %path.display(), workspaces = snap.workspaces.len(), "snapshot saved"); + Ok(()) + } + + /// Carga snapshot desde disco y restaura los Workspaces. + /// Errores no-fatales (workspaces inválidos) se loguean y se saltan. + pub async fn restore_snapshot(self: &std::sync::Arc, path: &Path) -> anyhow::Result { + let snap = match ShipoteSnapshot::read(path) { + Ok(s) => s, + Err(e) => { + warn!(?e, path = %path.display(), "no snapshot — start fresh"); + return Ok(0); + } + }; + let mut restored = 0usize; + for entry in snap.workspaces { + // v2+: reusamos el id original así clients que tracking + // workspace_id no se rompen al restart. + let label = entry.spec.label.clone(); + match self.create_with_id(entry.id, entry.spec).await { + Ok(_) => restored += 1, + Err(e) => warn!(?e, %label, "skipped workspace en restore"), + } + } + for entry in snap.saved_pipelines { + self.save_pipeline(entry.name, entry.spec).await; + } + info!(restored, "snapshot restored"); + Ok(restored) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::WorkspaceManager; + use ente_incarnate::IncarnatorConfig; + use shipote_card::{ExitPolicy, WorkspaceSpec}; + use std::sync::Arc; + + fn sample_ws(label: &str) -> WorkspaceSpec { + WorkspaceSpec { + label: label.into(), + soma: Default::default(), + permissions: Default::default(), + ttl: None, + flow_dirs: vec![], + on_exit: ExitPolicy::Reap, + } + } + + #[tokio::test] + async fn roundtrip_snapshot_preserves_ulids() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("state.json"); + + let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); + let (id1, _) = mgr1.create(sample_ws("a")).await.unwrap(); + let (id2, _) = mgr1.create(sample_ws("b")).await.unwrap(); + mgr1.save_snapshot(&path).await.unwrap(); + + let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); + let n = mgr2.restore_snapshot(&path).await.unwrap(); + assert_eq!(n, 2); + let listed = mgr2.list().await; + let restored_ids: std::collections::HashSet<_> = listed.iter().map(|s| s.id).collect(); + assert!(restored_ids.contains(&id1)); + assert!(restored_ids.contains(&id2)); + } + + #[tokio::test] + async fn snapshot_includes_saved_pipelines() { + use shipote_card::{CommandRef, DiscernPolicy, PipelineSpec}; + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("state.json"); + + let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); + let (ws_id, _) = mgr1.create(sample_ws("ws")).await.unwrap(); + let spec = PipelineSpec { + label: "echo-cat".into(), + workspace: ws_id, + nodes: vec![CommandRef { + label: "n1".into(), + payload: brahman_card::Payload::Native { + exec: "/bin/echo".into(), + argv: vec!["hi".into()], + envp: vec![], + }, + soma: Default::default(), + flows: Default::default(), + supervision: brahman_card::Supervision::OneShot, + }], + edges: vec![], + discern: DiscernPolicy::default(), + }; + mgr1.save_pipeline("daily".into(), spec).await; + mgr1.save_snapshot(&path).await.unwrap(); + + let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); + mgr2.restore_snapshot(&path).await.unwrap(); + let saved = mgr2.list_saved_pipelines().await; + assert_eq!(saved, vec!["daily".to_string()]); + let got = mgr2.get_saved_pipeline("daily").await.expect("saved"); + assert_eq!(got.label, "echo-cat"); + } + + #[test] + fn default_path_ends_with_state_json() { + let p = default_snapshot_path(); + assert!(p.to_string_lossy().ends_with("state.json")); + } +} diff --git a/crates/modules/shipote/shipote-core/src/pipeline.rs b/crates/modules/shipote/shipote-core/src/pipeline.rs new file mode 100644 index 0000000..9554cd1 --- /dev/null +++ b/crates/modules/shipote/shipote-core/src/pipeline.rs @@ -0,0 +1,420 @@ +//! Pipeline runtime: encadena nodos con pipes y opcionalmente intercepta +//! cada flow para discernir su contenido. +//! +//! Cada nodo se encarna via [`ente_incarnate::Incarnator`] — eso significa +//! que **cada comando puede tener su propio SomaSpec** (namespaces, cgroup, +//! rlimits) heredado del workspace. La conexión stdin↔stdout se hace con +//! `pipe2(2)` + `ChildStdio` declarativo: el callback de clone(2) hace los +//! `dup2` pre-execve sin romper la regla async-signal-safe. + +use crate::CoreError; +use brahman_card::Payload; +use ente_incarnate::{ChildStdio, Incarnator}; +use nix::fcntl::OFlag; +use nix::unistd::pipe2; +use shipote_card::{FlowEdge, PipelineSpec}; +use shipote_discern::{DiscernPipeline, Discernment, Hint}; +use std::os::fd::{AsRawFd, IntoRawFd, RawFd}; +use std::sync::Arc; +use tokio::io::unix::AsyncFd; +use tokio::io::Interest; +use tracing::{debug, info, warn}; +use ulid::Ulid; + +/// Resultado de lanzar un pipeline. +#[derive(Debug, Clone)] +pub struct PipelineLaunch { + pub pipeline: Ulid, + pub command_pids: Vec<(String, i32)>, + /// Discernments por edge, en el mismo orden que `spec.edges`. + pub edge_discernments: Vec, +} + +#[derive(Debug, Clone)] +pub struct EdgeDiscernment { + pub from_label: String, + pub from_output: String, + pub to_label: String, + pub to_input: String, + pub discernment: Option, +} + +/// Lanza un pipeline conectando nodos por stdin/stdout. Cada nodo se +/// encarna via `Incarnator` (con o sin namespacing según su SomaSpec). +/// +/// v1: pipeline lineal (un edge entrante por nodo). Múltiples edges +/// entrantes generan warning y sólo el primero se honra. +pub async fn run_pipeline( + spec: &PipelineSpec, + workspace_label: &str, + tap: bool, + discerner: Arc, + incarnator: Arc, +) -> Result { + spec.validate()?; + let n = spec.nodes.len(); + info!( + nodes = n, + edges = spec.edges.len(), + tap, + "launching pipeline (incarnated)" + ); + + // Predecessor: para cada nodo, su edge entrante (si tiene). + let mut predecessor: Vec> = vec![None; n]; + for e in &spec.edges { + if predecessor[e.to].is_some() { + warn!(node = e.to, "v1 pipeline: nodo con múltiples predecessors — sólo se honra el primero"); + continue; + } + predecessor[e.to] = Some(e); + } + + let mut pids = Vec::with_capacity(n); + let mut taps: Vec = Vec::new(); + // Para cada nodo i que produce, guardamos el FD de read del pipe + // del productor → al armar el consumidor lo consume. + // Pero como puede haber tap intermedio, llevamos un esquema: + // - Sin tap: read FD del pipe productor → stdin del consumidor. + // - 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 + // el siguiente consumidor debe usar como stdin. + let mut pending_stdin_for_next: Option = None; + + for (i, node) in spec.nodes.iter().enumerate() { + // Validar payload ejecutable. + match &node.payload { + Payload::Native { .. } | Payload::Legacy { .. } => {} + _ => { + return Err(CoreError::Incarnate( + ente_incarnate::IncarnateError::NonExecutablePayload, + )) + } + } + + // Compilamos a Card. + 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 = pending_stdin_for_next.take(); + let mut stdout_fd: Option = None; + let mut next_pending: Option = None; + + // FDs que el PADRE debe cerrar tras spawn (son nuestra copia del + // extremo que pasamos al hijo). + let mut parent_closes: Vec = 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 { + stdin_fd, + stdout_fd, + stderr_fd: None, + }; + + // Incarnator absorbe los fds de `stdio` — no los cerramos acá. + // `parent_closes` queda obsoleto. + let _ = parent_closes; + let outcome = incarnator + .incarnate_with(&card, stdio) + .map_err(CoreError::Incarnate)?; + let pid = outcome.pid; + pids.push((node.label.clone(), pid.as_raw())); + debug!(label = %node.label, pid = pid.as_raw(), "node incarnated"); + + pending_stdin_for_next = next_pending; + } + + let pipeline_id = Ulid::new(); + + let mut edge_discernments = Vec::with_capacity(taps.len()); + for t in taps { + match t.handle.await { + Ok(d) => edge_discernments.push(d), + Err(e) => warn!(?e, "tap handle joined with error"), + } + } + + Ok(PipelineLaunch { + pipeline: pipeline_id, + command_pids: pids, + edge_discernments, + }) +} + +struct TapHandle { + handle: tokio::task::JoinHandle, +} + +#[allow(clippy::too_many_arguments)] +fn spawn_tap( + producer_r_fd: RawFd, + consumer_w_fd: RawFd, + sample_bytes: usize, + discerner: Arc, + from_label: String, + from_output: String, + to_label: String, + to_input: String, +) -> TapHandle { + // Marcar non-blocking ANTES de envolverlos en AsyncFd. Sino tokio + // bloquea el reactor en operaciones lentas. + set_nonblocking(producer_r_fd); + set_nonblocking(consumer_w_fd); + + let handle = tokio::spawn(async move { + // SAFETY: el caller transfiere ownership de los fds al task. + let r_std = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(producer_r_fd) }; + let w_std = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(consumer_w_fd) }; + let r = AsyncFd::with_interest(r_std, Interest::READABLE).expect("AsyncFd r"); + let w = AsyncFd::with_interest(w_std, Interest::WRITABLE).expect("AsyncFd w"); + + let mut sample: Vec = Vec::with_capacity(sample_bytes); + let mut buf = [0u8; 4096]; + let mut total: u64 = 0; + + // Fase 1: sampling + pump. + let mut eof = false; + while !eof && sample.len() < sample_bytes { + let n = match async_read(&r, &mut buf).await { + Ok(0) => { eof = true; 0 } + Ok(n) => n, + Err(e) => { warn!(?e, "tap producer read failed"); break; } + }; + if n == 0 { break; } + let take = n.min(sample_bytes - sample.len()); + sample.extend_from_slice(&buf[..take]); + if let Err(e) = async_write_all(&w, &buf[..n]).await { + warn!(?e, "tap consumer write failed"); + break; + } + total += n as u64; + } + let d = discerner.discern(&sample, &Hint { path: None, size_total: None }); + + // Fase 2: pump-only hasta EOF. + while !eof { + let n = match async_read(&r, &mut buf).await { + Ok(0) => { eof = true; 0 } + Ok(n) => n, + Err(_) => break, + }; + if n == 0 { break; } + if async_write_all(&w, &buf[..n]).await.is_err() { break; } + total += n as u64; + } + debug!(bytes = total, "tap finished"); + EdgeDiscernment { + from_label, + from_output, + to_label, + to_input, + discernment: d, + } + }); + TapHandle { handle } +} + +async fn async_read( + afd: &AsyncFd, + buf: &mut [u8], +) -> std::io::Result { + loop { + let mut guard = afd.readable().await?; + let fd = afd.as_raw_fd(); + // SAFETY: lectura sobre fd válido propiedad del AsyncFd. + let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) }; + if r >= 0 { + return Ok(r as usize); + } + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + guard.clear_ready(); + continue; + } + return Err(err); + } +} + +async fn async_write_all( + afd: &AsyncFd, + mut buf: &[u8], +) -> std::io::Result<()> { + while !buf.is_empty() { + let mut guard = afd.writable().await?; + let fd = afd.as_raw_fd(); + // SAFETY: escritura sobre fd válido propiedad del AsyncFd. + let r = unsafe { libc::write(fd, buf.as_ptr() as *const _, buf.len()) }; + if r > 0 { + buf = &buf[r as usize..]; + continue; + } + if r == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "write 0", + )); + } + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + guard.clear_ready(); + continue; + } + return Err(err); + } + Ok(()) +} + +fn set_nonblocking(fd: RawFd) { + // SAFETY: fcntl con F_SETFL es seguro para fds válidos. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL, 0); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } +} + +// Extension trait para abstraer la API de OwnedFd entre versiones (compat). +trait OwnedFdFromRawCompat: Sized { + unsafe fn from_raw_fd_compat(fd: RawFd) -> Self; +} + +impl OwnedFdFromRawCompat for std::os::fd::OwnedFd { + unsafe fn from_raw_fd_compat(fd: RawFd) -> Self { + use std::os::fd::FromRawFd; + // SAFETY: el caller transfiere ownership de `fd` a la `OwnedFd`. + unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) } + } +} + +// Re-export para que el unused warning del AsRawFd se calle si no se usa. +#[allow(dead_code)] +fn _keep_raw(_: &dyn AsRawFd) {} + +#[cfg(test)] +mod tests { + use super::*; + use brahman_card::Payload; + use ente_incarnate::IncarnatorConfig; + use shipote_card::{CommandRef, DiscernPolicy, FlowEdge, PipelineSpec, WorkspaceId}; + + fn cmd(label: &str, exec: &str, argv: &[&str]) -> CommandRef { + CommandRef { + label: label.into(), + payload: Payload::Native { + exec: exec.into(), + argv: argv.iter().map(|s| s.to_string()).collect(), + envp: vec![], + }, + soma: Default::default(), + flows: Default::default(), + supervision: brahman_card::Supervision::OneShot, + } + } + + #[tokio::test] + async fn pipeline_isolated_echo_to_cat_runs() { + let spec = PipelineSpec { + label: "echo-cat".into(), + workspace: WorkspaceId::new(), + nodes: vec![ + cmd("p1", "/bin/echo", &["hola pipeline aislado"]), + cmd("p2", "/bin/cat", &[]), + ], + edges: vec![FlowEdge { + from: 0, + from_output: "stdout".into(), + to: 1, + 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).await.unwrap(); + assert_eq!(launch.command_pids.len(), 2); + // Cosecha. + for (_, pid) in &launch.command_pids { + let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None); + } + } + + #[tokio::test] + async fn pipeline_isolated_with_tap_captures_discernment() { + let spec = PipelineSpec { + label: "json-cat".into(), + workspace: WorkspaceId::new(), + nodes: vec![ + cmd("p1", "/bin/echo", &["{\"hello\": 1}"]), + cmd("p2", "/bin/cat", &[]), + ], + edges: vec![FlowEdge { + from: 0, + from_output: "stdout".into(), + to: 1, + to_input: "stdin".into(), + }], + discern: DiscernPolicy { + sample_bytes: 4096, + enrich_producer: true, + }, + }; + let disc = Arc::new(DiscernPipeline::default_pipeline()); + let inc = Arc::new(Incarnator::new(IncarnatorConfig::default())); + let launch = run_pipeline(&spec, "ws", true, disc, inc).await.unwrap(); + assert_eq!(launch.edge_discernments.len(), 1); + let d = &launch.edge_discernments[0]; + let dis = d.discernment.as_ref().expect("discernment present"); + assert_eq!(dis.mime.as_deref(), Some("application/json")); + // Cosecha. + for (_, pid) in &launch.command_pids { + let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None); + } + } +} diff --git a/crates/modules/shipote/shipote-discern/Cargo.toml b/crates/modules/shipote/shipote-discern/Cargo.toml new file mode 100644 index 0000000..7e52da3 --- /dev/null +++ b/crates/modules/shipote/shipote-discern/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "shipote-discern" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Discernidor de contenido sobre buffers: MIME, codificación, parser hints. Compartible con file_explorer y nouser." + +[dependencies] +brahman-card = { path = "../../../core/brahman-card" } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } diff --git a/crates/modules/shipote/shipote-discern/src/lib.rs b/crates/modules/shipote/shipote-discern/src/lib.rs new file mode 100644 index 0000000..f26344d --- /dev/null +++ b/crates/modules/shipote/shipote-discern/src/lib.rs @@ -0,0 +1,307 @@ +//! `shipote-discern` — detección de tipo de contenido sobre buffers. +//! +//! Trait + pipeline + discerners default. Devuelve un [`Discernment`] con +//! `TypeRef` consistente con el broker, confidence, MIME y un `lens` hint +//! para UIs (reusa el espíritu del `dominant_lens` de nouser). + +#![forbid(unsafe_code)] + +use brahman_card::TypeRef; + +#[derive(Debug, Clone)] +pub struct Hint<'a> { + pub path: Option<&'a str>, + pub size_total: Option, +} + +#[derive(Debug, Clone)] +pub struct Discernment { + pub ty: TypeRef, + pub confidence: f32, + pub mime: Option, + pub lens: Option, +} + +pub trait Discerner: Send + Sync { + fn name(&self) -> &str; + fn discern(&self, sample: &[u8], hint: &Hint<'_>) -> Option; +} + +pub struct DiscernPipeline { + discerners: Vec>, +} + +impl DiscernPipeline { + pub fn new() -> Self { + Self { discerners: Vec::new() } + } + + /// Pipeline con los discerners default. Orden importa: el primer match + /// con confidence ≥ `accept_threshold` corta. + pub fn default_pipeline() -> Self { + let mut p = Self::new(); + p.push(Box::new(MagicBytes)); + // CardProbe antes que JsonProbe: una Card es JSON, pero queremos el + // TypeRef más específico cuando aplique. + p.push(Box::new(CardProbe)); + p.push(Box::new(JsonProbe)); + p.push(Box::new(TomlProbe)); + p.push(Box::new(Utf8Probe)); + p + } + + pub fn push(&mut self, d: Box) { + self.discerners.push(d); + } + + /// Recorre los discerners y devuelve el primer Discernment con + /// confidence ≥ 0.5, o el más confidente si ninguno alcanza el umbral. + pub fn discern(&self, sample: &[u8], hint: &Hint<'_>) -> Option { + let mut best: Option = None; + for d in &self.discerners { + if let Some(r) = d.discern(sample, hint) { + if r.confidence >= 0.9 { + return Some(r); + } + best = match best { + Some(prev) if prev.confidence >= r.confidence => Some(prev), + _ => Some(r), + }; + } + } + best + } +} + +impl Default for DiscernPipeline { + fn default() -> Self { + Self::default_pipeline() + } +} + +// ===================================================================== +// Discerners +// ===================================================================== + +/// Magic-bytes para formatos comunes. Confidence alta cuando hay match. +pub struct MagicBytes; + +impl Discerner for MagicBytes { + fn name(&self) -> &str { "magic-bytes" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + let d = |ty: &str, mime: &str, lens: Option<&str>| Discernment { + ty: TypeRef::Primitive { name: ty.into() }, + confidence: 0.99, + mime: Some(mime.into()), + lens: lens.map(String::from), + }; + match s { + x if x.starts_with(&[0x89, b'P', b'N', b'G']) => Some(d("png", "image/png", Some("gallery"))), + x if x.starts_with(&[0xFF, 0xD8, 0xFF]) => Some(d("jpeg", "image/jpeg", Some("gallery"))), + x if x.starts_with(b"%PDF-") => Some(d("pdf", "application/pdf", Some("reader"))), + x if x.starts_with(&[0x7F, b'E', b'L', b'F']) => Some(d("elf", "application/x-executable", None)), + x if x.starts_with(&[0x00, 0x61, 0x73, 0x6D]) => Some(d("wasm", "application/wasm", None)), + x if x.starts_with(&[0x1F, 0x8B]) => Some(d("gzip", "application/gzip", None)), + x if x.starts_with(b"PK\x03\x04") || x.starts_with(b"PK\x05\x06") => { + Some(d("zip", "application/zip", None)) + } + x if x.starts_with(b"GIF87a") || x.starts_with(b"GIF89a") => { + Some(d("gif", "image/gif", Some("gallery"))) + } + _ => None, + } + } +} + +/// JSON: parsea el inicio. No requiere parsearlo entero; con que arranque +/// con `{`/`[` y haga progreso cuenta. +pub struct JsonProbe; + +impl Discerner for JsonProbe { + fn name(&self) -> &str { "json" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + let trimmed = trim_left(s); + let first = *trimmed.first()?; + if first != b'{' && first != b'[' { + return None; + } + // Intento parsear tal cual; si falla por truncated, igualmente confidence media. + let txt = std::str::from_utf8(trimmed).ok()?; + match serde_json::from_str::(txt) { + Ok(_) => Some(Discernment { + ty: TypeRef::Primitive { name: "json".into() }, + confidence: 0.95, + mime: Some("application/json".into()), + lens: Some("tree".into()), + }), + Err(_) => Some(Discernment { + ty: TypeRef::Primitive { name: "json".into() }, + confidence: 0.6, // sample truncado + mime: Some("application/json".into()), + lens: Some("tree".into()), + }), + } + } +} + +pub struct TomlProbe; + +impl Discerner for TomlProbe { + fn name(&self) -> &str { "toml" } + + fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option { + let txt = std::str::from_utf8(s).ok()?; + // Heurística: presencia de `[seccion]` y/o `clave = valor` y extensión. + let looks_like = txt.lines().any(|l| { + let l = l.trim(); + l.starts_with('[') && l.ends_with(']') + }) || txt.lines().any(|l| { + let l = l.trim(); + !l.starts_with('#') && l.contains(" = ") + }); + if !looks_like { + return None; + } + let confidence = if h.path.map_or(false, |p| p.ends_with(".toml")) { + 0.95 + } else { + 0.55 + }; + // Si parsea, sube confidence. + let parsed = toml::from_str::(txt).is_ok(); + Some(Discernment { + ty: TypeRef::Primitive { name: "toml".into() }, + confidence: if parsed { 0.93 } else { confidence }, + mime: Some("application/toml".into()), + lens: Some("tree".into()), + }) + } +} + +/// Si el JSON parsea como Card, lo emite como Wit { brahman:card }. +pub struct CardProbe; + +impl Discerner for CardProbe { + fn name(&self) -> &str { "card" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + let trimmed = trim_left(s); + if trimmed.first()? != &b'{' { + return None; + } + let txt = std::str::from_utf8(trimmed).ok()?; + let v: serde_json::Value = serde_json::from_str(txt).ok()?; + let obj = v.as_object()?; + if obj.contains_key("schema_version") && obj.contains_key("id") && obj.contains_key("payload") { + Some(Discernment { + ty: TypeRef::Wit { + package: "brahman:card".into(), + interface: None, + name: "card".into(), + }, + confidence: 0.97, + mime: Some("application/json".into()), + lens: Some("card".into()), + }) + } else { + None + } + } +} + +/// Texto UTF-8 plano. Fallback de baja confidence. +pub struct Utf8Probe; + +impl Discerner for Utf8Probe { + fn name(&self) -> &str { "utf8" } + + fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option { + if s.is_empty() { + return None; + } + let valid = std::str::from_utf8(s).is_ok(); + if !valid { + return None; + } + // Detectar binario disfrazado: bytes de control fuera de \t\n\r. + let suspicious = s.iter().filter(|&&b| b < 0x09 || (b > 0x0D && b < 0x20)).count(); + if suspicious * 100 / s.len().max(1) > 5 { + return None; + } + let lens = h.path.and_then(|p| { + if p.ends_with(".md") { Some("markdown") } + else if p.ends_with(".rs") || p.ends_with(".py") || p.ends_with(".go") || p.ends_with(".js") || p.ends_with(".ts") { + Some("code") + } else { None } + }).map(String::from); + Some(Discernment { + ty: TypeRef::Primitive { name: "text".into() }, + confidence: 0.5, + mime: Some("text/plain; charset=utf-8".into()), + lens, + }) + } +} + +fn trim_left(s: &[u8]) -> &[u8] { + let mut i = 0; + while i < s.len() && (s[i] == b' ' || s[i] == b'\t' || s[i] == b'\n' || s[i] == b'\r') { + i += 1; + } + &s[i..] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn discern(sample: &[u8]) -> Option { + DiscernPipeline::default_pipeline().discern(sample, &Hint { path: None, size_total: None }) + } + + #[test] + fn png_detected() { + let r = discern(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]).unwrap(); + assert_eq!(r.mime.as_deref(), Some("image/png")); + assert!(r.confidence > 0.9); + } + + #[test] + fn json_detected() { + let r = discern(b"{\"hello\": 1}").unwrap(); + assert_eq!(r.mime.as_deref(), Some("application/json")); + } + + #[test] + fn card_wins_over_plain_json() { + let payload = br#"{"schema_version":1,"id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","label":"x","payload":{"Virtual":null},"supervision":"OneShot"}"#; + let r = discern(payload).unwrap(); + match r.ty { + TypeRef::Wit { ref package, .. } => assert_eq!(package, "brahman:card"), + _ => panic!("expected card"), + } + } + + #[test] + fn utf8_text_fallback() { + let r = discern(b"hello world\nthis is text").unwrap(); + // Puede ser detected as toml (= heurística) o text. Ambos son aceptables, sólo aseguro algo razonable. + assert!(r.mime.is_some()); + } + + #[test] + fn binary_rejected_by_utf8() { + let mut bytes = vec![0u8; 100]; + bytes[0] = 0x00; + bytes[1] = 0x01; + bytes[2] = 0x02; + let r = DiscernPipeline::default_pipeline().discern(&bytes, &Hint { path: None, size_total: None }); + // Tras Utf8Probe rechazar, no hay match → None. + // Si por casualidad otro discerner mata antes, también es OK. + if let Some(r) = r { + assert_ne!(r.mime.as_deref(), Some("text/plain; charset=utf-8")); + } + } +} diff --git a/crates/modules/shipote/shipote-protocol/Cargo.toml b/crates/modules/shipote/shipote-protocol/Cargo.toml new file mode 100644 index 0000000..45000ef --- /dev/null +++ b/crates/modules/shipote/shipote-protocol/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "shipote-protocol" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Wire protocol entre shipote-daemon y clientes (cli/gui). Postcard length-prefixed sobre Unix socket." + +[dependencies] +shipote-card = { path = "../shipote-card" } +brahman-card = { path = "../../../core/brahman-card" } +serde = { workspace = true } +postcard = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +ulid = { workspace = true } +nix = { workspace = true } diff --git a/crates/modules/shipote/shipote-protocol/src/lib.rs b/crates/modules/shipote/shipote-protocol/src/lib.rs new file mode 100644 index 0000000..6e18842 --- /dev/null +++ b/crates/modules/shipote/shipote-protocol/src/lib.rs @@ -0,0 +1,290 @@ +//! `shipote-protocol` — wire daemon ↔ cliente (cli/gui). +//! +//! Framing: u32 BE length-prefix + payload postcard. Mismo patrón que +//! `ente-bus`/`brahman-handshake` para que clientes existentes compartan +//! reader/writer helpers si quieren. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use shipote_card::{PipelineSpec, WorkspaceId, WorkspaceSpec}; +use std::path::PathBuf; +use thiserror::Error; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixStream; +use ulid::Ulid; + +pub const DEFAULT_SOCK_NAME: &str = "shipote.sock"; +pub const MAX_FRAME: usize = 1 << 20; + +// ===================================================================== +// Mensajes +// ===================================================================== + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Request { + /// Health-check. + Ping, + + /// Crear un workspace nuevo. + WorkspaceCreate { spec: WorkspaceSpec }, + + /// Listar todos los workspaces vivos. + WorkspaceList, + + /// Detener un workspace y reapear sus comandos. + WorkspaceStop { id: WorkspaceId }, + + /// Ejecutar un comando one-shot dentro de un workspace existente. + Run { + workspace: WorkspaceId, + exec: String, + argv: Vec, + envp: Vec<(String, String)>, + }, + + /// Lanzar un Pipeline completo dentro de un workspace. + PipelineRun { + spec: PipelineSpec, + /// Si `true`, el daemon interpone un tap entre productor y + /// consumidor de cada FlowEdge, sampleando los primeros bytes + /// y discerniendo el TypeRef. + tap: bool, + }, + + /// Discernir un buffer ad-hoc (sin workspace). Útil para `shipote discern `. + Discern { sample: Vec, hint_path: Option }, + + /// Capacidades runtime del kernel/proceso del daemon. + Capabilities, + + /// Listar comandos vivos+pasados de un workspace. + CommandList { workspace: shipote_card::WorkspaceId }, + + /// Tail del log capturado para un comando. + CommandLogs { + workspace: shipote_card::WorkspaceId, + command: Ulid, + tail_bytes: usize, + }, + + /// Guardar (o reemplazar) un PipelineSpec bajo un nombre. + PipelineSave { name: String, spec: PipelineSpec }, + + /// Listar nombres de pipelines guardados. + PipelineSavedList, + + /// Eliminar un pipeline guardado. + PipelineDrop { name: String }, + + /// Ejecutar un pipeline guardado. + PipelineRunSaved { name: String, tap: bool }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Response { + Pong, + + WorkspaceCreated { + id: WorkspaceId, + warnings: Vec, + }, + + WorkspaceList { + items: Vec, + }, + + WorkspaceStopped { + id: WorkspaceId, + reaped: u32, + }, + + RunStarted { + workspace: WorkspaceId, + command_id: Ulid, + pid: i32, + }, + + PipelineStarted { + pipeline: Ulid, + command_pids: Vec<(String, i32)>, + /// Discernments por edge cuando tap=true. Vacío sin tap. + edges: Vec, + }, + + Discernment { + ty: String, + confidence: f32, + mime: Option, + lens: Option, + }, + + Capabilities { + kernel_version: (u32, u32, u32), + user_ns: String, + cgroup_v2: String, + cgroup_delegated: bool, + has_cap_sys_admin: bool, + }, + + CommandList { + items: Vec, + }, + + CommandLogs { + bytes: Vec, + }, + + PipelineSaved { + name: String, + }, + + PipelineSavedList { + names: Vec, + }, + + PipelineDropped { + name: String, + existed: bool, + }, + + Error { + message: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandInfo { + pub id: Ulid, + pub label: String, + pub pid: i32, + pub alive: bool, + pub exit_status: Option, + pub log_bytes: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EdgeDiscernmentInfo { + pub from_label: String, + pub from_output: String, + pub to_label: String, + pub to_input: String, + /// `Some(ty)` si el discerner detectó algo. `None` si no hubo data + /// suficiente o no matcheó ningún discerner. + pub ty: Option, + pub mime: Option, + pub lens: Option, + pub confidence: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceSummary { + pub id: WorkspaceId, + pub label: String, + pub commands: u32, + pub uptime_ms: u64, +} + +// ===================================================================== +// Errores +// ===================================================================== + +#[derive(Debug, Error)] +pub enum ProtocolError { + #[error("frame oversize: {0} bytes (max {MAX_FRAME})")] + FrameOversize(usize), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("postcard: {0}")] + Postcard(#[from] postcard::Error), + #[error("connection closed")] + Closed, +} + +// ===================================================================== +// Framing helpers +// ===================================================================== + +pub async fn write_frame(stream: &mut UnixStream, msg: &T) -> Result<(), ProtocolError> { + let bytes = postcard::to_allocvec(msg)?; + if bytes.len() > MAX_FRAME { + return Err(ProtocolError::FrameOversize(bytes.len())); + } + let len = (bytes.len() as u32).to_be_bytes(); + stream.write_all(&len).await?; + stream.write_all(&bytes).await?; + stream.flush().await?; + Ok(()) +} + +pub async fn read_frame Deserialize<'de>>( + stream: &mut UnixStream, +) -> Result { + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + ProtocolError::Closed + } else { + ProtocolError::Io(e) + } + })?; + let len = u32::from_be_bytes(len_buf) as usize; + if len > MAX_FRAME { + return Err(ProtocolError::FrameOversize(len)); + } + let mut buf = vec![0u8; len]; + stream.read_exact(&mut buf).await?; + Ok(postcard::from_bytes(&buf)?) +} + +/// Path canónico del socket del daemon: `$XDG_RUNTIME_DIR/shipote.sock`, +/// fallback `/run/user/$UID/shipote.sock`, fallback `/tmp/shipote-$UID.sock`. +pub fn default_socket_path() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(xdg).join(DEFAULT_SOCK_NAME); + } + let uid = nix::unistd::getuid().as_raw(); + let p = PathBuf::from(format!("/run/user/{uid}")); + if p.exists() { + return p.join(DEFAULT_SOCK_NAME); + } + PathBuf::from(format!("/tmp/shipote-{uid}.sock")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ping_roundtrip() { + let bytes = postcard::to_allocvec(&Request::Ping).unwrap(); + let back: Request = postcard::from_bytes(&bytes).unwrap(); + assert!(matches!(back, Request::Ping)); + } + + #[test] + fn workspace_create_roundtrip() { + let req = Request::WorkspaceCreate { + spec: WorkspaceSpec { + label: "demo".into(), + soma: Default::default(), + permissions: Default::default(), + ttl: None, + flow_dirs: vec![], + on_exit: shipote_card::ExitPolicy::Reap, + }, + }; + let bytes = postcard::to_allocvec(&req).unwrap(); + let back: Request = postcard::from_bytes(&bytes).unwrap(); + match back { + Request::WorkspaceCreate { spec } => assert_eq!(spec.label, "demo"), + _ => panic!("wrong variant"), + } + } + + #[test] + fn default_socket_path_uses_runtime_dir() { + let p = default_socket_path(); + assert!(p.to_string_lossy().ends_with("shipote.sock")); + } +} diff --git a/crates/modules/ui_engine/libs/providers/fs/Cargo.toml b/crates/modules/ui_engine/libs/providers/fs/Cargo.toml index 4812731..265a3d2 100644 --- a/crates/modules/ui_engine/libs/providers/fs/Cargo.toml +++ b/crates/modules/ui_engine/libs/providers/fs/Cargo.toml @@ -3,10 +3,11 @@ name = "yahweh-provider-fs" version = { workspace = true } edition = { workspace = true } license = { workspace = true } -description = "DataProvider de filesystem local." +description = "DataProvider de filesystem local con discernimiento de contenido (shipote-discern)." [dependencies] yahweh-core = { workspace = true } async-trait = { workspace = true } tokio = { workspace = true } notify = { workspace = true } +shipote-discern = { path = "../../../../../modules/shipote/shipote-discern" } diff --git a/crates/modules/ui_engine/libs/providers/fs/src/lib.rs b/crates/modules/ui_engine/libs/providers/fs/src/lib.rs index 3cfea50..42946cf 100644 --- a/crates/modules/ui_engine/libs/providers/fs/src/lib.rs +++ b/crates/modules/ui_engine/libs/providers/fs/src/lib.rs @@ -3,16 +3,45 @@ //! `std::fs::read_dir` y leyendo archivos a `Vec` via `tokio::io`. use async_trait::async_trait; +use shipote_discern::{DiscernPipeline, Hint}; use std::fs; -use std::io::Cursor; +use std::io::{Cursor, Read}; use std::path::Path; use std::pin::Pin; +use std::sync::Arc; use tokio::io::{AsyncRead, AsyncWrite}; use yahweh_core::{DataProvider, DisplayType, EntityNode}; pub const PROVIDER_ID: &str = "local_fs"; -pub struct FileDataProvider; +/// Bytes que samplea el discerner por archivo. 4 KiB cubre headers de +/// formatos comunes (PNG, ELF, JSON/TOML hasta una clave de profundidad +/// razonable) sin saturar I/O al expandir un directorio. +const DISCERN_SAMPLE_BYTES: usize = 4096; + +/// Tamaño máximo de archivo que sampleamos. Archivos más grandes se +/// discernen igual via los primeros 4 KiB: el `seek/read` siempre lee +/// head, y el costo es O(SAMPLE) sin importar el size total. +/// Mantenemos esta constante por documentación; no se usa para skipear. +const _DISCERN_SAMPLE_DOC: () = (); + +pub struct FileDataProvider { + discerner: Arc, +} + +impl FileDataProvider { + pub fn new() -> Self { + Self { + discerner: Arc::new(DiscernPipeline::default_pipeline()), + } + } +} + +impl Default for FileDataProvider { + fn default() -> Self { + Self::new() + } +} #[async_trait] impl DataProvider for FileDataProvider { @@ -32,17 +61,21 @@ impl DataProvider for FileDataProvider { .unwrap_or_default() .to_string_lossy() .into_owned(); - let display_type = if path.is_dir() { - DisplayType::Folder + let is_dir = path.is_dir(); + let display_type = if is_dir { DisplayType::Folder } else { DisplayType::File }; + + // Discernimos sólo archivos. Folders no tienen MIME útil. + let mime_type = if is_dir { + None } else { - DisplayType::File + discern_head(&path, &self.discerner) }; children.push(EntityNode { id: path.to_string_lossy().into_owned(), name, display_type, - mime_type: None, + mime_type, }); } } @@ -65,3 +98,22 @@ impl DataProvider for FileDataProvider { Err("Escritura en streaming no implementada para FS".to_string()) } } + +/// Lee el head del archivo y lo pasa por el DiscernPipeline. Devuelve el +/// MIME detectado (si alguno) o `None` si no hubo match. +/// +/// Sync intencional: estamos dentro del runtime que ya es async, pero la +/// lectura es de tamaño fijo (4 KiB) y va a page cache; el costo de +/// `tokio::fs` no compensaría para esto. +fn discern_head(path: &Path, discerner: &DiscernPipeline) -> Option { + let mut buf = vec![0u8; DISCERN_SAMPLE_BYTES]; + let mut f = fs::File::open(path).ok()?; + let n = f.read(&mut buf).ok()?; + buf.truncate(n); + let path_str = path.to_str(); + let hint = Hint { + path: path_str, + size_total: None, + }; + discerner.discern(&buf, &hint).and_then(|d| d.mime) +} diff --git a/crates/shared/ente-incarnate/Cargo.toml b/crates/shared/ente-incarnate/Cargo.toml new file mode 100644 index 0000000..3e2a00c --- /dev/null +++ b/crates/shared/ente-incarnate/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ente-incarnate" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Rutina extraída del Init para encarnar Cards en procesos aislados (clone+ns+cgroup+rlimits). Reusable por cualquier supervisor — no implica ser PID 1." + +[dependencies] +brahman-card = { path = "../../core/brahman-card" } +nix = { workspace = true } +libc = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/shared/ente-incarnate/src/caps.rs b/crates/shared/ente-incarnate/src/caps.rs new file mode 100644 index 0000000..29a6791 --- /dev/null +++ b/crates/shared/ente-incarnate/src/caps.rs @@ -0,0 +1,214 @@ +//! Detección runtime de capacidades del kernel/proceso para aislamiento. +//! +//! Esto NO se cachea entre instancias — sysctls pueden cambiar entre boot, y +//! cgroup delegation depende del proceso concreto. Cada `Incarnator::new` +//! hace su detección al construirse. + +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub struct CapabilitySet { + pub kernel_version: (u32, u32, u32), + pub has_cap_sys_admin: bool, + pub user_ns: UserNsStatus, + pub cgroup_v2: CgroupStatus, + pub cgroup_delegated: bool, + pub max_user_namespaces: Option, + pub our_cgroup: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UserNsStatus { + Allowed, + DisabledBySysctl, + RestrictedByLsm, + Unknown, +} + +impl UserNsStatus { + pub fn is_allowed(&self) -> bool { + matches!(self, UserNsStatus::Allowed) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CgroupStatus { + Unified, + Hybrid, + Legacy, + NotMounted, +} + +impl CapabilitySet { + pub fn detect() -> Self { + Self { + kernel_version: detect_kernel_version().unwrap_or((0, 0, 0)), + has_cap_sys_admin: detect_cap_sys_admin(), + user_ns: detect_user_ns(), + cgroup_v2: detect_cgroup_status(), + cgroup_delegated: detect_cgroup_delegated(), + max_user_namespaces: read_u64("/proc/sys/user/max_user_namespaces"), + our_cgroup: detect_our_cgroup(), + } + } + + /// ¿Podemos crear el namespace `ns`? + /// Reglas: + /// - user → necesita user_ns Allowed (o ya tener CAP_SYS_ADMIN, en cuyo caso no se crea uno nuevo). + /// - resto → CAP_SYS_ADMIN, o crearlos junto con user ns nuevo. + pub fn can_create_ns(&self, kind: NsKind) -> bool { + match kind { + NsKind::User => self.user_ns.is_allowed() || self.has_cap_sys_admin, + _ => { + self.has_cap_sys_admin + || (self.user_ns.is_allowed() && self.max_user_namespaces.unwrap_or(0) > 0) + } + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum NsKind { + Mount, + Pid, + Net, + Uts, + Ipc, + User, + Cgroup, +} + +impl NsKind { + pub fn name(self) -> &'static str { + match self { + NsKind::Mount => "mount", + NsKind::Pid => "pid", + NsKind::Net => "net", + NsKind::Uts => "uts", + NsKind::Ipc => "ipc", + NsKind::User => "user", + NsKind::Cgroup => "cgroup", + } + } +} + +fn detect_kernel_version() -> Option<(u32, u32, u32)> { + let s = std::fs::read_to_string("/proc/sys/kernel/osrelease").ok()?; + let head = s.split(|c: char| !c.is_ascii_digit() && c != '.').next()?; + let mut it = head.split('.'); + let a = it.next()?.parse().ok()?; + let b = it.next()?.parse().ok()?; + let c = it.next().and_then(|x| x.parse().ok()).unwrap_or(0); + Some((a, b, c)) +} + +fn detect_cap_sys_admin() -> bool { + // euid 0 implica caps por default. Modo simple: si euid==0, asumimos CAP_SYS_ADMIN. + // Podríamos parsear /proc/self/status > CapEff, pero para nuestros usos el + // discriminador útil es root vs no-root. + nix::unistd::geteuid().is_root() +} + +fn detect_user_ns() -> UserNsStatus { + // Sysctl tradicional Debian/Ubuntu pre-24. + if let Some(v) = read_u64("/proc/sys/kernel/unprivileged_userns_clone") { + if v == 0 { + return UserNsStatus::DisabledBySysctl; + } + } + // AppArmor restriction (Ubuntu 24+). 1 = restringido, 2 = restricción aplicada. + if let Some(v) = read_u64("/proc/sys/kernel/apparmor_restrict_unprivileged_userns") { + if v >= 1 { + return UserNsStatus::RestrictedByLsm; + } + } + if let Some(0) = read_u64("/proc/sys/user/max_user_namespaces") { + return UserNsStatus::DisabledBySysctl; + } + UserNsStatus::Allowed +} + +fn detect_cgroup_status() -> CgroupStatus { + // /sys/fs/cgroup montado como cgroup2 → unified. + let mounts = match std::fs::read_to_string("/proc/self/mountinfo") { + Ok(s) => s, + Err(_) => return CgroupStatus::NotMounted, + }; + let mut has_v2 = false; + let mut has_v1 = false; + for line in mounts.lines() { + // formato: ... - + let parts: Vec<&str> = line.split(" - ").collect(); + if parts.len() < 2 { + continue; + } + let tail = parts[1]; + let fields: Vec<&str> = tail.split_whitespace().collect(); + if fields.is_empty() { + continue; + } + match fields[0] { + "cgroup2" => has_v2 = true, + "cgroup" => has_v1 = true, + _ => {} + } + } + match (has_v2, has_v1) { + (true, false) => CgroupStatus::Unified, + (true, true) => CgroupStatus::Hybrid, + (false, true) => CgroupStatus::Legacy, + (false, false) => CgroupStatus::NotMounted, + } +} + +fn detect_our_cgroup() -> Option { + let s = std::fs::read_to_string("/proc/self/cgroup").ok()?; + let rel = s.lines().find_map(|l| l.strip_prefix("0::"))?.trim(); + let abs = if rel == "/" { + PathBuf::from("/sys/fs/cgroup") + } else { + PathBuf::from(format!("/sys/fs/cgroup{rel}")) + }; + Some(abs) +} + +fn detect_cgroup_delegated() -> bool { + // Heurística: ¿podemos escribir cgroup.subtree_control en nuestro cgroup + // o crear subdirectorios? En cgroup v2 con Delegate=yes, el dueño es el uid + // del usuario y `access(W_OK)` sobre el directorio devuelve OK. + let Some(p) = detect_our_cgroup() else { return false }; + use nix::unistd::{access, AccessFlags}; + access(&p, AccessFlags::W_OK).is_ok() +} + +fn read_u64(path: &str) -> Option { + let s = std::fs::read_to_string(Path::new(path)).ok()?; + s.trim().parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_does_not_panic() { + let _ = CapabilitySet::detect(); + } + + #[test] + fn ns_kind_names_unique() { + let names = [ + NsKind::Mount.name(), + NsKind::Pid.name(), + NsKind::Net.name(), + NsKind::Uts.name(), + NsKind::Ipc.name(), + NsKind::User.name(), + NsKind::Cgroup.name(), + ]; + let mut sorted = names.to_vec(); + sorted.sort(); + sorted.dedup(); + assert_eq!(sorted.len(), names.len()); + } +} diff --git a/crates/shared/ente-incarnate/src/cgroup.rs b/crates/shared/ente-incarnate/src/cgroup.rs new file mode 100644 index 0000000..580d478 --- /dev/null +++ b/crates/shared/ente-incarnate/src/cgroup.rs @@ -0,0 +1,91 @@ +//! Resolución y creación de cgroups v2 para el hijo. + +use crate::error::IncarnateError; +use brahman_card::CgroupSpec; +use std::path::PathBuf; + +/// Cgroup actual del proceso que llama. Lo usamos como prefijo para paths +/// declarados relativos en `CgroupSpec.path`. +pub fn current_cgroup() -> Option { + let s = std::fs::read_to_string("/proc/self/cgroup").ok()?; + s.lines() + .find_map(|l| l.strip_prefix("0::")) + .map(|s| s.trim().to_string()) +} + +/// Resuelve un path declarado contra la jerarquía real. +pub fn resolve_cgroup_path(spec_path: &str) -> String { + if spec_path.is_empty() { + return String::new(); + } + if spec_path.starts_with('/') { + return spec_path.to_string(); + } + let trimmed = spec_path.trim_start_matches('/'); + if let Some(cg) = current_cgroup() { + let base = if cg == "/" { + String::new() + } else { + cg.trim_end_matches('/').to_string() + }; + format!("{base}/{trimmed}") + } else { + format!("/{trimmed}") + } +} + +/// Crea el cgroup declarado y aplica weights. Devuelve el path absoluto +/// resultante bajo `/sys/fs/cgroup`. +pub fn ensure_cgroup(spec: &CgroupSpec) -> Result { + let rel = resolve_cgroup_path(&spec.path); + if rel.is_empty() { + return Err(IncarnateError::CgroupNotWritable { + path: PathBuf::from("(empty)"), + }); + } + let abs = PathBuf::from(format!("/sys/fs/cgroup{}", rel)); + std::fs::create_dir_all(&abs).map_err(|e| match e.kind() { + std::io::ErrorKind::PermissionDenied => IncarnateError::CgroupNotWritable { path: abs.clone() }, + _ => IncarnateError::Io(e), + })?; + if let Some(w) = spec.cpu_weight { + let _ = std::fs::write(abs.join("cpu.weight"), format!("{w}\n")); + } + if let Some(w) = spec.io_weight { + // io.weight requiere "default " en cgroup v2. + let _ = std::fs::write(abs.join("io.weight"), format!("default {w}\n")); + } + Ok(abs) +} + +/// Mueve `pid` a `cgroup_abs/cgroup.procs`. +pub fn move_to_cgroup(cgroup_abs: &std::path::Path, pid: nix::unistd::Pid) -> Result<(), IncarnateError> { + let procs = cgroup_abs.join("cgroup.procs"); + std::fs::write(&procs, format!("{}\n", pid.as_raw())).map_err(|e| match e.kind() { + std::io::ErrorKind::PermissionDenied => IncarnateError::CgroupNotWritable { + path: procs.clone(), + }, + _ => IncarnateError::Io(e), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn absolute_path_passthrough() { + assert_eq!(resolve_cgroup_path("/foo/bar"), "/foo/bar"); + } + + #[test] + fn empty_returns_empty() { + assert_eq!(resolve_cgroup_path(""), ""); + } + + #[test] + fn relative_path_prefixed() { + let r = resolve_cgroup_path("shipote/ws-1"); + assert!(r.ends_with("/shipote/ws-1") || r == "/shipote/ws-1"); + } +} diff --git a/crates/shared/ente-incarnate/src/child.rs b/crates/shared/ente-incarnate/src/child.rs new file mode 100644 index 0000000..30a2f09 --- /dev/null +++ b/crates/shared/ente-incarnate/src/child.rs @@ -0,0 +1,47 @@ +//! Helpers que corren EN el hijo post-clone, antes de execve. +//! +//! Reglas inviolables (la clausura de clone(2) corre en stack nuevo, COW): +//! - sólo syscalls async-signal-safe +//! - no `println!`/`tracing!`/cualquier I/O del runtime +//! - no allocator (vec/box/string) +//! - no Drop con efectos +//! - capturar sólo Copy o datos pre-construidos + +use brahman_card::ResourceLimits; + +/// SAFETY: invocada en el hijo post-clone, sólo libc. +pub unsafe fn apply_rlimits(rl: &ResourceLimits) { + if let Some(mem) = rl.mem_bytes { + let lim = libc::rlimit { + rlim_cur: mem, + rlim_max: mem, + }; + libc::setrlimit(libc::RLIMIT_AS, &lim); + } + if let Some(np) = rl.nproc { + let lim = libc::rlimit { + rlim_cur: np as u64, + rlim_max: np as u64, + }; + libc::setrlimit(libc::RLIMIT_NPROC, &lim); + } + if let Some(nf) = rl.nofile { + let lim = libc::rlimit { + rlim_cur: nf as u64, + rlim_max: nf as u64, + }; + libc::setrlimit(libc::RLIMIT_NOFILE, &lim); + } +} + +/// SAFETY: idem. `MS_PRIVATE | MS_REC` sobre `/` para que mounts del hijo +/// no se filtren al host. Trampa típica al delegar mount ns. +pub unsafe fn make_root_private() { + libc::mount( + std::ptr::null(), + b"/\0".as_ptr() as *const _, + std::ptr::null(), + libc::MS_PRIVATE | libc::MS_REC, + std::ptr::null(), + ); +} diff --git a/crates/shared/ente-incarnate/src/env.rs b/crates/shared/ente-incarnate/src/env.rs new file mode 100644 index 0000000..820ae57 --- /dev/null +++ b/crates/shared/ente-incarnate/src/env.rs @@ -0,0 +1,95 @@ +//! Construcción del entorno del hijo. Sin globals — toma EnvSpec por valor. + +use brahman_card::Card; +use std::path::PathBuf; + +/// Var env para el path del bus interno (cuando aplica). Mismo nombre que +/// usa ente-bus para que clientes existentes (`BusClient::from_env`) sigan +/// funcionando sin cambios. +pub const ENV_BUS_SOCK: &str = "ENTE_BUS_SOCK"; + +/// Var env para el ULID de la Card encarnada. +pub const ENV_ENTE_ID: &str = "ENTE_ID"; + +#[derive(Debug, Clone, Default)] +pub struct EnvSpec { + /// Si `Some`, se inyecta como ENTE_BUS_SOCK. + pub bus_sock: Option, + /// Si `Some`, se inyecta como NOTIFY_SOCKET (legacy sd_notify). + pub notify_socket: Option, + /// Vars adicionales que el caller quiere forzar. + pub extra: Vec<(String, String)>, +} + +/// Hereda env del padre, aplica el envp explícito de la Card, y al final +/// inyecta las vars del fractal según `EnvSpec`. +pub fn build_env(card: &Card, base_envp: &[(String, String)], spec: &EnvSpec) -> Vec<(String, String)> { + let mut env: Vec<(String, String)> = std::env::vars().collect(); + + for (k, v) in base_envp { + env.retain(|(ek, _)| ek != k); + env.push((k.clone(), v.clone())); + } + + if let Some(p) = &spec.bus_sock { + env.retain(|(k, _)| k != ENV_BUS_SOCK); + env.push((ENV_BUS_SOCK.into(), p.to_string_lossy().into_owned())); + } + + env.retain(|(k, _)| k != ENV_ENTE_ID); + env.push((ENV_ENTE_ID.into(), card.id.to_string())); + + if let Some(p) = &spec.notify_socket { + env.retain(|(k, _)| k != "NOTIFY_SOCKET"); + env.push(("NOTIFY_SOCKET".into(), p.to_string_lossy().into_owned())); + } + + for (k, v) in &spec.extra { + env.retain(|(ek, _)| ek != k); + env.push((k.clone(), v.clone())); + } + + env +} + +#[cfg(test)] +mod tests { + use super::*; + use brahman_card::Card; + + #[test] + fn env_id_and_bus_injected() { + let card = Card::new("test"); + let spec = EnvSpec { + bus_sock: Some(PathBuf::from("/tmp/bus.sock")), + notify_socket: None, + extra: vec![], + }; + let env = build_env(&card, &[], &spec); + assert!(env.iter().any(|(k, v)| k == ENV_ENTE_ID && v == &card.id.to_string())); + assert!(env.iter().any(|(k, v)| k == ENV_BUS_SOCK && v == "/tmp/bus.sock")); + } + + #[test] + fn extra_overrides_inherited() { + let card = Card::new("test"); + let spec = EnvSpec { + bus_sock: None, + notify_socket: None, + extra: vec![("PATH".into(), "/sandbox/bin".into())], + }; + let env = build_env(&card, &[], &spec); + let path_count = env.iter().filter(|(k, _)| k == "PATH").count(); + assert_eq!(path_count, 1); + assert_eq!(env.iter().find(|(k, _)| k == "PATH").unwrap().1, "/sandbox/bin"); + } + + #[test] + fn notify_socket_only_when_set() { + let card = Card::new("test"); + let spec = EnvSpec::default(); + let env = build_env(&card, &[], &spec); + assert!(!env.iter().any(|(k, _)| k == "NOTIFY_SOCKET" + && std::env::var("NOTIFY_SOCKET").is_err())); + } +} diff --git a/crates/shared/ente-incarnate/src/error.rs b/crates/shared/ente-incarnate/src/error.rs new file mode 100644 index 0000000..bd91a39 --- /dev/null +++ b/crates/shared/ente-incarnate/src/error.rs @@ -0,0 +1,44 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum IncarnateError { + #[error("namespace `{ns}` requires CAP_SYS_ADMIN or CLONE_NEWUSER (neither available)")] + NamespaceCapMissing { ns: &'static str }, + + #[error("user namespaces blocked by sysctl kernel.unprivileged_userns_clone=0")] + UserNsDisabledBySysctl, + + #[error("user namespaces restricted by LSM (apparmor/selinux)")] + UserNsRestrictedByLsm, + + #[error("cgroup path `{path}` is not writable (delegation missing?)")] + CgroupNotWritable { path: PathBuf }, + + #[error("payload is not executable in this incarnation path (Wasm/Virtual not supported here)")] + NonExecutablePayload, + + #[error("clone(2) failed: {0}")] + Clone(#[source] nix::errno::Errno), + + #[error("pipe2(2) failed: {0}")] + Pipe(#[source] nix::errno::Errno), + + #[error("post-clone setup: {0}")] + PostClone(#[source] anyhow::Error), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error("invalid argv: contains NUL byte")] + InvalidArgv, +} + +/// Cuando `strict_caps = false`, errores no-fatales se reportan como +/// `Degradation` y la encarnación continúa con menos aislamiento del pedido. +#[derive(Debug, Clone)] +pub enum Degradation { + NamespaceSkipped { ns: &'static str }, + CgroupSkipped { path: PathBuf, reason: String }, + CpuAffinitySkipped { reason: String }, + UidMapFailed { reason: String }, +} diff --git a/crates/shared/ente-incarnate/src/lib.rs b/crates/shared/ente-incarnate/src/lib.rs new file mode 100644 index 0000000..4f4b66b --- /dev/null +++ b/crates/shared/ente-incarnate/src/lib.rs @@ -0,0 +1,365 @@ +//! `ente-incarnate` — rutina extraída del Init para encarnar Cards en +//! procesos aislados (clone(2) + namespaces + cgroup + rlimits + cpu affinity). +//! +//! El núcleo histórico vivía en `ente-soma` con globals dependientes de PID 1. +//! Este crate elimina esos globals: se construye un [`Incarnator`] por +//! supervisor (Init, shipote, etc.), cada uno con su propio bus socket y su +//! propia política de capacidades. +//! +//! ## Limitaciones que NO desaparecen al extraer +//! +//! 1. `mount/pid/net/uts/ipc/cgroup` namespaces requieren `CAP_SYS_ADMIN` +//! o estar combinados con `CLONE_NEWUSER` en el mismo `clone(2)`. +//! 2. `user` namespace puede estar bloqueado por +//! `kernel.unprivileged_userns_clone=0` o por LSM (apparmor/selinux). +//! 3. cgroups v2 requieren delegación (sistemas modernos: systemd +//! `Delegate=yes`). Sin delegación, escribir en `/sys/fs/cgroup` falla. +//! 4. El primer proceso de un PID namespace es PID 1 *de ese ns*; si muere +//! el kernel mata el namespace entero. +//! +//! [`CapabilitySet::detect`] reporta lo que está disponible runtime; +//! [`Incarnator::dry_run`] valida un [`Card`] antes de ejecutar. + +#![doc(html_no_source)] + +pub mod caps; +pub mod cgroup; +pub mod child; +pub mod env; +pub mod error; +pub mod namespaced; +pub mod plain; + +pub use brahman_card::Card; +pub use caps::{CapabilitySet, CgroupStatus, NsKind, UserNsStatus}; +pub use env::{EnvSpec, ENV_BUS_SOCK, ENV_ENTE_ID}; +pub use error::{Degradation, IncarnateError}; + +use std::os::fd::RawFd; + +/// Redirección declarativa de stdio del hijo. Cada `Some(fd)` se `dup2`-ea +/// como stdin/stdout/stderr en el hijo. +/// +/// **Contrato de ownership**: el caller transfiere ownership de los FDs al +/// `Incarnator` (igual que pasaría a `Command::stdio(Stdio::from_raw_fd)`). +/// `Incarnator` se encarga de cerrarlos en el padre tras `incarnate` (path +/// namespaced) o de dejar que `std::process::Command` los absorba (path +/// plain). **No los cierres en el caller** — habría doble-close. +/// +/// Útil para conectar pipes entre procesos del pipeline de shipote sin +/// romper la regla async-signal-safe del callback de clone(2). +#[derive(Debug, Clone, Copy, Default)] +pub struct ChildStdio { + pub stdin_fd: Option, + pub stdout_fd: Option, + pub stderr_fd: Option, +} + +impl ChildStdio { + pub fn is_some(&self) -> bool { + self.stdin_fd.is_some() || self.stdout_fd.is_some() || self.stderr_fd.is_some() + } +} + +use nix::unistd::Pid; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default)] +pub struct IncarnatorConfig { + /// Path del Unix socket del bus interno (se inyecta como `ENTE_BUS_SOCK`). + /// `None` = no inyectar. + pub bus_sock: Option, + + /// Inyectar `NOTIFY_SOCKET` (legacy sd_notify). Default `None`. + /// `ente-zero` lo pasa = `Some("/run/systemd/notify")`. + pub notify_socket: Option, + + /// Vars adicionales que el caller fuerza en cada hijo. + pub extra_env: Vec<(String, String)>, + + /// Si `true`, falta de capacidades aborta `incarnate()` con error. + /// Si `false`, se reportan como `Degradation` y la encarnación continúa + /// con menos aislamiento (semántica histórica del Init). + pub strict_caps: bool, +} + +pub struct Incarnator { + cfg: IncarnatorConfig, + caps: CapabilitySet, +} + +#[derive(Debug, Clone)] +pub struct IncarnateOutcome { + pub pid: Pid, + pub degradations: Vec, +} + +#[derive(Debug, Default, Clone)] +pub struct ValidationReport { + pub will_work: bool, + pub blocking: Vec, + pub warnings: Vec, +} + +impl Incarnator { + pub fn new(cfg: IncarnatorConfig) -> Self { + Self { + caps: CapabilitySet::detect(), + cfg, + } + } + + /// Constructor para testing/inyección de capacidades pre-calculadas. + pub fn with_caps(cfg: IncarnatorConfig, caps: CapabilitySet) -> Self { + Self { cfg, caps } + } + + pub fn capabilities(&self) -> &CapabilitySet { + &self.caps + } + + pub fn config(&self) -> &IncarnatorConfig { + &self.cfg + } + + /// Valida una Card sin ejecutar nada. Útil para que el caller (shipote, + /// admin, tests) sepa de antemano si va a poder encarnar tal cual o si + /// va a tener que aflojar el SomaSpec. + pub fn dry_run(&self, card: &Card) -> ValidationReport { + let mut r = ValidationReport { + will_work: true, + ..Default::default() + }; + let ns = &card.soma.namespaces; + + // Si user_ns está pedido, evaluar su disponibilidad. + if ns.user { + match self.caps.user_ns { + UserNsStatus::DisabledBySysctl => { + r.blocking.push( + "user namespace requested but kernel.unprivileged_userns_clone=0".into(), + ); + r.will_work = false; + } + UserNsStatus::RestrictedByLsm => { + r.blocking.push( + "user namespace restricted by LSM (apparmor/selinux)".into(), + ); + r.will_work = false; + } + _ => {} + } + } + + // El resto de namespaces necesitan CAP_SYS_ADMIN o user ns. + let needs_priv = [ + (ns.mount, NsKind::Mount), + (ns.pid, NsKind::Pid), + (ns.net, NsKind::Net), + (ns.uts, NsKind::Uts), + (ns.ipc, NsKind::Ipc), + (ns.cgroup, NsKind::Cgroup), + ]; + for (wanted, kind) in needs_priv { + if wanted && !self.caps.can_create_ns(kind) { + r.blocking.push(format!( + "{} namespace requires CAP_SYS_ADMIN or user ns (neither available)", + kind.name() + )); + r.will_work = false; + } + } + + // Cgroup: si el card pide path, chequear que tengamos delegación. + if !card.soma.cgroup.path.is_empty() && !self.caps.cgroup_delegated { + r.warnings.push(format!( + "cgroup `{}` requested but our cgroup is not writable (delegation missing)", + card.soma.cgroup.path + )); + } + + // Payload ejecutable. + use brahman_card::Payload; + if !matches!(card.payload, Payload::Native { .. } | Payload::Legacy { .. }) { + r.blocking + .push("payload is not Native/Legacy (use ente-wasm for Wasm)".into()); + r.will_work = false; + } + + r + } + + /// Encarna la Card. Si `strict_caps`, valida primero y aborta ante + /// blocking. Si no, ejecuta y deja que las degradaciones se acumulen. + pub fn incarnate(&self, card: &Card) -> Result { + self.incarnate_with(card, ChildStdio::default()) + } + + /// Variante con redirección de stdio declarativa. Útil para conectar + /// pipes entre procesos (caso: pipeline aislado). + pub fn incarnate_with( + &self, + card: &Card, + stdio: ChildStdio, + ) -> Result { + if self.cfg.strict_caps { + let v = self.dry_run(card); + if !v.will_work { + // Mapeamos el primer blocking a IncarnateError tipado. + if let Some(first) = v.blocking.first() { + if first.contains("unprivileged_userns_clone") { + return Err(IncarnateError::UserNsDisabledBySysctl); + } + if first.contains("LSM") { + return Err(IncarnateError::UserNsRestrictedByLsm); + } + if let Some(ns) = which_ns_blocking(first) { + return Err(IncarnateError::NamespaceCapMissing { ns }); + } + if first.contains("payload") { + return Err(IncarnateError::NonExecutablePayload); + } + } + } + } + + let env_spec = EnvSpec { + bus_sock: self.cfg.bus_sock.clone(), + notify_socket: self.cfg.notify_socket.clone(), + extra: self.cfg.extra_env.clone(), + }; + + let mut degradations = Vec::new(); + let pid = if namespaced::needs_namespacing(&card.soma.namespaces) { + namespaced::incarnate_namespaced(card, &env_spec, &stdio, &mut degradations)? + } else { + plain::incarnate_plain(card, &env_spec, &stdio)? + }; + Ok(IncarnateOutcome { pid, degradations }) + } +} + +fn which_ns_blocking(msg: &str) -> Option<&'static str> { + for n in ["mount", "pid", "net", "uts", "ipc", "user", "cgroup"] { + if msg.starts_with(n) { + return Some(match n { + "mount" => "mount", + "pid" => "pid", + "net" => "net", + "uts" => "uts", + "ipc" => "ipc", + "user" => "user", + "cgroup" => "cgroup", + _ => unreachable!(), + }); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use brahman_card::{Card, NamespaceSet, Payload}; + + fn make_card(payload: Payload, ns: NamespaceSet) -> Card { + let mut c = Card::new("test"); + c.payload = payload; + c.soma.namespaces = ns; + c + } + + #[test] + fn dry_run_native_no_ns_works() { + let inc = Incarnator::new(IncarnatorConfig::default()); + let card = make_card( + Payload::Native { + exec: "/bin/true".into(), + argv: vec![], + envp: vec![], + }, + NamespaceSet::default(), + ); + let r = inc.dry_run(&card); + assert!(r.will_work, "{:?}", r); + } + + #[test] + fn dry_run_wasm_payload_blocks() { + let inc = Incarnator::new(IncarnatorConfig::default()); + let card = make_card( + Payload::Wasm { + module_sha256: [0u8; 32], + entry: "main".into(), + }, + NamespaceSet::default(), + ); + let r = inc.dry_run(&card); + assert!(!r.will_work); + assert!(r.blocking.iter().any(|m| m.contains("payload"))); + } + + /// Smoke: redirección stdout via ChildStdio en path plain. + /// Lanza /bin/echo con stdout conectado a un pipe que leemos. + #[test] + fn incarnate_with_stdout_redirection_captures_output() { + use nix::fcntl::OFlag; + use nix::unistd::{pipe2, read}; + use std::os::fd::{AsRawFd, IntoRawFd}; + + let inc = Incarnator::new(IncarnatorConfig::default()); + let card = make_card( + Payload::Native { + exec: "/bin/echo".into(), + argv: vec!["shipote-stdio".into()], + 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 out = inc.incarnate_with(&card, stdio).expect("incarnate"); + + // Cerramos nuestro extremo de write (el hijo tiene su dup2). + // Plain path: Command toma ownership y cierra al spawn. + // Namespaced path: el padre todavía tiene una copia... pero en plain + // no aplica. Para este test usamos plain (NamespaceSet vacío). + + // Cosechamos para no zombi. + let _ = nix::sys::wait::waitpid(out.pid, None); + + // Leemos la salida. + let mut buf = [0u8; 64]; + let n = read(r_raw, &mut buf).expect("read"); + assert!(n > 0); + let s = std::str::from_utf8(&buf[..n]).unwrap(); + assert!(s.contains("shipote-stdio"), "got: {s:?}"); + // r se cierra al drop del OwnedFd. + } + + /// Smoke: encarnar /bin/true sin ns. No requiere root. + #[test] + fn incarnate_plain_true_succeeds() { + let inc = Incarnator::new(IncarnatorConfig::default()); + let card = make_card( + Payload::Native { + exec: "/bin/true".into(), + argv: vec![], + envp: vec![], + }, + NamespaceSet::default(), + ); + let out = inc.incarnate(&card).expect("plain incarnation"); + assert!(out.pid.as_raw() > 0); + // Cosechamos para no dejar zombi. + let _ = nix::sys::wait::waitpid(out.pid, None); + } +} diff --git a/crates/shared/ente-incarnate/src/namespaced.rs b/crates/shared/ente-incarnate/src/namespaced.rs new file mode 100644 index 0000000..8a22609 --- /dev/null +++ b/crates/shared/ente-incarnate/src/namespaced.rs @@ -0,0 +1,302 @@ +//! Path namespaced: clone(2) + sync pipe + setup post-clone en padre + finalize en hijo. +//! +//! ## Protocolo padre↔hijo +//! +//! ```text +//! parent child +//! | | +//! |--- clone() ------->| (child empieza dentro de los nuevos NS) +//! | | +//! | |---- read(sync_r, 1) ---- (bloquea) +//! | | +//! | write uid_map | +//! | write gid_map | +//! | cgroup move | +//! | cpu affinity | +//! | | +//! |--- write(sync_w) ->| +//! | |---- setrlimit +//! | |---- mount(/, MS_PRIVATE | MS_REC) +//! | |---- execve() +//! ``` + +use crate::child::{apply_rlimits, make_root_private}; +use crate::cgroup::{ensure_cgroup, move_to_cgroup}; +use crate::env::{build_env, EnvSpec}; +use crate::error::{Degradation, IncarnateError}; +use crate::ChildStdio; +use brahman_card::{Card, NamespaceSet, Payload}; +use nix::fcntl::OFlag; +use nix::sched::CloneFlags; +use nix::unistd::{pipe2, Pid}; +use std::ffi::CString; +use std::os::fd::{IntoRawFd, RawFd}; +use tracing::{info, warn}; + +pub fn needs_namespacing(ns: &NamespaceSet) -> bool { + ns.mount || ns.pid || ns.net || ns.uts || ns.ipc || ns.user || ns.cgroup +} + +pub fn build_clone_flags(ns: &NamespaceSet) -> CloneFlags { + let mut f = CloneFlags::empty(); + if ns.mount { f |= CloneFlags::CLONE_NEWNS; } + if ns.pid { f |= CloneFlags::CLONE_NEWPID; } + if ns.net { f |= CloneFlags::CLONE_NEWNET; } + if ns.uts { f |= CloneFlags::CLONE_NEWUTS; } + if ns.ipc { f |= CloneFlags::CLONE_NEWIPC; } + if ns.user { f |= CloneFlags::CLONE_NEWUSER; } + if ns.cgroup { f |= CloneFlags::CLONE_NEWCGROUP; } + f +} + +pub fn incarnate_namespaced( + card: &Card, + env_spec: &EnvSpec, + stdio: &ChildStdio, + degradations: &mut Vec, +) -> Result { + let flags = build_clone_flags(&card.soma.namespaces); + info!(label = %card.label, ?flags, "namespaced incarnation"); + + let (exec, argv, base_envp) = match &card.payload { + Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()), + Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()), + _ => return Err(IncarnateError::NonExecutablePayload), + }; + + // Pipe O_CLOEXEC: el read del lado hijo es lo que hace race-free el setup. + // O_CLOEXEC garantiza cierre automático en execve. + let (sync_r, sync_w) = pipe2(OFlag::O_CLOEXEC).map_err(IncarnateError::Pipe)?; + let sync_r_raw: RawFd = sync_r.into_raw_fd(); + let sync_w_raw: RawFd = sync_w.into_raw_fd(); + + let exec_c = CString::new(exec.clone()).map_err(|_| IncarnateError::InvalidArgv)?; + let argv_c: Vec = std::iter::once(exec_c.clone()) + .chain(argv.iter().filter_map(|s| CString::new(s.as_str()).ok())) + .collect(); + let argv_ptrs: Vec<*const libc::c_char> = argv_c + .iter() + .map(|c| c.as_ptr()) + .chain(std::iter::once(std::ptr::null())) + .collect(); + + let env_pairs = build_env(card, &base_envp, env_spec); + let envp_c: Vec = env_pairs + .iter() + .filter_map(|(k, v)| CString::new(format!("{k}={v}")).ok()) + .collect(); + let envp_ptrs: Vec<*const libc::c_char> = envp_c + .iter() + .map(|c| c.as_ptr()) + .chain(std::iter::once(std::ptr::null())) + .collect(); + + let rlimits = card.soma.rlimits.clone(); + let mount_ns_enabled = card.soma.namespaces.mount; + let stdin_fd = stdio.stdin_fd; + let stdout_fd = stdio.stdout_fd; + let stderr_fd = stdio.stderr_fd; + + // 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, + // sin Drop con efectos. + let cb = Box::new(move || -> isize { + unsafe { libc::close(sync_w_raw); } + + let mut byte = [0u8; 1]; + let n = unsafe { libc::read(sync_r_raw, byte.as_mut_ptr() as *mut _, 1) }; + if n != 1 { + unsafe { libc::_exit(101); } + } + unsafe { libc::close(sync_r_raw); } + + unsafe { apply_rlimits(&rlimits); } + + if mount_ns_enabled { + unsafe { make_root_private(); } + } + + // dup2 declarativo: caller pasó fds que queremos como stdin/out/err. + // dup2 es async-signal-safe (POSIX) y cierra el fd target si estaba + // abierto. El fd source NO se cierra automáticamente — el padre + // tiene su propia copia. + if let Some(fd) = stdin_fd { + unsafe { + if libc::dup2(fd, 0) < 0 { + libc::_exit(103); + } + } + } + if let Some(fd) = stdout_fd { + unsafe { + if libc::dup2(fd, 1) < 0 { + libc::_exit(104); + } + } + } + if let Some(fd) = stderr_fd { + unsafe { + if libc::dup2(fd, 2) < 0 { + libc::_exit(105); + } + } + } + + unsafe { + libc::execve(exec_c.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr()); + libc::_exit(102); + } + }); + + let mut stack = vec![0u8; 1024 * 1024]; + + #[allow(deprecated)] + let pid = unsafe { nix::sched::clone(cb, &mut stack, flags, Some(libc::SIGCHLD)) } + .map_err(|e| { + unsafe { + libc::close(sync_r_raw); + libc::close(sync_w_raw); + } + IncarnateError::Clone(e) + })?; + + // Padre: cerrar el extremo de lectura. + unsafe { libc::close(sync_r_raw); } + + // Setup post-clone. Errores aquí los registramos como degradations y + // continuamos (la decisión strict_caps la toma el wrapper). + if let Err(e) = configure_child(pid, card, degradations) { + warn!(?e, ?pid, "configure_child errores"); + } + + // Despertar al hijo. + let signal_byte = [b'x']; + let written = unsafe { libc::write(sync_w_raw, signal_byte.as_ptr() as *const _, 1) }; + unsafe { libc::close(sync_w_raw); } + if written != 1 { + warn!(?pid, "write sync pipe devolvió {}", written); + } + + // El hijo ya dup2-eó los fds del ChildStdio. La copia del padre no + // sirve más y la cerramos para que el otro extremo del pipe reciba + // EOF cuando corresponda. + if let Some(fd) = stdio.stdin_fd { + unsafe { libc::close(fd); } + } + if let Some(fd) = stdio.stdout_fd { + unsafe { libc::close(fd); } + } + if let Some(fd) = stdio.stderr_fd { + unsafe { libc::close(fd); } + } + + Ok(pid) +} + +/// Setup que requiere capacidades del padre: uid_map, gid_map, cgroup move. +/// Estos archivos en `/proc//*` tienen reglas de propiedad que sólo el +/// padre puede satisfacer mientras el hijo está suspendido en el sync pipe. +fn configure_child( + pid: Pid, + card: &Card, + degradations: &mut Vec, +) -> Result<(), IncarnateError> { + if card.soma.namespaces.user { + // Desde kernel 3.19 hay que escribir "deny" a setgroups antes de + // poder escribir gid_map sin CAP_SETGID. Ignorar errores aquí: en + // kernels antiguos el archivo no existe. + let _ = std::fs::write(format!("/proc/{}/setgroups", pid.as_raw()), "deny"); + + let uid = nix::unistd::getuid().as_raw(); + let gid = nix::unistd::getgid().as_raw(); + if let Err(e) = std::fs::write( + format!("/proc/{}/uid_map", pid.as_raw()), + format!("0 {uid} 1"), + ) { + degradations.push(Degradation::UidMapFailed { + reason: format!("uid_map: {e}"), + }); + } + if let Err(e) = std::fs::write( + format!("/proc/{}/gid_map", pid.as_raw()), + format!("0 {gid} 1"), + ) { + degradations.push(Degradation::UidMapFailed { + reason: format!("gid_map: {e}"), + }); + } + } + + if !card.soma.cgroup.path.is_empty() { + match ensure_cgroup(&card.soma.cgroup) { + Ok(abs) => { + if let Err(e) = move_to_cgroup(&abs, pid) { + degradations.push(Degradation::CgroupSkipped { + path: abs, + reason: format!("{e}"), + }); + } + } + Err(e) => degradations.push(Degradation::CgroupSkipped { + path: std::path::PathBuf::from(&card.soma.cgroup.path), + reason: format!("{e}"), + }), + } + } + + if let Some(cpus) = &card.soma.cpu_affinity { + if let Err(e) = set_cpu_affinity(pid, cpus) { + degradations.push(Degradation::CpuAffinitySkipped { + reason: format!("{e}"), + }); + } + } + + Ok(()) +} + +fn set_cpu_affinity(pid: Pid, cpus: &[u32]) -> Result<(), std::io::Error> { + let mut set: libc::cpu_set_t = unsafe { std::mem::zeroed() }; + unsafe { libc::CPU_ZERO(&mut set); } + for &c in cpus { + unsafe { libc::CPU_SET(c as usize, &mut set); } + } + let r = unsafe { + libc::sched_setaffinity(pid.as_raw(), std::mem::size_of::(), &set) + }; + if r != 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use brahman_card::NamespaceSet; + + #[test] + fn empty_ns_does_not_need_namespacing() { + let ns = NamespaceSet::default(); + assert!(!needs_namespacing(&ns)); + } + + #[test] + fn any_ns_triggers_namespacing() { + let mut ns = NamespaceSet::default(); + ns.user = true; + assert!(needs_namespacing(&ns)); + } + + #[test] + fn flags_match_namespace_bools() { + let mut ns = NamespaceSet::default(); + ns.user = true; + ns.pid = true; + let f = build_clone_flags(&ns); + assert!(f.contains(CloneFlags::CLONE_NEWUSER)); + assert!(f.contains(CloneFlags::CLONE_NEWPID)); + assert!(!f.contains(CloneFlags::CLONE_NEWNET)); + } +} diff --git a/crates/shared/ente-incarnate/src/plain.rs b/crates/shared/ente-incarnate/src/plain.rs new file mode 100644 index 0000000..63ab835 --- /dev/null +++ b/crates/shared/ente-incarnate/src/plain.rs @@ -0,0 +1,41 @@ +//! Path simple: spawn directo, sin namespacing. + +use crate::env::{build_env, EnvSpec}; +use crate::error::IncarnateError; +use crate::ChildStdio; +use brahman_card::{Card, Payload}; +use nix::unistd::Pid; +use std::os::fd::FromRawFd; +use std::process::{Command, Stdio}; + +pub fn incarnate_plain( + card: &Card, + env_spec: &EnvSpec, + stdio: &ChildStdio, +) -> Result { + let (exec, argv, base_envp) = match &card.payload { + Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()), + Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()), + _ => return Err(IncarnateError::NonExecutablePayload), + }; + let env = build_env(card, &base_envp, env_spec); + let mut cmd = Command::new(&exec); + cmd.args(&argv); + cmd.env_clear(); + for (k, v) in &env { + cmd.env(k, v); + } + if let Some(fd) = stdio.stdin_fd { + // SAFETY: el caller garantiza que `fd` está abierto y le + // transfiere ownership al child. `Command` lo cierra tras spawn. + cmd.stdin(unsafe { Stdio::from_raw_fd(fd) }); + } + if let Some(fd) = stdio.stdout_fd { + cmd.stdout(unsafe { Stdio::from_raw_fd(fd) }); + } + if let Some(fd) = stdio.stderr_fd { + cmd.stderr(unsafe { Stdio::from_raw_fd(fd) }); + } + let child = cmd.spawn()?; + Ok(Pid::from_raw(child.id() as i32)) +}