refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG

Reorganización física de crates/:
- core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/
- shared/ (3 crates) se redistribuye en protocol/ e init/
- lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/

Renames de proyectos:
- shipote → shuma (runtime de sandboxes)
- nouser → akasha (explorador de Mónadas)
- yahweh → nahual (motor GPUI, antes ui_engine/)
- lapaloma → pineal (data-viz agnóstica)

Fraccionamiento UI → core agnóstico:
- vista-core (DeckState + snap, 175 LOC, 5 tests verdes)
- barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes)
- vista-web y barra-web ahora son thin DOM bindings

Documentación nueva:
- 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat
  + 10 módulos + apps/
- docs/STATUS.md con cifras reales por proyecto
- docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas)
- CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets)

Automatización:
- scripts/reorg.py — script idempotente que: git mv directorios, renombra
  package names, recomputa path = refs, reescribe imports rust, actualiza
  workspace Cargo.toml. Soporta --dry-run.
- scripts/split-changelog.py — particiona CHANGELOG por componente.

Validación:
- cargo check --workspace pasa (124 crates + 2 nuevos cores).
- 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-19 14:48:34 +00:00
parent 86fb6ae20b
commit 550c98f275
375 changed files with 8512 additions and 7155 deletions
+36
View File
@@ -0,0 +1,36 @@
# init/ — Init (PID 1) y encarnación Linux
**Propósito.** `ente-zero` arranca como PID 1 del fractal. Provee el
bucle primordial (reap + bus + handshake), bootstrap del kernel
surface, encarnación de Cards en procesos aislados con namespaces +
cgroups, y snapshot/restore del grafo.
## Crates
| crate | tipo | rol |
| ---------------- | ------- | ----------------------------------------------------- |
| `ente-zero` | binario | PID 1: reap + handshake server + bus dispatcher |
| `ente-kernel` | lib | `bootstrap_kernel_surface`, subreaper, SIGCHLD/uevent |
| `ente-soma` | lib | Shim sobre `ente-incarnate` (compatibilidad legacy) |
| `ente-incarnate` | lib | `clone(2) + namespaces + cgroup + rlimits + cpu` |
| `ente-snapshot` | lib | `FractalSnapshot` JSON: checkpoint del grafo Cards |
## Dependencias
- `ente-kernel``nix`, `libc`, syscalls Linux puras.
- `ente-incarnate` reusable: shuma (sandboxes) y supervisores no-PID-1.
- Consume `protocol/` (handshake server, brahman-net opcional).
## Boot path
1. Kernel pasa control → `ente-zero` arranca como `/sbin/init`.
2. Levanta sockets: `/run/brahman/bus`, `/run/brahman/handshake`.
3. Lee `Card` semilla (`seeds/arje-{minimal,host,prod}.card.json`).
4. Para cada `genesis` child Card: `incarnate(card)` → spawn aislado.
5. Reap loop atiende SIGCHLD; bus loop atiende anuncios/invokes.
## Estado
Funciona bare metal + QEMU + initramfs (ver `docs/arje-boot.md`). LOC
~2.2K en init core. Pendiente: cobertura de tests sobre snapshot
restore en escenarios con stale fds.
+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 = "../../protocol/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());
}
}
+116
View File
@@ -0,0 +1,116 @@
//! Resolución y creación de cgroups v2 para el hijo.
use crate::error::IncarnateError;
use brahman_card::{CgroupSpec, ResourceLimits};
use std::path::{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)
}
/// Escribe `memory.max` y `pids.max` al cgroup según `rlimits`. Falla
/// silenciosamente si los archivos no son escribibles (cgroup no
/// delegated). El kernel hace OOM kill cuando `memory.max` se excede,
/// y bloquea forks cuando `pids.max` se alcanza.
///
/// `memory.max` acepta `max` o un número en bytes. `pids.max` igual.
pub fn apply_rlimits_to_cgroup(cgroup_abs: &Path, rlimits: &ResourceLimits) -> Vec<String> {
let mut applied = Vec::new();
if let Some(mem) = rlimits.mem_bytes {
let path = cgroup_abs.join("memory.max");
match std::fs::write(&path, format!("{mem}\n")) {
Ok(_) => applied.push(format!("memory.max={mem}")),
Err(e) => tracing::warn!(?e, path = %path.display(), "memory.max write failed"),
}
}
if let Some(np) = rlimits.nproc {
let path = cgroup_abs.join("pids.max");
match std::fs::write(&path, format!("{np}\n")) {
Ok(_) => applied.push(format!("pids.max={np}")),
Err(e) => tracing::warn!(?e, path = %path.display(), "pids.max write failed"),
}
}
applied
}
/// Mueve `pid` a `cgroup_abs/cgroup.procs`.
pub fn move_to_cgroup(cgroup_abs: &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("shuma/ws-1");
assert!(r.ends_with("/shuma/ws-1") || r == "/shuma/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 },
}
+419
View File
@@ -0,0 +1,419 @@
//! `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, shuma, 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 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;
/// 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 shuma 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 (shuma,
/// 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> {
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);
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, &setup, &mut degradations)?
} else {
plain::incarnate_plain(card, &env_spec, &stdio, &setup)?
};
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!["shuma-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("shuma-stdio"), "got: {s:?}");
// 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() {
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,313 @@
//! 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::pre_exec::{apply_unchecked, ChildSetup};
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,
setup: &ChildSetup,
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;
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,
// 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);
}
}
}
// 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);
}
});
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));
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Path simple: spawn directo, sin namespacing.
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()),
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) });
}
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))
}
+103
View File
@@ -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
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "ente-kernel"
version = "0.0.1"
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
ente-card = { path = "../../protocol/ente-card" }
nix = { workspace = true }
libc = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
+11
View File
@@ -0,0 +1,11 @@
//! ente-kernel: primitivas de Linux que el Init usa pero que son reusables
//! desde tools/tests/sub-supervisores. Sin estado global. Cada función es
//! independiente y se puede testear de forma aislada.
pub mod sigchld;
pub mod surface;
pub mod uevent;
pub use sigchld::spawn_sigchld_stream;
pub use surface::{become_child_subreaper, bootstrap_kernel_surface};
pub use uevent::{spawn_uevent_stream, UAction, UEvent};
+66
View File
@@ -0,0 +1,66 @@
//! SIGCHLD vía signalfd, no signal handler.
//!
//! Los handlers async-signal sólo pueden invocar funciones async-signal-safe
//! — no allocator, no `mpsc::send`. Con signalfd la señal entra al runtime de
//! Tokio como un `fd` legible y la cosechamos en el bucle como cualquier otro
//! evento. Esto es lo que hace que un init en Rust moderno sea sano.
use anyhow::Context;
use nix::sys::signal::Signal;
use nix::sys::signalfd::{SfdFlags, SigSet, SignalFd};
use std::os::fd::AsRawFd;
use tokio::io::unix::AsyncFd;
use tokio::sync::mpsc;
use tracing::{error, trace};
/// Bloquea SIGCHLD para entrega asíncrona, abre signalfd, y emite un `()`
/// en el canal cada vez que llega al menos una señal.
pub fn spawn_sigchld_stream() -> anyhow::Result<mpsc::Receiver<()>> {
let mut mask = SigSet::empty();
mask.add(Signal::SIGCHLD);
mask.thread_block().context("SIGCHLD thread_block")?;
let sfd = SignalFd::with_flags(&mask, SfdFlags::SFD_NONBLOCK | SfdFlags::SFD_CLOEXEC)
.context("signalfd creation")?;
let async_fd = AsyncFd::new(SignalFdHandle(sfd)).context("AsyncFd::new")?;
let (tx, rx) = mpsc::channel(8);
tokio::spawn(async move {
loop {
let mut guard = match async_fd.readable().await {
Ok(g) => g,
Err(e) => { error!(?e, "signalfd readable failed"); return; }
};
// Drenamos todas las siginfos pendientes; signalfd las coalesce
// pero no las cuenta — un read por evento legible es suficiente.
drain(guard.get_inner());
guard.clear_ready();
if tx.send(()).await.is_err() { return; }
trace!("SIGCHLD batch coalesced");
}
});
Ok(rx)
}
struct SignalFdHandle(SignalFd);
impl AsRawFd for SignalFdHandle {
fn as_raw_fd(&self) -> std::os::fd::RawFd {
self.0.as_raw_fd()
}
}
fn drain(handle: &SignalFdHandle) {
let fd = handle.as_raw_fd();
// Tamaño exacto de signalfd_siginfo. Leemos en bucle hasta EAGAIN.
let mut buf = [0u8; std::mem::size_of::<libc::signalfd_siginfo>()];
loop {
let n = unsafe {
libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len())
};
if n < 0 { return; }
if n == 0 { return; }
}
}
+85
View File
@@ -0,0 +1,85 @@
//! Bootstrap del entorno kernel para PID 1: monta procfs/sysfs/devtmpfs/cgroup2
//! y registra al proceso como subreaper para adoptar huérfanos.
//!
//! Idempotente: si los puntos de montaje ya existen (initramfs los montó),
//! el segundo mount falla con EBUSY y simplemente lo ignoramos.
use nix::mount::{mount, MsFlags};
use tracing::debug;
/// Monta los pseudo-filesystems esenciales. Errores benignos (ya montados)
/// se ignoran; errores serios se propagan.
pub fn bootstrap_kernel_surface() -> anyhow::Result<()> {
// Cada uno con sus flags estándar — NOSUID/NOEXEC/NODEV donde aplica.
mount::<str, str, str, str>(
Some("proc"), "/proc", Some("proc"),
MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None,
).ok();
mount::<str, str, str, str>(
Some("sysfs"), "/sys", Some("sysfs"),
MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None,
).ok();
mount::<str, str, str, str>(
Some("devtmpfs"), "/dev", Some("devtmpfs"),
MsFlags::MS_NOSUID, None,
).ok();
mount::<str, str, str, str>(
Some("cgroup2"), "/sys/fs/cgroup", Some("cgroup2"),
MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None,
).ok();
debug!("kernel surface bootstrap completo");
Ok(())
}
/// PR_SET_CHILD_SUBREAPER: que adoptemos huérfanos del fractal.
///
/// En PID 1 esto es redundante (el kernel ya lo hace), pero se deja explícito
/// para que ente-zero corriendo como sub-init en un container mantenga la
/// misma semántica.
pub fn become_child_subreaper() -> anyhow::Result<()> {
let r = unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1u64, 0u64, 0u64, 0u64) };
if r != 0 {
anyhow::bail!(
"prctl PR_SET_CHILD_SUBREAPER falló: {}",
std::io::Error::last_os_error()
);
}
Ok(())
}
/// Cosechar zombis hasta vaciar la cola de niños muertos. Devuelve los
/// PIDs cosechados con su estado, como tuplas.
pub fn reap_all() -> Vec<ReapedChild> {
use nix::errno::Errno;
use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
let mut out = Vec::new();
loop {
match waitpid(None, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::Exited(pid, code)) => {
out.push(ReapedChild { pid: pid.as_raw(), status: ReapStatus::Exited(code) });
}
Ok(WaitStatus::Signaled(pid, sig, _core)) => {
out.push(ReapedChild { pid: pid.as_raw(), status: ReapStatus::Signaled(sig as i32) });
}
Ok(WaitStatus::StillAlive) => return out,
Err(Errno::ECHILD) => return out,
Err(_) => return out,
Ok(_) => continue, // Stopped/Continued — irrelevantes
}
}
// unreachable, satisface al borrow checker
#[allow(unreachable_code)]
out
}
#[derive(Debug, Clone)]
pub struct ReapedChild {
pub pid: i32,
pub status: ReapStatus,
}
#[derive(Debug, Clone)]
pub enum ReapStatus {
Exited(i32),
Signaled(i32),
}
+146
View File
@@ -0,0 +1,146 @@
//! Stream de uevents del kernel vía NETLINK_KOBJECT_UEVENT.
//!
//! Bind requiere CAP_NET_ADMIN. En dev mode normal eso no está disponible —
//! el caller debe estar preparado para que `spawn_uevent_stream` falle, y
//! continuar sin grafo de dispositivos.
use anyhow::Context;
use ente_card::DeviceClass;
use nix::sys::socket::{bind, socket, AddressFamily, NetlinkAddr, SockFlag, SockProtocol, SockType};
use std::collections::HashMap;
use std::os::fd::{AsRawFd, OwnedFd};
use tokio::io::unix::AsyncFd;
use tokio::sync::mpsc;
use tracing::{trace, warn};
#[derive(Debug, Clone)]
pub struct UEvent {
pub action: UAction,
pub devpath: String,
pub subsystem: Option<String>,
pub device_class: Option<DeviceClass>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UAction {
Add, Remove, Change, Move, Online, Offline, Bind, Unbind, Unknown,
}
pub fn spawn_uevent_stream() -> anyhow::Result<mpsc::Receiver<UEvent>> {
let fd: OwnedFd = socket(
AddressFamily::Netlink,
SockType::Datagram,
SockFlag::SOCK_NONBLOCK | SockFlag::SOCK_CLOEXEC,
SockProtocol::NetlinkKObjectUEvent,
).context("netlink socket")?;
// pid=0 → kernel asigna; groups=1 → multicast group del kernel uevent.
let addr = NetlinkAddr::new(0, 1);
bind(fd.as_raw_fd(), &addr).context("bind netlink uevent (CAP_NET_ADMIN?)")?;
let async_fd = AsyncFd::new(NetlinkHandle(fd)).context("AsyncFd::new(netlink)")?;
let (tx, rx) = mpsc::channel(128);
tokio::spawn(async move {
let mut buf = vec![0u8; 16 * 1024];
loop {
let mut guard = match async_fd.readable().await {
Ok(g) => g,
Err(e) => { warn!(?e, "netlink readable"); return; }
};
let raw_fd = guard.get_inner().as_raw_fd();
loop {
let n = unsafe {
libc::recv(raw_fd, buf.as_mut_ptr() as *mut _, buf.len(), 0)
};
if n <= 0 { break; }
if let Some(evt) = parse_uevent(&buf[..n as usize]) {
trace!(?evt.action, devpath = %evt.devpath, "uevent");
if tx.send(evt).await.is_err() { return; }
}
}
guard.clear_ready();
}
});
Ok(rx)
}
struct NetlinkHandle(OwnedFd);
impl AsRawFd for NetlinkHandle {
fn as_raw_fd(&self) -> std::os::fd::RawFd { self.0.as_raw_fd() }
}
fn parse_uevent(buf: &[u8]) -> Option<UEvent> {
// udev re-emite mensajes con magic "libudev\0..." al multicast group 2.
// Si llegan al grupo 1 (improbable con bind groups=1) los filtramos igual.
if buf.starts_with(b"libudev\0") {
return None;
}
let mut parts = buf.split(|b| *b == 0).filter(|s| !s.is_empty());
let header = std::str::from_utf8(parts.next()?).ok()?;
let (action_s, devpath) = header.split_once('@')?;
let mut env: HashMap<String, String> = HashMap::new();
for kv in parts {
if let Ok(s) = std::str::from_utf8(kv) {
if let Some((k, v)) = s.split_once('=') {
env.insert(k.to_string(), v.to_string());
}
}
}
let subsystem = env.remove("SUBSYSTEM");
let device_class = subsystem.as_deref().and_then(map_device_class);
Some(UEvent {
action: parse_action(action_s),
devpath: devpath.to_string(),
subsystem,
device_class,
})
}
fn parse_action(s: &str) -> UAction {
match s {
"add" => UAction::Add,
"remove" => UAction::Remove,
"change" => UAction::Change,
"move" => UAction::Move,
"online" => UAction::Online,
"offline" => UAction::Offline,
"bind" => UAction::Bind,
"unbind" => UAction::Unbind,
_ => UAction::Unknown,
}
}
fn map_device_class(subsys: &str) -> Option<DeviceClass> {
Some(match subsys {
"block" => DeviceClass::Block,
"tty" => DeviceClass::Tty,
"input" => DeviceClass::Input,
"drm" => DeviceClass::Drm,
"net" => DeviceClass::Net,
"hidraw" => DeviceClass::Hidraw,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_uevent() {
let buf = b"add@/devices/foo\0ACTION=add\0DEVPATH=/devices/foo\0SUBSYSTEM=block\0";
let evt = parse_uevent(buf).expect("parsed");
assert_eq!(evt.action, UAction::Add);
assert_eq!(evt.devpath, "/devices/foo");
assert_eq!(evt.subsystem.as_deref(), Some("block"));
assert!(matches!(evt.device_class, Some(DeviceClass::Block)));
}
#[test]
fn libudev_messages_filtered() {
let buf = b"libudev\0\xfe\xed\xca\xfeadd@/devices/foo\0";
assert!(parse_uevent(buf).is_none());
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "ente-snapshot"
version = "0.0.1"
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
ente-card = { path = "../../protocol/ente-card" }
serde = { workspace = true }
serde_json = { workspace = true }
ulid = { workspace = true }
anyhow = { workspace = true }
+61
View File
@@ -0,0 +1,61 @@
//! Persistencia del fractal. Captura el estado live (Cards encarnadas con
//! sus identidades preservadas) a un blob JSON. Al restaurar, las mismas
//! Ulids vuelven a la vida — los PIDs cambian (kernel no los preserva) pero
//! el grafo se reconstruye con la misma topología.
//!
//! Lo que NO se persiste:
//! - PIDs (irrelevantes tras reboot)
//! - bus_connections (runtime-only)
//! - pending_invokes (en vuelo, se descartan)
//! - device presence (uevents reconstruyen el índice)
use ente_card::EntityCard;
use serde::{Deserialize, Serialize};
use std::path::Path;
use ulid::Ulid;
pub const SNAPSHOT_VERSION: u16 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FractalSnapshot {
pub version: u16,
pub timestamp_ms: u64,
pub seed_id: Ulid,
pub seed_label: String,
/// Cards live al momento del checkpoint, excluyendo la Semilla.
/// Al restaurar se inyectan en `genesis` con sus Ulids originales.
pub entes: Vec<EntityCard>,
}
impl FractalSnapshot {
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();
}
// Escritura atómica: temp file + rename.
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: FractalSnapshot = serde_json::from_slice(&bytes)?;
if snap.version != SNAPSHOT_VERSION {
anyhow::bail!(
"snapshot version {} no soportada (esperada {})",
snap.version, SNAPSHOT_VERSION
);
}
Ok(snap)
}
}
pub fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "ente-soma"
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 = "../../protocol/ente-card" }
ente-bus = { path = "../../runtime/ente-bus" }
ente-incarnate = { path = "../ente-incarnate" }
nix = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
+44
View File
@@ -0,0 +1,44 @@
//! `ente-soma` — wrapper histórico sobre [`ente_incarnate`].
//!
//! La rutina de namespacing fue extraída a `ente-incarnate` para que
//! shuma, 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}`.
//!
//! 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::EntityCard;
use ente_incarnate::{Incarnator, IncarnatorConfig};
use nix::unistd::Pid;
use std::path::PathBuf;
use std::sync::OnceLock;
use tracing::warn;
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 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> {
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)
}
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "ente-zero"
version = "0.0.1"
edition.workspace = true
license.workspace = true
publish.workspace = true
[[bin]]
name = "ente-zero"
path = "src/main.rs"
[dependencies]
# Lib crates del fractal — orden: contratos → infra → encarnación → orquestación
ente-card = { path = "../../protocol/ente-card" }
ente-bus = { path = "../../runtime/ente-bus" }
ente-cas = { path = "../../runtime/ente-cas" }
ente-kernel = { path = "../ente-kernel" }
ente-soma = { path = "../ente-soma" }
ente-wasm = { path = "../../runtime/ente-wasm" }
ente-snapshot = { path = "../ente-snapshot" }
ente-brain = { path = "../../runtime/ente-brain" }
ente-echo = { path = "../../runtime/ente-echo" } # solo para constantes del demo
# Brahman protocol — handshake para módulos brahman conscientes
brahman-handshake = { path = "../../protocol/brahman-handshake" }
brahman-broker = { path = "../../protocol/brahman-broker" }
brahman-admin = { path = "../../protocol/brahman-admin" }
brahman-net = { path = "../../protocol/brahman-net" }
# Runtime / utilidades de PID 1
serde = { workspace = true }
serde_json = { workspace = true }
ulid = { workspace = true }
tokio = { workspace = true }
nix = { workspace = true }
libc = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
+129
View File
@@ -0,0 +1,129 @@
//! Glue entre el bucle primordial y `ente-brain`.
//!
//! Tres responsabilidades:
//! 1. Traducir eventos del grafo (`GraphEvent`) a `ente_brain::EventKind`
//! + `SubjectInfo` para el observador y el motor.
//! 2. Implementar `ActionSink` para que las Acciones del cerebro tengan
//! un canal de salida hacia el grafo (Spawn → SpawnRequest, etc.).
//! 3. Encapsular el snapshot de SubjectInfo desde el grafo sin filtrar
//! detalles internos al cerebro.
use crate::events::GraphEvent;
use crate::graph::EnteGraph;
use ente_brain::{ActionSink, EventKind as BrainEventKind, SubjectInfo};
use ente_card::Capability;
use serde::Deserialize;
use tokio::sync::mpsc;
use tracing::warn;
use ulid::Ulid;
/// Traduce un GraphEvent a (EventKind, SubjectInfo) para alimentar el cerebro.
///
/// Devuelve `None` para eventos puramente internos del bus (Response, Close)
/// que no son interesantes para reglas o estadística.
pub fn graph_event_to_brain<'a>(
evt: &'a GraphEvent,
graph: &EnteGraph,
) -> Option<(BrainEventKind, SubjectInfo)> {
match evt {
GraphEvent::EnteDied { id, .. } => {
Some((BrainEventKind::EnteDied, subject_info_for(graph, *id)))
}
GraphEvent::SpawnRequest { card, .. } => {
// El "sujeto" del spawn es el child que va a nacer.
let info = SubjectInfo {
id: Some(card.id),
label: Some(card.label.clone()),
capabilities: card.provides.iter().cloned().collect(),
};
Some((BrainEventKind::EnteSpawned, info))
}
GraphEvent::BusRequest { from, request, .. } => {
let kind = match request {
ente_bus::BusRequest::Announce { .. } => BrainEventKind::BusAnnounce,
ente_bus::BusRequest::Invoke { cap, .. } => {
BrainEventKind::BusInvokeOf(cap.clone())
}
_ => BrainEventKind::BusInvoke,
};
let info = match from {
Some(id) => subject_info_for(graph, *id),
None => SubjectInfo::default(),
};
Some((kind, info))
}
GraphEvent::CapabilityRequested { from, .. } => {
Some((BrainEventKind::BusInvoke, subject_info_for(graph, *from)))
}
// Responses, ConnClosed, Shutdown — irrelevantes para reglas
_ => None,
}
}
fn subject_info_for(graph: &EnteGraph, id: Ulid) -> SubjectInfo {
// Acceso de sólo lectura — usamos el método público lookup_pid + cards
// virtuales en el grafo. Si el Ente no existe (ya disuelto), info vacía.
if let Some(card) = graph.card_for(&id) {
SubjectInfo {
id: Some(id),
label: Some(card.label.clone()),
capabilities: card.provides.iter().cloned().collect(),
}
} else {
SubjectInfo { id: Some(id), label: None, capabilities: Vec::new() }
}
}
/// `ActionSink` que enruta acciones del cerebro al bucle primordial.
pub struct GraphSink {
pub graph_tx: mpsc::Sender<GraphEvent>,
pub requester: Ulid,
}
impl ActionSink for GraphSink {
fn spawn(&self, card_blob: &str) {
// El blob es JSON de EntityCard.
match serde_json::from_str::<ente_card::EntityCard>(card_blob) {
Ok(card) => {
let evt = GraphEvent::SpawnRequest { card, requester: self.requester };
if self.graph_tx.try_send(evt).is_err() {
warn!("brain spawn: graph_tx lleno o cerrado");
}
}
Err(e) => warn!(?e, "brain spawn: blob no parseable como EntityCard JSON"),
}
}
fn invoke(&self, target_cap: Capability, blob: Vec<u8>) {
// Sin BusClient en proceso — el sink registra la intención. Una mejora
// futura: spawn un BusClient::connect + call. Por ahora log estructurado.
warn!(?target_cap, blob_len = blob.len(), "brain invoke: no bus client en glue (TODO)");
}
fn notify(&self, target_id: Ulid, message: &str) {
warn!(%target_id, %message, "brain notify: no implementado en glue");
}
fn inhibit(&self, reason: &str) {
warn!(%reason, "brain inhibit: no implementado en glue");
}
}
/// Helper para que el grafo exponga la Card de un Ente vivo. Lo añadimos como
/// trait extension porque graph::EnteGraph mantiene `incarnated` privado.
pub trait GraphCardLookup {
fn card_for(&self, id: &Ulid) -> Option<&ente_card::EntityCard>;
}
impl GraphCardLookup for EnteGraph {
fn card_for(&self, id: &Ulid) -> Option<&ente_card::EntityCard> {
// Acceso vía método público que añadiremos en graph/mod.rs.
self.peek_card(id)
}
}
// Eliminar el campo `_unused` que rustc puede quejarse — placeholder para
// evitar warning si algún field queda sin uso.
#[allow(dead_code)]
#[derive(Deserialize)]
struct _Touch {}
+143
View File
@@ -0,0 +1,143 @@
//! Listener del bus interno. Vive en PID 1, acepta conexiones de Entes hijos,
//! extrae credenciales del kernel vía SO_PEERCRED, y enruta cada request al
//! grafo. Conexión bidireccional: el grafo puede *empujar* requests hacia
//! una conexión registrada (forwarding de Invoke al proveedor).
//!
//! ## Por qué bidireccional
//!
//! Un Ente que provee `Capability::Endpoint` debe poder *recibir* invokes
//! sin abrir más sockets. Después de Announce, el grafo guarda el lado de
//! escritura de su conexión y lo usa para forwardear.
use crate::events::GraphEvent;
use ente_bus::{read_frame, write_frame, BusMessage, BusPayload, BusResponse, PeerCreds};
use nix::sys::socket::{getsockopt, sockopt::PeerCredentials};
use std::path::PathBuf;
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{mpsc, oneshot};
use tracing::{error, info, trace, warn};
use ulid::Ulid;
pub fn default_socket_path() -> PathBuf {
if let Ok(p) = std::env::var(ente_bus::ENV_BUS_SOCK) {
return p.into();
}
let runtime = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into()));
let user = std::env::var("USER").unwrap_or_else(|_| "ente".into());
format!("{runtime}/ente-bus-{user}.sock").into()
}
pub fn spawn_bus(path: PathBuf, graph_tx: mpsc::Sender<GraphEvent>) -> anyhow::Result<PathBuf> {
let _ = std::fs::remove_file(&path);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let listener = UnixListener::bind(&path)?;
info!(path = %path.display(), "bus interno escuchando");
let path_returned = path.clone();
tokio::spawn(async move {
loop {
match listener.accept().await {
Ok((stream, _addr)) => {
let tx = graph_tx.clone();
tokio::spawn(async move {
if let Err(e) = handle_conn(stream, tx).await {
warn!(?e, "bus connection ended");
}
});
}
Err(e) => {
error!(?e, "bus accept failed, listener cerrando");
return;
}
}
}
});
Ok(path_returned)
}
async fn handle_conn(stream: UnixStream, graph_tx: mpsc::Sender<GraphEvent>) -> anyhow::Result<()> {
// SO_PEERCRED: el kernel adjunta pid/uid/gid al socket en connect/accept.
// No-falsificable desde el cliente.
let creds = getsockopt(&stream, PeerCredentials)
.map_err(|e| anyhow::anyhow!("getsockopt PEERCRED: {e}"))?;
let peer = PeerCreds {
pid: creds.pid(),
uid: creds.uid(),
gid: creds.gid(),
};
trace!(?peer, "bus conn aceptada");
let (mut reader, mut writer) = stream.into_split();
let (out_tx, mut out_rx) = mpsc::channel::<BusMessage>(64);
// Writer task: única vía de escritura al socket. Multiplexa entre
// respuestas a peticiones del cliente y forwards iniciados por el grafo.
let writer_task = tokio::spawn(async move {
while let Some(msg) = out_rx.recv().await {
if let Err(e) = write_frame(&mut writer, &msg).await {
warn!(?e, "bus writer falló, terminando");
return;
}
}
});
let mut announced_id: Option<Ulid> = None;
let result: anyhow::Result<()> = (async {
loop {
let msg = match read_frame(&mut reader).await {
Ok(m) => m,
Err(e) => {
trace!(?e, "bus conn read terminó");
return Ok(());
}
};
match msg.payload {
BusPayload::Request(req) => {
let is_announce = matches!(req, ente_bus::BusRequest::Announce { .. });
let (reply_tx, reply_rx) = oneshot::channel();
if graph_tx.send(GraphEvent::BusRequest {
peer,
from: msg.from,
request: req,
outbound: out_tx.clone(),
reply: reply_tx,
}).await.is_err() {
warn!("graph cerrado, terminando bus connection");
return Ok(());
}
let response = reply_rx.await.unwrap_or_else(|_| {
BusResponse::Error("graph dropped reply channel".into())
});
if is_announce && matches!(response, BusResponse::Ok) {
// Auth del Announce ya fue verificada por el grafo;
// memorizamos para cleanup en cierre.
announced_id = msg.from;
}
let out = BusMessage {
from: None,
seq: msg.seq,
payload: BusPayload::Response(response),
};
if out_tx.send(out).await.is_err() { return Ok(()); }
}
BusPayload::Response(resp) => {
// Respuesta a un Invoke que el grafo forwardeó a este peer.
let _ = graph_tx.send(GraphEvent::BusResponse {
seq: msg.seq,
response: resp,
}).await;
}
}
}
}).await;
if let Some(id) = announced_id {
let _ = graph_tx.send(GraphEvent::BusConnClosed { ente_id: Some(id) }).await;
}
writer_task.abort();
result
}
+65
View File
@@ -0,0 +1,65 @@
//! Eventos internos del bucle primordial. Todo cambio de estado del fractal
//! pasa por aquí — la única vía de mutación del grafo desde tasks externas.
//!
//! Este módulo es **vocabulario**: declara el universo completo de eventos
//! del fractal. Algunas variantes/campos están reservados para flujos
//! aún no implementados (capabilities, signal-driven shutdown). Silenciar
//! `dead_code` evita ruido sin perder la declaración del contrato.
#![allow(dead_code)]
use ente_bus::{BusMessage, BusRequest, BusResponse, PeerCreds};
use ente_card::{Capability, EntityCard};
use nix::sys::signal::Signal;
use tokio::sync::{mpsc, oneshot};
use ulid::Ulid;
#[derive(Debug)]
pub enum GraphEvent {
EnteDied { id: Ulid, status: ExitStatus },
CapabilityRequested {
from: Ulid,
cap: Capability,
reply: oneshot::Sender<CapabilityGrant>,
},
SpawnRequest { card: EntityCard, requester: Ulid },
/// Request del bus interno. `peer` es no-falsificable (kernel-injected
/// via SO_PEERCRED). `from` es la identidad reclamada por el cliente —
/// el grafo la verifica contra `peer.pid`.
BusRequest {
peer: PeerCreds,
from: Option<Ulid>,
request: BusRequest,
outbound: mpsc::Sender<BusMessage>,
reply: oneshot::Sender<BusResponse>,
},
/// Response a un Invoke forwardeado por el grafo a un proveedor.
/// `seq` debe coincidir con una entry en pending_invokes.
BusResponse { seq: u64, response: BusResponse },
/// Cliente del bus cerró su conexión. Si había anunciado identidad,
/// el grafo retira esa conexión del registry.
BusConnClosed { ente_id: Option<Ulid> },
Shutdown { reason: ShutdownReason },
}
#[derive(Debug, Clone)]
pub enum ExitStatus {
Exit(i32),
Killed(Signal),
}
#[derive(Debug, Clone)]
pub enum ShutdownReason {
SeedRequested,
Signal(Signal),
}
#[derive(Debug)]
pub enum CapabilityGrant {
Granted { token: u64 },
NoProvider,
Denied { reason: &'static str },
/// El holder ya tiene el máximo de tokens activos para esta cap.
/// Debe esperar a que alguno expire o renovar uno existente.
QuotaExceeded { active: u32, limit: u32 },
}
@@ -0,0 +1,240 @@
//! Bus mediator: integración de `EnteGraph` con el bus interno.
//!
//! Responsabilidades:
//! - Auth de Announce (verificar identidad reclamada contra SO_PEERCRED)
//! - Registro de conexiones (`bus_connections` indexado por Ulid)
//! - Forwarding de Invokes a proveedores
//! - Tracking de invokes en vuelo (`pending_invokes` por seq)
//! - Cleanup en cierre de conexión
use super::{EnteGraph, SERVER_SEQ_FLAG};
use ente_bus::{BusMessage, BusPayload, BusRequest, BusResponse, EnteInfo, PeerCreds};
use ente_card::Capability;
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
use ulid::Ulid;
/// Operaciones que requieren identidad verificada en el bus.
///
/// - `Announce`: establece bus_connections para forwarding.
/// - `UpdateCapabilities`: muta dynamic_provides del Ente — sólo el dueño.
///
/// Invoke, ListEntes y power-mgmt se aceptan anonymous — políticas por
/// capacidad se aplican aguas abajo, no aquí.
fn requires_auth(req: &BusRequest) -> bool {
matches!(
req,
BusRequest::Announce { .. } | BusRequest::UpdateCapabilities { .. }
)
}
impl EnteGraph {
pub async fn on_bus_request(
&mut self,
peer: PeerCreds,
from: Option<Ulid>,
request: BusRequest,
outbound: mpsc::Sender<BusMessage>,
reply: oneshot::Sender<BusResponse>,
) {
// ---- Auth: kernel-injected SO_PEERCRED vs identidad reclamada ----
let from_authenticated = match from {
None => None,
Some(claimed) => {
let expected = self.incarnated.get(&claimed).and_then(|i| i.pid);
match expected {
Some(p) if p.as_raw() == peer.pid => Some(claimed),
Some(p) => {
warn!(
claimed = %claimed, expected_pid = p.as_raw(),
actual_pid = peer.pid,
"identity mismatch — rechazando request"
);
let _ = reply.send(BusResponse::Error("identity mismatch".into()));
return;
}
None => {
warn!(?claimed, peer_pid = peer.pid, "Ente desconocido reclamando identidad");
let _ = reply.send(BusResponse::Error("unknown ente claimed".into()));
return;
}
}
}
};
if requires_auth(&request) && from_authenticated.is_none() {
let _ = reply.send(BusResponse::Error("auth required for this request".into()));
return;
}
// ---- Dispatch ----
match request {
BusRequest::Announce { capabilities } => {
let id = from_authenticated.expect("auth-required guarantees Some");
let label = self.incarnated.get(&id).map(|i| i.card.label.clone())
.unwrap_or_else(|| "anónimo".into());
info!(%id, %label, ?capabilities, peer_pid = peer.pid, "Announce autenticado");
self.bus_connections.insert(id, outbound);
let _ = reply.send(BusResponse::Ok);
}
BusRequest::ListEntes => {
let entes = self.incarnated.values()
.map(|i| EnteInfo {
id: i.card.id,
label: i.card.label.clone(),
provides: i.card.provides.iter().cloned().collect(),
pid: i.pid.map(|p| p.as_raw()),
})
.collect();
let _ = reply.send(BusResponse::Entes(entes));
}
BusRequest::PowerOff { interactive } => {
info!(?from_authenticated, interactive, peer_pid = peer.pid, "PowerOff via bus");
let _ = reply.send(BusResponse::Ok);
}
BusRequest::Reboot { interactive } => {
info!(?from_authenticated, interactive, "Reboot via bus");
let _ = reply.send(BusResponse::Ok);
}
BusRequest::Suspend { interactive } => {
info!(?from_authenticated, interactive, "Suspend via bus");
let _ = reply.send(BusResponse::Ok);
}
BusRequest::Hibernate { interactive } => {
info!(?from_authenticated, interactive, "Hibernate via bus");
let _ = reply.send(BusResponse::Ok);
}
BusRequest::Invoke { cap, blob } => {
self.forward_invoke(from_authenticated, cap, blob, reply).await;
}
BusRequest::UpdateCapabilities { adds, removes } => {
let id = from_authenticated.expect("auth-required guarantees Some");
self.apply_capability_update(id, adds, removes);
let _ = reply.send(BusResponse::Ok);
}
}
}
/// Muta `dynamic_provides` del Ente y actualiza el índice global de
/// providers. La Card original (immutable) no se toca.
fn apply_capability_update(
&mut self,
ente_id: Ulid,
adds: Vec<Capability>,
removes: Vec<Capability>,
) {
// Adiciones: dedupe contra Card.provides + dynamic_provides existentes.
let mut added = Vec::new();
let mut removed = Vec::new();
if let Some(inc) = self.incarnated.get_mut(&ente_id) {
for cap in adds {
if inc.card.provides.contains(&cap) || inc.dynamic_provides.contains(&cap) {
continue; // ya provista, no-op
}
inc.dynamic_provides.insert(cap.clone());
added.push(cap);
}
for cap in removes {
if inc.dynamic_provides.remove(&cap) {
removed.push(cap);
}
// Caps de la Card original no se pueden quitar — silenciosamente
// ignoradas. Una Card es contrato; sólo el dynamic es mutable.
}
}
// Actualizar índice global. Hacemos esto fuera del scope `inc` para
// evitar el doble-borrow de self.
for cap in &added {
self.register_dynamic_cap(ente_id, cap.clone());
}
for cap in &removed {
self.unregister_dynamic_cap(ente_id, cap);
// Revocar grants emitidos contra esta cap por este Ente.
let revoked: Vec<u64> = self.grants.iter()
.filter(|(_, g)| g.provider == ente_id && &g.cap == cap)
.map(|(t, _)| *t)
.collect();
for t in revoked {
self.grants.remove(&t);
}
}
info!(
%ente_id,
added_count = added.len(),
removed_count = removed.len(),
"capabilities actualizadas en runtime"
);
}
/// Enruta un Invoke al proveedor real de la capacidad. Aloca un seq
/// server-side, registra el reply oneshot en `pending_invokes`, y empuja
/// el request por la conexión del proveedor.
async fn forward_invoke(
&mut self,
from: Option<Ulid>,
cap: Capability,
blob: Vec<u8>,
reply: oneshot::Sender<BusResponse>,
) {
let provider_id = match self.pick_invokable_provider(&cap) {
Some(id) => id,
None => {
let _ = reply.send(BusResponse::Error(format!("sin proveedor invokable para {cap:?}")));
return;
}
};
let outbound = match self.bus_connections.get(&provider_id) {
Some(o) => o.clone(),
None => {
let _ = reply.send(BusResponse::Error("proveedor no conectado al bus".into()));
return;
}
};
let seq = self.alloc_invoke_seq();
self.pending_invokes.insert(seq, reply);
debug!(?from, ?cap, ?provider_id, seq, blob_len = blob.len(), "forwardeando Invoke");
let msg = BusMessage {
from: None,
seq,
payload: BusPayload::Request(BusRequest::Invoke { cap, blob }),
};
if outbound.send(msg).await.is_err() {
if let Some(orig) = self.pending_invokes.remove(&seq) {
let _ = orig.send(BusResponse::Error("conn del proveedor cerrada".into()));
}
}
}
fn pick_invokable_provider(&self, cap: &Capability) -> Option<Ulid> {
// Sólo proveedores con conexión al bus pueden recibir forwards.
// El propio Ente #0 está en `providers` para varias caps pero no
// debe recibir forwards — se filtra implícitamente porque la Semilla
// no tiene conexión al bus.
self.providers.get(cap)?
.iter()
.find(|id| self.bus_connections.contains_key(id))
.copied()
}
pub(in crate::graph) fn alloc_invoke_seq(&mut self) -> u64 {
self.next_invoke_seq = self.next_invoke_seq.wrapping_add(1);
SERVER_SEQ_FLAG | self.next_invoke_seq
}
pub async fn on_bus_response(&mut self, seq: u64, response: BusResponse) {
if let Some(orig) = self.pending_invokes.remove(&seq) {
let _ = orig.send(response);
} else {
warn!(seq, "Response sin pending invoke");
}
}
pub async fn on_bus_conn_closed(&mut self, ente_id: Option<Ulid>) {
if let Some(id) = ente_id {
self.bus_connections.remove(&id);
// No revocamos providers — la capacidad sigue declarada en su
// Card. Sólo perdimos el canal de invocación.
debug!(%id, "bus connection cerrada");
}
}
}
@@ -0,0 +1,98 @@
//! Mediación de capabilities: emisión, renovación, revocación de tokens.
//!
//! Los grants tienen TTL (`DEFAULT_GRANT_TTL`). El cliente debe renovarlos
//! periódicamente con `renew_grant(token)`; en caso contrario, el background
//! task `purge_expired_grants` los revoca al vencimiento.
use super::{quota_for_capability, ttl_for_capability, EnteGraph, GrantedCapability};
use crate::events::CapabilityGrant;
use ente_card::Capability;
use std::time::Instant;
use tokio::sync::oneshot;
use tracing::debug;
use ulid::Ulid;
impl EnteGraph {
pub async fn mediate_capability(
&mut self,
from: Ulid,
cap: Capability,
reply: oneshot::Sender<CapabilityGrant>,
) {
let grant = match self.providers.get(&cap).and_then(|s| s.iter().next().copied()) {
None => CapabilityGrant::NoProvider,
Some(provider) => {
// Quota: contar tokens vivos para (from, cap). Si excede,
// rechazar antes de emitir uno nuevo.
let limit = quota_for_capability(&cap);
let active = self.active_tokens_for(from, &cap);
if active >= limit {
CapabilityGrant::QuotaExceeded { active, limit }
} else {
let token = self.next_token;
self.next_token += 1;
let ttl = ttl_for_capability(&cap);
let expires_at = Instant::now() + ttl;
self.grants.insert(token, GrantedCapability {
cap: cap.clone(),
provider,
holder: from,
expires_at,
});
CapabilityGrant::Granted { token }
}
}
};
let _ = reply.send(grant);
}
/// Cuenta tokens vivos (no expirados) emitidos a un holder para una cap.
pub fn active_tokens_for(&self, holder: Ulid, cap: &Capability) -> u32 {
let now = Instant::now();
self.grants.values()
.filter(|g| g.holder == holder && &g.cap == cap && g.expires_at > now)
.count() as u32
}
/// Extiende un grant existente. Devuelve `true` si renovó. Si el token
/// no existe o ya expiró, `false` (el cliente debe re-acquire).
/// Usa el TTL específico de la cap del grant.
///
/// Reservado para el flujo de capability renewal (no cableado todavía).
#[allow(dead_code)]
pub fn renew_grant(&mut self, token: u64) -> bool {
let now = Instant::now();
if let Some(g) = self.grants.get_mut(&token) {
if g.expires_at > now {
g.expires_at = now + ttl_for_capability(&g.cap);
return true;
}
// Expired — purgamos aquí mismo.
self.grants.remove(&token);
}
false
}
/// GC: elimina grants vencidos. Devuelve cuántos fueron purgados.
pub fn purge_expired_grants(&mut self) -> usize {
let now = Instant::now();
let expired: Vec<u64> = self.grants.iter()
.filter(|(_, g)| g.expires_at <= now)
.map(|(t, _)| *t)
.collect();
for t in &expired {
self.grants.remove(t);
}
if !expired.is_empty() {
debug!(count = expired.len(), "grants expirados purgados");
}
expired.len()
}
/// Cuenta de grants vivos (no expirados). Usado por métricas.
pub fn active_grants_count(&self) -> usize {
let now = Instant::now();
self.grants.values().filter(|g| g.expires_at > now).count()
}
}
@@ -0,0 +1,60 @@
//! Device registry: mantiene el índice de dispositivos del kernel presentes,
//! traduce uevents en cambios de `Capability::Device { class }`.
use super::EnteGraph;
use crate::events::GraphEvent;
use ente_card::{Capability, DeviceClass};
use ente_kernel::{UAction, UEvent};
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
impl EnteGraph {
pub async fn on_uevent(&mut self, evt: UEvent, _tx: &mpsc::Sender<GraphEvent>) {
let class = match &evt.device_class {
Some(c) => c.clone(),
None => return, // subsystems sin DeviceClass mapeada — ignoramos.
};
match evt.action {
UAction::Add | UAction::Bind | UAction::Online => {
let was_first = self.devices_of_class(&class) == 0;
self.devices.insert(evt.devpath.clone(), evt.clone());
if was_first {
// Primera instancia de la clase → la registramos como
// capacidad disponible. El "proveedor" virtual es el
// Ente #0 (kernel surface).
let cap = Capability::Device { class: class.clone() };
self.providers.entry(cap).or_default().insert(self.seed.id);
info!(?class, devpath = %evt.devpath, "device capability disponible");
}
}
UAction::Remove | UAction::Unbind | UAction::Offline => {
self.devices.remove(&evt.devpath);
if self.devices_of_class(&class) == 0 {
let cap = Capability::Device { class: class.clone() };
if let Some(set) = self.providers.get_mut(&cap) {
set.remove(&self.seed.id);
}
let revoked: Vec<u64> = self.grants.iter()
.filter(|(_, g)| g.cap == cap)
.map(|(t, _)| *t)
.collect();
for t in revoked {
self.grants.remove(&t);
}
warn!(?class, "última instancia removida — capacidad revocada");
}
}
UAction::Change | UAction::Move => {
self.devices.insert(evt.devpath.clone(), evt);
debug!(?class, "device modified");
}
UAction::Unknown => {}
}
}
fn devices_of_class(&self, class: &DeviceClass) -> usize {
self.devices.values()
.filter(|e| e.device_class.as_ref() == Some(class))
.count()
}
}
@@ -0,0 +1,154 @@
//! Encarnación, muerte y supervisión.
//!
//! Aquí vive el flujo: Card → autorizar → soma::incarnate / wasm → registro
//! en el grafo → SIGCHLD → on_death → Restart/OneShot/Delegate.
use super::{EnteGraph, Incarnated};
use crate::events::{ExitStatus, GraphEvent};
use ente_bus::{BusMessage, BusPayload, BusRequest};
use ente_card::{Capability, EntityCard, Payload, Supervision};
use tokio::sync::mpsc;
use tracing::{info, warn};
use ulid::Ulid;
impl EnteGraph {
/// Encarna las dependencias declaradas en la Semilla. Único punto donde
/// el Init "decide": después sólo reacciona.
pub async fn instantiate_seed_dependencies(
&mut self,
_tx: &mpsc::Sender<GraphEvent>,
) -> anyhow::Result<()> {
let cards = std::mem::take(&mut self.pending_genesis);
if cards.is_empty() {
info!(seed = %self.seed.label, "semilla sin genesis cards");
return Ok(());
}
info!(seed = %self.seed.label, count = cards.len(), "instanciando genesis");
let seed_id = self.seed.id;
for card in cards {
if let Err(e) = self.authorize_and_spawn(card, seed_id).await {
warn!(?e, "genesis card falló");
}
}
Ok(())
}
/// Spawn solicitado por un Ente con `Capability::Spawn`. Verifica auth,
/// requires del grafo, y delega la encarnación al backend correspondiente
/// (`ente_soma` para procesos, `ente_wasm` para Wasm).
pub async fn authorize_and_spawn(
&mut self,
mut card: EntityCard,
requester: Ulid,
) -> anyhow::Result<()> {
if !self.holder_has(requester, &Capability::Spawn) {
warn!(?requester, "spawn denied: lacks Capability::Spawn");
return Ok(());
}
if let Err(e) = card.validate() {
warn!(?e, label = %card.label, "card inválida, spawn rechazado");
return Ok(());
}
// Falla rápida sobre `requires` — mejor que daemons en bucle.
for req in &card.requires {
if !self.providers.contains_key(req) {
warn!(?req, label = %card.label, "requires no satisfecho");
return Ok(());
}
}
// Lineage por defecto = quien pidió el spawn.
if card.lineage.is_none() {
card.lineage = Some(requester);
}
let pid = match &card.payload {
Payload::Virtual => None,
Payload::Native { .. } | Payload::Legacy { .. } => {
Some(ente_soma::incarnate(&card)?)
}
Payload::Wasm { module_sha256, entry } => {
// Wasm: hilo dedicado, sin PID. Su muerte se observa por
// estado del runtime, no por SIGCHLD.
let bytes = ente_cas::resolve(module_sha256)
.map_err(|e| anyhow::anyhow!("CAS resolve para {}: {e}", card.label))?;
ente_wasm::incarnate_wasm(&card, bytes, entry.clone())?;
None
}
};
if let Some(p) = pid {
self.by_pid.insert(p.as_raw(), card.id);
}
self.register_provider(&card);
if let Some(parent) = card.lineage {
self.children.entry(parent).or_default().push(card.id);
}
info!(label = %card.label, ?pid, lineage = ?card.lineage, "Ente encarnado");
self.incarnated.insert(card.id, Incarnated {
card, pid,
dynamic_provides: std::collections::BTreeSet::new(),
});
Ok(())
}
pub async fn on_death(
&mut self,
id: Ulid,
status: ExitStatus,
_tx: &mpsc::Sender<GraphEvent>,
) {
let Some(inc) = self.incarnated.remove(&id) else { return };
if let Some(p) = inc.pid {
self.by_pid.remove(&p.as_raw());
}
self.unregister_provider(&inc.card);
if let Some(parent) = inc.card.lineage {
if let Some(siblings) = self.children.get_mut(&parent) {
siblings.retain(|c| c != &id);
}
}
info!(label = %inc.card.label, ?status, "Ente disuelto");
match inc.card.supervision.clone() {
Supervision::Restart { initial, max: _ } => {
// Backoff exponencial: TODO real con timer del runtime.
tokio::time::sleep(initial).await;
let new_card = EntityCard { id: Ulid::new(), ..inc.card };
if let Err(e) = self.authorize_and_spawn(new_card, self.seed.id).await {
warn!(?e, "restart falló");
}
}
Supervision::OneShot => {}
Supervision::Delegate => {
self.notify_lineage_of_death(&inc, &status);
}
}
}
/// Fire-and-forget: si el parent tiene conexión al bus, le forwardeamos
/// un Invoke con la muerte del hijo. Sin retry, sin backpressure.
fn notify_lineage_of_death(&mut self, inc: &Incarnated, status: &ExitStatus) {
let Some(parent) = inc.card.lineage else { return };
info!(
child = %inc.card.id, parent = %parent, label = %inc.card.label,
?status,
"Supervision::Delegate — muerte notificada al lineage"
);
if let Some(out) = self.bus_connections.get(&parent).cloned() {
let blob = format!("{}:{:?}", inc.card.id, status);
let seq = self.alloc_invoke_seq();
let msg = BusMessage {
from: None,
seq,
payload: BusPayload::Request(BusRequest::Invoke {
cap: Capability::Endpoint {
interface: ente_card::InterfaceId([0xde; 16]),
version: 1,
},
blob: blob.into_bytes(),
}),
};
let _ = out.try_send(msg);
}
}
}
+229
View File
@@ -0,0 +1,229 @@
//! `EnteGraph`: estado del fractal vivo en PID 1.
//!
//! Diseño:
//! - Submódulos por concern: lifecycle, topology, shutdown, bus_mediator,
//! devices, capabilities. Cada uno extiende `impl EnteGraph` con métodos
//! relacionados.
//! - Estado plano (no substructs todavía) — la separación es por
//! comportamiento, no por compartimentación de datos.
//! - Toda mutación pasa por el bucle primordial vía `GraphEvent`. Los
//! submódulos se llaman desde `main.rs::primordial_loop`.
mod bus_mediator;
mod capabilities;
mod devices;
mod lifecycle;
mod shutdown;
mod topology;
use ente_bus::{BusMessage, BusResponse};
use ente_card::{Capability, EntityCard};
use nix::unistd::Pid;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use tokio::sync::{mpsc, oneshot};
use ulid::Ulid;
// `SHUTDOWN_GRACE` está re-exportado bajo `crate::graph::shutdown::SHUTDOWN_GRACE`
// directo; la re-export adicional aquí no se usa todavía.
/// Bit alto encendido en `seq` para invokes server-iniciados — evita choque
/// con secuencias allocadas por clientes.
pub(in crate::graph) const SERVER_SEQ_FLAG: u64 = 1u64 << 63;
pub struct EnteGraph {
pub(in crate::graph) seed: EntityCard,
/// Entes encarnados como proceso o nodo virtual. id↔pid bidireccional.
pub(in crate::graph) incarnated: HashMap<Ulid, Incarnated>,
pub(in crate::graph) by_pid: HashMap<i32, Ulid>,
/// Quién provee qué capacidad. Resuelve `requires` y `pick_invokable`.
pub(in crate::graph) providers: BTreeMap<Capability, BTreeSet<Ulid>>,
/// Tokens de capability emitidos. Revocables al morir el proveedor.
pub(in crate::graph) next_token: u64,
pub(in crate::graph) grants: HashMap<u64, GrantedCapability>,
/// Dispositivos del kernel presentes (devpath → última UEvent).
pub(in crate::graph) devices: HashMap<String, ente_kernel::UEvent>,
/// Cards genesis pendientes de instanciar (extraídas de la Semilla).
pub(in crate::graph) pending_genesis: Vec<EntityCard>,
/// Hijos directos por lineage. parent → [child, ...].
pub(in crate::graph) children: HashMap<Ulid, Vec<Ulid>>,
/// Conexiones del bus indexadas por la identidad anunciada y verificada
/// con SO_PEERCRED. El value es el extremo de escritura del writer task.
pub(in crate::graph) bus_connections: HashMap<Ulid, mpsc::Sender<BusMessage>>,
/// Invokes forwardeados pendientes de respuesta del proveedor.
pub(in crate::graph) pending_invokes: HashMap<u64, oneshot::Sender<BusResponse>>,
pub(in crate::graph) next_invoke_seq: u64,
}
pub(in crate::graph) struct Incarnated {
pub card: EntityCard,
pub pid: Option<Pid>,
/// Capacidades añadidas en runtime vía BusRequest::UpdateCapabilities.
/// La Card original es immutable; la "vista efectiva" del Ente es
/// `card.provides dynamic_provides`.
pub dynamic_provides: BTreeSet<Capability>,
}
pub(in crate::graph) struct GrantedCapability {
pub cap: Capability,
pub provider: Ulid,
pub holder: Ulid,
/// Instante en el que el grant deja de ser válido. El garbage collector
/// del cerebro purga grants con `Instant::now() > expires_at`.
pub expires_at: std::time::Instant,
}
/// TTL default para grants cuando la cap no tiene override. 60s es un
/// compromiso: largo enough para evitar churn en patrones interactivos,
/// corto enough para que credenciales filtradas expiren rápidamente.
///
/// Reservado para el flujo de capability granting (no cableado todavía).
#[allow(dead_code)]
pub const DEFAULT_GRANT_TTL: std::time::Duration = std::time::Duration::from_secs(60);
/// Quota máxima de tokens activos por (holder, cap). Caps escaladas tienen
/// quota baja para limitar fugas de credenciales; caps de uso frecuente
/// (Endpoint, Journal) son más laxas.
pub fn quota_for_capability(cap: &Capability) -> u32 {
match cap {
// Caps escaladas: pocos tokens, fuerza patrón request-act-release.
Capability::Spawn => 2,
Capability::FilesystemRoot => 2,
Capability::Device { .. } => 4,
// Caps de propósito general.
Capability::Endpoint { .. } => 16,
Capability::KernelNetlink(_) => 4,
Capability::LegacyLogind => 8,
// Logging: hasta 32 streams.
Capability::Journal => 32,
}
}
/// TTL específico por variante de Capability. Caps de mayor riesgo / costo
/// (Spawn, FilesystemRoot) tienen TTL más corto; caps "logging" como
/// Journal pueden vivir más.
///
/// Cualquier cap no listada cae al `DEFAULT_GRANT_TTL`.
pub fn ttl_for_capability(cap: &Capability) -> std::time::Duration {
use std::time::Duration;
match cap {
// Caps escaladas: TTL corto para forzar renovación frecuente.
Capability::Spawn => Duration::from_secs(30),
Capability::FilesystemRoot => Duration::from_secs(30),
Capability::Device { .. } => Duration::from_secs(60),
// Caps de propósito general.
Capability::Endpoint { .. } => Duration::from_secs(300), // 5 min
Capability::KernelNetlink(_) => Duration::from_secs(300),
Capability::LegacyLogind => Duration::from_secs(300),
// Logging puede vivir mucho.
Capability::Journal => Duration::from_secs(3600), // 1h
}
}
impl EnteGraph {
pub fn new(mut seed: EntityCard) -> Self {
// Extraemos genesis antes de almacenar la Semilla — evita duplicación
// en `incarnated[seed.id]`.
let pending_genesis = std::mem::take(&mut seed.genesis);
let mut g = Self {
seed: seed.clone(),
incarnated: HashMap::new(),
by_pid: HashMap::new(),
providers: BTreeMap::new(),
next_token: 1,
grants: HashMap::new(),
devices: HashMap::new(),
pending_genesis,
children: HashMap::new(),
bus_connections: HashMap::new(),
pending_invokes: HashMap::new(),
next_invoke_seq: 0,
};
// El Ente #0 se inscribe a sí mismo como proveedor de las capacidades
// que su Card declara — sólo así los hijos pueden requerirlas.
g.register_provider(&seed);
g.incarnated.insert(seed.id, Incarnated {
card: seed, pid: None,
dynamic_provides: BTreeSet::new(),
});
g
}
pub fn lookup_pid(&self, pid: Pid) -> Option<Ulid> {
self.by_pid.get(&pid.as_raw()).copied()
}
/// Acceso read-only a la Card de un Ente vivo. Usado por el cerebro
/// para hidratar `SubjectInfo` sin clonar todo el mapa.
pub fn peek_card(&self, id: &Ulid) -> Option<&EntityCard> {
self.incarnated.get(id).map(|i| &i.card)
}
/// Identidad de la Semilla. Usado como `requester` para spawns generados
/// por reglas auto-cristalizadas (única identidad con Capability::Spawn).
pub fn seed_id(&self) -> Ulid {
self.seed.id
}
/// Captura el estado live como snapshot serializable. Excluye la Semilla
/// (será re-sintetizada al restore con su seed_id preservado).
pub fn snapshot(&self) -> ente_snapshot::FractalSnapshot {
let entes: Vec<EntityCard> = self.incarnated.iter()
.filter(|(id, _)| **id != self.seed.id)
.map(|(_, inc)| inc.card.clone())
.collect();
ente_snapshot::FractalSnapshot {
version: ente_snapshot::SNAPSHOT_VERSION,
timestamp_ms: ente_snapshot::now_ms(),
seed_id: self.seed.id,
seed_label: self.seed.label.clone(),
entes,
}
}
pub(in crate::graph) fn register_provider(&mut self, card: &EntityCard) {
for cap in &card.provides {
self.providers.entry(cap.clone()).or_default().insert(card.id);
}
}
pub(in crate::graph) fn unregister_provider(&mut self, card: &EntityCard) {
for cap in &card.provides {
if let Some(set) = self.providers.get_mut(cap) {
set.remove(&card.id);
}
}
// Revocar grants emitidos por el Ente fallecido.
let revoked: Vec<u64> = self.grants.iter()
.filter(|(_, g)| g.provider == card.id)
.map(|(t, _)| *t)
.collect();
for t in revoked {
self.grants.remove(&t);
}
}
/// Quita una capacidad dinámica del índice de providers para un Ente
/// específico. Usado al recibir UpdateCapabilities con `removes`.
pub(in crate::graph) fn unregister_dynamic_cap(&mut self, ente_id: Ulid, cap: &Capability) {
if let Some(set) = self.providers.get_mut(cap) {
set.remove(&ente_id);
}
}
/// Inserta una capacidad dinámica al índice de providers para un Ente.
pub(in crate::graph) fn register_dynamic_cap(&mut self, ente_id: Ulid, cap: Capability) {
self.providers.entry(cap).or_default().insert(ente_id);
}
/// El Ente #0 (semilla) tiene todas sus capacidades declaradas. Otros
/// las tienen si su Card las declara o si poseen un grant vivo.
pub(in crate::graph) fn holder_has(&self, holder: Ulid, cap: &Capability) -> bool {
if holder == self.seed.id {
return self.seed.provides.contains(cap);
}
if let Some(inc) = self.incarnated.get(&holder) {
if inc.card.provides.contains(cap) { return true; }
}
self.grants.values().any(|g| g.holder == holder && &g.cap == cap)
}
}
+100
View File
@@ -0,0 +1,100 @@
//! Cascade shutdown: SIGTERM en orden topológico (hojas primero), grace
//! period, SIGKILL para stragglers, reap final.
use super::EnteGraph;
use nix::errno::Errno;
use nix::sys::signal::{kill, Signal};
use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
use nix::unistd::Pid;
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
/// Tiempo que damos a los Entes tras SIGTERM antes de escalar a SIGKILL.
pub const SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
impl EnteGraph {
pub async fn cascade_shutdown(&mut self) {
let order = self.topo_order();
let pids: Vec<Pid> = order.iter()
.filter_map(|id| self.incarnated.get(id).and_then(|i| i.pid))
.collect();
if pids.is_empty() {
info!("cascade shutdown: ningún Ente encarnado, salida limpia");
return;
}
info!(
count = pids.len(), grace_ms = SHUTDOWN_GRACE.as_millis() as u64,
"SIGTERM cascade (topológico, hojas primero)"
);
for pid in &pids {
match kill(*pid, Signal::SIGTERM) {
Ok(()) => {}
Err(Errno::ESRCH) => {} // ya muerto, lo cosecharemos abajo
Err(e) => warn!(?pid, ?e, "kill SIGTERM falló"),
}
}
let deadline = Instant::now() + SHUTDOWN_GRACE;
while Instant::now() < deadline {
if !self.incarnated.values().any(|i| i.pid.is_some()) {
break;
}
match waitpid(None, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::Exited(pid, code)) => {
self.reap_during_shutdown(pid);
debug!(?pid, code, "reaped (exited)");
}
Ok(WaitStatus::Signaled(pid, sig, _)) => {
self.reap_during_shutdown(pid);
debug!(?pid, ?sig, "reaped (signaled)");
}
Ok(WaitStatus::StillAlive) | Err(Errno::EINTR) => {
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(Errno::ECHILD) => return,
Ok(_) => {}
Err(e) => {
warn!(?e, "waitpid fallo en shutdown grace");
break;
}
}
}
let stragglers: Vec<Pid> = self.incarnated.values()
.filter_map(|i| i.pid)
.collect();
if stragglers.is_empty() {
info!("cascade shutdown completo (todos los Entes terminaron en gracia)");
return;
}
warn!(count = stragglers.len(), "stragglers post-SIGTERM, escalando a SIGKILL");
for pid in &stragglers {
let _ = kill(*pid, Signal::SIGKILL);
}
loop {
match waitpid(None, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::Exited(pid, _)) | Ok(WaitStatus::Signaled(pid, _, _)) => {
self.reap_during_shutdown(pid);
}
Ok(WaitStatus::StillAlive) => {
std::thread::sleep(Duration::from_millis(20));
}
Err(Errno::ECHILD) => break,
_ => break,
}
if !self.incarnated.values().any(|i| i.pid.is_some()) { break; }
}
info!("cascade shutdown completo (con SIGKILL)");
}
fn reap_during_shutdown(&mut self, pid: Pid) {
let Some(id) = self.by_pid.remove(&pid.as_raw()) else { return };
if let Some(inc) = self.incarnated.remove(&id) {
self.unregister_provider(&inc.card);
}
}
}
@@ -0,0 +1,35 @@
//! Topología del fractal: índice de hijos por lineage y orden topológico
//! para shutdown.
use super::EnteGraph;
use std::collections::BTreeSet;
use ulid::Ulid;
impl EnteGraph {
/// DFS post-order desde la Semilla. Hojas primero, raíz al final.
/// Garantiza que SIGTERM va a un padre sólo cuando sus hijos ya recibieron
/// la señal (evita orfandad transitoria que confunda Restart supervisors).
pub(in crate::graph) fn topo_order(&self) -> Vec<Ulid> {
let mut visited = BTreeSet::new();
let mut order = Vec::new();
self.dfs_post(self.seed.id, &mut visited, &mut order);
// Entes encarnados sin lineage hacia el seed (no debería pasar pero
// protege contra grafos huérfanos): añadirlos al final.
for id in self.incarnated.keys() {
if !visited.contains(id) {
self.dfs_post(*id, &mut visited, &mut order);
}
}
order
}
fn dfs_post(&self, node: Ulid, visited: &mut BTreeSet<Ulid>, order: &mut Vec<Ulid>) {
if !visited.insert(node) { return; }
if let Some(children) = self.children.get(&node) {
for c in children.clone() {
self.dfs_post(c, visited, order);
}
}
order.push(node);
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Persistencia de la keypair Ed25519 de identidad libp2p de Arje.
//!
//! El `peer_id` que Arje presenta en la malla `brahman-net` deriva de
//! esta keypair. Si se regenera en cada arranque, el peer_id cambia
//! y los nodos remotos pierden la referencia. Persistir el secret a
//! disco (32 bytes raw, permisos 0o600) garantiza identidad estable.
//!
//! ## Path
//!
//! Por orden de precedencia:
//! 1. `BRAHMAN_KEYPAIR_PATH` env var (override explícito).
//! 2. Si PID 1 / root: `/var/lib/brahman/init-keypair.bin`.
//! 3. Si dev mode: `$XDG_DATA_HOME/brahman/init-keypair.bin`, fallback
//! a `$HOME/.local/share/brahman/init-keypair.bin`, último recurso
//! `/tmp/brahman-init-keypair.bin` (sin persistencia útil pero al
//! menos no rompe en CI minimalista).
//!
//! ## Formato
//!
//! 32 bytes raw del secret Ed25519. Sin header, sin metadata. La
//! public key se deriva determinísticamente al cargar. Esto evita
//! depender de un schema de serialización (postcard, json) que
//! pudiera bumpear y romper compat de identidad.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use brahman_net::Keypair;
/// Tamaño exacto del secret Ed25519.
const SECRET_LEN: usize = 32;
/// Carga la keypair desde `path` si existe, o genera una nueva,
/// la persiste y la devuelve. Devuelve también si fue cargada (true)
/// o generada (false), para logging.
pub fn load_or_generate(path: &Path) -> Result<(Keypair, bool)> {
if path.exists() {
let bytes = std::fs::read(path)
.with_context(|| format!("leer keypair de {}", path.display()))?;
if bytes.len() != SECRET_LEN {
bail!(
"keypair en {} tiene {} bytes, esperaba {}",
path.display(),
bytes.len(),
SECRET_LEN
);
}
let mut secret = [0u8; SECRET_LEN];
secret.copy_from_slice(&bytes);
let kp = Keypair::ed25519_from_bytes(secret)
.with_context(|| format!("decodificar keypair en {}", path.display()))?;
Ok((kp, true))
} else {
let kp = Keypair::generate_ed25519();
save(path, &kp).context("persistir keypair recién generada")?;
Ok((kp, false))
}
}
/// Persiste el secret de `keypair` a `path`. Crea directorios padres,
/// escribe atómico (vía rename), y aplica permisos 0o600 (sólo dueño).
fn save(path: &Path, keypair: &Keypair) -> Result<()> {
let secret = extract_secret_bytes(keypair)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("crear dir {}", parent.display()))?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, secret).with_context(|| format!("write tmp {}", tmp.display()))?;
apply_owner_only_perms(&tmp).context("permisos 0o600 en tmp")?;
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {}{}", tmp.display(), path.display()))?;
Ok(())
}
fn extract_secret_bytes(keypair: &Keypair) -> Result<[u8; SECRET_LEN]> {
// libp2p::Keypair no expone secret() directo; pasamos
// por la variante ed25519. Solo Ed25519 soportado en brahman-net,
// así que el unwrap es seguro tras with_keypair.
let ed = keypair
.clone()
.try_into_ed25519()
.map_err(|_| anyhow::anyhow!("la keypair no es Ed25519 (no debería pasar)"))?;
let bytes = ed.secret();
let raw: &[u8] = bytes.as_ref();
if raw.len() != SECRET_LEN {
bail!("ed25519 secret no es {} bytes", SECRET_LEN);
}
let mut out = [0u8; SECRET_LEN];
out.copy_from_slice(raw);
Ok(out)
}
#[cfg(unix)]
fn apply_owner_only_perms(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
fn apply_owner_only_perms(_path: &Path) -> std::io::Result<()> {
Ok(())
}
/// Resuelve el path del keystore según convención (env > root path >
/// XDG > HOME > tmp).
pub fn default_path(dev_mode: bool) -> PathBuf {
if let Ok(p) = std::env::var("BRAHMAN_KEYPAIR_PATH") {
return PathBuf::from(p);
}
if !dev_mode {
// PID 1: paths del sistema. /var/lib es el lugar canónico
// para state persistente de servicios root.
return PathBuf::from("/var/lib/brahman/init-keypair.bin");
}
// Dev mode: paths de usuario.
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
return PathBuf::from(xdg).join("brahman").join("init-keypair.bin");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home)
.join(".local")
.join("share")
.join("brahman")
.join("init-keypair.bin");
}
PathBuf::from("/tmp/brahman-init-keypair.bin")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn generate_persist_and_reload_yields_same_peer_id() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("identity.bin");
let (kp1, loaded) = load_or_generate(&path).unwrap();
assert!(!loaded, "primera vez debe generar");
let peer1 = kp1.public().to_peer_id();
let (kp2, loaded) = load_or_generate(&path).unwrap();
assert!(loaded, "segunda vez debe cargar");
let peer2 = kp2.public().to_peer_id();
assert_eq!(peer1, peer2, "peer_id estable across reloads");
}
#[test]
fn rejects_corrupted_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("bad.bin");
std::fs::write(&path, b"too short").unwrap();
assert!(load_or_generate(&path).is_err());
}
#[test]
#[cfg(unix)]
fn persisted_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("perm.bin");
let _ = load_or_generate(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"permisos del keypair file deben ser 0o600 (solo dueño), got {:o}",
mode & 0o777
);
}
#[test]
fn default_path_honors_env() {
std::env::set_var("BRAHMAN_KEYPAIR_PATH", "/custom/path.bin");
assert_eq!(default_path(false), PathBuf::from("/custom/path.bin"));
assert_eq!(default_path(true), PathBuf::from("/custom/path.bin"));
std::env::remove_var("BRAHMAN_KEYPAIR_PATH");
}
}
+791
View File
@@ -0,0 +1,791 @@
//! Ente #0 — el primer Ente. PID 1 del fractal.
//!
//! Reglas no negociables:
//! 1. NUNCA lógica de servicio aquí. Sólo: leer Semilla, cosechar zombis,
//! mediar capacidades, propagar eventos.
//! 2. Single-threaded. Cualquier paralelismo se delega a Entes worker.
//! Un panic en un thread de PID 1 = kernel panic.
//! 3. Errores de hijos son *eventos* en `graph_tx`, no `Result` propagado.
//!
//! Este archivo es sólo wireup. La lógica vive en:
//! - `seed` : construcción/restauración de la Tarjeta Semilla
//! - `bus` : listener Unix + auth via SO_PEERCRED
//! - `graph::*` : estado del fractal (lifecycle, topology, shutdown,
//! bus_mediator, devices, capabilities)
//! - `events` : tipos de eventos del bucle primordial
//! - crates externos del workspace para CAS, soma, wasm, snapshot, kernel.
mod brain_glue;
mod bus;
mod events;
mod graph;
mod keypair_store;
mod seed;
use anyhow::Context;
use ente_brain::{BrainState, IntrospectServer};
use ente_kernel::{become_child_subreaper, bootstrap_kernel_surface, spawn_sigchld_stream, spawn_uevent_stream};
use events::{ExitStatus, GraphEvent, ShutdownReason};
use graph::EnteGraph;
use nix::errno::Errno;
use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
use nix::unistd::{getpid, Pid};
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{error, info, warn};
struct CliArgs {
checkpoint: Option<PathBuf>,
restore: Option<PathBuf>,
rules: Option<PathBuf>,
rules_out: Option<PathBuf>,
audit_head: Option<PathBuf>,
metrics_addr: Option<String>,
brain_half_life: Option<f64>,
autopromote_secs: Option<u64>,
}
fn parse_args() -> CliArgs {
let mut args = std::env::args().skip(1);
let mut checkpoint = None;
let mut restore = None;
let mut rules = None;
let mut rules_out = None;
let mut audit_head = None;
let mut metrics_addr = None;
let mut brain_half_life = None;
let mut autopromote_secs = None;
while let Some(a) = args.next() {
match a.as_str() {
"--checkpoint" => checkpoint = args.next().map(PathBuf::from),
"--restore" => restore = args.next().map(PathBuf::from),
"--rules" => rules = args.next().map(PathBuf::from),
"--rules-out" => rules_out = args.next().map(PathBuf::from),
"--audit-head" => audit_head = args.next().map(PathBuf::from),
"--metrics-addr" => metrics_addr = args.next(),
"--brain-half-life" => brain_half_life = args.next().and_then(|s| s.parse().ok()),
"--autopromote-secs" => autopromote_secs = args.next().and_then(|s| s.parse().ok()),
other => warn!(arg = %other, "argumento desconocido, ignorado"),
}
}
CliArgs {
checkpoint, restore, rules, rules_out, audit_head,
metrics_addr, brain_half_life, autopromote_secs,
}
}
fn main() -> anyhow::Result<()> {
init_tracing();
let cli = parse_args();
let pid = getpid();
let dev_mode = pid != Pid::from_raw(1);
if dev_mode {
warn!(?pid, "ente-zero corriendo en DEV MODE (no PID 1) — kernel surface no se monta");
} else {
info!("ente-zero despierta como PID 1");
bootstrap_kernel_surface().context("bootstrap kernel surface")?;
become_child_subreaper().context("PR_SET_CHILD_SUBREAPER")?;
}
let card = seed::load(dev_mode, cli.restore.as_deref())?;
// current_thread runtime: ver doctrina al inicio del módulo.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()?;
rt.block_on(primordial_loop(
card, dev_mode,
cli.checkpoint, cli.restore, cli.rules, cli.rules_out,
cli.audit_head, cli.metrics_addr, cli.brain_half_life,
cli.autopromote_secs,
))
}
async fn primordial_loop(
seed_card: ente_card::EntityCard,
dev_mode: bool,
checkpoint_path: Option<PathBuf>,
restore_path: Option<PathBuf>,
rules_path: Option<PathBuf>,
rules_out: Option<PathBuf>,
audit_head: Option<PathBuf>,
metrics_addr: Option<String>,
brain_half_life: Option<f64>,
autopromote_secs: Option<u64>,
) -> anyhow::Result<()> {
info!(seed_id = %seed_card.id, label = %seed_card.label, "Ente #0 entra al bucle primordial");
let (graph_tx, mut graph_rx) = mpsc::channel::<GraphEvent>(64);
let mut sigchld = spawn_sigchld_stream()?;
// Uevents puede fallar en dev (sin CAP_NET_ADMIN). Degradamos a un
// canal nunca-listo en lugar de abortar el bucle primordial.
let mut uevents = match spawn_uevent_stream() {
Ok(rx) => rx,
Err(e) => {
warn!(?e, "uevents deshabilitados (probablemente falta CAP_NET_ADMIN)");
let (_keep_tx, rx) = mpsc::channel::<ente_kernel::UEvent>(1);
std::mem::forget(_keep_tx);
rx
}
};
// Bus interno: listener antes de spawn de hijos para que su Announce
// tenga adónde llegar. Su path se inyecta en ENTE_BUS_SOCK por soma.
let bus_sock = bus::default_socket_path();
let bus_path = bus::spawn_bus(bus_sock, graph_tx.clone())?;
ente_soma::set_bus_sock(bus_path.to_string_lossy().into_owned());
// Brahman protocol: handshake socket + broker compartido.
//
// Es un canal paralelo al ente-bus, dedicado a módulos "brahman
// conscientes" que se presentan con una Card y declaran flujos
// tipados. Si el bind falla (socket en uso, FS no escribible),
// degradamos a "modo bus-only" — la doctrina de PID 1 no rompe
// por subsistemas opcionales.
// Contexto operativo del broker: configurable por env var. Útil para
// distinguir test/prod/foreground sin recompilar. Sin la var, los
// biases per-contexto declarados en las Cards quedan inactivos.
let broker_context = std::env::var("BRAHMAN_BROKER_CONTEXT").ok();
if let Some(ctx) = &broker_context {
info!(context = %ctx, "brahman broker bajo contexto operativo");
}
let brahman_broker = std::sync::Arc::new(tokio::sync::Mutex::new(
brahman_broker::Broker::new(brahman_broker::BrokerConfig {
strategy: brahman_broker::MatchStrategy::default(),
current_context: broker_context.clone(),
}),
));
// Brahman-net opcional: si BRAHMAN_LISTEN_MULTIADDR está set,
// levantamos la malla P2P y la pasamos como ServerConfig.net (Fase
// 2 wire) para que cada Card con outputs se anuncie al DHT y
// pueda ser descubierta por nodos remotos. Identidad libp2p
// persistida en disco vía keypair_store (peer_id estable across
// reboots).
let brahman_net = setup_brahman_net(dev_mode).await;
// Política opcional de peers libp2p: allowlist + denylist + hot
// reload. Activada si BRAHMAN_PEER_ALLOWLIST o BRAHMAN_PEER_DENYLIST
// están set. Sin ninguna, modo totalmente abierto (Fase 3 sin
// restricción adicional). El watcher se queda vivo en background
// observando los archivos para hot reload.
let (brahman_policy, _policy_watcher) = setup_brahman_policy();
// Si tenemos AMBOS net y policy, attachamos: el deny de la
// policy se proyecta al block_list del swarm para rechazar
// conexiones ANTES del Noise handshake (más eficiente que
// rechazar en el handshake brahman). Cada hot-reload de la
// policy también re-sincroniza vía diff.
if let (Some(net), Some(policy)) = (&brahman_net, &brahman_policy) {
policy.attach_to_net(net.clone());
let (allow, deny) = policy.sizes();
info!(
allow = ?allow,
deny = deny,
"policy attached al swarm — denies enforcedeados a nivel libp2p"
);
}
let brahman_sock = brahman_handshake::transport::default_socket_path();
match brahman_handshake::server::Server::bind(
&brahman_sock,
brahman_handshake::server::ServerConfig {
init_attached: true,
broker: Some(brahman_broker.clone()),
net: brahman_net.clone(),
policy: brahman_policy.clone(),
},
) {
Ok(server) => {
info!(socket = %brahman_sock.display(), "brahman handshake escuchando (Unix)");
// Si hay malla P2P, además del Unix accept loop levantamos
// el accept loop libp2p sobre el mismo Server compartido.
// Las sesiones locales y remotas conviven en las mismas
// tablas (sessions, push_table, broker).
let server = std::sync::Arc::new(server);
if let Some(net) = brahman_net.clone() {
let s_libp2p = server.clone();
let n_libp2p = net.clone();
tokio::spawn(async move {
if let Err(e) = brahman_handshake::network::run_libp2p_accept_loop(
s_libp2p, n_libp2p,
)
.await
{
warn!(?e, "brahman handshake libp2p accept loop cayó");
}
});
info!(
"brahman handshake escuchando también vía libp2p (peer_id {})",
net.peer_id
);
}
// Unix accept loop: usa Arc<Server> en lugar del consume
// de run() para coexistir con el libp2p accept loop.
let s_unix = server.clone();
tokio::spawn(async move {
loop {
match s_unix.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
if let Err(e) = session.handle().await {
warn!(?e, "session Unix terminó con error");
}
});
}
Err(e) => {
warn!(?e, "brahman handshake accept_one Unix falló");
break;
}
}
}
});
}
Err(e) => {
warn!(?e, socket = %brahman_sock.display(), "brahman handshake deshabilitado");
}
}
// Brahman admin: socket separado para snapshots de estado (sesiones +
// matches del broker). Misma política de degradación grácil.
let admin_sock = brahman_admin::transport::default_socket_path();
match brahman_admin::server::AdminServer::bind(
&admin_sock,
brahman_broker.clone(),
brahman_admin::server::AdminConfig {
init_attached: true,
current_context: broker_context.clone(),
},
) {
Ok(admin) => {
info!(socket = %admin_sock.display(), "brahman admin escuchando");
tokio::spawn(async move {
if let Err(e) = admin.run().await {
warn!(?e, "brahman admin server cayó");
}
});
}
Err(e) => {
warn!(?e, socket = %admin_sock.display(), "brahman admin deshabilitado");
}
}
let mut graph = EnteGraph::new(seed_card);
graph.instantiate_seed_dependencies(&graph_tx).await?;
// Cerebro: BrainState compartido + servidor de introspección.
// Window de 1024 eventos — suficiente para correlaciones interesantes
// sin gastar memoria de PID 1. En dev bajamos el umbral de cristalización
// para que el demo (pocos eventos) produzca cristales observables.
let mut brain = if dev_mode {
// Umbrales relajados para que el demo (pocos eventos) produzca
// cristales observables. Con P(b|a) normalizada a [0,1], los
// valores típicos en muestras pequeñas son 0.2-0.5.
BrainState::with_params(1024, ente_brain::CrystallizationParams {
min_support: 2,
min_conditional_prob: 0.3,
min_pmi: 1.0,
})
} else {
BrainState::new(1024)
};
if let Some(out_path) = rules_out {
brain = brain.with_rules_out(out_path);
}
if let Some(hl) = brain_half_life {
let mut obs = brain.observer.write().await;
// Reemplazar con un observer nuevo que tenga half-life. Estado
// anterior (vacío en este punto) descartado.
*obs = ente_brain::Observer::new(1024).with_half_life(hl);
info!(hl_secs = hl, "observer con time-decay activo");
}
if let Some(secs) = autopromote_secs {
ente_brain::spawn_autopromote_loop(
brain.clone(),
ente_brain::AutopromoteParams {
interval_secs: secs,
threshold: brain.params, // mismo threshold que crystals manuales
},
);
}
// Brain restore: si hay --restore <path>, cargamos el snapshot adjunto
// <path>.brain.json. Counters preservados across reboots.
if let Some(rpath) = &restore_path {
let brain_path = rpath.with_extension("brain.json");
if brain_path.exists() {
match read_brain_snapshot(&brain_path) {
Ok(snap) => {
let total = snap.total;
let kinds = snap.marginal.len();
let restored = ente_brain::Observer::from_snapshot(snap);
*brain.observer.write().await = restored;
info!(
path = %brain_path.display(),
total, kinds,
"brain snapshot restaurado"
);
}
Err(e) => warn!(?e, path = %brain_path.display(), "brain snapshot read falló"),
}
}
}
// Si --audit-head, configuramos el head pointer y arrancamos auto-flush.
if let Some(head_path) = audit_head {
// Re-creamos el AuditLog con head pointer.
let new_audit = ente_brain::audit::AuditLog::new()
.with_head_pointer(head_path);
*brain.audit.write().await = new_audit;
spawn_audit_auto_flush(brain.clone());
}
// Carga inicial de reglas desde JSON/JSONL si --rules path proporcionado.
if let Some(path) = &rules_path {
match ente_brain::load_rules_file(path) {
Ok(rules) => {
let mut engine = brain.engine.write().await;
for r in rules {
engine.insert(r);
}
info!(count = engine.len(), path = %path.display(), "reglas cargadas");
}
Err(e) => warn!(?e, path = %path.display(), "carga de reglas falló"),
}
}
// Endpoint Prometheus opcional. En dev por defecto en 127.0.0.1:9911 si
// el flag no se pasó.
let metrics_addr = metrics_addr.or_else(|| {
if dev_mode { Some("127.0.0.1:9911".to_string()) } else { None }
});
if let Some(addr_s) = metrics_addr {
match addr_s.parse::<std::net::SocketAddr>() {
Ok(addr) => {
let s = brain.clone();
tokio::spawn(async move {
if let Err(e) = ente_brain::serve_metrics(s, addr).await {
warn!(?e, "metrics server cayó");
}
});
}
Err(e) => warn!(?e, addr = %addr_s, "metrics-addr inválido"),
}
}
spawn_brain_introspect(brain.clone());
let brain_sink = brain_glue::GraphSink {
graph_tx: graph_tx.clone(),
// Spawns auto-disparados desde reglas usan la identidad de la Semilla
// (único Ente con Capability::Spawn por construcción).
requester: graph.seed_id(),
};
// Demo automático del forwarding (sólo dev, sólo si el binario existe).
if dev_mode && std::path::Path::new("target/debug/ente-echo").exists() {
spawn_echo_smoke_test(bus_path.clone());
}
// En dev mode no tenemos hijos por defecto y el bucle se quedaría inerte.
let dev_exit = if dev_mode {
Some(tokio::time::sleep(Duration::from_secs(4)))
} else {
None
};
tokio::pin!(dev_exit);
// GC de capability grants expirados — corre cada 10 segundos.
let mut grant_purge = tokio::time::interval(Duration::from_secs(10));
grant_purge.tick().await; // descartar primer tick inmediato
loop {
tokio::select! {
biased;
Some(_) = sigchld.recv() => {
reap_until_empty(&mut graph, &graph_tx).await;
}
Some(uevt) = uevents.recv() => {
graph.on_uevent(uevt, &graph_tx).await;
}
Some(evt) = graph_rx.recv() => {
// Cerebro observa antes que el grafo mute. Snapshot del
// SubjectInfo se hace contra el estado pre-mutación.
feed_brain(&brain, &brain_sink, &graph, &evt).await;
if dispatch_graph_event(&mut graph, evt, &graph_tx, &checkpoint_path, &brain).await {
return Ok(());
}
}
_ = grant_purge.tick() => {
let n = graph.purge_expired_grants();
if n > 0 {
info!(purged = n, active = graph.active_grants_count(), "GC capability grants");
}
}
_ = async { dev_exit.as_mut().as_pin_mut().unwrap().await }, if dev_mode => {
info!("dev mode: timer expirado, cerrando bucle primordial");
let _ = graph_tx.send(GraphEvent::Shutdown {
reason: ShutdownReason::SeedRequested,
}).await;
}
}
}
}
/// Devuelve `true` si el bucle primordial debe terminar.
async fn dispatch_graph_event(
graph: &mut EnteGraph,
evt: GraphEvent,
tx: &mpsc::Sender<GraphEvent>,
checkpoint: &Option<PathBuf>,
brain: &BrainState,
) -> bool {
match evt {
GraphEvent::EnteDied { id, status } => {
graph.on_death(id, status, tx).await;
}
GraphEvent::CapabilityRequested { from, cap, reply } => {
graph.mediate_capability(from, cap, reply).await;
}
GraphEvent::SpawnRequest { card, requester } => {
if let Err(e) = graph.authorize_and_spawn(card, requester).await {
warn!(?e, "spawn request error");
}
}
GraphEvent::BusRequest { peer, from, request, outbound, reply } => {
graph.on_bus_request(peer, from, request, outbound, reply).await;
}
GraphEvent::BusResponse { seq, response } => {
graph.on_bus_response(seq, response).await;
}
GraphEvent::BusConnClosed { ente_id } => {
graph.on_bus_conn_closed(ente_id).await;
}
GraphEvent::Shutdown { reason } => {
warn!(?reason, "shutdown del fractal");
if let Some(path) = checkpoint.as_ref() {
// Snapshot del grafo
let snap = graph.snapshot();
match snap.write(path) {
Ok(()) => info!(path = %path.display(), entes = snap.entes.len(), "snapshot fractal persistido"),
Err(e) => warn!(?e, "snapshot write falló"),
}
// Snapshot del cerebro (observer state) en archivo adjunto
let brain_path = path.with_extension("brain.json");
let obs_snap = brain.observer.write().await.snapshot();
match write_brain_snapshot(&brain_path, &obs_snap) {
Ok(()) => info!(
path = %brain_path.display(),
total = obs_snap.total,
kinds = obs_snap.marginal.len(),
"snapshot brain persistido"
),
Err(e) => warn!(?e, "brain snapshot write falló"),
}
}
graph.cascade_shutdown().await;
return true;
}
}
false
}
async fn reap_until_empty(graph: &mut EnteGraph, tx: &mpsc::Sender<GraphEvent>) {
loop {
match waitpid(None, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::StillAlive) => return,
Ok(WaitStatus::Exited(pid, code)) => {
emit_death(graph, tx, pid, ExitStatus::Exit(code)).await;
}
Ok(WaitStatus::Signaled(pid, sig, _core)) => {
emit_death(graph, tx, pid, ExitStatus::Killed(sig)).await;
}
Ok(_) => continue, // Stopped/Continued — irrelevantes
Err(Errno::ECHILD) => return,
Err(e) => {
error!(?e, "waitpid fallo no recuperable en bucle de reaping");
return;
}
}
}
}
async fn emit_death(
graph: &EnteGraph,
tx: &mpsc::Sender<GraphEvent>,
pid: Pid,
status: ExitStatus,
) {
let id = match graph.lookup_pid(pid) {
Some(id) => id,
None => {
// Proceso adoptado (subreaper): no está en nuestro grafo.
info!(?pid, ?status, "huérfano cosechado (no en grafo)");
return;
}
};
let _ = tx.send(GraphEvent::EnteDied { id, status }).await;
}
fn spawn_echo_smoke_test(bus_path: PathBuf) {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
match ente_bus::BusClient::connect(&bus_path).await {
Ok(mut client) => {
let req = ente_bus::BusRequest::Invoke {
cap: ente_echo::echo_capability(),
blob: b"hola fractal forwardeado".to_vec(),
};
match client.call(req).await {
Ok(ente_bus::BusResponse::Invoked { result }) => {
info!(echo = %String::from_utf8_lossy(&result), "Invoke ECHO round-trip OK");
}
Ok(other) => warn!(?other, "Invoke ECHO respuesta inesperada"),
Err(e) => warn!(?e, "Invoke ECHO falló"),
}
}
Err(e) => warn!(?e, "no se pudo conectar al bus para test"),
}
});
}
fn write_brain_snapshot(path: &std::path::Path, snap: &ente_brain::observer::ObserverSnapshot) -> anyhow::Result<()> {
let bytes = serde_json::to_vec_pretty(snap)?;
if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
fn read_brain_snapshot(path: &std::path::Path) -> anyhow::Result<ente_brain::observer::ObserverSnapshot> {
let bytes = std::fs::read(path)?;
let snap: ente_brain::observer::ObserverSnapshot = serde_json::from_slice(&bytes)?;
Ok(snap)
}
fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("ente_zero=debug,info"));
fmt().with_env_filter(filter).with_target(true).init();
}
fn brain_introspect_path() -> PathBuf {
if let Ok(p) = std::env::var("ENTE_BRAIN_SOCK") {
return p.into();
}
let runtime = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into()));
format!("{runtime}/ente-brain.sock").into()
}
/// Auto-flush del audit log a CAS cada 10 segundos. Ejecuta best-effort:
/// si el flush falla lo logeamos pero no abortamos. La integridad del log
/// queda garantizada por su hash chain — re-flushar es idempotente.
fn spawn_audit_auto_flush(state: BrainState) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(10));
tick.tick().await; // descartar primer tick inmediato
loop {
tick.tick().await;
let mut audit = state.audit.write().await;
match audit.flush_to_cas() {
Ok(0) => {} // nada nuevo
Ok(n) => info!(written = n, total = audit.flushed_count(), "audit auto-flush"),
Err(e) => warn!(?e, "audit auto-flush falló"),
}
}
});
}
fn spawn_brain_introspect(state: BrainState) {
let path = brain_introspect_path();
tokio::spawn(async move {
let server = IntrospectServer::new(state);
if let Err(e) = server.serve(&path).await {
warn!(?e, "introspect server cayó");
}
});
}
/// Registra el evento en el observer y dispatcha cualquier regla matched.
/// Para reglas Sequence: pasamos los últimos N eventos del observer como
/// history al engine.
async fn feed_brain(
brain: &BrainState,
sink: &brain_glue::GraphSink,
graph: &EnteGraph,
evt: &GraphEvent,
) {
let Some((kind, subj)) = brain_glue::graph_event_to_brain(evt, graph) else { return };
let history: Vec<ente_brain::TimedEvent> = {
let mut obs = brain.observer.write().await;
obs.record(kind.clone());
// Snapshot de los últimos 16 eventos — suficiente para cualquier
// Sequence pattern razonable. Clone hace una sola alocación.
obs.recent(16).cloned().collect()
};
let rules = {
let engine = brain.engine.read().await;
engine.dispatch(&kind, &subj, &history)
};
if !rules.is_empty() {
ente_brain::dispatch_actions(&rules, sink).await;
}
}
/// Inicializa la malla `brahman-net` opcional. Activa sólo si
/// `BRAHMAN_LISTEN_MULTIADDR` está set. Identidad libp2p persistente
/// vía `keypair_store`. Bootstrap del DHT vía `BRAHMAN_BOOTSTRAP_PEERS`
/// (lista coma-separada de multiaddrs, opcional).
///
/// Toda fase de setup degrada grácilmente: si la keypair no carga,
/// si el listen falla, si bootstrap dial falla — loggea y devuelve
/// `None`. El Init sigue funcionando en modo Unix-only.
async fn setup_brahman_net(
dev_mode: bool,
) -> Option<std::sync::Arc<brahman_net::BrahmanNet>> {
let listen_addr = match std::env::var("BRAHMAN_LISTEN_MULTIADDR") {
Ok(s) if !s.is_empty() => s,
_ => {
tracing::debug!(
"brahman-net deshabilitado (sin BRAHMAN_LISTEN_MULTIADDR)"
);
return None;
}
};
let multiaddr: brahman_net::Multiaddr = match listen_addr.parse() {
Ok(m) => m,
Err(e) => {
warn!(addr = %listen_addr, ?e, "BRAHMAN_LISTEN_MULTIADDR inválido — net deshabilitado");
return None;
}
};
let keypair_path = keypair_store::default_path(dev_mode);
let (keypair, loaded) = match keypair_store::load_or_generate(&keypair_path) {
Ok(kp) => kp,
Err(e) => {
warn!(path = %keypair_path.display(), ?e, "no pude cargar/generar keypair libp2p — net deshabilitado");
return None;
}
};
info!(
path = %keypair_path.display(),
peer_id = %keypair.public().to_peer_id(),
loaded = loaded,
"identidad libp2p {}",
if loaded { "cargada" } else { "generada y persistida" }
);
let net = match brahman_net::BrahmanNet::with_keypair(keypair) {
Ok(n) => std::sync::Arc::new(n),
Err(e) => {
warn!(?e, "BrahmanNet::with_keypair falló — net deshabilitado");
return None;
}
};
let actual = net.listen(multiaddr).await;
info!(addr = %actual, peer_id = %net.peer_id, "brahman-net escuchando");
// Bootstrap opcional: dial-ar a peers conocidos para entrar al
// DHT. Sin bootstrap, el nodo arranca aislado hasta que alguien
// se conecte a él.
if let Ok(bootstrap) = std::env::var("BRAHMAN_BOOTSTRAP_PEERS") {
let mut dialed = 0usize;
for entry in bootstrap.split(',').filter(|s| !s.is_empty()) {
match entry.parse::<brahman_net::Multiaddr>() {
Ok(addr) => {
net.dial(addr.clone());
dialed += 1;
tracing::debug!(peer = %addr, "dial bootstrap");
}
Err(e) => {
warn!(entry = %entry, ?e, "bootstrap multiaddr inválido — saltado");
}
}
}
if dialed > 0 {
info!(count = dialed, "bootstrap peers dial-eados");
}
}
Some(net)
}
/// Carga la política de peers libp2p desde los archivos apuntados por
/// `BRAHMAN_PEER_ALLOWLIST` y/o `BRAHMAN_PEER_DENYLIST`, y arranca un
/// watcher para hot reload sobre cualquier cambio.
///
/// - Sin ninguna env: `(None, None)` → modo totalmente abierto.
/// - Con cualquiera (o ambas) set: política activa + watcher vivo.
/// - Si los archivos fallan al cargar: degrada a `(None, None)`,
/// loggea, NO rompe el bucle primordial (doctrina PID 1).
///
/// Devuelve la política y el `JoinHandle` del watcher (que el caller
/// debe mantener para que el thread no se aborte). Si no hay paths,
/// el watcher es un no-op que termina inmediato.
fn setup_brahman_policy() -> (
Option<brahman_handshake::peer_policy::PeerPolicy>,
Option<std::thread::JoinHandle<()>>,
) {
let allow_path = std::env::var("BRAHMAN_PEER_ALLOWLIST")
.ok()
.filter(|s| !s.is_empty());
let deny_path = std::env::var("BRAHMAN_PEER_DENYLIST")
.ok()
.filter(|s| !s.is_empty());
if allow_path.is_none() && deny_path.is_none() {
tracing::debug!(
"BRAHMAN_PEER_ALLOWLIST y BRAHMAN_PEER_DENYLIST no set — modo abierto (todo peer pasa)"
);
return (None, None);
}
let allow_pb = allow_path.as_deref().map(std::path::Path::new);
let deny_pb = deny_path.as_deref().map(std::path::Path::new);
let policy = match brahman_handshake::peer_policy::PeerPolicy::from_files(allow_pb, deny_pb) {
Ok(p) => p,
Err(e) => {
warn!(
?e,
allow = ?allow_path,
deny = ?deny_path,
"policy de peers inválida — degradando a modo abierto (sin restricción)"
);
return (None, None);
}
};
let (allow_count, deny_count) = policy.sizes();
info!(
allow = ?allow_count,
deny = deny_count,
allow_path = ?allow_path,
deny_path = ?deny_path,
"policy de peers libp2p cargada"
);
// Spawn watcher para hot reload. Errores aquí no son fatales —
// tendrías política sin reload, que es razonable.
let watcher = match policy.spawn_watcher() {
Ok(h) => Some(h),
Err(e) => {
warn!(?e, "policy watcher no se pudo crear — hot reload deshabilitado");
None
}
};
(Some(policy), watcher)
}
+249
View File
@@ -0,0 +1,249 @@
//! Construcción de la Tarjeta Semilla.
//!
//! Tres caminos:
//! 1. `--restore <path>`: leer `FractalSnapshot` y reconstruir Semilla
//! con seed_id preservado + entes anteriores como genesis.
//! 2. `seed.card.json` en disco: deserialize directo (prod o dev).
//! 3. Fallback dev: sintetizar Semilla + 6 genesis Entes que ejercitan
//! todas las capacidades del fractal.
use anyhow::Context;
use ente_card::{
Capability, CardError, CgroupSpec, EntityCard, NamespaceSet, Payload,
ResourceLimits, SomaSpec, Supervision, CARD_SCHEMA_VERSION,
};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::{info, warn};
use ulid::Ulid;
const SEED_PATH_PROD: &str = "/ente/seed.card";
const SEED_PATH_DEV: &str = "seed.card";
pub fn load(dev_mode: bool, restore: Option<&Path>) -> anyhow::Result<EntityCard> {
let card = if let Some(path) = restore {
load_from_snapshot(path)?
} else {
load_or_synthesize(dev_mode)?
};
card.validate()
.map_err(|e: CardError| anyhow::anyhow!("semilla inválida: {e}"))?;
Ok(card)
}
fn load_from_snapshot(path: &Path) -> anyhow::Result<EntityCard> {
let snap = ente_snapshot::FractalSnapshot::read(path)
.with_context(|| format!("read snapshot {}", path.display()))?;
info!(
path = %path.display(),
seed_id = %snap.seed_id,
entes = snap.entes.len(),
timestamp_ms = snap.timestamp_ms,
"snapshot cargado, restaurando fractal"
);
// Reconstruimos la Semilla con su Ulid original. Las Cards persistidas
// van a `genesis` con sus Ulids preservados — son las mismas identidades
// que vivieron antes del checkpoint.
let mut provides = BTreeSet::new();
provides.insert(Capability::Spawn);
provides.insert(Capability::Journal);
Ok(EntityCard {
schema_version: CARD_SCHEMA_VERSION,
id: snap.seed_id,
lineage: None,
label: snap.seed_label,
provides,
requires: BTreeSet::new(),
soma: SomaSpec::default(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
genesis: snap.entes,
..Default::default()
})
}
fn load_or_synthesize(dev_mode: bool) -> anyhow::Result<EntityCard> {
// Buscamos primero `.json` (canónico), luego sin extensión por
// compatibilidad con instalaciones que dejan el archivo crudo. La puerta
// genética se cruza vía `ente_brain::load_card_file` que pasa por
// `validate()` extendido.
let candidates: &[&str] = if dev_mode {
&["seed.card.json", SEED_PATH_DEV]
} else {
&["/ente/seed.card.json", SEED_PATH_PROD]
};
for cand in candidates {
let path = PathBuf::from(cand);
if !path.exists() { continue; }
let card = ente_brain::load_card_file(&path)
.with_context(|| format!("load {}", path.display()))?;
info!(path = %path.display(), "Tarjeta Semilla cargada y validada");
return Ok(card);
}
if dev_mode {
info!("sin seed.card — sintetizando semilla mínima (dev)");
return Ok(synthesize_dev_seed());
}
anyhow::bail!("seed.card no encontrada en /ente/seed.card.json ni /ente/seed.card")
}
fn synthesize_dev_seed() -> EntityCard {
let mut provides = BTreeSet::new();
provides.insert(Capability::Spawn);
provides.insert(Capability::Journal);
// Pre-registramos el módulo Wasm demo en el CAS y obtenemos su SHA real.
// Si el CAS no es escribible (raro en dev) caemos a un SHA cero — la
// resolución fallará y el Wasm no encarnará, pero el resto queda intacto.
let demo_wasm_sha = match ente_wasm::demo_module_bytes()
.and_then(|b| ente_cas::store(&b))
{
Ok(sha) => sha,
Err(e) => {
warn!(?e, "CAS no disponible — demo-wasm no encarnará");
[0u8; 32]
}
};
let mut genesis = Vec::new();
genesis.push(make_card("demo-sleep", Payload::Native {
exec: "/bin/sleep".into(), argv: vec!["1".into()], envp: vec![],
}, Supervision::OneShot));
genesis.push(make_card("demo-persist", Payload::Native {
exec: "/bin/sleep".into(), argv: vec!["60".into()], envp: vec![],
}, restart_supervision()));
// Card namespaced: padre escribe uid_map, hijo cat /proc/self/uid_map.
let mut ns_card = make_card("demo-userns", Payload::Native {
exec: "/bin/cat".into(),
argv: vec!["/proc/self/uid_map".into()],
envp: vec![],
}, Supervision::OneShot);
ns_card.soma = SomaSpec {
namespaces: NamespaceSet { user: true, ..Default::default() },
..Default::default()
};
genesis.push(ns_card);
genesis.push(make_card("demo-wasm", Payload::Wasm {
module_sha256: demo_wasm_sha,
entry: "_start".into(),
}, Supervision::OneShot));
if let Some(card) = optional_native_card(
"demo-echo", "target/debug/ente-echo",
[ente_echo::echo_capability()].into_iter().collect(),
restart_supervision(),
) {
genesis.push(card);
}
if let Some(card) = optional_native_card(
"compat-logind", "target/debug/ente-logind-compat",
[Capability::LegacyLogind].into_iter().collect(),
restart_supervision(),
) {
genesis.push(card);
}
// Constelación de shims D-Bus que reemplazan systemd: cada uno provee
// un nombre `org.freedesktop.X1` que GNOME/KDE consultan al boot.
for (label, bin) in &[
("compat-hostnamed", "target/debug/ente-hostnamed-compat"),
("compat-timedated", "target/debug/ente-timedated-compat"),
("compat-localed", "target/debug/ente-localed-compat"),
("compat-journald", "target/debug/ente-journald-compat"),
("compat-resolved", "target/debug/ente-resolved-compat"),
("compat-polkit", "target/debug/ente-polkit-compat"),
("compat-machined", "target/debug/ente-machined-compat"),
("policy-provider", "target/debug/ente-policy-provider"),
("compat-systemd1", "target/debug/ente-systemd1-compat"),
("compat-notify", "target/debug/ente-notify-compat"),
("compat-timer", "target/debug/ente-timer-compat"),
] {
if let Some(card) = optional_native_card(
label, bin,
std::collections::BTreeSet::new(),
restart_supervision(),
) {
genesis.push(card);
}
}
EntityCard {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: "ente-zero-dev".into(),
provides,
requires: BTreeSet::new(),
soma: SomaSpec {
namespaces: NamespaceSet::default(),
rlimits: ResourceLimits::default(),
cgroup: CgroupSpec {
path: "ente.slice/zero".into(),
cpu_weight: None,
io_weight: None,
},
cpu_affinity: None,
},
payload: Payload::Virtual,
supervision: Supervision::OneShot,
genesis,
..Default::default()
}
}
fn make_card(label: &str, payload: Payload, supervision: Supervision) -> EntityCard {
EntityCard {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
soma: SomaSpec::default(),
payload,
supervision,
genesis: vec![],
..Default::default()
}
}
fn optional_native_card(
label: &str,
bin_path: &str,
provides: BTreeSet<Capability>,
supervision: Supervision,
) -> Option<EntityCard> {
let path = Path::new(bin_path);
if !path.exists() {
return None;
}
Some(EntityCard {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: label.into(),
provides,
requires: BTreeSet::new(),
soma: SomaSpec::default(),
payload: Payload::Native {
exec: path.to_string_lossy().into_owned(),
argv: vec![],
envp: vec![],
},
supervision,
genesis: vec![],
..Default::default()
})
}
fn restart_supervision() -> Supervision {
Supervision::Restart {
initial: Duration::from_millis(100),
max: Duration::from_secs(30),
}
}