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:
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Detección runtime de capacidades del kernel/proceso para aislamiento.
|
||||
//!
|
||||
//! Esto NO se cachea entre instancias — sysctls pueden cambiar entre boot, y
|
||||
//! cgroup delegation depende del proceso concreto. Cada `Incarnator::new`
|
||||
//! hace su detección al construirse.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapabilitySet {
|
||||
pub kernel_version: (u32, u32, u32),
|
||||
pub has_cap_sys_admin: bool,
|
||||
pub user_ns: UserNsStatus,
|
||||
pub cgroup_v2: CgroupStatus,
|
||||
pub cgroup_delegated: bool,
|
||||
pub max_user_namespaces: Option<u64>,
|
||||
pub our_cgroup: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UserNsStatus {
|
||||
Allowed,
|
||||
DisabledBySysctl,
|
||||
RestrictedByLsm,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl UserNsStatus {
|
||||
pub fn is_allowed(&self) -> bool {
|
||||
matches!(self, UserNsStatus::Allowed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CgroupStatus {
|
||||
Unified,
|
||||
Hybrid,
|
||||
Legacy,
|
||||
NotMounted,
|
||||
}
|
||||
|
||||
impl CapabilitySet {
|
||||
pub fn detect() -> Self {
|
||||
Self {
|
||||
kernel_version: detect_kernel_version().unwrap_or((0, 0, 0)),
|
||||
has_cap_sys_admin: detect_cap_sys_admin(),
|
||||
user_ns: detect_user_ns(),
|
||||
cgroup_v2: detect_cgroup_status(),
|
||||
cgroup_delegated: detect_cgroup_delegated(),
|
||||
max_user_namespaces: read_u64("/proc/sys/user/max_user_namespaces"),
|
||||
our_cgroup: detect_our_cgroup(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ¿Podemos crear el namespace `ns`?
|
||||
/// Reglas:
|
||||
/// - user → necesita user_ns Allowed (o ya tener CAP_SYS_ADMIN, en cuyo caso no se crea uno nuevo).
|
||||
/// - resto → CAP_SYS_ADMIN, o crearlos junto con user ns nuevo.
|
||||
pub fn can_create_ns(&self, kind: NsKind) -> bool {
|
||||
match kind {
|
||||
NsKind::User => self.user_ns.is_allowed() || self.has_cap_sys_admin,
|
||||
_ => {
|
||||
self.has_cap_sys_admin
|
||||
|| (self.user_ns.is_allowed() && self.max_user_namespaces.unwrap_or(0) > 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum NsKind {
|
||||
Mount,
|
||||
Pid,
|
||||
Net,
|
||||
Uts,
|
||||
Ipc,
|
||||
User,
|
||||
Cgroup,
|
||||
}
|
||||
|
||||
impl NsKind {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
NsKind::Mount => "mount",
|
||||
NsKind::Pid => "pid",
|
||||
NsKind::Net => "net",
|
||||
NsKind::Uts => "uts",
|
||||
NsKind::Ipc => "ipc",
|
||||
NsKind::User => "user",
|
||||
NsKind::Cgroup => "cgroup",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_kernel_version() -> Option<(u32, u32, u32)> {
|
||||
let s = std::fs::read_to_string("/proc/sys/kernel/osrelease").ok()?;
|
||||
let head = s.split(|c: char| !c.is_ascii_digit() && c != '.').next()?;
|
||||
let mut it = head.split('.');
|
||||
let a = it.next()?.parse().ok()?;
|
||||
let b = it.next()?.parse().ok()?;
|
||||
let c = it.next().and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||
Some((a, b, c))
|
||||
}
|
||||
|
||||
fn detect_cap_sys_admin() -> bool {
|
||||
// euid 0 implica caps por default. Modo simple: si euid==0, asumimos CAP_SYS_ADMIN.
|
||||
// Podríamos parsear /proc/self/status > CapEff, pero para nuestros usos el
|
||||
// discriminador útil es root vs no-root.
|
||||
nix::unistd::geteuid().is_root()
|
||||
}
|
||||
|
||||
fn detect_user_ns() -> UserNsStatus {
|
||||
// Sysctl tradicional Debian/Ubuntu pre-24.
|
||||
if let Some(v) = read_u64("/proc/sys/kernel/unprivileged_userns_clone") {
|
||||
if v == 0 {
|
||||
return UserNsStatus::DisabledBySysctl;
|
||||
}
|
||||
}
|
||||
// AppArmor restriction (Ubuntu 24+). 1 = restringido, 2 = restricción aplicada.
|
||||
if let Some(v) = read_u64("/proc/sys/kernel/apparmor_restrict_unprivileged_userns") {
|
||||
if v >= 1 {
|
||||
return UserNsStatus::RestrictedByLsm;
|
||||
}
|
||||
}
|
||||
if let Some(0) = read_u64("/proc/sys/user/max_user_namespaces") {
|
||||
return UserNsStatus::DisabledBySysctl;
|
||||
}
|
||||
UserNsStatus::Allowed
|
||||
}
|
||||
|
||||
fn detect_cgroup_status() -> CgroupStatus {
|
||||
// /sys/fs/cgroup montado como cgroup2 → unified.
|
||||
let mounts = match std::fs::read_to_string("/proc/self/mountinfo") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return CgroupStatus::NotMounted,
|
||||
};
|
||||
let mut has_v2 = false;
|
||||
let mut has_v1 = false;
|
||||
for line in mounts.lines() {
|
||||
// formato: ... - <fstype> <source> <opts>
|
||||
let parts: Vec<&str> = line.split(" - ").collect();
|
||||
if parts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let tail = parts[1];
|
||||
let fields: Vec<&str> = tail.split_whitespace().collect();
|
||||
if fields.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match fields[0] {
|
||||
"cgroup2" => has_v2 = true,
|
||||
"cgroup" => has_v1 = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match (has_v2, has_v1) {
|
||||
(true, false) => CgroupStatus::Unified,
|
||||
(true, true) => CgroupStatus::Hybrid,
|
||||
(false, true) => CgroupStatus::Legacy,
|
||||
(false, false) => CgroupStatus::NotMounted,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_our_cgroup() -> Option<PathBuf> {
|
||||
let s = std::fs::read_to_string("/proc/self/cgroup").ok()?;
|
||||
let rel = s.lines().find_map(|l| l.strip_prefix("0::"))?.trim();
|
||||
let abs = if rel == "/" {
|
||||
PathBuf::from("/sys/fs/cgroup")
|
||||
} else {
|
||||
PathBuf::from(format!("/sys/fs/cgroup{rel}"))
|
||||
};
|
||||
Some(abs)
|
||||
}
|
||||
|
||||
fn detect_cgroup_delegated() -> bool {
|
||||
// Heurística: ¿podemos escribir cgroup.subtree_control en nuestro cgroup
|
||||
// o crear subdirectorios? En cgroup v2 con Delegate=yes, el dueño es el uid
|
||||
// del usuario y `access(W_OK)` sobre el directorio devuelve OK.
|
||||
let Some(p) = detect_our_cgroup() else { return false };
|
||||
use nix::unistd::{access, AccessFlags};
|
||||
access(&p, AccessFlags::W_OK).is_ok()
|
||||
}
|
||||
|
||||
fn read_u64(path: &str) -> Option<u64> {
|
||||
let s = std::fs::read_to_string(Path::new(path)).ok()?;
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detect_does_not_panic() {
|
||||
let _ = CapabilitySet::detect();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ns_kind_names_unique() {
|
||||
let names = [
|
||||
NsKind::Mount.name(),
|
||||
NsKind::Pid.name(),
|
||||
NsKind::Net.name(),
|
||||
NsKind::Uts.name(),
|
||||
NsKind::Ipc.name(),
|
||||
NsKind::User.name(),
|
||||
NsKind::Cgroup.name(),
|
||||
];
|
||||
let mut sorted = names.to_vec();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), names.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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");
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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};
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user