//! The meta flake's text: one `nixosConfigurations.` per agent, built //! from hyperhive's `agent-base` and the agent's config input or archetype. //! //! Pure: the output is a function of the arguments only. hive-c0re and //! swarm-controller both render through [`render_flake`], so the flake a hive //! builds has one renderer. use anyhow::{Context, Result}; use swarm_wire::agent_archetype::ArchetypeRef; /// One agent of the meta flake. #[derive(Debug, Clone)] pub struct AgentSpec { pub name: String, pub port: u16, /// The archetype the agent is built from, `None` /// for a custom agent built from its own config repo. pub archetype: Option, /// The pin of a custom agent's config input. `None` until the caller /// resolves it; an archetype agent has no config input and keeps `None`. pub pin: Option, } /// What the rendered flake needs to know about one agent's config input, all /// of it read from the agent's config repo at one commit. The rendered text /// is a function of this and nothing on the host. #[derive(Debug, Clone)] pub struct AgentPin { /// `git+/agent-configs/.git?rev=`, `` being the head of /// `main` on the forge when the flake was rendered. pub url: String, /// The `CANONICAL_INPUTS` the agent's `flake.lock` declares at that commit. pub canonical_inputs: Vec<&'static str>, } /// `git+?rev=`. Errors on a missing or empty rev rather /// than rendering an unpinned URL. pub fn pinned_git_url(forge_git_url: &str, rev: Option<&str>) -> Result { let rev = rev.filter(|r| !r.is_empty()).context( "hyperhive rev unknown: the host was built from a dirty tree \ (`self.rev` unset) and `services.hyperhive.c0re.hyperhiveRev` is not set", )?; Ok(format!("git+{forge_git_url}?rev={rev}")) } /// Canonical inputs meta knows how to dedup. An agent that declares one /// of these as a top-level input in its own `flake.nix` will get a /// `follows = ""` line emitted in meta — collapsing the /// otherwise-separate-but-identical `nixpkgs_N` nodes into a single /// meta-level reference. pub const CANONICAL_INPUTS: &[&str] = &["nixpkgs"]; /// Env vars the meta flake sets in every agent container, from the /// `(name, value)` pairs [`render_flake`] is given. A caller passes only the /// names it has a non-empty value for, so absent options render no no-op /// `FOO = ""` lines. pub const FORWARDED_VARS: &[&str] = &[ // Where the swarm secret store listens. The hive's own `BAO_ADDR` value, // forwarded under a separate name so the container never inherits the // hive's store environment wholesale — the address is the only part of it // an agent may have, the certificate beside it being the hive's own. "HIVE_AGENT_BAO_ADDR", // The mount path of the store's services `pki` engine, whose root an agent // fetches to trust the swarm. Forwarded with the address, under the same gate. "HIVE_AGENT_BAO_SERVICES_PKI_MOUNT", // Where an agent's `swarm-logs` queries, computed by the host: it is a // gateway address, and a container cannot derive it from anything it holds. "HIVE_AGENT_LOGS_QUERY_URL", // The metrics store's origin, for `swarm-metrics`, for the same reason. "HIVE_AGENT_METRICS_URL", "HIVE_FORGE_URL", "HIVE_FORGE_PUBLIC_URL", "HIVE_MATRIX_URL", "HYPERHIVE_SWARM_NAME", ]; /// Map of forwarded env var -> the agent option carrying the same value. /// /// Both exist because they're consumed at different times: the option is baked /// into scripts at build time (forge-avatar-sync bakes `FORGE_URL` from it), the env /// 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 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. pub const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[ // Read at build time to decide whether the in-container identity check is // generated at all — see `nix/agent-modules/bao.nix`. Both halves wired // for this map's own reason: the unit bakes the address into its own // environment, so an agent whose option says no store and whose env says // otherwise would have a check that is simply not there. ("HIVE_AGENT_BAO_ADDR", "services.hyperhive.agent.bao.addr"), // Read at build time to bake the root-fetch URL into // `nix/agent-modules/hive-ca.nix`'s unit. ( "HIVE_AGENT_BAO_SERVICES_PKI_MOUNT", "services.hyperhive.agent.bao.servicesPkiMount", ), // Read at build time to decide whether `swarm-logs` is installed at all — // see `nix/agent-modules/logs.nix`. Both halves wired for this map's own // reason: an agent whose option says no log store and whose env says // otherwise has a binary that is not on PATH and coordinates that claim // it is. ( "HIVE_AGENT_LOGS_QUERY_URL", "services.hyperhive.agent.logs.queryUrl", ), // The same for `swarm-metrics`: see `nix/agent-modules/metrics.nix`. ( "HIVE_AGENT_METRICS_URL", "services.hyperhive.agent.metrics.url", ), ("HIVE_FORGE_URL", "services.hyperhive.agent.forge.url"), ("HIVE_MATRIX_URL", "services.hyperhive.agent.matrix.url"), ("HYPERHIVE_SWARM_NAME", "services.hyperhive.agent.swarmName"), ]; /// Render the forwarded-var option assignments for one agent's module block. /// /// Split out of `render_flake` so it can be tested without touching process /// env: the render-level tests have to `set_var`, which forces them to /// serialise on the crate's `test_env` lock (a `#[cfg(test)]` module, so this /// deliberately isn't an intra-doc link). A pure function over the /// already-collected pairs has no such hazard. /// /// A var that isn't present emits nothing rather than a guess. For an optional /// service that is the whole point — the agent option defaults to `null`, /// meaning "not configured", and the units that would use it aren't generated. /// For a service the hive cannot run without, silence would instead produce a /// fleet of agents quietly missing an integration, so the caller checks /// those before rendering. pub fn push_forwarded_var_options(out: &mut String, vars: &[(&'static str, String)]) { use std::fmt::Write as _; for (var, val) in vars { let Some((_, option)) = FORWARDED_VAR_OPTIONS.iter().find(|(name, _)| name == var) else { continue; }; let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); let _ = writeln!(out, " {option} = \"{escaped}\";"); } } /// The subset of `CANONICAL_INPUTS` that the `flake.lock` text `raw` declares /// as direct (root-level) inputs. Empty when the lock is unparsable — safe /// degradation, the worst case is no dedup for that agent. #[must_use] pub fn canonical_inputs_in_lock(raw: &str) -> Vec<&'static str> { let Ok(json) = serde_json::from_str::(raw) else { return Vec::new(); }; let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else { return Vec::new(); }; let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { return Vec::new(); }; let Some(root_inputs) = nodes .get(root_name) .and_then(|n| n.get("inputs")) .and_then(|v| v.as_object()) else { return Vec::new(); }; CANONICAL_INPUTS .iter() .copied() .filter(|canon| root_inputs.contains_key(*canon)) .collect() } /// Render the meta flake. `vars` are the [`FORWARDED_VARS`] pairs with a /// value; `github_disabled` turns the agents' GitHub integration off. Errors /// on a custom agent without an [`AgentPin`]. pub fn render_flake( hyperhive_url: &str, agents: &[AgentSpec], vars: &[(&'static str, String)], github_disabled: bool, ) -> Result { let mut out = String::new(); push_inputs(&mut out, hyperhive_url, agents)?; push_mk_agent_open(&mut out); push_agent_service_options(&mut out, vars, github_disabled); push_agent_environment(&mut out, vars); push_agent_entries(&mut out, agents); Ok(out) } /// The flake header and `inputs` block: `hyperhive`, `nixpkgs`, and one input /// per config-repo agent. fn push_inputs(out: &mut String, hyperhive_url: &str, agents: &[AgentSpec]) -> Result<()> { use std::fmt::Write as _; out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n"); // `hyperhive` is pinned by forge URL and rev, so the flake resolves on // any machine that can reach the forge. `nixpkgs` follows hyperhive's // own pin, which cascades to every agent because // `agent-.inputs.nixpkgs.follows = "nixpkgs"` resolves to this node. let _ = writeln!(out, " hyperhive.url = \"{hyperhive_url}\";"); out.push_str(" nixpkgs.follows = \"hyperhive/nixpkgs\";\n"); // Each agent's config input is its forge repo at the pinned commit, so // the flake text depends only on (agent, rev) and resolves anywhere that // can reach the forge. for spec in agents { // An archetype agent has no config repo, so no input: `mkAgent` // takes its module from the archetype. if spec.archetype.is_some() { continue; } let pin = spec.pin.as_ref().with_context(|| { format!( "agent {}: no pinned rev, refusing to render an unpinned config input", spec.name ) })?; let _ = writeln!( out, " agent-{name}.url = \"{url}\";", name = spec.name, url = pin.url, ); // For each canonical input the agent's `flake.lock` declares at the // pinned commit, emit // `inputs.agent-.inputs..follows = ""`. // Collapses otherwise-separate-but-identical nixpkgs nodes // (root + every agent's own nixpkgs) into one. Skipped // silently for agents that don't declare the input — emitting // follows on a non-existent input would error at // `nix flake lock` time. for canon in &pin.canonical_inputs { let _ = writeln!( out, " agent-{}.inputs.{canon}.follows = \"{canon}\";", spec.name, ); } } Ok(()) } /// Closes `inputs`, opens `outputs` and the `mkAgent` function up to the /// start of the per-agent module. fn push_mk_agent_open(out: &mut String) { out.push_str( " };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n mkAgent = { name, port, archetype ? null }:\n", ); out.push_str( r#" let base = hyperhive.nixosConfigurations.agent-base; # Every flake an archetype can come from, by its source name. archetypeSources = { inherit hyperhive; }; agentModule = if archetype == null then inputs."agent-${name}".nixosModules.default else (archetypeSources.${archetype.source} or (throw "agent ${name}: archetype source ${archetype.source} is not an input of this hive's meta flake") ).hyperhiveArchetypes.${archetype.name}.module or (throw "agent ${name}: this hive's ${archetype.source} input has no archetype ${archetype.name}; update that input"); service = "hive-agent"; in base.extendModules { modules = [ agentModule { "#, ); } /// Service URL options and the GitHub override inside the per-agent module. fn push_agent_service_options( out: &mut String, vars: &[(&'static str, String)], github_disabled: bool, ) { // Agent-facing service URLs (`services.hyperhive.agent.forge.url`, `services.hyperhive.agent.matrix.url`): // emit the host's real values as build-time agent config, the same // host-state -> agent-module shape as the CA block above. // // These options exist already, but until now *nothing set them*, so every // agent silently fell back to their `localhost:` defaults while the // true value reached the container only as an env var (below). Two sources // of truth that disagree, with the winner decided by which code path // happens to read which one --- the option default is baked into scripts // at build time, the env var is read at runtime. // // Rendering them here makes the option the single source, which is the // precondition for removing those defaults: a loopback address is only // ever correct when the callee shares the caller's netns, and the forge // and homeserver are moving to swarm level, possibly on other hosts. // // Absent vars emit nothing rather than a guess, and the agent option // treats "unset" as "this service is not configured" rather than // substituting a loopback address — an absent integration instead of a // misdirected one. // // That is the right default for an optional service and the wrong one for // the forge, which a running hive always has, so the caller rejects a // missing `HIVE_FORGE_URL` before it renders. The check is not here because // rendering is a pure string operation the tests exercise directly. push_forwarded_var_options(out, vars); // GitHub integration is on by default in every agent // (`services.hyperhive.agent.github.enable`); the host turns it off swarm-wide via // `services.hyperhive.swarm.github.enable = false`, passed here as // `github_disabled`. Only the OFF // override is propagated — the enabled default needs no per-agent line. if github_disabled { out.push_str(" services.hyperhive.agent.github.enable = false;\n"); } } /// The rest of the per-agent module: user, hostname, git identity, and the /// container-wide and harness-service environments. fn push_agent_environment(out: &mut String, vars: &[(&'static str, String)]) { use std::fmt::Write as _; out.push_str( r#" # The harness service inside the container runs as a # non-root unix user named after the agent (`damocles`, # `iris`, `root`, …). UID auto-assigned by NixOS; the # per-agent override here is what makes # `services.hyperhive.agent.user.name` match the agent's identity # instead of the harness default of `"agent"`. services.hyperhive.agent.user.name = name; # Without this an agent container is called `nixos` and ships # every log line under that: nixpkgs sets the hostname from the # attr name in the `containers.` submodule, and an # agent's system is its own `nixosSystem` off this flake, which # never evaluates that option. The machine name, not the # logical one — it is what `container.name` on our metrics and # nspawn itself already call this container, so logs join to # both without a prefix transform. networking.hostName = "h-${name}"; programs.git.config.user = { name = name; email = "${name}@hyperhive.local"; }; # Container-wide env: every service + co-process daemon can # resolve the agent's durable state dir without hard-coding it. # `environment.variables` only writes /etc/environment (login # shells); `systemd.globalEnvironment` is the analogue for # systemd units so forge-avatar-sync / # hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR` # without each service having to redeclare it. environment.variables = { HIVE_LABEL = name; HYPERHIVE_STATE_DIR = "/agents/${name}/state"; HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; }; systemd.globalEnvironment = { HIVE_LABEL = name; HYPERHIVE_STATE_DIR = "/agents/${name}/state"; HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; "#, ); // Forwarded vars (HIVE_FORGE_URL etc.) also go into globalEnvironment, // not just the harness service env below, so EVERY service + shell in // the container inherits them — crucially the bash-task runner (where // `hive-forge` + `git` actually run), plus the matrix daemon, forge-avatar-sync // and interactive shells. Scoped to the harness service alone they were // invisible to bash tasks: harmless in shared netns (the localhost // default works) but broken under isolation, where the in-cluster // `forge.` URL is the only reachable path. for (var, val) in vars { let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); let _ = writeln!(out, " {var} = \"{escaped}\";"); } out.push_str( r" }; systemd.services.${service}.environment = { HIVE_PORT = toString port; HIVE_LABEL = name; ", ); // Forwarded env vars, as the caller passed them. We copy whatever's set into // each sub-agent's harness service env so the in-container surfaces // (`identity.rs`, `forge_notify`) see a consistent view across the // whole hive. Absent host-side env (option not set) → skip emission // → in-container accessors fall back to None / defaults gracefully. // // - HIVE_FORGE_URL: agents poll this for Forgejo notifications. // - HYPERHIVE_SWARM_NAME: human display name for the swarm. for (var, val) in vars { let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); let _ = writeln!(out, " {var} = \"{escaped}\";"); } out.push_str( r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state"; HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; }; } # Tells the harness the agent has no config repo of its own. (if archetype == null then { } else { systemd.services.${service}.environment.HIVE_ARCHETYPE = archetype.name; }) ]; }; in { nixosConfigurations = { "#, ); } /// One `mkAgent` line per agent, closing `outputs`. fn push_agent_entries(out: &mut String, agents: &[AgentSpec]) { for spec in agents { out.push_str(&agent_entry(&spec.name, spec.port, spec.archetype.as_ref())); out.push('\n'); } out.push_str(" };\n };\n}\n"); } /// One agent's `mkAgent` line in `nixosConfigurations`, without its newline. fn agent_entry(name: &str, port: u16, archetype: Option<&ArchetypeRef>) -> String { // `source` and `name` are identifiers (`archetypes::store_read`), so // they need no escaping. let archetype = archetype.map_or(String::new(), |a| { format!( " archetype = {{ source = \"{}\"; name = \"{}\"; }};", a.source, a.name ) }); format!(" {name} = mkAgent {{ name = \"{name}\"; port = {port};{archetype} }};") } /// Web UI port range, shared by every agent including the manager. /// Deterministic from the agent's name (FNV-1a hash mod range size), so the /// dashboard can compute the same port without asking hive-c0re. const WEB_PORT_BASE: u16 = 8100; const WEB_PORT_RANGE: u16 = 900; /// FNV-1a hash of a string — used by `agent_web_port`. fn fnv1a(s: &str) -> u32 { let mut hash: u32 = 2_166_136_261; for b in s.bytes() { hash ^= u32::from(b); hash = hash.wrapping_mul(16_777_619); } hash } /// Per-agent web UI port — `WEB_PORT_BASE + FNV-1a(name) % /// WEB_PORT_RANGE` for every agent including the manager. The port /// allocation rule reads the same for every name; collisions are /// possible (birthday paradox at ~30 agents) and the operator /// resolves them by renaming an agent (different hash → different /// port). Stable across hosts, restarts, and dashboard renders — /// no state-file dance. #[must_use] pub fn agent_web_port(name: &str) -> u16 { // Modulo of a u32 by a u16's value is guaranteed < u16::MAX, so try_from never fails. WEB_PORT_BASE + u16::try_from(fnv1a(name) % u32::from(WEB_PORT_RANGE)).unwrap_or(0) } /// Whether `rev` is a full git commit id: 40 lowercase hex digits. #[must_use] pub fn is_rev(rev: &str) -> bool { rev.len() == 40 && rev.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) } /// The commit `input` is pinned to in a flake [`render_flake`] rendered: /// the `` of its `.url = "git+?rev=";` line. `None` when /// the flake has no such input, or its rev is not a commit id. #[must_use] pub fn pinned_rev<'a>(flake_nix: &'a str, input: &str) -> Option<&'a str> { let prefix = format!(" {input}.url = \""); let url = flake_nix .lines() .find_map(|l| l.strip_prefix(prefix.as_str()))? .split_once('"')? .0; let (_, rev) = url.split_once("?rev=")?; let rev = rev.split('&').next()?; is_rev(rev).then_some(rev) } /// Whether a flake [`render_flake`] rendered builds agent `name` the way the /// caller wants it built: from `archetype` when that is set, otherwise from /// its own config repo at the commit `config_head`. A flake that lists the /// agent under another archetype, or as custom where `archetype` is set (or /// the reverse), does not; nor does a custom agent with no `config_head`. #[must_use] pub fn builds_agent_at( flake_nix: &str, name: &str, archetype: Option<&ArchetypeRef>, config_head: Option<&str>, ) -> bool { let entry = format!("\n{}\n", agent_entry(name, agent_web_port(name), archetype)); if !flake_nix.contains(&entry) { return false; } archetype.is_some() || config_head .is_some_and(|head| pinned_rev(flake_nix, &format!("agent-{name}")) == Some(head)) } #[cfg(test)] mod tests { use super::*; const HYPERHIVE: &str = "1111111111111111111111111111111111111111"; const ARGUS: &str = "2222222222222222222222222222222222222222"; fn spec(name: &str, archetype: Option, pin: Option) -> AgentSpec { AgentSpec { name: name.to_owned(), port: agent_web_port(name), archetype, pin, } } fn rendered() -> String { let url = |repo: &str, rev: &str| { pinned_git_url(&format!("https://f.t/{repo}.git"), Some(rev)).unwrap() }; render_flake( &url("hyperhive/hyperhive", HYPERHIVE), &[ spec( "argus", None, Some(AgentPin { url: url("agent-configs/argus", ARGUS), canonical_inputs: vec![], }), ), spec( "rev", Some(ArchetypeRef { source: "hyperhive".to_owned(), name: "reviewer".to_owned(), }), None, ), ], &[], false, ) .unwrap() } #[test] fn a_rendered_flake_names_the_commit_each_input_is_pinned_to() { let flake = rendered(); assert_eq!(pinned_rev(&flake, "hyperhive"), Some(HYPERHIVE)); assert_eq!(pinned_rev(&flake, "agent-argus"), Some(ARGUS)); assert_eq!( pinned_rev(&flake, "agent-rev"), None, "an archetype has no input" ); assert_eq!(pinned_rev(&flake, "agent-ghost"), None); } #[test] fn a_pin_that_is_not_a_commit_id_is_no_pin() { let flake = " hyperhive.url = \"git+https://f.t/h.git?rev=main\";\n"; assert_eq!(pinned_rev(flake, "hyperhive"), None); assert_eq!( pinned_rev( " hyperhive.url = \"git+https://f.t/h.git\";\n", "hyperhive" ), None ); } fn reviewer() -> ArchetypeRef { ArchetypeRef { source: "hyperhive".to_owned(), name: "reviewer".to_owned(), } } #[test] fn a_custom_agent_is_built_only_at_the_config_head_the_flake_pins() { let flake = rendered(); assert!(builds_agent_at(&flake, "argus", None, Some(ARGUS))); assert!( !builds_agent_at(&flake, "argus", None, Some(HYPERHIVE)), "a stale pin" ); assert!( !builds_agent_at(&flake, "argus", None, None), "a custom agent with no config commit" ); assert!( !builds_agent_at(&flake, "ghost", None, Some(ARGUS)), "an agent the flake lacks" ); } #[test] fn an_archetype_agent_is_built_only_from_the_archetype_the_flake_lists() { let flake = rendered(); assert!(builds_agent_at(&flake, "rev", Some(&reviewer()), None)); let other = ArchetypeRef { name: "planner".to_owned(), ..reviewer() }; assert!( !builds_agent_at(&flake, "rev", Some(&other), None), "another archetype of the same source" ); assert!( !builds_agent_at(&flake, "rev", None, Some(ARGUS)), "listed as an archetype agent, wanted as a custom one" ); assert!( !builds_agent_at(&flake, "argus", Some(&reviewer()), None), "listed as a custom agent, wanted as an archetype one" ); assert!(!builds_agent_at(&flake, "ghost", Some(&reviewer()), None)); } #[test] fn an_agent_whose_name_ends_another_s_is_not_that_agent() { let flake = rendered(); assert!(!builds_agent_at(&flake, "gus", None, Some(ARGUS))); assert!(!builds_agent_at(&flake, "v", Some(&reviewer()), None)); } }