diff --git a/docs/gateway.md b/docs/gateway.md index 29b3cd09..952c6cce 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -103,10 +103,10 @@ now set unconditionally for every agent. The mechanism: (Legacy name `.bound` also accepted during the transition window.) 4. **Gateway side**. `gateway_nginx::write` generates `/var/lib/hyperhive/gateway/agents.conf` — a plain nginx include - file with one `location /agent//` block per agent. Always - a UDS upstream (`http://unix:/run/hive-agent//web.sock:/`); - if the socket is not yet bound, nginx returns 502 caught by the - `error_page 502 503 504 = /__hive_agent_unreachable` directive. + file with one `location /agent//` block per agent. UDS + upstream (`http://unix:/run/hive-agent//web.sock:/`) when + `hyperhive-socket-bound` marker present; TCP loopback for agents + that haven't yet been rebuilt under the new config. The gateway container bind-mounts `/var/lib/hyperhive/gateway/` at `/run/hive-state/`; nginx includes `/run/hive-state/agents.conf`. After each write, c0re triggers the appropriate nginx action inside @@ -127,6 +127,17 @@ idempotent — skips the rename when content is unchanged. Failed reloads are retried automatically on subsequent poll ticks via `gateway_nginx::reload_if_pending`. +## TCP loopback fallback + +While an agent's unix-socket marker is absent, the gateway routes its +`/agent//` traffic to a TCP loopback upstream in `agents.conf`. The +port is derived on the fly from `lifecycle::agent_web_port(name)` — a pure +FNV-1a hash of the name, reproducible from the name alone, no name +special-cased (so no on-disk port map is needed). Once the agent binds its +unix socket — every agent does, via `HIVE_WEB_SOCKET` — the gateway +switches to the socket upstream from `agent-sockets.json`. The root agent's +UI is routed at `/agent/root/`. + `agents.conf` uses atomic `.tmp` + `rename()` writes so a crashing c0re process never leaves a partial or unparseable file behind. diff --git a/hive-c0re/src/gateway_nginx.rs b/hive-c0re/src/gateway_nginx.rs index 673d70a2..e84f7a22 100644 --- a/hive-c0re/src/gateway_nginx.rs +++ b/hive-c0re/src/gateway_nginx.rs @@ -1,7 +1,7 @@ //! Runtime nginx include-file generator for the gateway's per-agent //! `/agent//` location blocks. Writes //! `/var/lib/hyperhive/gateway/agents.conf` on every topology change. -//! UDS upstream selection, reload trigger (`systemd-run +//! UDS vs TCP upstream selection, reload trigger (`systemd-run //! --machine=hive-gateway`), and idempotency: //! `docs/gateway.md::Per-agent unix-socket upstream`. @@ -14,6 +14,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::priv_client; use crate::agent_sockets; +use crate::lifecycle; /// Set when `write` publishes a new agents.conf; cleared when /// `reload_gateway_nginx` submits the reload command successfully. @@ -73,11 +74,12 @@ const PROXY_HEADER_BLOCK: &str = " proxy_http_version 1.1; /// When `frontend_dir` is `None` (legacy) each agent gets a single /// `location /agent//` proxy block. /// -/// Output is deterministic for the same (sorted) set of names: -/// no timestamps, no UUIDs. `BTreeMap` would give alphabetical order; -/// we rely on the caller to pass sorted names if they care about diff -/// stability, but the gateway treats the blocks as unordered by nginx's -/// longest-prefix-match rules so ordering only affects human readability. +/// Output is deterministic for the same (sorted) set of names + +/// `.bound` state: no timestamps, no UUIDs. `BTreeMap` would give +/// alphabetical order; we rely on the caller to pass sorted names if +/// they care about diff stability, but the gateway treats the blocks +/// as unordered by nginx's longest-prefix-match rules so ordering only +/// affects human readability. fn render(names: &[String], frontend_dir: Option<&str>) -> String { let mut out = String::from( "# Generated by hive-c0re \u{2014} do not edit.\ @@ -85,16 +87,24 @@ fn render(names: &[String], frontend_dir: Option<&str>) -> String { \n# Reload triggered by hive-c0re via systemd-run --machine=hive-gateway.\n", ); for name in names { + let port = lifecycle::agent_web_port(name); // Two upstream forms because named locations (split mode's // `@_dynamic`) forbid a URI part on `proxy_pass`. The // legacy prefix-location path keeps the trailing `/` so nginx // strips `/agent//` automatically; the named-location // path strips the prefix via `rewrite` and uses a bare upstream. - // When the socket is not yet bound, nginx returns 502, caught by - // the error_page directive below. - let sock = agent_sockets::socket_path_for(name).display().to_string(); - let upstream_prefix = format!("http://unix:{sock}:/"); - let upstream_bare = format!("http://unix:{sock}:"); + let (upstream_prefix, upstream_bare) = if agent_sockets::ready_marker_for(name).exists() { + let sock = agent_sockets::socket_path_for(name).display().to_string(); + ( + format!("http://unix:{sock}:/"), + format!("http://unix:{sock}:"), + ) + } else { + ( + format!("http://127.0.0.1:{port}/"), + format!("http://127.0.0.1:{port}"), + ) + }; if let Some(frontend) = frontend_dir { // Split mode: try to serve files from the nix-store dist first; @@ -293,18 +303,14 @@ mod tests { } #[test] - fn render_uds_upstream_unconditional() { + fn render_tcp_upstream_when_no_bound_marker() { + // No .bound file on disk → falls back to TCP loopback. let names = vec!["iris".to_owned()]; let body = render(&names, None); - // UDS form present + let port = lifecycle::agent_web_port("iris"); assert!( - body.contains("proxy_pass http://unix:"), - "expected UDS upstream for iris, got:\n{body}" - ); - // No TCP loopback fallback - assert!( - !body.contains("127.0.0.1"), - "unexpected TCP loopback in output:\n{body}" + body.contains(&format!("proxy_pass http://127.0.0.1:{port}/")), + "expected TCP upstream for iris, got:\n{body}" ); }