Compare commits

..
3 changed files with 78 additions and 41 deletions

View file

@ -1,8 +1,8 @@
//! `/var/lib/hyperhive/agent-sockets.json` writer. Sibling to
//! `agent_ports.rs`; same atomic `<path>.tmp` + `rename()` shape so
//! the gateway's nginx worker never reads a partial file. Includes
//! manager and sub-agents so the gateway can route
//! `/agent/<name>/` for all containers with a bound unix socket.
//! 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>/`).
//!
//! Full mechanism — per-agent subdir bind-mount, `hyperhive-socket-bound`
//! marker gate, gateway UDS upstream, transition vs `agent-ports.json`,
@ -67,9 +67,14 @@ pub fn socket_path_for(name: &str) -> PathBuf {
}
/// Compute the agent-socket map for the given logical agent names.
/// Includes manager and sub-agents. Filters by `READY_MARKER`
/// presence: only agents whose harness has actually bound the unix
/// socket appear in the map. Without this, the gateway would
/// Sub-agents only — manager is filtered out at the call boundary
/// for the same reason it's filtered from `agent_ports::build_map`
/// (manager UI is routed via the c0re dashboard upstream, not via
/// `/agent/<name>/`).
///
/// Also filters by `READY_MARKER` presence: only agents whose
/// harness has actually bound the unix socket (and dropped the
/// marker) appear in the map. Without this, the gateway would
/// `proxy_pass` to a non-existent socket for every sub-agent that
/// hasn't yet flipped `hyperhive.web.useUnixSocket = true`.
///
@ -100,6 +105,7 @@ where
{
names
.iter()
.filter(|n| n.as_str() != MANAGER_NAME)
.filter(|n| is_ready(n))
.map(|n| (n.clone(), socket_path_for(n)))
.collect()
@ -235,13 +241,18 @@ mod tests {
}
#[test]
fn build_map_includes_manager() {
fn build_map_filters_manager() {
// Use `MANAGER_NAME` in the input so the assert actually
// exercises the filter path — a literal `"hm1nd"` would pass
// trivially if the constant ever changed and the filter
// silently became a no-op. All-ready predicate bypasses the
// marker check so we exercise the manager filter in isolation.
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
.iter()
.map(|s| (*s).to_owned())
.collect();
let map = build_map_with(&names, |_| true);
assert!(map.contains_key(MANAGER_NAME));
assert!(!map.contains_key(MANAGER_NAME));
assert!(map.contains_key("iris"));
assert!(map.contains_key("argus"));
}
@ -332,4 +343,3 @@ mod tests {
assert!(body.contains("\"/run/hive-agent/iris/web.sock\""));
}
}

View file

@ -1174,31 +1174,60 @@ fn set_nspawn_flags(
std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?;
let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config");
// Per-agent socket subdir. Bind-mounts `/run/hive-agent/<name>/`
// into the container at the same path so the harness's
// `HIVE_WEB_SOCKET` bind has a stable location both sides can
// see. Sub-agents only — the manager's UI is served at `/`
// via the c0re dashboard upstream, not via `/agent/<name>/`,
// so it never needs the per-agent socket dir.
//
// Bind-mounting the SUBDIR (not the socket file) is mandatory:
// the harness's `bind_unix` helper unlinks any stale socket
// before calling `bind(2)`, and a file bind-mount drops its
// host-side anchor on unlink — the rebind would land in the
// container's private namespace, invisible to the gateway.
// Dir bind keeps the same dir inode visible on both sides, so
// the new `web.sock` shows up on the host the moment the
// harness binds it.
//
// Per-agent dir (rather than a shared `/run/hive-agent/`
// mount) means the agent's container only sees its own
// subdir — never siblings'. See `docs/gateway.md::Per-agent
// unix-socket upstream`.
//
// mkdir source defensively: nspawn refuses to start when the
// bind source is missing, and on a fresh host `/run/hive-agent/`
// doesn't exist yet.
let socket_dir = crate::agent_sockets::agent_dir_for(agent_name);
std::fs::create_dir_all(&socket_dir)
.with_context(|| format!("create {}", socket_dir.display()))?;
// chown to the in-container agent user so its harness can
// `bind(2)` web.sock here. `create_dir_all` lands the dir at
// 0755 root:root and the harness runs as the non-root agent
// user; without this chown the bind fails with EACCES, the
// gateway's agent-sockets.json stays empty, and the agent
// looks unreachable. uid resolution can return None on the
// very first spawn (container's /etc/passwd not yet rendered)
// — fall back to a permissive 0777 in that window so the
// first harness boot still binds. nspawn shares uids with the
// host (no PrivateUsers), so the in-container uid is the same
// uid we chown to here.
if let Some((uid, gid)) = agent_uid_gid(agent_name) {
std::os::unix::fs::chown(&socket_dir, Some(uid), Some(gid))
.with_context(|| format!("chown {} to {uid}:{gid}", socket_dir.display()))?;
} else {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o777))
.with_context(|| {
format!("chmod 0777 {} (uid lookup failed)", socket_dir.display())
})?;
}
let _ = write!(
binds,
" --bind={socket_dir}:{socket_dir}",
socket_dir = socket_dir.display(),
);
}
// Web-socket subdir: bind-mount `/run/hive-agent/<name>/` into the
// container so the harness can bind `web.sock` there and the host-side
// gateway sees it. Subdir bind (not socket file) keeps the inode
// visible after the harness unlinks a stale socket on rebind.
// Applies to manager and sub-agents alike.
let socket_dir = crate::agent_sockets::agent_dir_for(agent_name);
std::fs::create_dir_all(&socket_dir)
.with_context(|| format!("create {}", socket_dir.display()))?;
// Chown to the agent user so the non-root harness can bind(2) here.
// Falls back to 0777 on first spawn when uid lookup returns None
// (container /etc/passwd not yet rendered).
if let Some((uid, gid)) = agent_uid_gid(agent_name) {
std::os::unix::fs::chown(&socket_dir, Some(uid), Some(gid))
.with_context(|| format!("chown {} to {uid}:{gid}", socket_dir.display()))?;
} else {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o777))
.with_context(|| format!("chmod 0777 {}", socket_dir.display()))?;
}
let _ = write!(
binds,
" --bind={socket_dir}:{socket_dir}",
socket_dir = socket_dir.display(),
);
let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
let mut lines: Vec<String> = original
.lines()
@ -1440,4 +1469,3 @@ mod tests {
);
}
}

View file

@ -95,9 +95,12 @@ in
4. eventually drop this option once every agent is on unix and
the TCP fallback is removed from the harness.
Sub-agents only: the manager always has `HIVE_WEB_SOCKET` set
unconditionally in the `isManager` env block, so this toggle
has no effect when `hyperhive.role = "manager"`.
Sub-agent-only by design: the manager's UI serves at `/` via
the c0re dashboard upstream, not via `/agent/<name>/`, so this
option has no effect when `hyperhive.role = "manager"` (the
env var is set unconditionally for clarity, but the manager's
web UI doesn't route through the gateway's per-agent unix
upstream its bind socket would just sit unused).
'';
};
@ -1322,9 +1325,6 @@ in
# HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100.
HIVE_PORT = "8875";
HIVE_LABEL = "hm1nd";
# Manager always uses a unix socket so the gateway can route
# /agent/<name>/ to it the same way it routes sub-agents.
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
};
serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
@ -1343,4 +1343,3 @@ in
system.stateVersion = "25.11";
};
}