This commit is contained in:
sergio
2026-05-10 21:58:16 +00:00
parent 3d55f189c0
commit c22d2480b9
36 changed files with 5158 additions and 363 deletions
+1 -1
View File
@@ -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(),
+23
View File
@@ -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 }
+391
View File
@@ -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<PathBuf>,
#[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<String>,
},
/// 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<Response> {
write_frame(stream, &req).await?;
let resp: Response = read_frame(stream).await?;
Ok(resp)
}
fn parse_ws_id(s: &str) -> Result<WorkspaceId> {
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(())
}
}
}
+28
View File
@@ -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 }
+361
View File
@@ -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<WorkspaceManager>,
disc: Arc<DiscernPipeline>,
) -> 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<WorkspaceManager>, 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<ProtoCommandInfo> = 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();
}
+25
View File
@@ -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 }
+363
View File
@@ -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<WorkspaceSummary>,
/// Comandos por workspace, indexados por workspace id.toString().
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
saved_pipelines: Vec<String>,
caps: Option<CapsSummary>,
last_probe_ms: u64,
}
fn main() {
launch_app("Shipote — Shell", (820., 560.), Shell::new);
}
impl Shell {
fn new(cx: &mut Context<Self>) -> 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<WorkspaceSummary>,
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
saved_pipelines: Vec<String>,
caps: CapsSummary,
}
fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
// 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<Self>) -> 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<String> = 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<String> = 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<String> = 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<String> = self.saved_pipelines.clone();
let saved_descr = if saved_items.is_empty() {
"shipote pipeline save <name> <file> 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)
}
}
+1 -1
View File
@@ -114,7 +114,7 @@ impl TextViewer {
}
fn spawn_load_fs(&self, path: String, gen: u64, cx: &mut Context<Self>) {
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));
+2 -1
View File
@@ -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 }
+32 -350
View File
@@ -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<String> = OnceLock::new();
static INCARNATOR: OnceLock<Incarnator> = 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<Pid> {
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<Pid> {
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<Pid> {
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<CString> = 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<CString> = 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/<pid>/* 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::<libc::cpu_set_t>(), &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<String> {
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<String> {
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 <n>" 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) {}
+1
View File
@@ -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" }
+40 -2
View File
@@ -152,10 +152,12 @@ fn top_extensions(files: &[&FileEntry], n: usize) -> Vec<String> {
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<Lens> {
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,
}
}
@@ -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 }
@@ -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<Duration>,
/// Slots de flow pre-declarados. Limitan qué consumidores externos al
/// workspace pueden empatar contra los productores internos.
#[serde(default)]
pub flow_dirs: Vec<FlowSlot>,
/// 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<S: Serializer>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
d.map(|x| x.as_millis() as u64).serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
let v: Option<u64> = 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<CommandRef>,
#[serde(default)]
pub edges: Vec<FlowEdge>,
#[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<Card, CompileError> {
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<Card, CompileError> {
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<T: Ord + Copy>(a: Option<T>, b: Option<T>) -> Option<T> {
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<WorkspaceSpec, LoadError> {
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<PipelineSpec, LoadError> {
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);
}
}
@@ -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 }
@@ -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<Ulid, CommandState>,
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<i32>,
/// 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<logbuf::LogBuf>,
}
pub struct WorkspaceManager {
inner: Arc<Mutex<Inner>>,
incarnator: Arc<Incarnator>,
}
struct Inner {
workspaces: HashMap<WorkspaceId, WorkspaceState>,
/// 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<String, PipelineSpec>,
}
#[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<i32>,
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<Incarnator> {
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<String> {
let g = self.inner.lock().await;
let mut v: Vec<String> = 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<PipelineSpec> {
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<String> {
self.inner
.lock()
.await
.workspaces
.get(&id)
.map(|w| w.spec.label.clone())
}
pub async fn create(
self: &Arc<Self>,
spec: WorkspaceSpec,
) -> Result<(WorkspaceId, Vec<String>), 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<Self>,
id: WorkspaceId,
spec: WorkspaceSpec,
) -> Result<(WorkspaceId, Vec<String>), 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<WorkspaceSnapshot> {
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<u32, CoreError> {
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<String>,
envp: Vec<(String, String)>,
) -> Result<CommandSummary, CoreError> {
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<Vec<u8>> {
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<CommandInfo> {
let g = self.inner.lock().await;
let Some(ws) = g.workspaces.get(&workspace) else { return Vec::new() };
let mut out: Vec<CommandInfo> = 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<Vec<(String, Pid)>, 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;
}
}
@@ -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<Mutex<Inner>>,
}
#[derive(Debug)]
struct Inner {
/// Bytes raw. Cuando se acerca al cap, descartamos head para mantener
/// el tail.
buf: Vec<u8>,
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<u8> {
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);
}
}
@@ -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<WorkspaceEntry>,
#[serde(default)]
pub saved_pipelines: Vec<PipelineEntry>,
}
#[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<Self> {
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<Self>, path: &Path) -> anyhow::Result<usize> {
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"));
}
}
@@ -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<EdgeDiscernment>,
}
#[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<Discernment>,
}
/// 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<DiscernPipeline>,
incarnator: Arc<Incarnator>,
) -> Result<PipelineLaunch, CoreError> {
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<Option<&FlowEdge>> = 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<TapHandle> = 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<RawFd> = 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<RawFd> = pending_stdin_for_next.take();
let mut stdout_fd: Option<RawFd> = None;
let mut next_pending: Option<RawFd> = None;
// FDs que el PADRE debe cerrar tras spawn (son nuestra copia del
// extremo que pasamos al hijo).
let mut parent_closes: Vec<RawFd> = Vec::new();
if i_is_producer {
let (r, w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
})?;
let r_raw = r.into_raw_fd();
let w_raw = w.into_raw_fd();
stdout_fd = Some(w_raw);
parent_closes.push(w_raw);
if tap {
// Necesitamos un segundo pipe entre tap y consumidor.
let (r2, w2) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
})?;
let r2_raw = r2.into_raw_fd();
let w2_raw = w2.into_raw_fd();
next_pending = Some(r2_raw);
// El tap lee de r_raw y escribe a w2_raw.
let edge = predecessor
.iter()
.find_map(|p| *p)
.and_then(|e| if e.from == i { Some(e) } else { None })
// Edge donde i es from:
.or_else(|| spec.edges.iter().find(|e| e.from == i));
let from_label = node.label.clone();
let to_label = edge
.map(|e| spec.nodes[e.to].label.clone())
.unwrap_or_default();
let from_output = edge.map(|e| e.from_output.clone()).unwrap_or_default();
let to_input = edge.map(|e| e.to_input.clone()).unwrap_or_default();
let sample_bytes = spec.discern.sample_bytes;
let disc = discerner.clone();
let h = spawn_tap(
r_raw, w2_raw, sample_bytes, disc, from_label, from_output, to_label, to_input,
);
taps.push(h);
// r_raw y w2_raw pasaron a manos del tokio task. No los
// cerramos en el padre.
} else {
// Sin tap, el read del productor va directo al stdin del
// siguiente consumidor.
next_pending = Some(r_raw);
}
}
let stdio = ChildStdio {
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<EdgeDiscernment>,
}
#[allow(clippy::too_many_arguments)]
fn spawn_tap(
producer_r_fd: RawFd,
consumer_w_fd: RawFd,
sample_bytes: usize,
discerner: Arc<DiscernPipeline>,
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<u8> = 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<std::os::fd::OwnedFd>,
buf: &mut [u8],
) -> std::io::Result<usize> {
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<std::os::fd::OwnedFd>,
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);
}
}
}
@@ -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 }
@@ -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<u64>,
}
#[derive(Debug, Clone)]
pub struct Discernment {
pub ty: TypeRef,
pub confidence: f32,
pub mime: Option<String>,
pub lens: Option<String>,
}
pub trait Discerner: Send + Sync {
fn name(&self) -> &str;
fn discern(&self, sample: &[u8], hint: &Hint<'_>) -> Option<Discernment>;
}
pub struct DiscernPipeline {
discerners: Vec<Box<dyn Discerner>>,
}
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<dyn Discerner>) {
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<Discernment> {
let mut best: Option<Discernment> = 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<Discernment> {
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<Discernment> {
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::<serde_json::Value>(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<Discernment> {
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::<toml::Value>(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<Discernment> {
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<Discernment> {
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<Discernment> {
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"));
}
}
}
@@ -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 }
@@ -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<String>,
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 <file>`.
Discern { sample: Vec<u8>, hint_path: Option<PathBuf> },
/// 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<String>,
},
WorkspaceList {
items: Vec<WorkspaceSummary>,
},
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<EdgeDiscernmentInfo>,
},
Discernment {
ty: String,
confidence: f32,
mime: Option<String>,
lens: Option<String>,
},
Capabilities {
kernel_version: (u32, u32, u32),
user_ns: String,
cgroup_v2: String,
cgroup_delegated: bool,
has_cap_sys_admin: bool,
},
CommandList {
items: Vec<CommandInfo>,
},
CommandLogs {
bytes: Vec<u8>,
},
PipelineSaved {
name: String,
},
PipelineSavedList {
names: Vec<String>,
},
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<i32>,
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<String>,
pub mime: Option<String>,
pub lens: Option<String>,
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<T: Serialize>(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<T: for<'de> Deserialize<'de>>(
stream: &mut UnixStream,
) -> Result<T, ProtocolError> {
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"));
}
}
@@ -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" }
@@ -3,16 +3,45 @@
//! `std::fs::read_dir` y leyendo archivos a `Vec<u8>` 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<DiscernPipeline>,
}
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<String> {
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)
}
+20
View File
@@ -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 }
+214
View File
@@ -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<u64>,
pub our_cgroup: Option<PathBuf>,
}
#[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: ... - <fstype> <source> <opts>
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<PathBuf> {
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<u64> {
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());
}
}
@@ -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<String> {
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<PathBuf, IncarnateError> {
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 <n>" 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");
}
}
+47
View File
@@ -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(),
);
}
+95
View File
@@ -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<PathBuf>,
/// Si `Some`, se inyecta como NOTIFY_SOCKET (legacy sd_notify).
pub notify_socket: Option<PathBuf>,
/// 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()));
}
}
+44
View File
@@ -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 },
}
+365
View File
@@ -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<RawFd>,
pub stdout_fd: Option<RawFd>,
pub stderr_fd: Option<RawFd>,
}
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<PathBuf>,
/// Inyectar `NOTIFY_SOCKET` (legacy sd_notify). Default `None`.
/// `ente-zero` lo pasa = `Some("/run/systemd/notify")`.
pub notify_socket: Option<PathBuf>,
/// 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<Degradation>,
}
#[derive(Debug, Default, Clone)]
pub struct ValidationReport {
pub will_work: bool,
pub blocking: Vec<String>,
pub warnings: Vec<String>,
}
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<IncarnateOutcome, IncarnateError> {
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<IncarnateOutcome, IncarnateError> {
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);
}
}
@@ -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<Degradation>,
) -> Result<Pid, IncarnateError> {
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<CString> = 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<CString> = 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/<pid>/*` 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<Degradation>,
) -> 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::<libc::cpu_set_t>(), &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));
}
}
+41
View File
@@ -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<Pid, IncarnateError> {
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))
}