feat(shipote): data plane + DAG fan-in/out + stats + lifecycle (fases F-I)
Pipeline runtime:
- Fan-out 1→N (splitter task replica al N consumers) y fan-in N→1 (merger
task con mpsc + reader-per-input). DAGs no lineales soportados.
- Flow channels: Unix socket + tokio broadcast con replay buffer
configurable por pipeline (DiscernPolicy.replay_chunks). Subscribers
externos vía `shipote flow tail <socket>`.
- Templating en specs con `${KEY}` (CLI `--var KEY=VALUE`). Walk
recursivo sobre serde_json::Value, soporta todos los strings del schema.
- Pipelines guardados (`pipeline save/saved-list/drop/run-saved`)
persisten con el snapshot.
Lifecycle de comandos:
- Log capture per-stream (stdout/stderr separados) via pipe O_CLOEXEC +
AsyncFd. CLI `shipote logs <ws> <cmd> --stream {stdout,stderr,both}`.
- Stop graceful con tiempo configurable: SIGTERM → grace → SIGKILL.
Tanto a nivel workspace como pipeline individual.
- TTL auto-stop ya existente (Fase C) sigue funcionando.
ente-incarnate:
- ChildStdio declarativo (Fase C) + ChildPreExec declarativo nuevo:
NoNewPrivs, ParentDeathSig, Dumpable, NewSession, Chdir, Umask.
- Aplicación pre-execve async-signal-safe en ambos paths (plain via
Command::pre_exec, namespaced via callback del clone(2)).
Observabilidad:
- WorkspaceStats: RSS + RSS peak (VmHWM o memory.peak cgroup) + CPU usec
+ uptime. Fuente per-proc o cgroup según delegation.
- shipote-shell con sparkline ASCII por workspace (history cap 24),
card de flow channels activos, vista de comandos + saved pipelines.
- Tap → broker: cada edge enriquecido con TypeRef se anuncia como Card
efímera vía SidecarPool (graceful si broker no corre).
Discern:
- Integrado en yahweh-provider-fs (mime_type en EntityNode).
- Integrado en nouser-core::cluster::pick_lens como fallback cuando la
extensión cae a Lens::Grid.
79 tests pasan: ente-incarnate (16), nouser-core (27), shipote-card (8),
shipote-core (20), shipote-discern (5), yahweh-provider-fs (3).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -29,11 +29,13 @@ pub mod env;
|
||||
pub mod error;
|
||||
pub mod namespaced;
|
||||
pub mod plain;
|
||||
pub mod pre_exec;
|
||||
|
||||
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};
|
||||
pub use pre_exec::{ChildPreExec, ChildSetup};
|
||||
|
||||
use std::os::fd::RawFd;
|
||||
|
||||
@@ -201,6 +203,16 @@ impl Incarnator {
|
||||
&self,
|
||||
card: &Card,
|
||||
stdio: ChildStdio,
|
||||
) -> Result<IncarnateOutcome, IncarnateError> {
|
||||
self.incarnate_full(card, stdio, ChildSetup::default())
|
||||
}
|
||||
|
||||
/// Variante full: stdio + setup pre-execve.
|
||||
pub fn incarnate_full(
|
||||
&self,
|
||||
card: &Card,
|
||||
stdio: ChildStdio,
|
||||
setup: ChildSetup,
|
||||
) -> Result<IncarnateOutcome, IncarnateError> {
|
||||
if self.cfg.strict_caps {
|
||||
let v = self.dry_run(card);
|
||||
@@ -231,9 +243,9 @@ impl Incarnator {
|
||||
|
||||
let mut degradations = Vec::new();
|
||||
let pid = if namespaced::needs_namespacing(&card.soma.namespaces) {
|
||||
namespaced::incarnate_namespaced(card, &env_spec, &stdio, &mut degradations)?
|
||||
namespaced::incarnate_namespaced(card, &env_spec, &stdio, &setup, &mut degradations)?
|
||||
} else {
|
||||
plain::incarnate_plain(card, &env_spec, &stdio)?
|
||||
plain::incarnate_plain(card, &env_spec, &stdio, &setup)?
|
||||
};
|
||||
Ok(IncarnateOutcome { pid, degradations })
|
||||
}
|
||||
@@ -345,6 +357,48 @@ mod tests {
|
||||
// r se cierra al drop del OwnedFd.
|
||||
}
|
||||
|
||||
/// child_pre_exec aplica chdir + NoNewPrivs en path plain.
|
||||
#[test]
|
||||
fn child_pre_exec_chdir_changes_pwd() {
|
||||
use crate::{ChildPreExec, ChildSetup};
|
||||
use nix::fcntl::OFlag;
|
||||
use nix::unistd::{pipe2, read};
|
||||
use std::ffi::CString;
|
||||
use std::os::fd::{AsRawFd, IntoRawFd};
|
||||
|
||||
let inc = Incarnator::new(IncarnatorConfig::default());
|
||||
// Comando: /bin/pwd. Si chdir funciona, output = /tmp.
|
||||
let card = make_card(
|
||||
Payload::Native {
|
||||
exec: "/bin/pwd".into(),
|
||||
argv: vec![],
|
||||
envp: vec![],
|
||||
},
|
||||
NamespaceSet::default(),
|
||||
);
|
||||
|
||||
let (r, w) = pipe2(OFlag::empty()).expect("pipe");
|
||||
let w_raw = w.into_raw_fd();
|
||||
let r_raw = r.as_raw_fd();
|
||||
|
||||
let stdio = ChildStdio {
|
||||
stdin_fd: None,
|
||||
stdout_fd: Some(w_raw),
|
||||
stderr_fd: None,
|
||||
};
|
||||
let setup = ChildSetup::new()
|
||||
.with(ChildPreExec::Chdir(CString::new("/tmp").unwrap()))
|
||||
.with(ChildPreExec::NoNewPrivs);
|
||||
let out = inc.incarnate_full(&card, stdio, setup).expect("incarnate");
|
||||
|
||||
let _ = nix::sys::wait::waitpid(out.pid, None);
|
||||
|
||||
let mut buf = [0u8; 64];
|
||||
let n = read(r_raw, &mut buf).expect("read");
|
||||
let s = std::str::from_utf8(&buf[..n]).unwrap();
|
||||
assert!(s.starts_with("/tmp"), "pwd output was: {s:?}");
|
||||
}
|
||||
|
||||
/// Smoke: encarnar /bin/true sin ns. No requiere root.
|
||||
#[test]
|
||||
fn incarnate_plain_true_succeeds() {
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::child::{apply_rlimits, make_root_private};
|
||||
use crate::cgroup::{ensure_cgroup, move_to_cgroup};
|
||||
use crate::env::{build_env, EnvSpec};
|
||||
use crate::error::{Degradation, IncarnateError};
|
||||
use crate::pre_exec::{apply_unchecked, ChildSetup};
|
||||
use crate::ChildStdio;
|
||||
use brahman_card::{Card, NamespaceSet, Payload};
|
||||
use nix::fcntl::OFlag;
|
||||
@@ -53,6 +54,7 @@ pub fn incarnate_namespaced(
|
||||
card: &Card,
|
||||
env_spec: &EnvSpec,
|
||||
stdio: &ChildStdio,
|
||||
setup: &ChildSetup,
|
||||
degradations: &mut Vec<Degradation>,
|
||||
) -> Result<Pid, IncarnateError> {
|
||||
let flags = build_clone_flags(&card.soma.namespaces);
|
||||
@@ -96,6 +98,7 @@ pub fn incarnate_namespaced(
|
||||
let stdin_fd = stdio.stdin_fd;
|
||||
let stdout_fd = stdio.stdout_fd;
|
||||
let stderr_fd = stdio.stderr_fd;
|
||||
let setup_ops = setup.ops.clone();
|
||||
|
||||
// SAFETY: la clausura corre en stack nuevo dentro de un proceso recién
|
||||
// clonado, COW del padre. Sólo syscalls async-signal-safe; sin allocator,
|
||||
@@ -142,6 +145,14 @@ pub fn incarnate_namespaced(
|
||||
}
|
||||
}
|
||||
|
||||
// Aplica las ops declarativas pre-execve (NoNewPrivs, chdir, etc.).
|
||||
if !setup_ops.is_empty() {
|
||||
let r = unsafe { apply_unchecked(&setup_ops) };
|
||||
if r != 0 {
|
||||
unsafe { libc::_exit(r) };
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
libc::execve(exec_c.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr());
|
||||
libc::_exit(102);
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
|
||||
use crate::env::{build_env, EnvSpec};
|
||||
use crate::error::IncarnateError;
|
||||
use crate::pre_exec::{apply_unchecked, ChildSetup};
|
||||
use crate::ChildStdio;
|
||||
use brahman_card::{Card, Payload};
|
||||
use nix::unistd::Pid;
|
||||
use std::os::fd::FromRawFd;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
pub fn incarnate_plain(
|
||||
card: &Card,
|
||||
env_spec: &EnvSpec,
|
||||
stdio: &ChildStdio,
|
||||
setup: &ChildSetup,
|
||||
) -> Result<Pid, IncarnateError> {
|
||||
let (exec, argv, base_envp) = match &card.payload {
|
||||
Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()),
|
||||
@@ -36,6 +39,22 @@ pub fn incarnate_plain(
|
||||
if let Some(fd) = stdio.stderr_fd {
|
||||
cmd.stderr(unsafe { Stdio::from_raw_fd(fd) });
|
||||
}
|
||||
if !setup.is_empty() {
|
||||
// Clone para que la closure sea 'static (Command::pre_exec lo exige).
|
||||
let ops = setup.ops.clone();
|
||||
// SAFETY: pre_exec corre post-fork pre-exec. apply_unchecked sólo
|
||||
// hace syscalls async-signal-safe.
|
||||
unsafe {
|
||||
cmd.pre_exec(move || {
|
||||
let r = apply_unchecked(&ops);
|
||||
if r != 0 {
|
||||
Err(std::io::Error::from_raw_os_error(libc::EINVAL))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let child = cmd.spawn()?;
|
||||
Ok(Pid::from_raw(child.id() as i32))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Hook declarativo pre-execve para el hijo.
|
||||
//!
|
||||
//! Las ops corren EN EL HIJO, post-fork/clone, pre-execve. Reglas:
|
||||
//! - sólo syscalls async-signal-safe.
|
||||
//! - sin allocator (los CStrings ya están construidos por el padre).
|
||||
//! - sin Drop con efectos.
|
||||
|
||||
use std::ffi::CString;
|
||||
|
||||
/// Operaciones declarativas aplicables pre-execve.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChildPreExec {
|
||||
/// `PR_SET_NO_NEW_PRIVS = 1` — bloquea escaladas futuras
|
||||
/// (suid bits, file caps, AT_SECURE). Recomendado en sandboxes.
|
||||
NoNewPrivs,
|
||||
/// `PR_SET_PDEATHSIG = sig` — el child recibe esta señal cuando su
|
||||
/// padre (PID 1 del namespace, o el que sea) muere. Útil para
|
||||
/// auto-cleanup de procesos huérfanos.
|
||||
ParentDeathSig(i32),
|
||||
/// `PR_SET_DUMPABLE` — controla si el proceso permite core dump.
|
||||
Dumpable(bool),
|
||||
/// `setsid()` — nuevo session/group leader (desconecta del controlling tty).
|
||||
NewSession,
|
||||
/// `chdir(path)` — cambiar working dir. Path pre-allocado.
|
||||
Chdir(CString),
|
||||
/// `umask(mode)` — fijar umask (octal, e.g. 0o022).
|
||||
Umask(libc::mode_t),
|
||||
}
|
||||
|
||||
/// Setup completo del hijo. Default = sin ops.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ChildSetup {
|
||||
pub ops: Vec<ChildPreExec>,
|
||||
}
|
||||
|
||||
impl ChildSetup {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, op: ChildPreExec) -> &mut Self {
|
||||
self.ops.push(op);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with(mut self, op: ChildPreExec) -> Self {
|
||||
self.ops.push(op);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.ops.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Aplica las ops en orden. SAFETY: ejecuta en el hijo, post-fork,
|
||||
/// pre-execve. Sólo libc, sin allocator, sin Drop.
|
||||
///
|
||||
/// En caso de error, retorna el código de exit que el caller usará para
|
||||
/// abortar el child (igual semántica que el resto de la closure de clone).
|
||||
/// 0 = todo OK.
|
||||
pub unsafe fn apply_unchecked(ops: &[ChildPreExec]) -> i32 {
|
||||
for op in ops {
|
||||
match op {
|
||||
ChildPreExec::NoNewPrivs => {
|
||||
// PR_SET_NO_NEW_PRIVS = 38 en Linux.
|
||||
let r = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1u64, 0u64, 0u64, 0u64) };
|
||||
if r != 0 {
|
||||
return 110;
|
||||
}
|
||||
}
|
||||
ChildPreExec::ParentDeathSig(sig) => {
|
||||
let r = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, *sig as u64, 0u64, 0u64, 0u64) };
|
||||
if r != 0 {
|
||||
return 111;
|
||||
}
|
||||
}
|
||||
ChildPreExec::Dumpable(yes) => {
|
||||
let v: u64 = if *yes { 1 } else { 0 };
|
||||
let r = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, v, 0u64, 0u64, 0u64) };
|
||||
if r != 0 {
|
||||
return 112;
|
||||
}
|
||||
}
|
||||
ChildPreExec::NewSession => {
|
||||
let r = unsafe { libc::setsid() };
|
||||
if r < 0 {
|
||||
return 113;
|
||||
}
|
||||
}
|
||||
ChildPreExec::Chdir(path) => {
|
||||
let r = unsafe { libc::chdir(path.as_ptr()) };
|
||||
if r != 0 {
|
||||
return 114;
|
||||
}
|
||||
}
|
||||
ChildPreExec::Umask(mode) => {
|
||||
unsafe { libc::umask(*mode) };
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
Reference in New Issue
Block a user