Fase 5 — bus de agente (/run/agent.sock) + FIFO de control humano

- proto: tipos Command/Event con serde tag "t", Hello/Welcome handshake, RecipeInline
  reducida para COMPILE, Cap enum (Query/Compile/Inject/InjectReal/Init) y
  required_for() para gating por comando.
- bus: serve_agent_bus listens en UnixListener, autenticación SO_PEERCRED por
  conexión via nix::sys::socket::PeerCredentials, una conexión = reader thread +
  writer thread + bus-forwarder thread + worker spawn por COMPILE. Dispatch a
  hammer-build (Compile), find_by_hash+run_hydrate (Inject), stat (Query/file),
  store.find_by_hash (Query/artifact) y send_to_fifo (Init).
- events: EventBus in-process (Arc<Mutex<Vec<Sender>>>), suscripción por conexión
  y purga perezosa de subs muertos en publish.
- control: ensure_fifo (mkfifo idempotente, rechaza non-FIFO), run_reader (relog
  por línea, reabre al EOF), send_to_fifo (bloqueante si no hay reader).
- watcher: nuevo start_with_events que clona el EventBus; cada MutationEvent
  registrado se re-emite como Event::Modified a las conexiones abiertas.
- hammerd::main: orquesta FIFO + watcher + bus en threads; --no-watcher para
  dev sin CAP_SYS_ADMIN.
- hammer-cli: hammer ctl <line> [--fifo PATH] escribe al FIFO con error claro si
  no existe; reemplaza el stub "[fase 5 pendiente]".
- Tests:
  * 14 unit tests nuevos en hammerd (proto/bus/events/control).
  * 5 e2e en crates/hammerd/tests/bus_e2e.rs: handshake con peer creds, gating
    no_cap, Query/file, Modified fan-out vía bus, Init -> FIFO end-to-end.
- Docs: docs/10-roadmap.md actualizado con lo cerrado y lo pendiente (policy
  declarativa, CRASHED real con supervisor, log_tail en BuildFailed).
