Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/meta.rs

2094 lines
85 KiB
Rust

//! 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<swarm_queue_client::agent_archetype::ArchetypeRef>,
/// 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<AgentPin>,
}
/// 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+<forge>/agent-configs/<n>.git?rev=<rev>`, `<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();
if !initial && on_disk == new_flake {
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()))?;
// Agents fetch the swarm root from the store at boot, so no CA file sits
// next to the flake; drop the ones earlier renders left behind.
let ca_touched = remove_stale_ca_files(&dir);
// 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<String> = agents.iter().map(|a| a.name.clone()).collect();
let pending: Vec<String> = 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 stale CA file removed above. `git add <path>` 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"),
// Only ever a removal; see `remove_stale_ca_files`.
f if is_stale_ca_name(f) => Some("stale-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 -- <path>` 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+<forge>/hyperhive/hyperhive.git?rev=<rev>`.
/// Errors when the host has no rev, rather than rendering an unpinned URL.
fn hyperhive_input_url(hive: &HiveEnv) -> Result<String> {
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<String> {
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<String> {
render_hive_flake_at(hive, &hyperhive_input_url(hive)?, agents)
}
fn render_hive_flake_at(
hive: &HiveEnv,
hyperhive_url: &str,
agents: &[AgentSpec],
) -> Result<String> {
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 = "<name>"` 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",
// 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 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 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 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()
}
/// 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<String> {
std::env::var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES")
.ok()
.filter(|v| !v.is_empty())
}
/// Remove the CA files earlier renders embedded next to `flake.nix`
/// (`hive-ca.pem`, and the per-peer `peer-ca-<N>.pem` before it), returning
/// every filename removed so the caller stages the deletion. Nothing writes
/// one now: agents fetch the swarm root from the store at boot, so a file left
/// here is referenced by nothing.
fn remove_stale_ca_files(dir: &Path) -> Vec<String> {
let mut removed = 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_stale_ca_name(name) && std::fs::remove_file(dir.join(name)).is_ok() {
removed.push(name.to_owned());
}
}
}
removed
}
/// True for the filenames [`remove_stale_ca_files`] owns.
fn is_stale_ca_name(name: &str) -> bool {
name == "hive-ca.pem"
|| (name.starts_with("peer-ca-")
&& Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("pem")))
}
/// Resolve every custom agent's [`AgentPin`] and return `agents` with it set.
async fn pin_agents(agents: &[AgentSpec]) -> Result<Vec<AgentSpec>> {
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/<name>`
/// `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<F>(
name: &str,
repo: &Path,
forge_git_url: &str,
forge_ref: &str,
fetch_forge: F,
) -> Result<AgentPin>
where
F: std::future::Future<Output = Result<std::path::PathBuf>>,
{
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::<serde_json::Value>(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<String> {
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-<n>.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-<name>.inputs.<canon>.follows = "<canon>"`.
// 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}\";"
);
}
// 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:<port>` 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.<name>` 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.<domain>` 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<Vec<String>> {
// `--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<bool> {
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 <args>` in `dir` (flakes enabled), capturing combined output.
/// The core of [`nix`].
async fn nix_output(dir: &Path, args: &[&str]) -> Result<std::process::Output> {
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-<name>` 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<AgentPin> {
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_no_ca() {
// Agents fetch the swarm root from the store at boot; the flake names
// no CA file whatever the host env says, including the variable
// hive-c0re used to embed from.
//
// Serialised against every other env-mutating test in the crate.
let _env = crate::test_env::lock();
let render = || {
render(
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
None,
8000,
&[sample_spec("alice", 9001)],
)
};
// SAFETY: serialised by the guard at the top of this test; restored
// before returning.
unsafe {
std::env::set_var("HIVE_TLS_CA_PATH", "/nonexistent/trust-bundle.pem");
}
let flake = render();
unsafe {
std::env::remove_var("HIVE_TLS_CA_PATH");
}
// Presence control: the render does carry per-agent module config, so
// the absences below are not an empty render.
assert!(
flake.contains("services.hyperhive.agent."),
"per-agent module block missing:\n{flake}"
);
for needle in ["hive-ca.pem", "certificateFiles", "peer-ca-"] {
assert!(
!flake.contains(needle),
"the flake must not name {needle}:\n{flake}"
);
}
}
#[test]
fn stale_ca_files_are_removed_and_nothing_else() {
let dir = tempfile::tempdir().expect("tempdir");
for name in ["hive-ca.pem", "peer-ca-0.pem", "peer-ca-1.PEM", "flake.nix"] {
std::fs::write(dir.path().join(name), "x").expect("write");
}
// Not CA files this machinery owns, though they look similar.
for name in ["peer-ca-0.txt", "other-ca.pem"] {
std::fs::write(dir.path().join(name), "x").expect("write");
}
let mut removed = remove_stale_ca_files(dir.path());
removed.sort();
assert_eq!(removed, ["hive-ca.pem", "peer-ca-0.pem", "peer-ca-1.PEM"]);
for kept in ["flake.nix", "peer-ca-0.txt", "other-ca.pem"] {
assert!(dir.path().join(kept).exists(), "{kept} must survive");
}
assert!(remove_stale_ca_files(dir.path()).is_empty());
}
#[test]
fn services_pki_mount_is_forwarded_as_both_env_and_option() {
assert!(FORWARDED_VARS.contains(&"HIVE_AGENT_BAO_SERVICES_PKI_MOUNT"));
let mut out = String::new();
push_forwarded_var_options(
&mut out,
&[("HIVE_AGENT_BAO_SERVICES_PKI_MOUNT", "pki".to_string())],
);
assert_eq!(
out,
" services.hyperhive.agent.bao.servicesPkiMount = \"pki\";\n"
);
}
#[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"
);
}
}