Watch
0
0
Fork
You've already forked hyperhive
0

agents: read the hive's name and domain at runtime, not from the build

Part of #5081 (slice S0-1a). An agent's closure no longer depends on the
name or domain of the hive that renders it.

- hive-priv writes /etc/hyperhive-hive-facts (HYPERHIVE_HIVE_NAME,
  HYPERHIVE_HIVE_DOMAIN) into the container from the same WriteNspawnFlags
  call that writes /etc/hyperhive-bridge-dns. hive-c0re passes the values
  from its own unit env. hive-priv refuses a value outside [A-Za-z0-9._-].
- A systemd environment generator in the agent (hive-facts.nix) loads the
  file into the container manager's environment, so every unit sees it,
  as with the systemd.globalEnvironment entries the render used to emit.
  It also builds CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX
  (<hive>-<agent>), which left claude's managed settings.
- meta.rs stops forwarding HYPERHIVE_HIVE_{NAME,DOMAIN} and stops setting
  services.hyperhive.agent.hiveName. The option is removed (both
  spellings now fail with a removal message).
- otel.nix drops the agent's hive= resource label. The swarm collector
  upserts hive per authenticated receiver (swarm-otel.nix resource/<h>,
  on both the metrics and logs pipelines), so stored series keep it.

The otel first hop, otel enable and the dashboard port are still baked;
they are S0-1b.
This commit is contained in:
atlas 2026-10-12 04:40:13 +02:00 • committed by mara
commit c87a4d2c1a
14 changed files with 303 additions and 127 deletions

View file

