Compare commits

...
3 changed files with 41 additions and 78 deletions

View file

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

View file

@ -1174,60 +1174,31 @@ fn set_nspawn_flags(
std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?; std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?;
let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config"); 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 bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
let mut lines: Vec<String> = original let mut lines: Vec<String> = original
.lines() .lines()
@ -1469,3 +1440,4 @@ mod tests {
); );
} }
} }

View file

@ -95,12 +95,9 @@ in
4. eventually drop this option once every agent is on unix and 4. eventually drop this option once every agent is on unix and
the TCP fallback is removed from the harness. the TCP fallback is removed from the harness.
Sub-agent-only by design: the manager's UI serves at `/` via Sub-agents only: the manager always has `HIVE_WEB_SOCKET` set
the c0re dashboard upstream, not via `/agent/<name>/`, so this unconditionally in the `isManager` env block, so this toggle
option has no effect when `hyperhive.role = "manager"` (the has no effect when `hyperhive.role = "manager"`.
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).
''; '';
}; };
@ -1325,6 +1322,9 @@ in
# HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100. # HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100.
HIVE_PORT = "8875"; HIVE_PORT = "8875";
HIVE_LABEL = "hm1nd"; 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 = { serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve"; ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
@ -1343,3 +1343,4 @@ in
system.stateVersion = "25.11"; system.stateVersion = "25.11";
}; };
} }