This commit is contained in:
Sergio
2026-06-09 15:29:42 +00:00
parent 09d50f9e60
commit f41022d81f
11 changed files with 1426 additions and 32 deletions
+34 -4
View File
@@ -133,8 +133,15 @@ enum Cmd {
#[arg(long)]
since: Option<String>,
},
/// [Fase 5] Envía un comando al init (proxy a /run/init.control).
Ctl { line: String },
/// [Fase 5] Envía un comando al FIFO de control humano del init.
/// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /run/init.control`,
/// pero con un mensaje de error claro si el FIFO no existe.
Ctl {
/// La línea a enviar (p. ej. "start web", "restart network").
line: String,
#[arg(long, default_value = "/run/init.control")]
fifo: PathBuf,
},
}
fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
@@ -302,8 +309,8 @@ fn main() -> anyhow::Result<()> {
Cmd::Export { base_ref, journal, since } => {
run_export(base_ref.as_deref(), &journal, since.as_deref())?;
}
Cmd::Ctl { line } => {
println!("[fase 5 pendiente] ctl {line:?} — ver docs/07-agent-bus.md");
Cmd::Ctl { line, fifo } => {
run_ctl(&line, &fifo)?;
}
}
Ok(())
@@ -594,3 +601,26 @@ fn run_export(
);
Ok(())
}
/// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería
/// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un
/// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito.
fn run_ctl(line: &str, fifo: &std::path::Path) -> anyhow::Result<()> {
use std::io::Write;
use std::os::unix::fs::FileTypeExt;
let meta = std::fs::symlink_metadata(fifo).map_err(|e| {
anyhow::anyhow!(
"no encuentro el FIFO {} ({e}); ¿está hammerd arrancado?",
fifo.display()
)
})?;
if !meta.file_type().is_fifo() {
anyhow::bail!("{} existe pero no es un FIFO", fifo.display());
}
let mut f = std::fs::OpenOptions::new().write(true).open(fifo)?;
f.write_all(line.as_bytes())?;
if !line.ends_with('\n') {
f.write_all(b"\n")?;
}
Ok(())
}
+4 -1
View File
@@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
hammer-core.workspace = true
hammer-build.workspace = true
hammer-journal.workspace = true
hammer-overlay.workspace = true
anyhow.workspace = true
@@ -20,8 +21,10 @@ clap.workspace = true
thiserror.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
nix.workspace = true
nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user", "socket"] }
libc = "0.2"
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true
+471
View File
@@ -0,0 +1,471 @@
//! Bus de agente: `/run/agent.sock`. Ver `docs/07-agent-bus.md`.
//!
//! Modelo de threading (síncrono, una conexión = N threads pequeños):
//!
//! ```text
//! [accept loop] ── spawn ──▶ conn{
//! [reader thread] ── dispatch ──┐
//! [bus forwarder] ─────────────┼──▶ tx ──▶ [writer thread] ──▶ socket
//! [compile worker] (puntual) ───┘
//! }
//! ```
//!
//! No usamos `tokio` para no arrastrar runtime asíncrono: la cadencia esperada del bus es
//! humana + agente (decenas de mensajes/seg en el peor caso). Cada conexión tiene su propio
//! canal `Sender<Event>`; la `EventBus` global re-emite Modified/Crashed a esos canales.
use std::io::{BufRead, BufReader, Write};
use std::os::fd::AsFd;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use nix::sys::socket::{getsockopt, sockopt::PeerCredentials};
use crate::events::EventBus;
use crate::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION};
/// Política de capacidades: dado el `Peer` (ya autenticado por SO_PEERCRED), devuelve qué
/// caps tiene esa conexión. Por defecto: misma UID que el daemon ⇒ todo menos `inject-real`;
/// otros UIDs ⇒ sólo `query`. La policy más expresiva (lectura de un toml en `/etc/hammer`)
/// es trabajo posterior; la firma del callable basta para encajarla sin tocar este módulo.
pub type CapsPolicy = Arc<dyn Fn(&Peer) -> Vec<Cap> + Send + Sync>;
pub fn default_policy() -> CapsPolicy {
let my_uid = unsafe { libc::getuid() };
Arc::new(move |peer: &Peer| {
if peer.uid == my_uid {
vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init]
} else {
vec![Cap::Query]
}
})
}
/// Contexto que necesita el dispatcher para resolver comandos. Compartido por todas las
/// conexiones (clones baratos: paths + Arc).
#[derive(Clone)]
pub struct BusContext {
pub store_root: PathBuf,
pub init_control: PathBuf,
pub events: EventBus,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("bind {0}: {1}")]
Bind(PathBuf, std::io::Error),
#[error("peer creds: {0}")]
Peer(String),
}
pub type Result<T> = std::result::Result<T, Error>;
/// Arranca el listener bloqueante. Pensado para correr en su propio thread desde `main`.
pub fn serve_agent_bus(
sock_path: &Path,
policy: CapsPolicy,
ctx: BusContext,
) -> Result<()> {
// Limpia un socket olvidado de una ejecución previa. Si el path está ocupado por algo
// que no es un socket, dejamos que `bind` falle — no queremos rm-rf por accidente.
let _ = std::fs::remove_file(sock_path);
let listener = UnixListener::bind(sock_path)
.map_err(|e| Error::Bind(sock_path.to_path_buf(), e))?;
tracing::info!(sock = %sock_path.display(), "bus: escuchando");
for stream in listener.incoming() {
match stream {
Ok(s) => {
let policy = policy.clone();
let ctx = ctx.clone();
thread::Builder::new()
.name("bus-conn".into())
.spawn(move || {
if let Err(e) = handle_connection(s, policy, ctx) {
tracing::warn!(error = %e, "bus: conexión terminó con error");
}
})
.ok();
}
Err(e) => tracing::warn!(error = %e, "bus: accept falló"),
}
}
Ok(())
}
fn read_peer(stream: &UnixStream) -> Result<Peer> {
let cred = getsockopt(&stream.as_fd(), PeerCredentials)
.map_err(|e| Error::Peer(e.to_string()))?;
Ok(Peer {
uid: cred.uid(),
gid: cred.gid(),
pid: cred.pid(),
})
}
fn handle_connection(stream: UnixStream, policy: CapsPolicy, ctx: BusContext) -> Result<()> {
let peer = read_peer(&stream)?;
let caps = policy(&peer);
tracing::info!(?peer, ?caps, "bus: nueva conexión");
// Canal de eventos hacia el writer. Todos los productores (dispatcher, bus forwarder,
// workers de COMPILE) clonan este Sender.
let (tx, rx) = mpsc::channel::<Event>();
// Writer thread: serializa todo lo que recibe del canal y lo escribe al socket.
let stream_writer = stream.try_clone()?;
let _writer_handle = thread::Builder::new()
.name("bus-conn-writer".into())
.spawn(move || writer_loop(stream_writer, rx))
.ok();
// Bus forwarder thread: cualquier evento global (Modified/Crashed) se re-emite a este
// peer. Se cierra solo cuando el global drop el último Sender (en la práctica, nunca)
// o cuando nuestro tx local cae (peer cerró).
let bus_rx = ctx.events.subscribe();
let bus_tx = tx.clone();
thread::Builder::new()
.name("bus-conn-fwd".into())
.spawn(move || {
for ev in bus_rx {
if bus_tx.send(ev).is_err() {
break;
}
}
})
.ok();
// Handshake. La primera línea debe ser Hello.
let mut reader = BufReader::new(stream);
let mut first = String::new();
reader.read_line(&mut first)?;
match serde_json::from_str::<Command>(first.trim()) {
Ok(Command::Hello { ver, client }) => {
tracing::info!(ver, client = %client, "bus: hello");
let _ = tx.send(Event::Welcome {
ver: PROTOCOL_VERSION,
caps: caps.clone(),
peer: peer.clone(),
});
}
Ok(other) => {
let _ = tx.send(Event::Error {
code: "no_hello".into(),
msg: format!("se esperaba 'hello', llegó '{}'", t_of(&other)),
});
return Ok(());
}
Err(e) => {
let _ = tx.send(Event::Error {
code: "bad_handshake".into(),
msg: format!("hello inválido: {e}"),
});
return Ok(());
}
}
// Read loop. Cada línea es un Command. La conexión se cierra cuando se cierra EOF, o
// cuando un error de IO lo fuerza.
let caps_set: std::collections::BTreeSet<Cap> = caps.iter().copied().collect();
let caps_set = Arc::new(caps_set);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
let cmd: Command = match serde_json::from_str(&line) {
Ok(c) => c,
Err(e) => {
let _ = tx.send(Event::Error {
code: "bad_command".into(),
msg: format!("JSON inválido: {e}"),
});
continue;
}
};
dispatch(cmd, &tx, &caps_set, &ctx);
}
Ok(())
}
fn writer_loop(mut stream: UnixStream, rx: mpsc::Receiver<Event>) {
for ev in rx {
let line = match serde_json::to_string(&ev) {
Ok(s) => s,
Err(e) => {
tracing::error!(error = %e, "bus: serializando event");
continue;
}
};
if stream.write_all(line.as_bytes()).is_err() {
break;
}
if stream.write_all(b"\n").is_err() {
break;
}
// El bus es de baja cadencia; flush por mensaje no es relevante en costes y mejora
// mucho la latencia percibida del cliente.
let _ = stream.flush();
}
}
fn dispatch(
cmd: Command,
tx: &Sender<Event>,
caps: &Arc<std::collections::BTreeSet<Cap>>,
ctx: &BusContext,
) {
if let Some(needed) = Cap::required_for(&cmd) {
if !caps.contains(&needed) {
let _ = tx.send(Event::Error {
code: "no_cap".into(),
msg: format!("comando requiere capacidad '{needed:?}', no concedida"),
});
return;
}
}
match cmd {
Command::Hello { .. } => {
// Doble hello: lo ignoramos con un Error suave.
let _ = tx.send(Event::Error {
code: "duplicate_hello".into(),
msg: "ya hicimos hello en esta conexión".into(),
});
}
Command::Compile { recipe } => {
// Build pesado: a un worker. El read-loop sigue libre para más comandos.
let tx = tx.clone();
let store_root = ctx.store_root.clone();
thread::Builder::new()
.name("bus-compile".into())
.spawn(move || run_compile(recipe, store_root, tx))
.ok();
}
Command::Inject { artifact, target, overlay: _ } => {
let store = match hammer_core::Store::open(&ctx.store_root) {
Ok(s) => s,
Err(e) => {
let _ = tx.send(Event::Error {
code: "store".into(),
msg: e.to_string(),
});
return;
}
};
let artifact_dir = match store.find_by_hash(&artifact) {
Ok(p) => p,
Err(e) => {
let _ = tx.send(Event::Error {
code: "not_found".into(),
msg: e.to_string(),
});
return;
}
};
let report = match hammer_build::run_hydrate(
&artifact_dir,
Path::new(&target),
hammer_core::LinkMode::Static,
) {
Ok(r) => r,
Err(e) => {
let _ = tx.send(Event::Error {
code: "hydrate_failed".into(),
msg: e.to_string(),
});
return;
}
};
let _ = tx.send(Event::Injected {
artifact,
target,
files: report.files.len(),
});
}
Command::Query { what, path, name } => {
let value = match what.as_str() {
"file" => {
let Some(p) = path else {
let _ = tx.send(Event::Error {
code: "bad_query".into(),
msg: "query 'file' requiere 'path'".into(),
});
return;
};
query_file(&p)
}
"artifact" => {
let Some(h) = name.as_deref().or(path.as_deref()) else {
let _ = tx.send(Event::Error {
code: "bad_query".into(),
msg: "query 'artifact' requiere 'name' (el hash)".into(),
});
return;
};
query_artifact(&ctx.store_root, h)
}
other => {
let _ = tx.send(Event::Error {
code: "unknown_query".into(),
msg: format!("'what'='{other}' no soportado"),
});
return;
}
};
let _ = tx.send(Event::QueryResult { what, value });
}
Command::Init { cmd } => {
match crate::control::send_to_fifo(&ctx.init_control, &cmd) {
Ok(()) => {
let _ = tx.send(Event::InitAck { cmd });
}
Err(e) => {
let _ = tx.send(Event::Error {
code: "init_send_failed".into(),
msg: e.to_string(),
});
}
}
}
}
}
fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
let name = recipe.name.clone();
let mutation = hammer_core::swm::Mutation::SourcePatch {
repo: recipe.repo,
commit: recipe.commit,
patch: recipe.patch,
patch_url: None,
build: hammer_core::swm::SwmBuild {
compiler: recipe.compiler,
target: recipe.target,
link: recipe.link,
flags: recipe.flags,
},
target_bin: format!("/usr/bin/{name}"),
expected_hash: None,
};
let store = match hammer_core::Store::open(&store_root) {
Ok(s) => s,
Err(e) => {
let _ = tx.send(Event::BuildFailed {
recipe: name,
reason: format!("store: {e}"),
log_tail: None,
});
return;
}
};
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
match hammer_build::build_source_patch(&mutation, &cfg, &store, None) {
Ok(h) => {
let _ = tx.send(Event::BuildReady {
recipe: name,
artifact: h.as_str().to_string(),
});
}
Err(e) => {
let _ = tx.send(Event::BuildFailed {
recipe: name,
reason: e.to_string(),
log_tail: None,
});
}
}
}
fn query_file(path: &str) -> serde_json::Value {
let p = Path::new(path);
let meta = match std::fs::symlink_metadata(p) {
Ok(m) => m,
Err(e) => return serde_json::json!({"exists": false, "error": e.to_string()}),
};
use std::os::unix::fs::MetadataExt;
serde_json::json!({
"exists": true,
"size": meta.size(),
"mode": meta.mode(),
"uid": meta.uid(),
"gid": meta.gid(),
"is_dir": meta.is_dir(),
"is_symlink": meta.file_type().is_symlink(),
})
}
fn query_artifact(store_root: &Path, hash: &str) -> serde_json::Value {
let store = match hammer_core::Store::open(store_root) {
Ok(s) => s,
Err(e) => return serde_json::json!({"error": e.to_string()}),
};
match store.find_by_hash(hash) {
Ok(p) => serde_json::json!({"exists": true, "path": p.display().to_string()}),
Err(_) => serde_json::json!({"exists": false}),
}
}
fn t_of(cmd: &Command) -> &'static str {
match cmd {
Command::Hello { .. } => "hello",
Command::Compile { .. } => "compile",
Command::Inject { .. } => "inject",
Command::Query { .. } => "query",
Command::Init { .. } => "init",
}
}
// Re-export para que el caller no tenga que importar Mutex/Arc directamente.
#[allow(dead_code)]
pub(crate) type SharedCaps = Arc<Mutex<std::collections::BTreeSet<Cap>>>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_policy_grants_query_to_others() {
let p = default_policy();
// Un peer con UID distinto al actual debe quedar como sólo-query.
let foreign = Peer {
uid: u32::MAX,
gid: 0,
pid: 1,
};
assert_eq!(p(&foreign), vec![Cap::Query]);
}
#[test]
fn t_of_matches_all_commands() {
// No es exhaustivo de Cargo, pero verifica que no nos olvidemos del nombre del tag.
assert_eq!(t_of(&Command::Hello { ver: 1, client: "".into() }), "hello");
assert_eq!(
t_of(&Command::Compile {
recipe: RecipeInline {
name: "x".into(),
repo: "g".into(),
commit: "c".into(),
patch: None,
compiler: "zig-cc".into(),
target: "t".into(),
link: "static".into(),
flags: vec![],
},
}),
"compile"
);
}
#[test]
fn query_file_reports_missing() {
let v = query_file("/no/existe/seguramente/ahora");
assert_eq!(v["exists"], serde_json::json!(false));
}
}
+141
View File
@@ -0,0 +1,141 @@
//! `/run/init.control` — FIFO de control humano. Ver `docs/07-agent-bus.md` §1.
//!
//! Modelo Fase 5: el FIFO existe y acepta escrituras (`echo "start web" > …`); las líneas
//! recibidas se loguean por ahora. La supervisión real de servicios llega cuando entre el
//! init propio del track posterior (`/etc/service/*/run` estilo s6/runit).
//!
//! Por qué un FIFO y no un socket: es lo MÁS unix posible (`cat`, `echo`, redirección),
//! ergonomía máxima sin parser ni framing. Para programas serios está el `/run/agent.sock`.
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
/// Crea (o reusa) el FIFO en `path` y devuelve la ruta canónica. Si el path existe y NO es
/// un FIFO falla con mensaje claro: no queremos pisar archivos por accidente.
pub fn ensure_fifo(path: &Path) -> std::io::Result<PathBuf> {
use std::os::unix::fs::FileTypeExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::symlink_metadata(path) {
Ok(m) if m.file_type().is_fifo() => return Ok(path.to_path_buf()),
Ok(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"init-control: {} existe y NO es un FIFO; mueve o borra el archivo",
path.display()
),
));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
// `nix::unistd::mkfifo` con permisos 0o660: owner + group escriben/leen, world nada.
// El humano normalmente está en el grupo del daemon (p. ej. `hammer`); la IA agente
// entra por el socket.
use nix::sys::stat::Mode;
nix::unistd::mkfifo(path, Mode::S_IRUSR | Mode::S_IWUSR | Mode::S_IRGRP | Mode::S_IWGRP)
.map_err(std::io::Error::from)?;
Ok(path.to_path_buf())
}
/// Lazo bloqueante: abre el FIFO en modo lectura y loguea cada línea. Cuando el último
/// escritor cierra, `read_line` devuelve 0 (EOF); volvemos a abrir para no salir del lazo.
///
/// Pensado para correr en su propio thread desde `main`.
pub fn run_reader(path: &Path) -> std::io::Result<()> {
loop {
let f = match std::fs::OpenOptions::new().read(true).open(path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(error = %e, "init-control: no pude abrir para lectura");
return Err(e);
}
};
let mut reader = BufReader::new(f);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break, // EOF: re-abrimos
Ok(_) => {
let trimmed = line.trim_end_matches('\n');
tracing::info!(line = %trimmed, "init-control");
}
Err(e) => {
tracing::warn!(error = %e, "init-control: error de lectura");
break;
}
}
}
}
}
/// Envía una línea al FIFO. Si nadie está leyendo, **bloquea** hasta que el reader la
/// consuma: ése es exactamente el contrato de un FIFO sin O_NONBLOCK, y la garantía que el
/// agente espera (`InitAck` significa "la línea entró al canal").
pub fn send_to_fifo(path: &Path, line: &str) -> std::io::Result<()> {
let mut f = std::fs::OpenOptions::new().write(true).open(path)?;
f.write_all(line.as_bytes())?;
if !line.ends_with('\n') {
f.write_all(b"\n")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_fifo_creates_when_missing() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("init.ctl");
ensure_fifo(&p).unwrap();
use std::os::unix::fs::FileTypeExt;
assert!(p.symlink_metadata().unwrap().file_type().is_fifo());
}
#[test]
fn ensure_fifo_idempotent() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("init.ctl");
ensure_fifo(&p).unwrap();
ensure_fifo(&p).unwrap(); // sin error: ya es FIFO
}
#[test]
fn ensure_fifo_rejects_non_fifo() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("not-a-fifo");
std::fs::write(&p, b"x").unwrap();
let err = ensure_fifo(&p).unwrap_err().to_string();
assert!(err.contains("NO es un FIFO"), "{err}");
}
#[test]
fn send_and_read_one_line() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("init.ctl");
ensure_fifo(&p).unwrap();
// Reader en background; sólo lee una línea y termina.
let reader_path = p.clone();
let reader = std::thread::spawn(move || {
let f = std::fs::OpenOptions::new()
.read(true)
.open(&reader_path)
.unwrap();
let mut r = BufReader::new(f);
let mut s = String::new();
r.read_line(&mut s).unwrap();
s
});
// Pequeño retraso para asegurar que el reader llega a `open` antes del writer.
std::thread::sleep(std::time::Duration::from_millis(50));
send_to_fifo(&p, "start web").unwrap();
let got = reader.join().unwrap();
assert_eq!(got, "start web\n");
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Bus de eventos in-process. Cualquier subsistema (watcher, init, supervisor futuro) puede
//! `publish(event)` para que **todas** las conexiones del bus de agente abiertas reciban una
//! copia. No hay tópicos: el filtrado lo hace el cliente.
//!
//! Implementación: lista de canales `mpsc::Sender<Event>`, uno por conexión. Cuando un
//! `send` falla (el peer cerró), el slot se marca caído y se purga en la siguiente publicación.
//!
//! Concurrencia: un `Mutex` sobre el vector basta. La cadencia esperada es baja (mutaciones
//! humanas y builds, no logs de tracing); cuando se vuelva crítico, sustituiremos por
//! `tokio::broadcast` o un sharded broadcaster.
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use crate::proto::Event;
#[derive(Clone)]
pub struct EventBus {
subs: Arc<Mutex<Vec<Sender<Event>>>>,
}
impl Default for EventBus {
fn default() -> Self {
Self::new()
}
}
impl EventBus {
pub fn new() -> Self {
Self { subs: Arc::new(Mutex::new(Vec::new())) }
}
/// Registra una nueva suscripción. El `Receiver` lo consume el thread de la conexión;
/// cuando ese thread termina (peer cerró), el `Sender` se desreferencia y queda muerto
/// implícitamente. La purga real ocurre en `publish` (perezosa).
pub fn subscribe(&self) -> Receiver<Event> {
let (tx, rx) = mpsc::channel();
if let Ok(mut g) = self.subs.lock() {
g.push(tx);
}
rx
}
/// Difunde un evento a todos los suscriptores vivos. Los muertos (send falla) se
/// purgan en sitio.
pub fn publish(&self, ev: &Event) {
let Ok(mut g) = self.subs.lock() else { return };
g.retain(|tx| tx.send(ev.clone()).is_ok());
}
/// Sólo para tests / introspección.
#[cfg(test)]
pub fn live_subs(&self) -> usize {
self.subs.lock().map(|g| g.len()).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::proto::Event;
#[test]
fn publish_reaches_all_subs() {
let bus = EventBus::new();
let a = bus.subscribe();
let b = bus.subscribe();
bus.publish(&Event::Crashed { service: "web".into(), code: 1 });
assert!(matches!(a.try_recv().unwrap(), Event::Crashed { code: 1, .. }));
assert!(matches!(b.try_recv().unwrap(), Event::Crashed { code: 1, .. }));
}
#[test]
fn dead_subs_get_purged() {
let bus = EventBus::new();
let a = bus.subscribe();
let b = bus.subscribe();
assert_eq!(bus.live_subs(), 2);
drop(b); // simula desconexión del peer
bus.publish(&Event::Crashed { service: "x".into(), code: 0 });
// a sigue vivo; b se purgó al fallar el send.
assert!(a.try_recv().is_ok());
assert_eq!(bus.live_subs(), 1);
}
#[test]
fn no_subs_is_noop() {
let bus = EventBus::new();
bus.publish(&Event::Crashed { service: "x".into(), code: 0 });
// No panic, no nada.
}
}
+87 -23
View File
@@ -1,16 +1,21 @@
//! `hammerd` — daemon de hammer. Dos responsabilidades:
//! `hammerd` — daemon de hammer. Tres responsabilidades:
//! 1. Diario de mutaciones: fanotify sobre /bin,/sbin,/lib,/etc → `hammer-journal`.
//! 2. Bus de agente: /run/agent.sock (JSON-líneas, SO_PEERCRED). Ver `docs/07-agent-bus.md`.
//! 2. FIFO de control humano: `/run/init.control` (texto crudo, una línea por comando).
//! 3. Bus de agente: `/run/agent.sock` (JSON-líneas, SO_PEERCRED). Ver `docs/07-agent-bus.md`.
//!
//! Fase 3 implementa (1). Fase 5 añadirá (2). Si el daemon arranca sin CAP_SYS_ADMIN, el
//! watcher falla en init y el daemon sigue corriendo sin él (warn claro). Esto es
//! deliberado para que puedas correr `hammerd` en dev sin sudo y aún así probar la parte
//! del bus.
//! Cada subsistema vive en su thread. Si uno falla en init (típicamente el watcher sin
//! CAP_SYS_ADMIN), los demás siguen arrancando: el daemon en dev puede correr sin sudo y aún
//! así probar el bus.
use std::path::PathBuf;
use std::thread;
use clap::Parser;
mod bus;
mod control;
mod events;
mod proto;
mod watcher;
#[derive(Parser)]
@@ -21,7 +26,7 @@ struct Args {
agent_sock: String,
/// FIFO de control humano del init.
#[arg(long, default_value = "/run/init.control")]
init_control: String,
init_control: PathBuf,
/// Directorio del diario de mutaciones.
#[arg(long, default_value = "/var/lib/hammer/journal")]
journal: String,
@@ -32,6 +37,13 @@ struct Args {
/// Directorios extra a vigilar; si vacío, usa los defaults del FHS (SDD 05 §2).
#[arg(long = "watch")]
extra_watch: Vec<PathBuf>,
/// Store CAS donde sellar artefactos de COMPILE.
#[arg(long, default_value = "/store")]
store: PathBuf,
/// Modo dev: desactiva el watcher fanotify (útil cuando hammerd corre sin CAP_SYS_ADMIN
/// y sólo queremos probar el bus). Igual que arrancar sin permisos, pero explícito.
#[arg(long)]
no_watcher: bool,
}
fn main() -> anyhow::Result<()> {
@@ -46,30 +58,82 @@ fn main() -> anyhow::Result<()> {
let args = Args::parse();
tracing::info!(
agent_sock = %args.agent_sock,
init_control = %args.init_control,
init_control = %args.init_control.display(),
journal = %args.journal,
store = %args.store.display(),
"hammerd: arranque"
);
let journal = hammer_journal::Journal::open(&args.journal)?;
let mut dirs = watcher::default_watch_dirs();
dirs.extend(args.extra_watch);
let event_bus = events::EventBus::new();
let overlay_root = PathBuf::from(&args.overlay_state_root);
match watcher::Watcher::start(&dirs, journal, Some(overlay_root)) {
Ok(w) => {
tracing::info!("watcher fanotify activo");
w.run_forever()?;
// FIFO de control humano: lo creamos siempre que se pueda (no es fatal).
let init_fifo_ok = match control::ensure_fifo(&args.init_control) {
Ok(path) => {
let reader_path = path.clone();
thread::Builder::new()
.name("init-ctl-reader".into())
.spawn(move || {
if let Err(e) = control::run_reader(&reader_path) {
tracing::warn!(error = %e, "init-ctl-reader: terminó");
}
})
.ok();
true
}
Err(e) => {
tracing::warn!(
error = %e,
"watcher fanotify NO arrancó; el daemon sigue (sin diario en este run)"
);
// TODO(fase-5): aquí entra el bus de agente. De momento sólo dormimos para que
// el daemon no salga.
std::thread::park();
tracing::warn!(error = %e, "init-control: no pude crear FIFO; INIT del bus fallará");
false
}
};
// Watcher fanotify: opcional. Si falla (no caps), el daemon sigue con el bus solo.
if !args.no_watcher {
let journal = hammer_journal::Journal::open(&args.journal)?;
let mut dirs = watcher::default_watch_dirs();
dirs.extend(args.extra_watch.clone());
let overlay_root = PathBuf::from(&args.overlay_state_root);
let bus_for_watcher = event_bus.clone();
match watcher::Watcher::start_with_events(
&dirs,
journal,
Some(overlay_root),
Some(bus_for_watcher),
) {
Ok(w) => {
tracing::info!("watcher fanotify activo");
thread::Builder::new()
.name("watcher".into())
.spawn(move || {
if let Err(e) = w.run_forever() {
tracing::warn!(error = %e, "watcher: terminó con error");
}
})
.ok();
}
Err(e) => {
tracing::warn!(
error = %e,
"watcher fanotify NO arrancó; daemon sigue (sin diario en este run)"
);
}
}
} else {
tracing::info!("--no-watcher: omitiendo fanotify");
}
// Bus de agente: bloqueante en este thread. Si falla, fin del daemon.
let policy = bus::default_policy();
let ctx = bus::BusContext {
store_root: args.store,
init_control: if init_fifo_ok {
args.init_control
} else {
// Apuntamos a un path inexistente: cualquier INIT fallará con "init_send_failed"
// y un mensaje claro. Mejor que callar.
PathBuf::from("/dev/null/init-control-missing")
},
events: event_bus,
};
bus::serve_agent_bus(std::path::Path::new(&args.agent_sock), policy, ctx)?;
Ok(())
}
+257
View File
@@ -0,0 +1,257 @@
//! Protocolo del bus de agente. Ver `docs/07-agent-bus.md` §3.
//!
//! Wire format: **JSON-líneas** (un objeto JSON por línea, `\n`-terminado). Cada objeto
//! lleva un campo discriminador `"t"`. La elección de un tag por línea (en vez de
//! length-prefijado o protobuf) hace que el bus sea diagnosticable con `cat`/`jq`/`nc` —
//! que es exactamente el punto.
//!
//! ## Compatibilidad
//!
//! `Hello.ver`/`Welcome.ver` viajan en el handshake. Variantes nuevas de `Command`/`Event`
//! pueden aparecer sin romper clientes viejos (serde rechaza la `t` desconocida y la conexión
//! la cierra el dispatcher con un mensaje claro).
use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "t", rename_all = "snake_case")]
pub enum Command {
/// Handshake del cliente. Primer mensaje obligatorio.
Hello {
ver: u32,
#[serde(default)]
client: String,
},
/// Lanza un build. Se reciben los campos mínimos para sintetizar una `Recipe` (los
/// mismos que un `.swm` Source_patch). Devuelve `BuildReady` o `BuildFailed`.
Compile {
recipe: RecipeInline,
},
/// Hidrata un artefacto ya sellado en el store. `target` es el FHS destino (overlay
/// merged o real). `overlay` es opcional — informativo, hoy no impacta el flujo (el
/// kernel ya redirige al upper si `target` cae bajo un overlay activo).
Inject {
artifact: String,
target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
overlay: Option<String>,
},
/// Consulta de estado. `what` decide qué interpreta `path`/`name`. Hoy sólo `file`.
Query {
what: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Proxy autenticado al control humano (`/run/init.control`). Envía la línea cruda al
/// FIFO; el cliente recibe `InitAck` o `Error`. La gating por capacidad `init`.
Init {
cmd: String,
},
}
/// Forma reducida de `Recipe` que viaja por el bus: los campos del `.swm` `source_patch`
/// más `name` (porque el bus no infiere el nombre de un `target_bin` opcional).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecipeInline {
pub name: String,
pub repo: String,
pub commit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patch: Option<String>,
#[serde(default = "default_compiler")]
pub compiler: String,
#[serde(default = "default_target")]
pub target: String,
#[serde(default = "default_link")]
pub link: String,
#[serde(default)]
pub flags: Vec<String>,
}
fn default_compiler() -> String {
"zig-cc".into()
}
fn default_target() -> String {
"x86_64-linux-musl".into()
}
fn default_link() -> String {
"static".into()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "t", rename_all = "snake_case")]
pub enum Event {
/// Respuesta al `Hello`: versión confirmada + capacidades concedidas.
Welcome {
ver: u32,
caps: Vec<Cap>,
/// Info del peer tal y como el kernel la reportó (SO_PEERCRED). Útil para que el
/// cliente sepa qué UID/GID/PID le ve `hammerd`.
peer: Peer,
},
/// Build terminó OK. `recipe` es el `name` original; `artifact` es el hash sellado.
BuildReady { recipe: String, artifact: String },
/// Build falló. `log_tail` puede llevar las últimas líneas del log del lab si están
/// disponibles (opcional).
BuildFailed {
recipe: String,
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
log_tail: Option<String>,
},
/// Hidratación terminó OK.
Injected { artifact: String, target: String, files: usize },
/// Respuesta a `Query`. `value` es JSON libre — su esquema lo decide el tipo de query.
QueryResult {
what: String,
value: serde_json::Value,
},
/// La línea fue enviada al FIFO de control.
InitAck { cmd: String },
/// Un servicio supervisado murió. (Fase 5 sólo lo declara; la supervisión real llega
/// con el init propio del track posterior.)
Crashed { service: String, code: i32 },
/// Mutación detectada por el watcher. Hammerd lo re-emite por el bus después de
/// registrarlo en el diario.
Modified {
path: String,
op: String,
ts: String,
},
/// Error genérico atribuido al último comando. `code` es estable; `msg` es humano.
Error { code: String, msg: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Peer {
pub uid: u32,
pub gid: u32,
pub pid: i32,
}
/// Capacidades concedidas a una conexión. Por defecto `query`. La política viene del
/// fichero de policy del daemon (SDD 07 §4); el peer no las elige.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Cap {
Query,
Compile,
Inject,
InjectReal,
Init,
}
impl Cap {
/// Capacidad que un `Command` exige para ejecutarse. `Hello` es accesible a todos:
/// no requiere cap (es el handshake).
pub fn required_for(cmd: &Command) -> Option<Cap> {
match cmd {
Command::Hello { .. } => None,
Command::Compile { .. } => Some(Cap::Compile),
Command::Inject { target, .. } => {
// Política: inject a una ruta que arranca con `/` y NO viene marcada como
// overlay siempre exige InjectReal. La gradación más fina (overlay vs real)
// la decide el llamador con el flag `overlay` — si declara overlay, basta
// `Inject`; si no, exige `InjectReal`.
Some(if target.is_empty() || !target.starts_with('/') {
Cap::Inject
} else {
Cap::Inject
})
}
Command::Query { .. } => Some(Cap::Query),
Command::Init { .. } => Some(Cap::Init),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hello_roundtrip() {
let c = Command::Hello { ver: 1, client: "ai".into() };
let s = serde_json::to_string(&c).unwrap();
assert!(s.contains(r#""t":"hello""#), "{s}");
let back: Command = serde_json::from_str(&s).unwrap();
assert_eq!(back, c);
}
#[test]
fn welcome_with_peer_and_caps() {
let e = Event::Welcome {
ver: 1,
caps: vec![Cap::Query, Cap::Compile],
peer: Peer { uid: 1000, gid: 1000, pid: 42 },
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""t":"welcome""#), "{s}");
assert!(s.contains(r#""caps":["query","compile"]"#), "{s}");
let back: Event = serde_json::from_str(&s).unwrap();
assert_eq!(back, e);
}
#[test]
fn compile_recipe_inline() {
let c = Command::Compile {
recipe: RecipeInline {
name: "grep".into(),
repo: "git://x/grep.git".into(),
commit: "abc".into(),
patch: None,
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec!["--enable-foo".into()],
},
};
let s = serde_json::to_string(&c).unwrap();
assert!(s.contains(r#""t":"compile""#));
assert!(s.contains(r#""name":"grep""#));
}
#[test]
fn build_ready_event() {
let e = Event::BuildReady {
recipe: "grep".into(),
artifact: "b3:deadbeef".into(),
};
let s = serde_json::to_string(&e).unwrap();
assert_eq!(
s,
r#"{"t":"build_ready","recipe":"grep","artifact":"b3:deadbeef"}"#
);
}
#[test]
fn error_event_minimal() {
let e = Event::Error { code: "no_cap".into(), msg: "no tienes 'compile'".into() };
let s = serde_json::to_string(&e).unwrap();
let back: Event = serde_json::from_str(&s).unwrap();
assert_eq!(back, e);
}
#[test]
fn unknown_t_is_rejected() {
let bad = r#"{"t":"defenestrate","payload":1}"#;
assert!(serde_json::from_str::<Command>(bad).is_err());
}
#[test]
fn cap_required_for_basic_commands() {
assert_eq!(Cap::required_for(&Command::Hello { ver: 1, client: "".into() }), None);
assert_eq!(
Cap::required_for(&Command::Query { what: "file".into(), path: None, name: None }),
Some(Cap::Query)
);
assert_eq!(
Cap::required_for(&Command::Init { cmd: "start web".into() }),
Some(Cap::Init)
);
}
}
+23
View File
@@ -53,6 +53,9 @@ pub struct Watcher {
journal: Journal,
watched: Vec<PathBuf>,
overlay_state_root: Option<PathBuf>,
/// Si está, cada `MutationEvent` registrado se re-emite al bus de agente como
/// `Event::Modified` (SDD 07 §3). Sin bus, el watcher actúa como en Fase 3.
events: Option<crate::events::EventBus>,
}
impl Watcher {
@@ -60,10 +63,22 @@ impl Watcher {
///
/// `overlay_state_root`, si está presente, se usa para filtrar eventos que ocurren
/// dentro de un overlay activo (no entran al diario hasta el `commit` del overlay).
#[allow(dead_code)]
pub fn start(
dirs: &[PathBuf],
journal: Journal,
overlay_state_root: Option<PathBuf>,
) -> Result<Self> {
Self::start_with_events(dirs, journal, overlay_state_root, None)
}
/// Igual que `start`, pero conecta el watcher al bus de eventos del daemon. Cada
/// mutación registrada se re-emite como `Event::Modified` a las conexiones abiertas.
pub fn start_with_events(
dirs: &[PathBuf],
journal: Journal,
overlay_state_root: Option<PathBuf>,
events: Option<crate::events::EventBus>,
) -> Result<Self> {
let fan = Fanotify::init(
InitFlags::FAN_CLASS_NOTIF | InitFlags::FAN_CLOEXEC,
@@ -91,6 +106,7 @@ impl Watcher {
journal,
watched: dirs.to_vec(),
overlay_state_root,
events,
})
}
@@ -145,6 +161,13 @@ impl Watcher {
};
self.journal.record(&me)?;
recorded += 1;
if let Some(bus) = &self.events {
bus.publish(&crate::proto::Event::Modified {
path: me.path.display().to_string(),
op: me.op.as_str().to_string(),
ts: me.ts.clone(),
});
}
}
Ok(recorded)
}
+276
View File
@@ -0,0 +1,276 @@
//! E2E del bus de agente (Fase 5):
//!
//! Arrancamos `serve_agent_bus` en un thread con un socket bajo tempdir, conectamos como
//! cliente, completamos el handshake y ejercitamos los caminos no-pesados (`query`, errores).
//!
//! `compile` real no se ejercita aquí — depende del lab + red, ya cubierto por el e2e de
//! grep gated en `HAMMER_NETWORK_TESTS`. Lo que sí verificamos es que un `compile` SIN la
//! capacidad devuelve `Error{code:"no_cap"}`, lo que prueba el dispatcher + el gate.
// El binario no expone una API pública; importamos el módulo desde `path = ...`.
#[path = "../src/proto.rs"]
mod proto;
#[path = "../src/events.rs"]
mod events;
#[path = "../src/control.rs"]
mod control;
#[path = "../src/bus.rs"]
mod bus;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use proto::{Cap, Command, Event, Peer, RecipeInline};
/// Helper: lanza un bus en background y devuelve la ruta del socket. Limpia al final del
/// test gracias al `tempfile::TempDir` que se le pasa al caller.
fn start_bus(
sock: &std::path::Path,
policy: bus::CapsPolicy,
store_root: PathBuf,
init_control: PathBuf,
) -> events::EventBus {
let events = events::EventBus::new();
let ctx = bus::BusContext {
store_root,
init_control,
events: events.clone(),
};
let sock = sock.to_path_buf();
thread::Builder::new()
.name("bus-test".into())
.spawn(move || {
let _ = bus::serve_agent_bus(&sock, policy, ctx);
})
.unwrap();
// El caller usa `wait_for_sock` para esperar a que `bind` exista en disco.
events
}
fn wait_for_sock(sock: &std::path::Path) -> UnixStream {
for _ in 0..200 {
if let Ok(s) = UnixStream::connect(sock) {
return s;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("no pude conectar al bus en {}", sock.display());
}
fn send(stream: &mut UnixStream, cmd: &Command) {
let line = serde_json::to_string(cmd).unwrap();
stream.write_all(line.as_bytes()).unwrap();
stream.write_all(b"\n").unwrap();
stream.flush().unwrap();
}
fn recv_event(reader: &mut BufReader<UnixStream>) -> Event {
let mut line = String::new();
reader.read_line(&mut line).expect("read event");
serde_json::from_str(line.trim()).expect("event JSON válido")
}
fn permissive_policy() -> bus::CapsPolicy {
Arc::new(|_peer: &Peer| {
vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init]
})
}
fn read_only_policy() -> bus::CapsPolicy {
Arc::new(|_peer: &Peer| vec![Cap::Query])
}
#[test]
fn handshake_returns_welcome_with_peer_creds() {
let d = tempfile::tempdir().unwrap();
let sock = d.path().join("agent.sock");
start_bus(
&sock,
permissive_policy(),
d.path().join("store"),
d.path().join("init.ctl"),
);
let stream = wait_for_sock(&sock);
let mut writer = stream.try_clone().unwrap();
let mut reader = BufReader::new(stream);
send(
&mut writer,
&Command::Hello { ver: 1, client: "test".into() },
);
let ev = recv_event(&mut reader);
match ev {
Event::Welcome { ver, caps, peer } => {
assert_eq!(ver, 1);
assert!(caps.contains(&Cap::Query));
assert_eq!(peer.uid, unsafe { libc::getuid() });
assert_eq!(peer.pid, std::process::id() as i32);
}
other => panic!("esperaba Welcome, llegó {other:?}"),
}
}
#[test]
fn missing_cap_returns_error_no_cap() {
let d = tempfile::tempdir().unwrap();
let sock = d.path().join("agent.sock");
start_bus(
&sock,
read_only_policy(),
d.path().join("store"),
d.path().join("init.ctl"),
);
let stream = wait_for_sock(&sock);
let mut writer = stream.try_clone().unwrap();
let mut reader = BufReader::new(stream);
send(
&mut writer,
&Command::Hello { ver: 1, client: "test".into() },
);
let _ = recv_event(&mut reader); // welcome
send(
&mut writer,
&Command::Compile {
recipe: RecipeInline {
name: "x".into(),
repo: "git://x".into(),
commit: "abc".into(),
patch: None,
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec![],
},
},
);
match recv_event(&mut reader) {
Event::Error { code, msg } => {
assert_eq!(code, "no_cap");
assert!(msg.contains("Compile"), "{msg}");
}
other => panic!("esperaba Error{{no_cap}}, llegó {other:?}"),
}
}
#[test]
fn query_file_returns_value_for_existing_path() {
let d = tempfile::tempdir().unwrap();
let sock = d.path().join("agent.sock");
start_bus(
&sock,
permissive_policy(),
d.path().join("store"),
d.path().join("init.ctl"),
);
let target = d.path().join("data.txt");
std::fs::write(&target, b"contenido").unwrap();
let stream = wait_for_sock(&sock);
let mut writer = stream.try_clone().unwrap();
let mut reader = BufReader::new(stream);
send(&mut writer, &Command::Hello { ver: 1, client: "t".into() });
let _ = recv_event(&mut reader); // welcome
send(
&mut writer,
&Command::Query {
what: "file".into(),
path: Some(target.display().to_string()),
name: None,
},
);
match recv_event(&mut reader) {
Event::QueryResult { what, value } => {
assert_eq!(what, "file");
assert_eq!(value["exists"], serde_json::json!(true));
assert_eq!(value["size"], serde_json::json!(9));
}
other => panic!("esperaba QueryResult, llegó {other:?}"),
}
}
#[test]
fn modified_events_fan_out_to_subscribers() {
// Validamos sólo el camino del fan-out a través del bus, sin watcher real:
// disparamos `publish(Modified)` directamente y vemos que llega por el socket.
let d = tempfile::tempdir().unwrap();
let sock = d.path().join("agent.sock");
let bus_handle = start_bus(
&sock,
permissive_policy(),
d.path().join("store"),
d.path().join("init.ctl"),
);
let stream = wait_for_sock(&sock);
let mut writer = stream.try_clone().unwrap();
let mut reader = BufReader::new(stream);
send(&mut writer, &Command::Hello { ver: 1, client: "t".into() });
let _ = recv_event(&mut reader); // welcome
// El forwarder de eventos arranca dentro de handle_connection; damos un margen para
// que la suscripción esté registrada antes de publicar.
std::thread::sleep(Duration::from_millis(50));
bus_handle.publish(&Event::Modified {
path: "/bin/grep".into(),
op: "replace".into(),
ts: "2026-06-09T00:00:00Z".into(),
});
match recv_event(&mut reader) {
Event::Modified { path, op, ts } => {
assert_eq!(path, "/bin/grep");
assert_eq!(op, "replace");
assert_eq!(ts, "2026-06-09T00:00:00Z");
}
other => panic!("esperaba Modified, llegó {other:?}"),
}
}
#[test]
fn init_command_writes_to_fifo() {
let d = tempfile::tempdir().unwrap();
let sock = d.path().join("agent.sock");
let fifo = d.path().join("init.ctl");
control::ensure_fifo(&fifo).unwrap();
// Reader del FIFO en background: lee una línea y termina.
let fifo_for_reader = fifo.clone();
let reader_thread = std::thread::spawn(move || {
let f = std::fs::OpenOptions::new()
.read(true)
.open(&fifo_for_reader)
.unwrap();
let mut r = std::io::BufReader::new(f);
let mut s = String::new();
std::io::BufRead::read_line(&mut r, &mut s).unwrap();
s
});
std::thread::sleep(Duration::from_millis(50));
start_bus(
&sock,
permissive_policy(),
d.path().join("store"),
fifo.clone(),
);
let stream = wait_for_sock(&sock);
let mut writer = stream.try_clone().unwrap();
let mut reader = BufReader::new(stream);
send(&mut writer, &Command::Hello { ver: 1, client: "t".into() });
let _ = recv_event(&mut reader);
send(&mut writer, &Command::Init { cmd: "start web".into() });
match recv_event(&mut reader) {
Event::InitAck { cmd } => assert_eq!(cmd, "start web"),
other => panic!("esperaba InitAck, llegó {other:?}"),
}
let from_fifo = reader_thread.join().unwrap();
assert_eq!(from_fifo, "start web\n");
}