Watch
0
0
Fork
You've already forked hyperhive
0

move the meta flake render into the hive-meta-flake crate

hive-c0re's render_flake and the pieces it needs (AgentSpec, AgentPin,
pinned_git_url, the forwarded-var tables, canonical_inputs_in_lock) move
into a new crate with no IO. The forwarded values and the GitHub switch
are now arguments; hive-c0re passes what it read from its own unit env,
as before. Nothing changes on deploy: the rendered flake is byte-for-byte
the same, and hive-c0re's render tests pass unchanged.

Refs #4857
This commit is contained in:
atlas 2026-10-22 07:40:51 +02:00
commit ba97c64dda
13 changed files with 457 additions and 392 deletions

View file

@ -0,0 +1,13 @@
[package]
name = "hive-meta-flake"
edition.workspace = true
version.workspace = true
readme = "README.md"
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
serde_json.workspace = true
swarm-wire.workspace = true

10
hive-meta-flake/README.md Normal file
View file

@ -0,0 +1,10 @@
# hive-meta-flake
The meta flake's `flake.nix` text: one `nixosConfigurations.<agent>` per agent,
each built from hyperhive's `agent-base` plus the agent's config input or
archetype.
Pure string rendering, with no IO and no environment reads. The caller passes
the hyperhive pin, the agents with their pins, and the values every agent
container gets. hive-c0re and swarm-controller both call `render_flake`, so
there is one renderer.

399
hive-meta-flake/src/lib.rs Normal file
View file

@ -0,0 +1,399 @@
//! The meta flake's text: one `nixosConfigurations.<agent>` per agent, built
//! from hyperhive's `agent-base` and the agent's config input or archetype.
//!
//! Pure: the output is a function of the arguments only. hive-c0re and
//! swarm-controller both render through [`render_flake`], so the flake a hive
//! builds has one renderer.
use anyhow::{Context, Result};
/// One agent of the meta flake.
#[derive(Debug, Clone)]
pub struct AgentSpec {
pub name: String,
pub port: u16,
/// The archetype the agent is built from, `None`
/// for a custom agent built from its own config repo.
pub archetype: Option<swarm_wire::agent_archetype::ArchetypeRef>,
/// The pin of a custom agent's config input. `None` until the caller
/// resolves it; an archetype agent has no config input and keeps `None`.
pub pin: Option<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>,
}
/// `git+<forge_git_url>?rev=<rev>`. Errors on a missing or empty rev rather
/// than rendering an unpinned URL.
pub fn pinned_git_url(forge_git_url: &str, rev: Option<&str>) -> Result<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}"))
}
/// 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.
pub const CANONICAL_INPUTS: &[&str] = &["nixpkgs"];
/// Env vars the meta flake sets in every agent container, from the
/// `(name, value)` pairs [`render_flake`] is given. A caller passes only the
/// names it has a non-empty value for, so absent options render no no-op
/// `FOO = ""` lines.
pub const FORWARDED_VARS: &[&str] = &[
// Where the swarm queue is, as an agent container reaches it. An address
// the *host* computes, never a loopback address, which inside a container
// is the agent itself.
"HIVE_AGENT_NATS_URL",
// Where the swarm secret store listens. The hive's own `BAO_ADDR` value,
// forwarded under a separate name so the container never inherits the
// hive's store environment wholesale — the address is the only part of it
// an agent may have, the certificate beside it being the hive's own.
"HIVE_AGENT_BAO_ADDR",
// The mount path of the store's services `pki` engine, whose root an agent
// fetches to trust the swarm. Forwarded with the address, under the same gate.
"HIVE_AGENT_BAO_SERVICES_PKI_MOUNT",
// Where an agent's `swarm-logs` queries, computed by the host for the
// same reason as the queue's: it is a gateway address, and a container
// cannot derive it from anything it holds.
"HIVE_AGENT_LOGS_QUERY_URL",
// The metrics store's origin, for `swarm-metrics`, for the same reason.
"HIVE_AGENT_METRICS_URL",
"HIVE_FORGE_URL",
"HIVE_FORGE_PUBLIC_URL",
"HIVE_MATRIX_URL",
"HYPERHIVE_SWARM_NAME",
];
/// Map of forwarded env var -> the agent option carrying the same value.
///
/// Both exist because they're consumed at different times: the option is baked
/// into scripts at build time (forge-avatar-sync bakes `FORGE_URL` from it), the env
/// var is read at runtime. Setting only one leaves the other on its default,
/// which is how a hive ends up with two disagreeing answers for one value.
///
/// The swarm display name is here for that reason: `otel.nix` reads the option
/// at eval time for the `swarm=` resource label, and with the env var alone
/// that label evaluates to `unknown` while the process env holds the right
/// name. Half-wiring this map is a value that evaluates fine and is silently
/// wrong.
pub const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[
// Read at build time to decide whether the in-container identity check is
// generated at all — see `nix/agent-modules/bao.nix`. Both halves wired
// for this map's own reason: the unit bakes the address into its own
// environment, so an agent whose option says no store and whose env says
// otherwise would have a check that is simply not there.
("HIVE_AGENT_BAO_ADDR", "services.hyperhive.agent.bao.addr"),
// Read at build time to bake the root-fetch URL into
// `nix/agent-modules/hive-ca.nix`'s unit.
(
"HIVE_AGENT_BAO_SERVICES_PKI_MOUNT",
"services.hyperhive.agent.bao.servicesPkiMount",
),
// Read at build time to decide whether `swarm-logs` is installed at all —
// see `nix/agent-modules/logs.nix`. Both halves wired for this map's own
// reason: an agent whose option says no log store and whose env says
// otherwise has a binary that is not on PATH and coordinates that claim
// it is.
(
"HIVE_AGENT_LOGS_QUERY_URL",
"services.hyperhive.agent.logs.queryUrl",
),
// The same for `swarm-metrics`: see `nix/agent-modules/metrics.nix`.
(
"HIVE_AGENT_METRICS_URL",
"services.hyperhive.agent.metrics.url",
),
("HIVE_FORGE_URL", "services.hyperhive.agent.forge.url"),
("HIVE_MATRIX_URL", "services.hyperhive.agent.matrix.url"),
("HYPERHIVE_SWARM_NAME", "services.hyperhive.agent.swarmName"),
];
/// Render the forwarded-var option assignments for one agent's module block.
///
/// Split out of `render_flake` so it can be tested without touching process
/// env: the render-level tests have to `set_var`, which forces them to
/// serialise on the crate's `test_env` lock (a `#[cfg(test)]` module, so this
/// deliberately isn't an intra-doc link). A pure function over the
/// already-collected pairs has no such hazard.
///
/// A var that isn't present emits nothing rather than a guess. For an optional
/// service that is the whole point — the agent option defaults to `null`,
/// meaning "not configured", and the units that would use it aren't generated.
/// For a service the hive cannot run without, silence would instead produce a
/// fleet of agents quietly missing an integration, so the caller checks
/// those before rendering.
pub fn push_forwarded_var_options(out: &mut String, vars: &[(&'static str, String)]) {
use std::fmt::Write as _;
for (var, val) in vars {
let Some((_, option)) = FORWARDED_VAR_OPTIONS.iter().find(|(name, _)| name == var) else {
continue;
};
let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {option} = \"{escaped}\";");
}
}
/// The subset of `CANONICAL_INPUTS` that the `flake.lock` text `raw` declares
/// as direct (root-level) inputs. Empty when the lock is unparsable — safe
/// degradation, the worst case is no dedup for that agent.
#[must_use]
pub fn canonical_inputs_in_lock(raw: &str) -> Vec<&'static str> {
let Ok(json) = serde_json::from_str::<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. `vars` are the [`FORWARDED_VARS`] pairs with a
/// value; `github_disabled` turns the agents' GitHub integration off. Errors
/// on a custom agent without an [`AgentPin`].
#[allow(
clippy::too_many_lines,
reason = "templated string-builder for the meta flake — the length is one \
contiguous fmt block, splitting it would just hide the shape"
)]
pub fn render_flake(
hyperhive_url: &str,
agents: &[AgentSpec],
vars: &[(&'static str, String)],
github_disabled: bool,
) -> Result<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 mkAgent = { name, port, archetype ? null }:\n",
);
out.push_str(
r#" let
base = hyperhive.nixosConfigurations.agent-base;
# Every flake an archetype can come from, by its source name.
archetypeSources = { inherit hyperhive; };
agentModule =
if archetype == null then
inputs."agent-${name}".nixosModules.default
else
(archetypeSources.${archetype.source}
or (throw "agent ${name}: archetype source ${archetype.source} is not an input of this hive's meta flake")
).hyperhiveArchetypes.${archetype.name}.module
or (throw "agent ${name}: this hive's ${archetype.source} input has no archetype ${archetype.name}; update that input");
service = "hive-agent";
in
base.extendModules {
modules = [
agentModule
{
"#,
);
// Agent-facing service URLs (`services.hyperhive.agent.forge.url`, `services.hyperhive.agent.matrix.url`):
// emit the host's real values as build-time agent config, the same
// host-state -> agent-module shape as the CA block above.
//
// These options exist already, but until now *nothing set them*, so every
// agent silently fell back to their `localhost:<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 the caller rejects a
// missing `HIVE_FORGE_URL` before it renders. The check is not here because
// rendering is a pure string operation the tests exercise directly.
push_forwarded_var_options(&mut out, vars);
// GitHub integration is on by default in every agent
// (`services.hyperhive.agent.github.enable`); the host turns it off swarm-wide via
// `services.hyperhive.swarm.github.enable = false`, passed here as
// `github_disabled`. Only the OFF
// override is propagated — the enabled default needs no per-agent line.
if github_disabled {
out.push_str(" services.hyperhive.agent.github.enable = false;\n");
}
out.push_str(
r#" # The harness service inside the container runs as a
# non-root unix user named after the agent (`damocles`,
# `iris`, `root`, …). UID auto-assigned by NixOS; the
# per-agent override here is what makes
# `services.hyperhive.agent.user.name` match the agent's identity
# instead of the harness default of `"agent"`.
services.hyperhive.agent.user.name = name;
# Without this an agent container is called `nixos` and ships
# every log line under that: nixpkgs sets the hostname from the
# attr name in the `containers.<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 vars {
let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {var} = \"{escaped}\";");
}
out.push_str(
r" };
systemd.services.${service}.environment = {
HIVE_PORT = toString port;
HIVE_LABEL = name;
",
);
// Forwarded env vars, as the caller passed them. We copy whatever's set into
// each sub-agent's harness service env so the in-container surfaces
// (`identity.rs`, `forge_notify`) see a consistent view across the
// whole hive. Absent host-side env (option not set) → skip emission
// → in-container accessors fall back to None / defaults gracefully.
//
// - HIVE_FORGE_URL: agents poll this for Forgejo notifications.
// - HYPERHIVE_SWARM_NAME: human display name for the swarm.
for (var, val) in vars {
let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {var} = \"{escaped}\";");
}
out.push_str(
r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state";
HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness";
};
}
# Tells the harness the agent has no config repo of its own.
(if archetype == null then { } else {
systemd.services.${service}.environment.HIVE_ARCHETYPE = archetype.name;
})
];
};
in
{
nixosConfigurations = {
"#,
);
for spec in agents {
// `source` and `name` are identifiers (`archetypes::store_read`), so
// they need no escaping.
let archetype = spec.archetype.as_ref().map_or(String::new(), |a| {
format!(
" archetype = {{ source = \"{}\"; name = \"{}\"; }};",
a.source, a.name
)
});
let _ = writeln!(
out,
" {} = mkAgent {{ name = \"{}\"; port = {};{archetype} }};",
spec.name, spec.name, spec.port,
);
}
out.push_str(" };\n };\n}\n");
Ok(out)
}