//! 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}; /// 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 queue is, as an agent container reaches it. An address // the *host* computes, never a loopback address, which inside a container // is the agent itself. "HIVE_AGENT_NATS_URL", // 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 for the // same reason as the queue's: 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`]. #[allow( clippy::too_many_lines, reason = "templated string-builder for the meta flake — the length is one \ contiguous fmt block, splitting it would just hide the shape" )] pub fn render_flake( hyperhive_url: &str, agents: &[AgentSpec], vars: &[(&'static str, String)], github_disabled: bool, ) -> Result { use std::fmt::Write as _; let mut out = String::new(); 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, ); } } 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 { "#, ); // 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(&mut 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"); } 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 = { "#, ); for spec in agents { // `source` and `name` are identifiers (`archetypes::store_read`), so // they need no escaping. let archetype = spec.archetype.as_ref().map_or(String::new(), |a| { format!( " archetype = {{ source = \"{}\"; name = \"{}\"; }};", a.source, a.name ) }); let _ = writeln!( out, " {} = mkAgent {{ name = \"{}\"; port = {};{archetype} }};", spec.name, spec.name, spec.port, ); } out.push_str(" };\n };\n}\n"); Ok(out) }