@ -508,8 +508,6 @@ const FORWARDED_VARS: &[&str] = &[
"HIVE_FORGE_URL",
"HIVE_FORGE_PUBLIC_URL",
"HIVE_MATRIX_URL",
"HYPERHIVE_HIVE_DOMAIN",
"HYPERHIVE_HIVE_NAME",
"HYPERHIVE_SWARM_NAME",
];
@ -520,14 +518,11 @@ const FORWARDED_VARS: &[&str] = &[
/// var is read at runtime. Setting only one leaves the other on its default,
/// which is how a hive ends up with two disagreeing answers for one value.
///
/// The hive/swarm display names are here for exactly that reason, learned the
/// hard way: they were forwarded as runtime env only, while the agent's
/// claude-settings module read them from the container's `environment.variables`
/// at *eval* time — where they were never set. Every agent baked
/// `hive=unknown,swarm=unknown` into its OTEL resource attributes and shipped
/// that label on every metric, while the same process's env held the right
/// answer. Half-wiring this map is not a missing nicety; it is a value that
/// evaluates fine and is silently wrong.
/// The swarm display name is here for that reason: `otel.nix` reads the option
/// at eval time for the `swarm=` resource label, and with the env var alone
/// that label evaluates to `unknown` while the process env holds the right
/// name. Half-wiring this map is a value that evaluates fine and is silently
/// wrong.
const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[
// Read at build time to decide whether the harness unit imports the hive's
// queue credential at all — see `nix/agent-modules/queue.nix`. Both halves
@ -558,7 +553,6 @@ const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[
),
("HIVE_FORGE_URL", "services.hyperhive.agent.forge.url"),
("HIVE_MATRIX_URL", "services.hyperhive.agent.matrix.url"),
("HYPERHIVE_HIVE_NAME", "services.hyperhive.agent.hiveName"),
("HYPERHIVE_SWARM_NAME", "services.hyperhive.agent.swarmName"),
];
@ -1164,9 +1158,7 @@ where
// → in-container accessors fall back to None / defaults gracefully.
//
// - HIVE_FORGE_URL: agents poll this for Forgejo notifications.
// - HYPERHIVE_HIVE_DOMAIN: machine-readable hive DNS.
// - HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME: human display
// names for hive + swarm.
// - HYPERHIVE_SWARM_NAME: human display name for the swarm.
for (var, val) in forwarded_env_vars() {
let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {var} = \"{escaped}\";");
@ -1770,6 +1762,53 @@ mod tests {
);
}
/// The hive's name and domain reach an agent at runtime
/// (`/etc/hyperhive-hive-facts`), so the rendered flake does not change with
/// either.
#[test]
fn render_flake_is_identical_across_hive_names_and_domains() {
const VARS: [&str; 2] = ["HYPERHIVE_HIVE_NAME", "HYPERHIVE_HIVE_DOMAIN"];
let _env = crate::test_env::lock();
let saved = VARS.map(std::env::var_os);
let render_on = |name: &str, domain: &str| {
// SAFETY: serialised by the guard above; restored before returning.
unsafe {
std::env::set_var(VARS[0], name);
std::env::set_var(VARS[1], domain);
}
render_flake(
"github:example/hyperhive",
"path:/nix/store/bbbb-hyperhive-docs-source",
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
let alpha = render_on("alpha-hive", "alpha-hive.example.test");
let beta = render_on("beta-hive", "beta-hive.example.test");
for (var, value) in VARS.iter().zip(saved) {
// SAFETY: as above.
unsafe {
match value {
Some(v) => std::env::set_var(var, v),
None => std::env::remove_var(var),
}
}
}
for hive in ["alpha-hive", "beta-hive"] {
assert!(
!alpha.contains(hive) && !beta.contains(hive),
"the rendered flake must not name the hive ({hive}):\n{alpha}"
);
}
assert_eq!(alpha, beta, "the render must not depend on the hive");
}
#[test]
fn service_url_options_render_from_forwarded_pairs() {
// Pure over the pairs, deliberately: the render-level tests have to
@ -1782,21 +1821,21 @@ mod tests {
&[
("HIVE_FORGE_URL", "http://forge.example.test".to_string()),
("HIVE_MATRIX_URL", "http://matrix.example.test".to_string()),
// Mapped too, and that is the fix this test now guards: the
// display names used to be forwarded as runtime env *only*,
// so the build-time reader fell back to "unknown" and every
// agent shipped that label on every metric.
("HYPERHIVE_HIVE_NAME", "pr1ma".to_string()),
// Mapped too: `otel.nix` reads the option at eval time.
("HYPERHIVE_SWARM_NAME", "constellat1on".to_string()),
// Forwarded but genuinely unmapped — proves the map is a
// filter, not a pass-through.
("HYPERHIVE_HIVE_DOMAIN", "pr1ma.darkest.space".to_string()),
(
"HIVE_FORGE_PUBLIC_URL",
"https://forge.example.test".to_string(),
),
],
);
assert_eq!(
out,
" services.hyperhive.agent.forge.url = \"http://forge.example.test\";\n\
\x20 services.hyperhive.agent.matrix.url = \"http://matrix.example.test\";\n\
\x20 services.hyperhive.agent.hiveName = \"pr1ma\";\n",
\x20 services.hyperhive.agent.swarmName = \"constellat1on\";\n",
"expected exactly the mapped options, in input order, indented for the module block"
);
}
@ -1836,7 +1875,7 @@ mod tests {
fn require_service_urls_accepts_a_rendered_forge_url() {
require_service_urls(&[
("HIVE_FORGE_URL", "http://forge.example.test".to_string()),
("HYPERHIVE_HIVE_NAME", "pr1ma".to_string()),
("HYPERHIVE_SWARM_NAME", "constellat1on".to_string()),
])
.expect("a forwarded forge URL satisfies the requirement");
}
@ -1847,7 +1886,7 @@ mod tests {
// every agent would simply come up with no forge login and no way to
// tell that was unintended. The hive asserts its own requirement
// because it is the only layer that knows a forge is mandatory.
let err = require_service_urls(&[("HYPERHIVE_HIVE_NAME", "pr1ma".to_string())])
let err = require_service_urls(&[("HYPERHIVE_SWARM_NAME", "constellat1on".to_string())])
.expect_err("a missing forge URL must stop the write");
assert!(
err.to_string().contains("HIVE_FORGE_URL is unset"),
@ -1862,13 +1901,16 @@ mod tests {
// so absence is an absent integration rather than a misdirected one.
// Required services don't reach here: `require_service_urls` rejects
// them first.
// `HYPERHIVE_HIVE_DOMAIN` is forwarded as env but carries no option —
// `HIVE_FORGE_PUBLIC_URL` is forwarded as env but carries no option —
// deliberately picked over a mapped var, since the whole point is a
// forwarded var the option map does not know about.
let mut out = String::new();
push_forwarded_var_options(
&mut out,
&[("HYPERHIVE_HIVE_DOMAIN", "pr1ma.darkest.space".to_string())],
&[(
"HIVE_FORGE_PUBLIC_URL",
"https://forge.example.test".to_string(),
)],
);
assert!(
out.is_empty(),