hive-c0re: extract agent-socket-plumbing prose (#715 batch 5)
This commit is contained in:
parent
f8e0061f3f
commit
d2f1a9d291
3 changed files with 44 additions and 98 deletions
|
|
@ -1,11 +1,9 @@
|
||||||
//! `/var/lib/hyperhive/agent-ports.json` writer (#15 / #740).
|
//! `/var/lib/hyperhive/agent-ports.json` writer. Legacy TCP map for
|
||||||
//!
|
//! per-agent `/agent/<name>/` routing — the unix-socket replacement
|
||||||
//! The hive-gateway nginx container lives in the host's system config,
|
//! lives in `agent_sockets.rs`. The gateway reads this JSON at
|
||||||
//! not in the meta flake — its build can't be triggered by a
|
//! request-handling time rather than at gateway build time, so a
|
||||||
//! meta-rebuild on every agent spawn / move / destroy. Instead it
|
//! `nixos-container update` of the gateway isn't needed every time
|
||||||
//! reads this JSON file at request-handling time to look up per-agent
|
//! an agent spawns / moves / destroys.
|
||||||
//! upstream ports, so the file IS the source of truth for "which
|
|
||||||
//! agents exist and what's their web port" from the gateway's POV.
|
|
||||||
//!
|
//!
|
||||||
//! Shape (flat object keyed by logical agent name → web port):
|
//! Shape (flat object keyed by logical agent name → web port):
|
||||||
//!
|
//!
|
||||||
|
|
@ -20,13 +18,9 @@
|
||||||
//!
|
//!
|
||||||
//! Ports come from [`crate::lifecycle::agent_web_port`] — pure
|
//! Ports come from [`crate::lifecycle::agent_web_port`] — pure
|
||||||
//! FNV-1a(name) hash so the value is reproducible from the name
|
//! FNV-1a(name) hash so the value is reproducible from the name
|
||||||
//! alone. The manager is intentionally excluded from the map: the
|
//! alone. Manager is excluded from the map: the gateway routes `/`
|
||||||
//! gateway routes `/` straight to it via the c0re dashboard upstream
|
//! straight to it via the c0re dashboard upstream rather than a
|
||||||
//! (see `nix/modules/hive-gateway.nix`, atlas's #740) rather than a
|
//! per-agent `/agent/<name>/` prefix.
|
||||||
//! per-agent `/agent/<name>/` prefix. Post-#753 the manager's port
|
|
||||||
//! is computed by the same hash as every other agent, but it still
|
|
||||||
//! doesn't appear here — keeping the routing surface "sub-agents
|
|
||||||
//! only" matches the gateway's current shape.
|
|
||||||
//!
|
//!
|
||||||
//! Atomicity: write to a sibling `.tmp` file + rename so a partial
|
//! Atomicity: write to a sibling `.tmp` file + rename so a partial
|
||||||
//! write never leaves an unparseable file in place. The gateway's
|
//! write never leaves an unparseable file in place. The gateway's
|
||||||
|
|
@ -116,7 +110,7 @@ mod tests {
|
||||||
// Use MANAGER_NAME in the input so the assert below actually
|
// Use MANAGER_NAME in the input so the assert below actually
|
||||||
// exercises the filter path — a literal `"hm1nd"` would pass
|
// exercises the filter path — a literal `"hm1nd"` would pass
|
||||||
// trivially if the constant ever changed and the filter
|
// trivially if the constant ever changed and the filter
|
||||||
// silently became a no-op (argus on PR #748).
|
// silently became a no-op.
|
||||||
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
|
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| (*s).to_owned())
|
.map(|s| (*s).to_owned())
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,12 @@ pub fn start(agent: &str, socket_path: &Path, coord: Arc<Coordinator>) -> Result
|
||||||
let listener = UnixListener::bind(socket_path)
|
let listener = UnixListener::bind(socket_path)
|
||||||
.with_context(|| format!("bind agent socket {}", socket_path.display()))?;
|
.with_context(|| format!("bind agent socket {}", socket_path.display()))?;
|
||||||
// The socket is bind-mounted into exactly one container as
|
// The socket is bind-mounted into exactly one container as
|
||||||
// `/run/hive/mcp.sock` (`lifecycle::set_nspawn_flags`); post-#658
|
// `/run/hive/mcp.sock` (`lifecycle::set_nspawn_flags`); the
|
||||||
// the in-container harness connects as the per-agent unix user,
|
// in-container harness connects as the per-agent unix user,
|
||||||
// not root, so the default `tokio::net::UnixListener::bind` perms
|
// not root, so the default `tokio::net::UnixListener::bind`
|
||||||
// (0755) lock it out. 0666 lets the agent user connect; the bind
|
// perms (0755) lock it out. 0666 lets the agent user connect;
|
||||||
// source dir is per-agent on host so blast radius is unchanged.
|
// the bind source dir is per-agent on host so blast radius is
|
||||||
|
// unchanged.
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o666))
|
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o666))
|
||||||
.with_context(|| format!("chmod agent socket {}", socket_path.display()))?;
|
.with_context(|| format!("chmod agent socket {}", socket_path.display()))?;
|
||||||
|
|
@ -230,7 +231,7 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AgentRequest::SetStatus { text } => {
|
AgentRequest::SetStatus { text } => {
|
||||||
// #720: cap length + reject multi-line so a confused caller
|
// Cap length + reject multi-line so a confused caller
|
||||||
// can't dump a multi-paragraph session report into the
|
// can't dump a multi-paragraph session report into the
|
||||||
// dashboard chip.
|
// dashboard chip.
|
||||||
if let Err(message) = crate::limits::check_status_text(text) {
|
if let Err(message) = crate::limits::check_status_text(text) {
|
||||||
|
|
@ -261,10 +262,10 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
|
||||||
}
|
}
|
||||||
AgentRequest::GetAgentMeta { name } => {
|
AgentRequest::GetAgentMeta { name } => {
|
||||||
let target = name.as_deref().unwrap_or(agent);
|
let target = name.as_deref().unwrap_or(agent);
|
||||||
// #432: gate status on the target's running state so a
|
// Gate status on the target's running state so a stopped
|
||||||
// stopped container's stale on-disk status doesn't leak
|
// container's stale on-disk status doesn't leak through.
|
||||||
// through. Also surface `running` itself so callers can
|
// Also surface `running` itself so callers can tell
|
||||||
// tell (e.g. "iris is down" vs "iris has no status set").
|
// (e.g. "iris is down" vs "iris has no status set").
|
||||||
let (status_text, status_set_at, running) =
|
let (status_text, status_set_at, running) =
|
||||||
crate::container_view::read_agent_status_live(target).await;
|
crate::container_view::read_agent_status_live(target).await;
|
||||||
let role = if target == hive_sh4re::MANAGER_AGENT {
|
let role = if target == hive_sh4re::MANAGER_AGENT {
|
||||||
|
|
@ -339,8 +340,9 @@ fn handle_send(
|
||||||
}
|
}
|
||||||
// Resolve magic-recipient sentinels (currently `<parent>`) against
|
// Resolve magic-recipient sentinels (currently `<parent>`) against
|
||||||
// topology.json; no-op for ordinary names. Lets agents address
|
// topology.json; no-op for ordinary names. Lets agents address
|
||||||
// structural roles without learning the label — runtime reparenting
|
// structural roles without learning the label — runtime
|
||||||
// (#486) propagates for free (#692).
|
// reparenting propagates for free. See `docs/conventions.md::
|
||||||
|
// Recipient sentinels`.
|
||||||
let resolved = crate::topology::resolve_recipient(agent, to);
|
let resolved = crate::topology::resolve_recipient(agent, to);
|
||||||
match coord.broker.send(&Message {
|
match coord.broker.send(&Message {
|
||||||
from: agent.to_owned(),
|
from: agent.to_owned(),
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,12 @@
|
||||||
//! `/var/lib/hyperhive/agent-sockets.json` writer (#784 phase 2,
|
//! `/var/lib/hyperhive/agent-sockets.json` writer. Sibling to
|
||||||
//! prerequisite to #14 container netns isolation).
|
//! `agent_ports.rs`; same atomic `<path>.tmp` + `rename()` shape so
|
||||||
|
//! the gateway's nginx worker never reads a partial file. Manager
|
||||||
|
//! excluded from the map (manager UI routes via the dashboard
|
||||||
|
//! upstream, not per-agent `/agent/<name>/`).
|
||||||
//!
|
//!
|
||||||
//! Sibling to `agent_ports.rs`. The gateway needs to know which unix
|
//! Full mechanism — per-agent subdir bind-mount, `.bound` marker
|
||||||
//! socket to `proxy_pass` to per agent once the per-agent web UI
|
//! gate, gateway UDS upstream, transition vs `agent-ports.json`,
|
||||||
//! flips off TCP and on to `UnixListener::bind` (#784 phase 1
|
//! 10s poll loop: `docs/gateway.md::Per-agent unix-socket upstream`.
|
||||||
//! landed via PR #800). This file is the source of truth for
|
|
||||||
//! "which agents exist + where to reach their web UI over a domain
|
|
||||||
//! socket" from the gateway's POV — read at request-handling time,
|
|
||||||
//! not at gateway build time, so a `nixos-container update` of the
|
|
||||||
//! gateway isn't needed every time an agent spawns / moves /
|
|
||||||
//! destroys.
|
|
||||||
//!
|
|
||||||
//! Shape (flat object keyed by logical agent name → socket path):
|
|
||||||
//!
|
|
||||||
//! ```json
|
|
||||||
//! {
|
|
||||||
//! "iris": "/run/hive-agent/iris.sock",
|
|
||||||
//! "atlas": "/run/hive-agent/atlas.sock",
|
|
||||||
//! "argus": "/run/hive-agent/argus.sock",
|
|
||||||
//! "damocles": "/run/hive-agent/damocles.sock"
|
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! Socket paths are deterministic from the agent name —
|
|
||||||
//! [`socket_path_for`] computes them, so a name alone resolves to a
|
|
||||||
//! reproducible path. Manager is intentionally excluded from the map
|
|
||||||
//! (same reasoning as `agent_ports.rs`: the gateway routes the
|
|
||||||
//! manager's UI at `/` straight to the dashboard upstream, not via
|
|
||||||
//! per-agent `/agent/<name>/`).
|
|
||||||
//!
|
|
||||||
//! Coexists with `agent-ports.json` during the #784 phase-3
|
|
||||||
//! transition: agents that haven't opted in to `HIVE_WEB_SOCKET` yet
|
|
||||||
//! still appear in both files; the gateway picks the socket upstream
|
|
||||||
//! when one exists, falls back to the TCP port otherwise. Step 4
|
|
||||||
//! drops the TCP path entirely once every agent's web UI has flipped.
|
|
||||||
//!
|
|
||||||
//! Atomicity: same `<path>.tmp` + `rename()` shape as `agent_ports.rs`
|
|
||||||
//! so the gateway's nginx worker never reads a partial file.
|
|
||||||
//!
|
|
||||||
//! ## Per-agent subdir layout
|
|
||||||
//!
|
|
||||||
//! `<sockets-root>/<name>/web.sock`, NOT `<sockets-root>/<name>.sock`.
|
|
||||||
//! Each agent's container bind-mounts the per-agent SUBDIR
|
|
||||||
//! (`/run/hive-agent/<name>/`), and the harness binds the socket
|
|
||||||
//! inside it. File-level bind-mounts don't survive the harness's
|
|
||||||
//! "unlink stale socket then `bind(2)` a new one" cycle — the unlink
|
|
||||||
//! drops the bind, the rebind happens in private container
|
|
||||||
//! namespace, host never sees the new inode. Bind-mounting the
|
|
||||||
//! parent dir keeps both sides looking at the same dir inode so the
|
|
||||||
//! socket appears on the host the moment the harness binds it.
|
|
||||||
//!
|
|
||||||
//! Per-agent dir isolation (one dir per agent rather than a shared
|
|
||||||
//! `/run/hive-agent/` bind) satisfies mara on #800: an agent's
|
|
||||||
//! container only sees its own dir + socket, never siblings'.
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -66,8 +20,8 @@ const HOST_SOCKETS_PATH: &str = "/var/lib/hyperhive/agent-sockets.json";
|
||||||
/// Host-side parent directory holding per-agent socket subdirs. The
|
/// Host-side parent directory holding per-agent socket subdirs. The
|
||||||
/// gateway container bind-mounts this whole tree (read-only) so it
|
/// gateway container bind-mounts this whole tree (read-only) so it
|
||||||
/// can `proxy_pass` to any agent. Each agent's container bind-mounts
|
/// can `proxy_pass` to any agent. Each agent's container bind-mounts
|
||||||
/// only its own `<name>/` subdir, scoping access per mara's #800
|
/// only its own `<name>/` subdir — agents can only access their own
|
||||||
/// directive ("agents can only access their own sockets").
|
/// sockets.
|
||||||
pub const AGENT_SOCKET_DIR: &str = "/run/hive-agent";
|
pub const AGENT_SOCKET_DIR: &str = "/run/hive-agent";
|
||||||
|
|
||||||
/// Socket filename inside each per-agent subdir. Fixed so the path
|
/// Socket filename inside each per-agent subdir. Fixed so the path
|
||||||
|
|
@ -79,10 +33,9 @@ pub const SOCKET_FILENAME: &str = "web.sock";
|
||||||
/// successful `bind_unix`. Presence = "this agent has opted in to
|
/// successful `bind_unix`. Presence = "this agent has opted in to
|
||||||
/// `hyperhive.web.useUnixSocket = true` and its harness has bound
|
/// `hyperhive.web.useUnixSocket = true` and its harness has bound
|
||||||
/// the socket"; absence = "the harness is still on TCP, don't
|
/// the socket"; absence = "the harness is still on TCP, don't
|
||||||
/// publish the unix upstream for this agent yet". Atlas's gate on
|
/// publish the unix upstream for this agent yet". Without this gate
|
||||||
/// PR #813 — without it, the gateway would `proxy_pass` to a
|
/// the gateway would `proxy_pass` to a non-existent socket for every
|
||||||
/// non-existent socket for every sub-agent that hasn't flipped the
|
/// sub-agent that hasn't flipped the option yet.
|
||||||
/// option yet.
|
|
||||||
pub const READY_MARKER: &str = ".bound";
|
pub const READY_MARKER: &str = ".bound";
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -101,7 +54,7 @@ pub fn agent_dir_for(name: &str) -> PathBuf {
|
||||||
/// Compute the deterministic socket path for an agent. Pure function
|
/// Compute the deterministic socket path for an agent. Pure function
|
||||||
/// of the agent name so the value matches whatever
|
/// of the agent name so the value matches whatever
|
||||||
/// [`agent_sockets::write`] writes for that agent, and whatever the
|
/// [`agent_sockets::write`] writes for that agent, and whatever the
|
||||||
/// harness binds via `HIVE_WEB_SOCKET` post-#784 phase 1.
|
/// harness binds via `HIVE_WEB_SOCKET`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn socket_path_for(name: &str) -> PathBuf {
|
pub fn socket_path_for(name: &str) -> PathBuf {
|
||||||
agent_dir_for(name).join(SOCKET_FILENAME)
|
agent_dir_for(name).join(SOCKET_FILENAME)
|
||||||
|
|
@ -115,10 +68,9 @@ pub fn socket_path_for(name: &str) -> PathBuf {
|
||||||
///
|
///
|
||||||
/// Also filters by `READY_MARKER` presence: only agents whose
|
/// Also filters by `READY_MARKER` presence: only agents whose
|
||||||
/// harness has actually bound the unix socket (and dropped the
|
/// harness has actually bound the unix socket (and dropped the
|
||||||
/// marker) appear in the map. Atlas's gate on #813 — without this,
|
/// marker) appear in the map. Without this, the gateway would
|
||||||
/// the gateway would `proxy_pass` to a non-existent socket for
|
/// `proxy_pass` to a non-existent socket for every sub-agent that
|
||||||
/// every sub-agent that hasn't yet flipped
|
/// hasn't yet flipped `hyperhive.web.useUnixSocket = true`.
|
||||||
/// `hyperhive.web.useUnixSocket = true`.
|
|
||||||
///
|
///
|
||||||
/// `BTreeMap` keeps the JSON output sorted by key so a re-emit
|
/// `BTreeMap` keeps the JSON output sorted by key so a re-emit
|
||||||
/// without churn produces byte-identical output — same idempotency
|
/// without churn produces byte-identical output — same idempotency
|
||||||
|
|
@ -270,8 +222,7 @@ mod tests {
|
||||||
// Use `MANAGER_NAME` in the input so the assert actually
|
// Use `MANAGER_NAME` in the input so the assert actually
|
||||||
// exercises the filter path — a literal `"hm1nd"` would pass
|
// exercises the filter path — a literal `"hm1nd"` would pass
|
||||||
// trivially if the constant ever changed and the filter
|
// trivially if the constant ever changed and the filter
|
||||||
// silently became a no-op (same pattern as #748 fix on
|
// silently became a no-op. All-ready predicate bypasses the
|
||||||
// agent_ports::build_map). All-ready predicate bypasses the
|
|
||||||
// marker check so we exercise the manager filter in isolation.
|
// marker check so we exercise the manager filter in isolation.
|
||||||
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
|
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -320,9 +271,8 @@ mod tests {
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| (*s).to_owned())
|
.map(|s| (*s).to_owned())
|
||||||
.collect();
|
.collect();
|
||||||
// Pretend only `atlas` has flipped + bound (mara on PR #813:
|
// Pretend only `atlas` has flipped + bound — the gate makes
|
||||||
// "agents can only access their own sockets" — the gate
|
// sure only opted-in agents get a UDS upstream.
|
||||||
// makes sure only opted-in agents get a UDS upstream).
|
|
||||||
let map = build_map_with(&names, |name| name == "atlas");
|
let map = build_map_with(&names, |name| name == "atlas");
|
||||||
assert!(map.contains_key("atlas"));
|
assert!(map.contains_key("atlas"));
|
||||||
assert!(!map.contains_key("iris"));
|
assert!(!map.contains_key("iris"));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue