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:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user