//! Single hive-c0re-owned flake at `/var/lib/hyperhive/meta/` that //! exports one `nixosConfiguration` per agent and drives the system-wide //! deploy audit trail. Flow (`sync_agents`, `lock_update_for_rebuild`, //! `lock_update_hyperhive`): //! `docs/agent-lifecycle/config-changes.md::Meta flake`. use std::path::Path; use anyhow::{Context, Result, bail}; use tokio::process::Command; use tokio::sync::Mutex; use crate::coordinator::HiveEnv; use crate::lifecycle; const GIT_NAME: &str = "c0re"; const GIT_EMAIL: &str = "c0re@hyperhive.local"; /// Single-writer lock around every meta-repo operation. Git isn't /// safe to drive from concurrent processes against the same `.git/` /// — two simultaneous `git add` / `commit` invocations race on /// `.git/index.lock`; if either dies before releasing, the lock /// sticks and the next operation hits "another git process seems to /// be running" until somebody `rm`s it manually. Holding this mutex /// across each public function's git+nix calls makes parallel /// rebuilds (`auto_update` + dashboard-triggered + apply-commit) /// take turns instead of colliding. static META_LOCK: Mutex<()> = Mutex::const_new(()); // Exclusivity for meta-repo *windows* that span multiple `META_LOCK` // acquisitions is **not** a mutex in this module. `META_LOCK` above // serializes individual git ops but cannot keep another op out of a window // spanning several of them; that window is owned by the job queue instead, // as `Resource::MetaWindow`, declared by every meta-mutating node kind. A // resource can be held by a subtree root across its children, which a `MutexGuard` // (bounded by one executor fn) cannot. /// Where the manager sees this directory inside its container (RO bind). pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta"; #[derive(Debug, Clone)] pub struct AgentSpec { pub name: String, pub port: u16, /// The archetype the agent is built from (`crate::archetypes`), `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 [`sync_agents`] /// 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>, } /// Stage topology.json if it exists: it is regenerated by `reconcile` /// whenever the agent set changed. Without staging, an /// existing-but-untracked file shows up as untracked in the meta repo, /// which can confuse nix's dirty-tree fetch. `git add` is a no-op when /// content is unchanged. async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> { if crate::topology::topology_path().exists() { git(dir, &["add", "topology.json"]).await?; } Ok(()) } /// Idempotently reconcile the meta repo with the current agent set. /// First call inits the git repo, runs `nix flake lock`, and lands a /// seed commit. Subsequent calls only touch `flake.nix` when the /// rendered contents differ from disk; an unchanged `flake.nix` is a /// no-op. pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { let _guard = META_LOCK.lock().await; // Before anything is written: a hive without a forge URL would deploy a // whole fleet of agents that silently never log in, because the agent // option treats "unset" as "no forge configured" rather than erroring. // This is the layer that knows a forge is mandatory, so it says so here. require_service_urls(&forwarded_env_vars())?; let dir = crate::paths::meta_root(); std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; // One agent whose forge main cannot be read fails the whole // sync, like a missing forge URL or hyperhive rev above: rendering the // fleet without it would drop a running agent from the flake. let pinned = pin_agents(agents).await?; let agents = pinned.as_slice(); let new_flake = render_hive_flake(hive, agents)?; let flake_path = dir.join("flake.nix"); let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default(); let initial = !dir.join(".git").exists(); // Embedded-CA list: keep the `./hive-ca.pem` file at the meta root in // lockstep with its host source so the build-time `certificateFiles` // list render_flake emits always resolves. Empty when the hive has no // trust bundle. let (ca_files, ca_changed) = ca_embed_state(&dir); // Skip only when both the flake AND the embedded CA are unchanged — a // CA rotation with an otherwise-identical flake must still re-commit. if !initial && on_disk == new_flake && !ca_changed { return Ok(()); } // Safety guard: refuse to write an empty agent list over a non-empty // on-disk flake. An empty `agents` slice is never intentional — it // means `nixos-container list` failed and the caller got an empty // fallback. Overwriting here would drop every agent from the meta // flake and trigger unnecessary (and potentially destructive) cascade // rebuilds. Callers that genuinely need to clear the agent list // (there are none today) must handle this case explicitly. if agents.is_empty() && !initial && !on_disk.is_empty() { tracing::warn!( "sync_agents: refusing to overwrite non-empty meta flake with empty agent list \ (nixos-container list may have failed)" ); return Ok(()); } std::fs::write(&flake_path, &new_flake) .with_context(|| format!("write {}", flake_path.display()))?; // Materialise the embedded CA list next to flake.nix + drop any stale // CA file; `ca_touched` is every filename written or removed, staged // for commit below. Public CA certs only; no private key is embedded. let ca_touched = materialise_ca_files(&dir, &ca_files)?; // Reconcile topology.json against the live agent set — adds // newly-spawned agents and drops removed ones. Idempotent; when // nothing changed the file isn't touched. let agent_names: Vec = agents.iter().map(|a| a.name.clone()).collect(); let pending: Vec = crate::coordinator::Coordinator::pending_init_names() .into_iter() .map(hive_types::Ident::into_string) .collect(); crate::topology::reconcile(&agent_names, &pending) .with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?; // Refresh /var/lib/hyperhive/run/agent-sockets.json — drives the // gateway's unix-socket upstreams. Every agent binds a unix socket // (`HIVE_WEB_SOCKET`); the gateway routes via this map, falling back // to a computed TCP loopback port (`lifecycle::agent_web_port`) only // while an agent's socket marker is absent. Best-effort + non-fatal. // See `docs/networking/gateway.md::Per-agent unix-socket upstream`. if let Err(e) = crate::agent_sockets::write(&agent_names) { tracing::warn!(error = ?e, "agent_sockets::write failed (non-fatal)"); } // Refresh /var/lib/hive-gateway/conf/agents.conf — the nginx include // file the gateway reads at runtime. c0re then triggers a reload (or // start) of the host's nginx via hive-priv, since c0re is unprivileged. // Same best-effort + non-fatal shape. if let Err(e) = crate::gateway_nginx::write(&agent_names).await { tracing::warn!(error = ?e, "gateway_nginx::write failed (non-fatal)"); } if initial { git(&dir, &["init", "--initial-branch=main"]).await?; } // Stage flake.nix *before* running nix flake lock. When meta is // a git repo, nix treats it as a `git+file://` self-reference; // its dirty-tree fetcher includes index entries (tracked + // staged) but skips untracked files, so without the stage step // an untracked flake.nix surfaces as "source tree does not // contain '/flake.nix'". Lock then commit once with both // flake.nix and flake.lock — single commit per change. git(&dir, &["add", "flake.nix"]).await?; // Stage every embedded CA file we wrote or removed (trust bundle + // stale peer CAs). `git add ` stages a deletion when the path is // tracked and now gone; best-effort so the never-tracked-and-absent case // (pathspec mismatch) is a harmless no-op. for name in &ca_touched { let _ = git(&dir, &["add", "--", name]).await; } stage_generated_meta_files(&dir).await?; nix(&dir, &["flake", "lock"]).await?; if std::path::Path::new(&dir).join("flake.lock").exists() { git(&dir, &["add", "flake.lock"]).await?; } // Build the commit message from what's actually staged so it // reflects reality — and skip the commit entirely when nothing // changed (avoids "nothing to commit" errors on redundant syncs). let staged = git_staged_names(&dir).await?; if staged.is_empty() { return Ok(()); } let msg = if initial { format!("seed meta from {} agent(s)", agents.len()) } else { // Compose a message that names every file that actually changed, // mapping the on-disk filename to a short human label. let labels: Vec<&str> = staged .iter() .filter_map(|f| match f.as_str() { "flake.nix" => Some("flake"), "flake.lock" => Some("lock"), "hive-ca.pem" => Some("hive-ca"), // Only ever a REMOVAL now; see `is_embedded_ca_name`. f if f.starts_with("peer-ca-") && has_pem_ext(f) => Some("peer-ca"), "topology.json" => Some("topology"), _ => None, }) .collect(); if labels.is_empty() { "meta: update".to_owned() } else { format!("meta: update {}", labels.join(", ")) } }; git_commit(&dir, &msg).await?; Ok(()) } /// One-shot used by the manual-rebuild path: relock just one /// agent's input and commit the lock change if any. Single-phase /// (no separate finalize) because rebuild has no failure-revert /// semantics. The input follows the url `sync_agents` rendered just before. pub async fn lock_update_for_rebuild(name: &str) -> Result<()> { let _guard = META_LOCK.lock().await; let dir = crate::paths::meta_root(); let input = format!("agent-{name}"); nix(&dir, &rebuild_relock_args(&input)).await?; if !paths_dirty(&dir, &["flake.lock"]).await? { return Ok(()); } git(&dir, &["add", "flake.lock"]).await?; git_commit_paths( &dir, &format!("rebuild {name}: lock update"), &["flake.lock"], ) .await } /// `nix` arguments re-locking one agent's config input. The input is updated /// against the url `sync_agents` rendered, the forge url pinned by `?rev=`: /// there is no `--override-input`, so the lock never records another source. fn rebuild_relock_args(input: &str) -> [&str; 3] { ["flake", "update", input] } /// Update one or more named inputs in the meta flake and commit /// the resulting lock change with a single combined message. /// Used by the dashboard's "update meta inputs" form so the /// operator can bulk-bump `hyperhive` + selected agents in one /// shot. Each input name is passed verbatim to /// Run `nix flake update [inputs...]` on the meta flake and commit the /// resulting lock changes. When `inputs` is empty, updates ALL inputs /// (bare `nix flake update`). The caller is responsible for picking /// real input keys (e.g. via `inputs_view()` snapshotted from the lock /// file) when targeting specific inputs. pub async fn lock_update(inputs: &[String]) -> Result<()> { let _guard = META_LOCK.lock().await; let dir = crate::paths::meta_root(); let mut args: Vec<&str> = vec!["flake", "update"]; for i in inputs { args.push(i.as_str()); } nix(&dir, &args).await?; if !paths_dirty(&dir, &["flake.lock"]).await? { return Ok(()); } git(&dir, &["add", "flake.lock"]).await?; let msg = if inputs.is_empty() { "lock update: all inputs".to_string() } else if inputs.len() == 1 { format!("lock update: {}", inputs[0]) } else { format!("lock update: {}", inputs.join(", ")) }; git_commit_paths(&dir, &msg, &["flake.lock"]).await } /// One-shot used by the auto-update path: pin the latest hyperhive /// rev, commit if the lock changed. Cheaper than `sync_agents` /// because the per-agent inputs aren't touched. pub async fn lock_update_hyperhive() -> Result<()> { let _guard = META_LOCK.lock().await; let dir = crate::paths::meta_root(); nix(&dir, &["flake", "update", "hyperhive"]).await?; if !paths_dirty(&dir, &["flake.lock"]).await? { return Ok(()); } git(&dir, &["add", "flake.lock"]).await?; git_commit_paths(&dir, "bump hyperhive", &["flake.lock"]).await } /// Delete the meta repo's permission files and commit the removal under /// `META_LOCK`. Their grants live in the swarm's `agent-permissions` bucket /// once `workers::permissions_import` has sent them. /// /// # Errors /// /// Removing a file or a git stage/commit step fails. pub async fn remove_permission_files() -> Result<()> { let _guard = META_LOCK.lock().await; let dir = crate::paths::meta_root(); let mut removed = Vec::new(); for file in crate::workers::permissions_import::META_FILES { match std::fs::remove_file(dir.join(file)) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e).with_context(|| format!("removing {file}")), } git(&dir, &["rm", "--cached", "--ignore-unmatch", "-q", file]).await?; // Only a tracked file has a removal to commit; naming an untracked // one makes `git commit -- ` fail. if paths_dirty(&dir, &[file]).await? { removed.push(*file); } } if !removed.is_empty() { git_commit_paths( &dir, "permissions moved to the swarm's agent-permissions", &removed, ) .await?; } Ok(()) } /// Pinned forge URL of the hyperhive flake: `git+/hyperhive/hyperhive.git?rev=`. /// Errors when the host has no rev, rather than rendering an unpinned URL. fn hyperhive_input_url(hive: &HiveEnv) -> Result { pinned_git_url( &crate::forge::forge_git_url("hyperhive/hyperhive"), hive.hyperhive_rev.as_deref(), ) } /// The pure half of [`hyperhive_input_url`], testable without `HIVE_FORGE_URL`. 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}")) } /// [`render_flake`] with its inputs taken from `hive`, as [`sync_agents`] /// renders it. fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> Result { render_hive_flake_at(hive, &hyperhive_input_url(hive)?, agents) } fn render_hive_flake_at( hive: &HiveEnv, hyperhive_url: &str, agents: &[AgentSpec], ) -> Result { render_flake( hyperhive_url, hive.claude_code_path.as_deref(), hive.dashboard_port, agents, ) } /// 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. const CANONICAL_INPUTS: &[&str] = &["nixpkgs"]; /// Env vars hive-c0re forwards from its own systemd unit env into every /// sub-agent's harness service env. Each entry is `(env_var_name, /// host_value)`. Empty / unset vars are filtered out so absent options /// don't render no-op `FOO = ""` lines into the meta flake. /// /// Returns owned `String` values so the result is `'static`-friendly + /// trivial to stub from tests (which build their own slice instead of /// touching process-wide env). const FORWARDED_VARS: &[&str] = &[ // Where the swarm queue is and where its tokens are minted, as an agent // container reaches them. Both are addresses the *host* computes — the // queue's from the bridge IP an agent routes over, never a loopback // address, which inside a container is the agent itself. "HIVE_AGENT_NATS_URL", "HIVE_AGENT_OIDC_TOKEN_ENDPOINT", // 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", // Where an agent's `swarm-logs` queries, computed by the host for the // same reason as the two above: 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. 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 // wired for this map's own reason: an agent whose option says no queue and // whose env says otherwise imports no credential for `swarm-logs`. ( "HIVE_AGENT_NATS_URL", "services.hyperhive.agent.queue.natsUrl", ), ( "HIVE_AGENT_OIDC_TOKEN_ENDPOINT", "services.hyperhive.agent.queue.tokenEndpoint", ), // 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 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 those are checked by /// [`require_service_urls`] before this is called. 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}\";"); } } /// Service URLs a running hive must always supply, checked before rendering. /// /// The forge is not optional on a real hive: the hyperhive NixOS module sets /// `HIVE_FORGE_URL` unconditionally, so its absence means this daemon was /// started outside its unit. The agent option is nullable — `null` /// legitimately means "no forge" when the modules are evaluated on their own — /// which is exactly why the hive has to assert its own requirement here rather /// than leaning on the module to reject the empty case. const REQUIRED_SERVICE_URL_VARS: &[&str] = &["HIVE_FORGE_URL"]; /// Fails unless every [`REQUIRED_SERVICE_URL_VARS`] entry is present in /// `vars`. /// /// Pure over the already-collected pairs so it can be tested without touching /// process env (the same reason [`push_forwarded_var_options`] is split out). /// /// Checked by `sync_agents` — the point at which the hive commits a rendered /// flake to disk — rather than inside the renderer. Rendering is a pure string /// operation that many tests exercise directly; making *it* env-dependent /// would mean every one of those tests either sets a process-wide var (the /// parallel-test race this module already avoids) or fails for reasons that /// have nothing to do with what it asserts. /// /// # Errors /// /// When a required var is missing. The hyperhive NixOS module sets it /// unconditionally, so this means the daemon is running outside its unit; /// refusing to /// write the flake beats writing one whose agents would silently all lack a /// forge. fn require_service_urls(vars: &[(&'static str, String)]) -> Result<()> { for required in REQUIRED_SERVICE_URL_VARS { if !vars.iter().any(|(name, _)| name == required) { anyhow::bail!( "{required} is unset — the hyperhive NixOS module sets it unconditionally on \ the hive-c0re unit, so this process was started outside that unit. Refusing \ to write a meta flake whose agents would every one of them have no forge \ configured." ); } } Ok(()) } fn forwarded_env_vars() -> Vec<(&'static str, String)> { FORWARDED_VARS .iter() .filter_map(|&name| { std::env::var(name) .ok() .filter(|v| !v.is_empty()) .map(|v| (name, v)) }) .collect() } /// Filename the hive's own trust anchors are embedded under at the /// meta-flake root — the only entry of the embedded-CA list /// `render_flake` references in `security.pki.certificateFiles` (see /// `embedded_ca_files`). const HIVE_CA_FILE: &str = "hive-ca.pem"; /// Host path of the hive's TLS trust anchors, when self-signed TLS is /// active. The hyperhive TLS module sets `HIVE_TLS_CA_PATH` in hive-c0re's env /// (to `/trust-bundle.pem`) whenever the /// gateway serves a leaf issued by the secret store's `pki` mount. The /// file holds that mount's root, the one anchor every hive's gateway leaf /// chains to, and is copied verbatim, which `security.pki.certificateFiles` /// accepts; nothing here parses it. Returns `Some(path)` only when the var /// is set AND the cert exists on disk — so render + write stay consistent /// (we never emit a `certificateFiles` reference to a file we didn't /// embed). Only the public cert is ever read here. fn hive_ca_source() -> Option { let path = std::env::var("HIVE_TLS_CA_PATH").ok()?; if path.is_empty() || !std::path::Path::new(&path).is_file() { return None; } Some(path) } /// Extra `OTEL_RESOURCE_ATTRIBUTES` entries for every agent, read off /// hive-c0re's own unit env /// (`services.hyperhive.deploy.hive-controller.otel.extraResourceAttributes`). /// `None` when unset or empty. This is the only OTEL value the renderer /// writes into the flake: agents export to the hive collector by default and /// find it by name, so there is no enable flag, endpoint or protocol to /// carry. fn otel_extra_resource_attributes() -> Option { std::env::var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES") .ok() .filter(|v| !v.is_empty()) } /// The ordered set of CA certs embedded next to the meta flake, as /// `(filename, host_source_path)` — now just the hive's own trust /// anchors as `hive-ca.pem`, when self-signed TLS is active. /// /// That single file carries the store's `pki` root (the hyperhive TLS /// module writes it into the trust bundle), so every hive's gateway leaf /// validates from it and nothing is embedded per peer. /// /// `render_flake` emits exactly these filenames into /// `security.pki.certificateFiles` and `sync_agents` materialises them, /// so the rendered reference and the embedded files always agree. fn embedded_ca_files() -> Vec<(String, String)> { let mut out = Vec::new(); if let Some(p) = hive_ca_source() { out.push((HIVE_CA_FILE.to_owned(), p)); } out } /// Write each desired embedded CA file next to `flake.nix` and remove /// any stale one (self-signed TLS turned off, or a leftover peer CA), /// so the flake never references a file we didn't write. Returns every /// filename written or removed, for the caller to stage. The public CA /// certs only; no private key is ever embedded. fn materialise_ca_files(dir: &Path, ca_files: &[(String, String)]) -> Result> { let desired: std::collections::HashSet<&str> = ca_files.iter().map(|(n, _)| n.as_str()).collect(); let mut touched: Vec = Vec::new(); if let Ok(entries) = std::fs::read_dir(dir) { for e in entries.flatten() { let fname = e.file_name(); let Some(name) = fname.to_str() else { continue }; if is_embedded_ca_name(name) && !desired.contains(name) { let _ = std::fs::remove_file(dir.join(name)); touched.push(name.to_owned()); } } } for (name, content) in ca_files { let path = dir.join(name); std::fs::write(&path, content).with_context(|| format!("write {}", path.display()))?; touched.push(name.clone()); } Ok(touched) } /// True for a filename the embedded-CA machinery owns. Lets /// `sync_agents` find stale CA files to clean up (a CA dropped from /// config) without touching unrelated meta files. /// /// ⚠️ Still matches `peer-ca-.pem`, which `embedded_ca_files` no /// longer produces — deliberately. Cleanup is driven by /// "recognised but not desired", so this arm is exactly what removes a /// per-peer CA file still sitting at a meta root. Drop it and those files /// are orphaned at every meta root forever, referenced by nothing and /// cleaned by nobody. fn is_embedded_ca_name(name: &str) -> bool { name == HIVE_CA_FILE || (name.starts_with("peer-ca-") && has_pem_ext(name)) } /// True when `name` ends in a `.pem` extension (case-insensitive). Split /// out so the embedded-CA filename checks share one spelling and dodge /// clippy's case-sensitive-extension lint. fn has_pem_ext(name: &str) -> bool { Path::new(name) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("pem")) } /// Embedded-CA state for the meta repo: `(desired_files, changed)`. /// `desired_files` is `(filename, contents)` for every CA that should sit /// next to flake.nix. `changed` is true when the on-disk set differs in /// any way — the file's contents changed, a CA appeared, or a /// previously-embedded CA (`hive-ca.pem` / a legacy `peer-ca-*.pem`) is /// no longer wanted (stale, to be removed). Drives both the re-commit /// decision and the materialise/cleanup in `sync_agents`, so a CA /// rotation re-commits even when the flake itself is byte-identical. fn ca_embed_state(dir: &std::path::Path) -> (Vec<(String, String)>, bool) { let desired: Vec<(String, String)> = embedded_ca_files() .into_iter() .filter_map(|(name, path)| std::fs::read_to_string(&path).ok().map(|c| (name, c))) .collect(); let desired_names: std::collections::HashSet<&str> = desired.iter().map(|(n, _)| n.as_str()).collect(); let mut changed = desired.iter().any(|(name, content)| { std::fs::read_to_string(dir.join(name)).unwrap_or_default() != *content }); // A previously-embedded CA file no longer wanted → stale (removal is // a change even when every desired file already matches on disk). if !changed && let Ok(entries) = std::fs::read_dir(dir) { changed = entries.flatten().any(|e| { e.file_name() .to_str() .is_some_and(|name| is_embedded_ca_name(name) && !desired_names.contains(name)) }); } (desired, changed) } /// Resolve every custom agent's [`AgentPin`] and return `agents` with it set. async fn pin_agents(agents: &[AgentSpec]) -> Result> { let mut out = Vec::with_capacity(agents.len()); for spec in agents { let mut spec = spec.clone(); spec.pin = if spec.archetype.is_some() { None } else { Some( pin_agent( &spec.name, &crate::paths::applied_dir(&spec.name), &crate::forge::forge_git_url(&format!( "{}/{}", crate::forge::CONFIG_ORG, spec.name )), crate::forge::FORGE_MAIN_REF, crate::forge::fetch_forge_main(&spec.name), ) .await?, ) }; out.push(spec); } Ok(out) } /// Pin the config input of agent `name` to the head of `agent-configs/` /// `main` on the forge. `fetch_forge` fetches that branch into `repo` as /// `forge_ref` ([`crate::forge::fetch_forge_main`]); `repo` is only the object /// store that fetch lands in, its own branches and files are never read. The /// pin is a commit the forge has, so a flake rendered from it resolves off this /// host. Errors, with no fallback to a local path, when the forge cannot be /// reached or has no `main`. async fn pin_agent( name: &str, repo: &Path, forge_git_url: &str, forge_ref: &str, fetch_forge: F, ) -> Result where F: std::future::Future>, { fetch_forge .await .with_context(|| format!("agent {name}: read agent-configs/{name} main from the forge"))?; let rev = lifecycle::git_rev_parse(repo, forge_ref) .await .with_context(|| format!("agent {name}: agent-configs/{name} has no main on the forge"))?; let lock = lifecycle::git_show_file(repo, &rev, "flake.lock").await?; Ok(AgentPin { url: pinned_git_url(forge_git_url, Some(&rev))?, canonical_inputs: lock .as_deref() .map(canonical_inputs_in_lock) .unwrap_or_default(), }) } /// 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. 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. Errors on a custom agent without a [`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" )] fn render_flake( hyperhive_url: &str, claude_code_path: Option<&str>, dashboard_port: u16, agents: &[AgentSpec], ) -> 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"); let _ = writeln!( out, " dashboardPort = {dashboard_port};\n mkAgent = {{ name, port, archetype ? null }}:" ); 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 { "#, ); // The `claude-code` agents run, as a bare store path rather than a // flake input: containers share the host's `/nix/store`, so the // binary is already there with its closure and has nothing to // travel. A string literal is also the only shape that evaluates — // `lib.types.package` on a bare path runs `builtins.storePath`, // which pure eval rejects. The agent module puts its `bin/` on the // harness PATH; the host module holds the gc root, since a path // spelled out here is text and references nothing. // `None` = no override; agents keep their own nixpkgs' `claude-code`. if let Some(path) = claude_code_path { let _ = writeln!( out, " services.hyperhive.agent.claudeCodePath = \"{path}\";" ); } // CA trust: embed the hive's trust bundle so each agent validates // the store's `pki` root at build time (one file; see // `hive_ca_source`). `certificateFiles` is build-time, so the certs // travel with the flake source: `sync_agents` writes `./hive-ca.pem` // next to flake.nix and stages it. Only public CA certs are embedded; // no private key ever leaves the host. The filename list matches // `sync_agents` exactly (both derive it from `embedded_ca_files`), so // we never reference a file we didn't embed; emitted only when the // list is non-empty. let ca_refs: Vec = embedded_ca_files() .into_iter() .map(|(name, _)| format!("./{name}")) .collect(); if !ca_refs.is_empty() { let _ = writeln!( out, " security.pki.certificateFiles = [ {} ];", ca_refs.join(" ") ); } // Extra OTEL resource labels (`deploy.hive-controller.otel.*`) are the one // OTEL value rendered here; enablement and the collector endpoint are // agent-module defaults. if let Some(attrs) = otel_extra_resource_attributes() { let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\""); let _ = writeln!( out, " services.hyperhive.agent.otel.extraResourceAttributes = \"{}\";", esc(&attrs) ); } // 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 `sync_agents` rejects a // missing `HIVE_FORGE_URL` before it writes anything (`require_service_urls`). // The check lives there rather than here because rendering is a pure string // operation the tests exercise directly. push_forwarded_var_options(&mut out, &forwarded_env_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`, surfaced here as the // `HYPERHIVE_GITHUB_DISABLED` env on hive-c0re's unit. Only the OFF // override is propagated — the enabled default needs no per-agent line. if std::env::var_os("HYPERHIVE_GITHUB_DISABLED").is_some() { 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 forwarded_env_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; HIVE_DASHBOARD_PORT = toString dashboardPort; ", ); // Forwarded env vars — picked up from hive-c0re's own systemd unit // env (`services.hyperhive.*` options flow through the hyperhive // NixOS module into the host process). 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 forwarded_env_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) } /// Return the list of file names that are currently staged (index differs /// from HEAD). On the initial commit (`HEAD` doesn't exist yet) falls back /// to `git diff --cached --name-only HEAD` failing gracefully by using /// `git status --porcelain` and collecting the `A ` / `M ` prefix lines. async fn git_staged_names(dir: &Path) -> Result> { // `--diff-filter=ACM` skips deleted entries — we only care about // additions and modifications for message-building purposes. let out = lifecycle::git_command() .current_dir(dir) .args(["diff", "--cached", "--name-only", "--diff-filter=ACM"]) .output() .await .with_context(|| format!("git diff --cached in {}", dir.display()))?; if out.status.success() { let names = String::from_utf8_lossy(&out.stdout) .lines() .map(str::trim) .filter(|l| !l.is_empty()) .map(ToOwned::to_owned) .collect(); return Ok(names); } // Fallback for initial commit (no HEAD yet): parse `git status --porcelain`. let st = lifecycle::git_command() .current_dir(dir) .args(["status", "--porcelain"]) .output() .await .with_context(|| format!("git status in {}", dir.display()))?; let names = String::from_utf8_lossy(&st.stdout) .lines() .filter(|l| l.starts_with("A ") || l.starts_with("M ")) .filter_map(|l| l.get(3..)) .map(ToOwned::to_owned) .collect(); Ok(names) } async fn git(dir: &Path, args: &[&str]) -> Result<()> { let out = lifecycle::git_command() .current_dir(dir) .args(args) .output() .await .with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?; if !out.status.success() { bail!( "git {} failed ({}): {}", args.join(" "), out.status, String::from_utf8_lossy(&out.stderr).trim() ); } Ok(()) } async fn git_commit(dir: &Path, message: &str) -> Result<()> { git( dir, &[ "-c", &format!("user.name={GIT_NAME}"), "-c", &format!("user.email={GIT_EMAIL}"), "commit", "-m", message, ], ) .await?; // Best-effort mirror to the bundled forge. No-op when the forge // isn't seeded (no core token on disk); push failures log a warn // but don't bubble up — a missing mirror shouldn't fail an // otherwise successful deploy. if let Err(e) = crate::forge::push_meta(dir).await { tracing::warn!(error = ?e, "forge: meta push after commit failed (non-fatal)"); } Ok(()) } /// Path-limited commit: commits ONLY the given paths, so unrelated /// staged content can never be swept into someone else's commit. Every targeted /// meta commit (limits, topology, lock bumps) goes through this; /// only `sync_agents` uses the bare [`git_commit`], because its /// staged set *is* its intentional commit set. async fn git_commit_paths(dir: &Path, message: &str, paths: &[&str]) -> Result<()> { let name = format!("user.name={GIT_NAME}"); let email = format!("user.email={GIT_EMAIL}"); let mut args = vec!["-c", &name, "-c", &email, "commit", "-m", message, "--"]; args.extend_from_slice(paths); git(dir, &args).await?; if let Err(e) = crate::forge::push_meta(dir).await { tracing::warn!(error = ?e, "forge: meta push after commit failed (non-fatal)"); } Ok(()) } /// True when any of `paths` differs between HEAD and the index or /// working tree — the path-scoped replacement for whole-tree /// `git_is_clean` / `has_staged_changes` guards, which a concurrently /// staged deploy lock would otherwise trip. async fn paths_dirty(dir: &Path, paths: &[&str]) -> Result { let mut args = vec!["diff", "--quiet", "HEAD", "--"]; args.extend_from_slice(paths); let out = lifecycle::git_command() .current_dir(dir) .args(&args) .output() .await .with_context(|| format!("git diff --quiet in {}", dir.display()))?; // Exit 0 = no differences; 1 = differences; anything else (e.g. // no HEAD yet on a fresh repo) → treat as dirty so the commit // path runs and surfaces real errors loudly. Ok(!out.status.success()) } /// Full argv for a `nix` invocation, prefixed with the flakes-enabling /// experimental-features flag. `--extra-experimental-features` is /// belt-and-suspenders for hosts that haven't set this in nix.conf: the /// hyperhive module's deploy guide assumes flakes are already enabled, /// but the cost of being defensive is one extra argv each call. fn nix_argv<'a>(args: &[&'a str]) -> Vec<&'a str> { let mut all = vec!["--extra-experimental-features", "nix-command flakes"]; all.extend_from_slice(args); all } /// Run `nix ` in `dir` (flakes enabled), capturing combined output. /// The core of [`nix`]. async fn nix_output(dir: &Path, args: &[&str]) -> Result { Command::new("nix") .current_dir(dir) .args(nix_argv(args)) // Kill the nix child if the caller's future is dropped (e.g. the // startup-migration timeout around `sync_agents` fires) rather than // orphaning it against an unreachable forge. No-op on normal exit. .kill_on_drop(true) .output() .await .with_context(|| format!("nix {} in {}", args.join(" "), dir.display())) } /// Turn a finished nix [`Output`](std::process::Output) into `Result<()>`, /// bailing with the trimmed stderr tail on non-zero exit. fn nix_check(args: &[&str], out: &std::process::Output) -> Result<()> { if !out.status.success() { bail!( "nix {} failed ({}): {}", args.join(" "), out.status, String::from_utf8_lossy(&out.stderr).trim() ); } Ok(()) } async fn nix(dir: &Path, args: &[&str]) -> Result<()> { let out = nix_output(dir, args).await?; nix_check(args, &out) } #[cfg(test)] mod tests { use super::*; /// A path-limited commit must leave an unrelated staged file untouched. #[tokio::test] async fn path_limited_commit_leaves_unrelated_staged_file_alone() { let tmp = tempfile::tempdir().expect("tempdir"); let dir = tmp.path(); git(dir, &["init", "--initial-branch=main"]) .await .expect("git init"); std::fs::write(dir.join("resource-limits.json"), "{}").expect("write"); std::fs::write(dir.join("flake.lock"), "v1").expect("write"); git(dir, &["add", "-A"]).await.expect("add"); git_commit(dir, "seed").await.expect("seed commit"); // Something stages a new lock (uncommitted)… std::fs::write(dir.join("flake.lock"), "v2-staged-by-deploy").expect("write"); git(dir, &["add", "flake.lock"]).await.expect("stage lock"); // …and a limits change commits, path-limited. std::fs::write(dir.join("resource-limits.json"), r#"{"alice":[]}"#).expect("write"); git(dir, &["add", "resource-limits.json"]) .await .expect("add"); git_commit_paths( dir, "set resource limits for alice", &["resource-limits.json"], ) .await .expect("path-limited commit"); // The limits file is committed; the staged lock is not. assert!( !paths_dirty(dir, &["resource-limits.json"]) .await .expect("check"), "limits file must be committed" ); assert!( paths_dirty(dir, &["flake.lock"]).await.expect("check"), "staged deploy lock must survive the limits commit" ); } const SAMPLE_REV: &str = "1111111111111111111111111111111111111111"; fn sample_pin(name: &str, rev: &str, canonical_inputs: Vec<&'static str>) -> AgentPin { AgentPin { url: format!("git+https://forge.example/agent-configs/{name}.git?rev={rev}"), canonical_inputs, } } fn sample_spec(name: &str, port: u16) -> AgentSpec { AgentSpec { name: name.to_owned(), port, archetype: None, pin: Some(sample_pin(name, SAMPLE_REV, Vec::new())), } } fn render( hyperhive_url: &str, claude_code_path: Option<&str>, dashboard_port: u16, agents: &[AgentSpec], ) -> String { render_flake(hyperhive_url, claude_code_path, dashboard_port, agents).expect("render") } #[test] fn rebuild_relock_updates_the_input_without_an_override() { let args = rebuild_relock_args("agent-iris"); assert_eq!(args, ["flake", "update", "agent-iris"]); assert!( !args .iter() .any(|a| *a == "--override-input" || a.contains("git+file")), "a rebuild must not lock a local path: {args:?}" ); } /// The url a rebuild relocks is the one `sync_agents` rendered: the forge, /// pinned by rev. #[test] fn the_input_a_rebuild_relocks_is_a_pinned_forge_url() { let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("iris", 9001)], ); let url = out .lines() .find_map(|l| l.trim().strip_prefix("agent-iris.url = \"")) .expect("agent-iris input is rendered"); assert!( url.starts_with("git+https://forge.example/agent-configs/iris.git?rev="), "{url}" ); assert!(!url.contains("git+file"), "{url}"); } #[test] fn pinned_git_url_carries_the_rev_and_refuses_without_one() { assert_eq!( pinned_git_url( "https://forge.example/hyperhive/hyperhive.git", Some("abc123") ) .unwrap(), "git+https://forge.example/hyperhive/hyperhive.git?rev=abc123" ); for missing in [None, Some("")] { let err = pinned_git_url("https://forge.example/x.git", missing).unwrap_err(); assert!(err.to_string().contains("rev unknown"), "{err}"); } } #[test] fn render_flake_pins_hyperhive_by_url_and_follows_its_nixpkgs() { let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("alice", 9001)], ); assert!( out.contains( "hyperhive.url = \"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd\";" ), "expected hyperhive pinned by forge URL:\n{out}" ); assert!( out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\";"), "expected nixpkgs to follow hyperhive:\n{out}" ); assert!( !out.contains("nixpkgs.url") && !out.contains("hyperhive.inputs.nixpkgs"), "no separate nixpkgs input:\n{out}" ); // docs come from the pinned hyperhive input: no separate docs input, // no docs.source override. assert!( !out.contains("hyperhive-docs") && !out.contains("docs.source"), "no docs input or override:\n{out}" ); } #[test] fn render_flake_names_each_agent_container_after_the_agent() { let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("alice", 9001)], ); assert!( out.contains(r#"networking.hostName = "h-${name}";"#), "an agent container with no hostname of its own is called `nixos`, \ and ships every log line under that:\n{out}" ); // The machine name `nixos-container` and our own metrics already use, // so a log line joins to a metric without a prefix transform. assert!( !out.contains("networking.hostName = name;"), "must be the machine name, not the logical one:\n{out}" ); // Inside `mkAgent`'s module block, so it applies to every agent and // resolves to that agent's name — not emitted once at the top level. let mk = out.find("mkAgent = {").expect("mkAgent block"); let host = out .find(r#"networking.hostName = "h-${name}";"#) .expect("hostname line"); assert!( host > mk, "hostname must be emitted inside the per-agent module block:\n{out}" ); } #[test] fn render_flake_pins_claude_path_without_adding_an_input() { // The host-pinned claude travels as a bare store path assigned to // an option — deliberately NOT as a flake input. Containers share // the host store, so the build is already reachable; making it an // input would re-copy it as a reference-less `-source` and strip // the closure the binary actually needs. let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", Some("/nix/store/cccc-claude-code-2.1.220"), 8000, &[sample_spec("alice", 9001)], ); assert!( out.contains( "services.hyperhive.agent.claudeCodePath = \"/nix/store/cccc-claude-code-2.1.220\";" ), "claude path assigned as a plain string literal:\n{out}" ); assert!( !out.contains("claude-code-2.1.220\".url"), "the pinned claude must not become a flake input:\n{out}" ); } #[test] fn render_flake_omits_claude_path_when_unset() { // `None` = no host-level pin: the option is left undefined so the // agent module keeps its own nixpkgs' `claude-code` (and keeps it // in `environment.systemPackages`, which is what makes the // unpinned case self-contained). let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("alice", 9001)], ); assert!( !out.contains("claudeCodePath"), "no claude assignment when unpinned:\n{out}" ); } #[test] fn render_flake_emits_follows_for_agents_declaring_nixpkgs() { // `bitburner` + `dmatrix` declare `nixpkgs` at their root, while // `argus` has no canonical inputs at all. let with_nixpkgs = |name, port| AgentSpec { pin: Some(sample_pin(name, SAMPLE_REV, vec!["nixpkgs"])), ..sample_spec(name, port) }; let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[ sample_spec("argus", 9001), with_nixpkgs("bitburner", 9002), with_nixpkgs("dmatrix", 9003), ], ); // bitburner declares nixpkgs → follows emitted. assert!( out.contains("agent-bitburner.inputs.nixpkgs.follows = \"nixpkgs\""), "missing bitburner nixpkgs follows:\n{out}" ); // dmatrix declares nixpkgs → follows emitted. assert!(out.contains("agent-dmatrix.inputs.nixpkgs.follows = \"nixpkgs\"")); // argus declares neither → no follows emitted for it. Asserting // ABSENCE is the important bit: emitting a follows on a // non-existent input errors at `nix flake lock` time. assert!( !out.contains("agent-argus.inputs.nixpkgs"), "argus shouldn't have nixpkgs follows:\n{out}" ); } #[test] fn render_flake_builds_every_agent_from_agent_base() { let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("alice", 9001), sample_spec("ruth", 9002)], ); assert_eq!( out.matches("hyperhive.nixosConfigurations.").count(), 1, "one base for every agent:\n{out}" ); assert!( out.contains("base = hyperhive.nixosConfigurations.agent-base;"), "{out}" ); for line in [ "alice = mkAgent { name = \"alice\"; port = 9001; };", "ruth = mkAgent { name = \"ruth\"; port = 9002; };", ] { assert!(out.contains(line), "missing `{line}`:\n{out}"); } } #[test] fn render_flake_skips_canonical_follows_when_the_lock_declares_none() { let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("alice", 9001)], ); // No agent-side follows when the lock declares nothing // declared — protects agents whose flake.lock can't be read // (missing / unparsable) from being broken by a follows on a // non-existent input. assert!( !out.contains("agent-alice.inputs."), "alice shouldn't have any inputs follows:\n{out}" ); } /// An archetype agent has no `agent-` input, and `mkAgent` is /// handed its archetype; a custom agent beside it keeps its input. #[test] fn render_flake_builds_an_archetype_agent_from_its_archetype() { let android = AgentSpec { archetype: Some(swarm_queue_client::agent_archetype::ArchetypeRef { source: "hyperhive".to_owned(), name: "android-dev".to_owned(), }), ..sample_spec("droid", 9002) }; let alice = AgentSpec { pin: Some(sample_pin("alice", SAMPLE_REV, vec!["nixpkgs"])), ..sample_spec("alice", 9001) }; let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[alice, android], ); assert!( !out.contains("agent-droid"), "an archetype agent has no config input:\n{out}" ); assert!( out.contains( "droid = mkAgent { name = \"droid\"; port = 9002; \ archetype = { source = \"hyperhive\"; name = \"android-dev\"; }; };" ), "{out}" ); assert!(out.contains("agent-alice.url = \"git+https://"), "{out}"); assert!(out.contains("agent-alice.inputs.nixpkgs.follows"), "{out}"); assert!( out.contains("alice = mkAgent { name = \"alice\"; port = 9001; };"), "{out}" ); assert!( out.contains("hyperhiveArchetypes.${archetype.name}.module"), "{out}" ); assert!( out.contains("environment.HIVE_ARCHETYPE = archetype.name"), "{out}" ); } #[test] fn render_flake_forwards_env_into_global_environment() { // Regression for the isolation breakage where forwarded vars // (HIVE_FORGE_URL etc.) landed only on the harness service env, // so the bash-task runner / matrix daemon / shells defaulted to // localhost and couldn't reach the in-cluster gateway. They must // also appear in `systemd.globalEnvironment`, which every unit + // shell in the container inherits. // // Serialised against every other env-mutating test in the crate: // `render_flake_sets_service_url_options_from_forwarded_env` sets this // same variable, and both land in the one test binary. let _env = crate::test_env::lock(); // SAFETY: serialised by the guard above; restored before returning. unsafe { std::env::set_var("HIVE_FORGE_URL", "http://forge.example.test"); } let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("alice", 9001)], ); unsafe { std::env::remove_var("HIVE_FORGE_URL"); } // The var must be emitted inside the globalEnvironment block, i.e. // before the per-service harness env block that follows it. let global_at = out .find("systemd.globalEnvironment = {") .expect("globalEnvironment block must exist"); let service_at = out .find("systemd.services.${service}.environment") .expect("harness service env block must exist"); let forge_at = out .find("HIVE_FORGE_URL = \"http://forge.example.test\"") .expect("HIVE_FORGE_URL must be forwarded into the flake"); assert!( forge_at > global_at && forge_at < service_at, "HIVE_FORGE_URL must land inside systemd.globalEnvironment, \ not only the harness service env:\n{out}" ); } /// 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( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("alice", 9001)], ) }; 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 // mutate process env, which makes them race each other under the // parallel runner — the first version of this test did exactly that // and failed for that reason rather than for a real defect. let mut out = String::new(); push_forwarded_var_options( &mut out, &[ ("HIVE_FORGE_URL", "http://forge.example.test".to_string()), ("HIVE_MATRIX_URL", "http://matrix.example.test".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. ( "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.swarmName = \"constellat1on\";\n", "expected exactly the mapped options, in input order, indented for the module block" ); } /// The queue coordinates travel as a pair and are wired through both /// halves of the forwarded-var machinery: the env var the harness reads at /// runtime and the option the harness *unit* is built from. An agent that /// got only the env var would declare no credential and then report a /// partial config — visible, but a rebuild away from working. #[test] fn queue_coordinates_are_forwarded_as_both_env_and_option() { for var in ["HIVE_AGENT_NATS_URL", "HIVE_AGENT_OIDC_TOKEN_ENDPOINT"] { assert!( FORWARDED_VARS.contains(&var), "{var} must be forwarded into every agent's env" ); } let mut out = String::new(); push_forwarded_var_options( &mut out, &[ ("HIVE_AGENT_NATS_URL", "nats://10.42.0.1:4222".to_string()), ( "HIVE_AGENT_OIDC_TOKEN_ENDPOINT", "https://auth.t.local/api/oidc/token".to_string(), ), ], ); assert_eq!( out, " services.hyperhive.agent.queue.natsUrl = \"nats://10.42.0.1:4222\";\n\ \x20 services.hyperhive.agent.queue.tokenEndpoint = \"https://auth.t.local/api/oidc/token\";\n" ); } /// `swarm-metrics` is installed only when the option is set, and reads /// the env var at runtime, so the metrics store's origin needs both halves. #[test] fn the_metrics_url_is_forwarded_as_both_env_and_option() { assert!(FORWARDED_VARS.contains(&"HIVE_AGENT_METRICS_URL")); let mut out = String::new(); push_forwarded_var_options( &mut out, &[( "HIVE_AGENT_METRICS_URL", "https://metrics.t.local".to_string(), )], ); assert_eq!( out, " services.hyperhive.agent.metrics.url = \"https://metrics.t.local\";\n" ); } #[test] fn require_service_urls_accepts_a_rendered_forge_url() { require_service_urls(&[ ("HIVE_FORGE_URL", "http://forge.example.test".to_string()), ("HYPERHIVE_SWARM_NAME", "constellat1on".to_string()), ]) .expect("a forwarded forge URL satisfies the requirement"); } #[test] fn require_service_urls_refuses_to_write_without_a_forge() { // The agent option is nullable, so nothing downstream would complain: // 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_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"), "the error must name the missing variable: {err}" ); } #[test] fn service_url_options_emit_nothing_when_absent() { // No guess when the host doesn't say — the agent option stays `null` // ("not configured") and the units that would use it aren't generated, // so absence is an absent integration rather than a misdirected one. // Required services don't reach here: `require_service_urls` rejects // them first. // `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, &[( "HIVE_FORGE_PUBLIC_URL", "https://forge.example.test".to_string(), )], ); assert!( out.is_empty(), "no option should be emitted for an unmapped forwarded var, got:\n{out}" ); } #[test] fn service_url_options_escape_quotes() { // The value lands inside a nix string literal; an unescaped quote // would end the string and change the surrounding config rather than // merely corrupting one value. let mut out = String::new(); push_forwarded_var_options( &mut out, &[("HIVE_FORGE_URL", "http://x/\"; evil = \"".to_string())], ); assert!( out.contains("\\\"") && !out.contains("/\";"), "quotes in the value must be escaped:\n{out}" ); } #[test] #[ignore = "mutates process env; races the other render_flake env tests under the parallel runner"] fn render_flake_sets_service_url_options_from_forwarded_env() { // The forwarded env vars must ALSO become option assignments, because // the two are consumed at different times: the option is baked into // scripts at build time (forge-avatar-sync's FORGE_URL), the env var is read at // runtime. Emitting only the env var leaves the option on its default, // which is how a hive ends up with two disagreeing answers for the same // URL. // // Asserts PLACEMENT, not just presence: an option line rendered outside // the per-agent module block would appear in the file and change // nothing. It has to land before the environment blocks that close the // module out. // // Serialised against every other env-mutating test in the crate: // `render_flake_forwards_env_into_global_environment` sets HIVE_FORGE_URL // too, and both land in the one test binary. let _env = crate::test_env::lock(); // SAFETY: serialised by the guard above; restored before returning. unsafe { std::env::set_var("HIVE_FORGE_URL", "http://forge.example.test"); std::env::set_var("HIVE_MATRIX_URL", "http://matrix.example.test"); } let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("alice", 9001)], ); unsafe { std::env::remove_var("HIVE_FORGE_URL"); std::env::remove_var("HIVE_MATRIX_URL"); } let forge_opt_at = out .find("services.hyperhive.agent.forge.url = \"http://forge.example.test\"") .expect("services.hyperhive.agent.forge.url must be rendered from HIVE_FORGE_URL"); let matrix_opt_at = out .find("services.hyperhive.agent.matrix.url = \"http://matrix.example.test\"") .expect("services.hyperhive.agent.matrix.url must be rendered from HIVE_MATRIX_URL"); let env_block_at = out .find("environment.variables = {") .expect("per-agent environment.variables block must exist"); assert!( forge_opt_at < env_block_at && matrix_opt_at < env_block_at, "service URL options must land inside the per-agent module block:\n{out}" ); } #[test] fn render_flake_pins_each_agent_input_to_its_own_forge_rev() { let at = |name, port, rev: &str| AgentSpec { pin: Some(sample_pin(name, rev, Vec::new())), ..sample_spec(name, port) }; let out = render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[at("alice", 9001, "aaaa"), at("bob", 9002, "bbbb")], ); assert!( out.contains( "agent-alice.url = \"git+https://forge.example/agent-configs/alice.git?rev=aaaa\";" ), "{out}" ); assert!( out.contains( "agent-bob.url = \"git+https://forge.example/agent-configs/bob.git?rev=bbbb\";" ), "{out}" ); assert!( !out.contains("git+file://"), "no host-local path may remain in the flake:\n{out}" ); } #[test] fn render_flake_refuses_a_custom_agent_without_a_pin() { let args = |agents: &[AgentSpec]| { render_flake( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, agents, ) }; let unpinned = AgentSpec { pin: None, ..sample_spec("alice", 9001) }; let err = args(&[unpinned]).expect_err("an unpinned agent must not render"); assert!( format!("{err:#}").contains("agent alice: no pinned rev"), "{err:#}" ); // Presence control: the same agent with a pin renders. assert!(args(&[sample_spec("alice", 9001)]).is_ok()); // An archetype agent has no config input and needs no pin. let android = AgentSpec { archetype: Some(swarm_queue_client::agent_archetype::ArchetypeRef { source: "hyperhive".to_owned(), name: "android-dev".to_owned(), }), pin: None, ..sample_spec("droid", 9002) }; assert!(args(&[android]).is_ok()); } /// A repo whose commits carry a `flake.lock`; `lock` is its content, `None` /// for a commit without one. Returns the dir and the commit. async fn commit_lock(dir: &Path, lock: Option<&str>) -> String { let _ = std::fs::remove_file(dir.join("flake.lock")); if let Some(lock) = lock { std::fs::write(dir.join("flake.lock"), lock).expect("write lock"); } git(dir, &["add", "-A"]).await.expect("add"); git( dir, &[ "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "c", ], ) .await .expect("commit"); lifecycle::git_rev_parse(dir, "HEAD").await.expect("HEAD") } async fn repo() -> tempfile::TempDir { let dir = tempfile::tempdir().expect("tempdir"); git(dir.path(), &["init", "-q", "--initial-branch=main"]) .await .expect("init"); dir } const NIXPKGS_LOCK: &str = r#"{"nodes":{"root":{"inputs":{"nixpkgs":"nixpkgs"}},"nixpkgs":{}},"root":"root","version":7}"#; const NO_INPUTS_LOCK: &str = r#"{"nodes":{"root":{"inputs":{}}},"root":"root","version":7}"#; const TEST_FORGE_REF: &str = "refs/test/forge-main"; const TEST_URL: &str = "https://forge.example/agent-configs/alice.git"; /// `pin_agent` where the forge fetch leaves `forge_ref` at `forge_sha`. async fn pin_with_forge_at(repo: &Path, forge_sha: Option<&str>) -> Result { pin_agent("alice", repo, TEST_URL, TEST_FORGE_REF, async { if let Some(sha) = forge_sha { lifecycle::git_update_ref(repo, TEST_FORGE_REF, sha).await?; } Ok(repo.to_path_buf()) }) .await } #[tokio::test] async fn pin_agent_pins_forge_main_not_the_local_main() { let dir = repo().await; let forge = commit_lock(dir.path(), Some(NIXPKGS_LOCK)).await; // Local main moves on past the forge's: its commit and lock are never read. let local = commit_lock(dir.path(), Some(NO_INPUTS_LOCK)).await; std::fs::write(dir.path().join("flake.lock"), NO_INPUTS_LOCK).expect("edit"); assert_ne!(forge, local); let pin = pin_with_forge_at(dir.path(), Some(&forge)) .await .expect("pinned"); assert_eq!(pin.url, format!("git+{TEST_URL}?rev={forge}")); // The lock at the forge commit declares nixpkgs; local main's does not. assert_eq!(pin.canonical_inputs, vec!["nixpkgs"]); } #[tokio::test] async fn two_agents_at_different_forge_mains_get_distinct_pins() { let a = repo().await; let b = repo().await; let rev_a = commit_lock(a.path(), None).await; let rev_b = commit_lock(b.path(), Some(NIXPKGS_LOCK)).await; assert_ne!(rev_a, rev_b); let pin_a = pin_with_forge_at(a.path(), Some(&rev_a)).await.expect("a"); let pin_b = pin_with_forge_at(b.path(), Some(&rev_b)).await.expect("b"); assert_ne!(pin_a.url, pin_b.url); assert!(pin_a.url.ends_with(&rev_a) && pin_b.url.ends_with(&rev_b)); // No lock at a's commit, so no follows; b's lock declares nixpkgs. assert!(pin_a.canonical_inputs.is_empty()); assert_eq!(pin_b.canonical_inputs, vec!["nixpkgs"]); } #[tokio::test] async fn pin_agent_errors_when_the_forge_is_unreachable_or_has_no_main() { let dir = repo().await; let rev = commit_lock(dir.path(), None).await; let unreachable = pin_agent("alice", dir.path(), TEST_URL, TEST_FORGE_REF, async { anyhow::bail!("forge down") }) .await .expect_err("unreachable forge"); let msg = format!("{unreachable:#}"); assert!( msg.contains("agent alice") && msg.contains("forge down"), "{msg}" ); // The fetch succeeds but leaves no forge main. let no_main = pin_with_forge_at(dir.path(), None) .await .expect_err("no main on the forge"); let msg = format!("{no_main:#}"); assert!( msg.contains("agent alice") && msg.contains("no main"), "{msg}" ); // Presence control: with a forge main, the same call pins it. let pin = pin_with_forge_at(dir.path(), Some(&rev)) .await .expect("pinned"); assert!(pin.url.ends_with(&rev)); } #[test] fn render_flake_embeds_hive_ca_when_signalled() { // When the hyperhive TLS module signals a trust bundle via // HIVE_TLS_CA_PATH (and the cert exists), the agent module must // trust it at build time via security.pki.certificateFiles. Absent // the signal, no reference is emitted (so the flake doesn't point at // a file that was never embedded). // // Serialised against every other env-mutating test in the crate. Taken // before the fixture files so the guard covers the whole window in // which HIVE_TLS_CA_PATH / HIVE_PEER_CA_PATHS are perturbed. let _env = crate::test_env::lock(); let ca_file = std::env::temp_dir().join(format!("hive-ca-test-{}.pem", std::process::id())); std::fs::write( &ca_file, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n", ) .expect("write temp CA"); let render = || { render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("alice", 9001)], ) }; // A leftover peer-CA file + the env var that names it. Both must be // inert: the store's root rides in the hive's own trust bundle, so // nothing per-peer is embedded. let stale_peer = std::env::temp_dir().join(format!("peer-ca0-test-{}.pem", std::process::id())); std::fs::write( &stale_peer, "-----BEGIN CERTIFICATE-----\np0\n-----END CERTIFICATE-----\n", ) .expect("write stale peer CA"); // SAFETY: serialised by the guard at the top of this test; restored // before returning. unsafe { std::env::remove_var("HIVE_PEER_CA_PATHS"); std::env::set_var("HIVE_TLS_CA_PATH", &ca_file); } let with_ca = render(); unsafe { std::env::set_var("HIVE_PEER_CA_PATHS", stale_peer.display().to_string()); } let with_stale_peer_env = render(); // The stale var alone, with no trust bundle: must produce nothing. unsafe { std::env::remove_var("HIVE_TLS_CA_PATH"); } let stale_peer_env_only = render(); unsafe { std::env::remove_var("HIVE_PEER_CA_PATHS"); } let without_ca = render(); let _ = std::fs::remove_file(&ca_file); let _ = std::fs::remove_file(&stale_peer); assert!( with_ca.contains("security.pki.certificateFiles = [ ./hive-ca.pem ]"), "CA cert must be wired into certificateFiles when signalled:\n{with_ca}" ); // The regression guard, scoped to the CA list rather than the whole // render. Comparing the two flakes wholesale looks stronger and is // actually FLAKY: `cargo test` runs these in parallel threads and // sibling tests mutate process env (OTEL, forge URLs) between the // two `render()` calls, so a whole-output equality assertion fails // on changes that have nothing to do with this test. assert!( with_stale_peer_env.contains("security.pki.certificateFiles = [ ./hive-ca.pem ]"), "HIVE_PEER_CA_PATHS must not change the CA list — the store's \ root inside the hive's own trust bundle is the only anchor \ embedded:\n{with_stale_peer_env}" ); assert!( !with_stale_peer_env.contains("peer-ca-"), "no peer-ca-.pem may be embedded, however HIVE_PEER_CA_PATHS \ is set:\n{with_stale_peer_env}" ); assert!( !stale_peer_env_only.contains("security.pki.certificateFiles"), "a stale HIVE_PEER_CA_PATHS must not resurrect a certificateFiles \ reference on its own:\n{stale_peer_env_only}" ); assert!( !without_ca.contains("security.pki.certificateFiles"), "no certificateFiles reference without any CA signal:\n{without_ca}" ); } #[test] fn render_flake_carries_no_otel_enablement() { // The agent module defaults otel on and finds the collector by name, so // the flake carries no enable, endpoint or protocol line however the // host's OTEL env is set. Only the per-hive resource labels are // rendered, and only when set. // // Serialised against every other env-mutating test in the crate. // `stats::otel_metrics::tests` perturbs OTEL env too, and lands in this // same test binary — "no other test asserts on these" was true of this // module and false of the process. let _env = crate::test_env::lock(); // SAFETY: serialised by the guard above; restored before returning. let render = || { render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("alice", 9001)], ) }; unsafe { std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES"); std::env::set_var("HYPERHIVE_OTEL_ENDPOINT", "https://c.example/otel"); std::env::set_var("HYPERHIVE_OTEL_PROTOCOL", "grpc"); } let plain = render(); unsafe { std::env::set_var( "HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES", "deployment.environment=prod", ); } let labelled = render(); unsafe { std::env::remove_var("HYPERHIVE_OTEL_ENDPOINT"); std::env::remove_var("HYPERHIVE_OTEL_PROTOCOL"); std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES"); } // Presence control: the render is the real flake, carrying the pin. assert!( plain.contains("rev=0123abcd"), "the hyperhive pin line must be rendered:\n{plain}" ); for (name, flake) in [("plain", &plain), ("labelled", &labelled)] { for needle in [ "agent.otel.enable", "agent.otel.endpoint", "agent.otel.protocol", "c.example", ] { assert!( !flake.contains(needle), "{name}: `{needle}` must not be rendered:\n{flake}" ); } } assert!( !plain.contains("agent.otel.extraResourceAttributes"), "extraResourceAttributes must not appear when unset:\n{plain}" ); assert!( labelled.contains( "services.hyperhive.agent.otel.extraResourceAttributes = \"deployment.environment=prod\";" ), "extraResourceAttributes must be rendered when set:\n{labelled}" ); } #[test] fn render_flake_injects_github_disable_only_when_signalled() { // services.hyperhive.swarm.github.enable = false -> HYPERHIVE_GITHUB_DISABLED // on hive-c0re's unit -> `services.hyperhive.agent.github.enable = false` injected into // every agent. On by default, so nothing is emitted unless disabled. // // Serialised against every other env-mutating test in the crate — the // variable is this module's alone, but the process it lives in is not. let _env = crate::test_env::lock(); // SAFETY: serialised by the guard above; restored before returning. let render = || { render( "git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd", None, 8000, &[sample_spec("alice", 9001)], ) }; unsafe { std::env::remove_var("HYPERHIVE_GITHUB_DISABLED"); } let on_default = render(); unsafe { std::env::set_var("HYPERHIVE_GITHUB_DISABLED", "1"); } let disabled = render(); unsafe { std::env::remove_var("HYPERHIVE_GITHUB_DISABLED"); } assert!( !on_default.contains("services.hyperhive.agent.github.enable"), "github.enable must not be emitted by default (agents keep the true default):\n{on_default}" ); assert!( disabled.contains("services.hyperhive.agent.github.enable = false;"), "github.enable = false must be injected when the host disables it:\n{disabled}" ); } /// The agent reads its memory cap at boot from the copy /// `lifecycle::write_dropins` hands it, so the cap is no build input. #[test] fn render_hive_flake_is_identical_across_memory_caps() { let hive = |cap: &str| HiveEnv { agent_memory_max: cap.to_owned(), ..HiveEnv::default() }; let agents = [sample_spec("alice", 9001)]; let small = render_hive_flake_at( &hive("4G"), "git+https://forge.example/h.git?rev=abc", &agents, ) .expect("render"); let large = render_hive_flake_at( &hive("16G"), "git+https://forge.example/h.git?rev=abc", &agents, ) .expect("render"); assert_eq!( small, large, "the rendered flake must not depend on the memory cap" ); } }