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 // Web-socket subdir: bind-mount `/run/hive-agent/<name>/` into the
// `HIVE_WEB_SOCKET` bind has a stable location both sides can // container so the harness can bind `web.sock` there and the host-side
// see. Sub-agents only — the manager's UI is served at `/` // gateway sees it. Subdir bind (not socket file) keeps the inode
// via the c0re dashboard upstream, not via `/agent/<name>/`, // visible after the harness unlinks a stale socket on rebind.
// so it never needs the per-agent socket dir. // Applies to manager and sub-agents alike.
//
// 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); let socket_dir = crate::agent_sockets::agent_dir_for(agent_name);
std::fs::create_dir_all(&socket_dir) std::fs::create_dir_all(&socket_dir)
.with_context(|| format!("create {}", socket_dir.display()))?; .with_context(|| format!("create {}", socket_dir.display()))?;
// chown to the in-container agent user so its harness can // Chown to the agent user so the non-root harness can bind(2) here.
// `bind(2)` web.sock here. `create_dir_all` lands the dir at // Falls back to 0777 on first spawn when uid lookup returns None
// 0755 root:root and the harness runs as the non-root agent // (container /etc/passwd not yet rendered).
// 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) { if let Some((uid, gid)) = agent_uid_gid(agent_name) {
std::os::unix::fs::chown(&socket_dir, Some(uid), Some(gid)) std::os::unix::fs::chown(&socket_dir, Some(uid), Some(gid))
.with_context(|| format!("chown {} to {uid}:{gid}", socket_dir.display()))?; .with_context(|| format!("chown {} to {uid}:{gid}", socket_dir.display()))?;
} else { } else {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o777)) std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o777))
.with_context(|| { .with_context(|| format!("chmod 0777 {}", socket_dir.display()))?;
format!("chmod 0777 {} (uid lookup failed)", socket_dir.display())
})?;
} }
let _ = write!( let _ = write!(
binds, binds,
" --bind={socket_dir}:{socket_dir}", " --bind={socket_dir}:{socket_dir}",
socket_dir = socket_dir.display(), 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";
}; };
} }