Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-meta-flake/src/lib.rs
iris d516bb890c remove the forge links from the hive dashboard and the agent page
Each agent row on the hive dashboard, and the agent page's link menu, linked
to the agent's forge profile and its config repo. Both are gone, with what
only they used:

- hive-agent no longer builds the two links, the `Forge` link kind, or the
  `forge_public_url` field of its state, and no longer reads
  `HIVE_FORGE_PUBLIC_URL`. The agent page's link menu and the dashboard row's
  icon strip (which skips a forge link an agent not yet rebuilt still sends)
  drop their forge handling.
- hive-c0re's dashboard state no longer carries `forge_public_url`.
- `HIVE_FORGE_PUBLIC_URL` is no longer forwarded to agents through the meta
  flake (the forwarded-variable list, its test fixture, the controller's
  settings and the VM test), so the next meta flake render differs by that
  one line. hive-c0re still reads it for the forge URL a hive publishes, so
  `swarm.forge.publicUrl` stays, with its description corrected.
- The docs stop describing the links.
2026-11-07 13:43:44 +01:00

742 lines
31 KiB
Rust

//! 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. swarm-controller
//! renders through [`render_flake`] and reads the pins of the result
//! ([`pinned_rev`]).
use anyhow::{Context, Result};
use swarm_wire::agent_archetype::ArchetypeRef;
/// One agent of the meta flake.
#[derive(Debug, Clone)]
pub struct AgentSpec {
pub name: String,
pub port: u16,
/// The archetype the agent is built from, `None`
/// for a custom agent built from its own config repo.
pub archetype: Option<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.deploy.hive-controller.hyperhiveRev` is not set",
)?;
Ok(format!("git+{forge_git_url}?rev={rev}"))
}
/// Canonical inputs meta knows how to dedup. An agent whose `flake.lock` or
/// `flake.nix` declares one of these as a top-level input gets a
/// `follows = "<name>"` line emitted in meta — collapsing the
/// otherwise-separate-but-identical `nixpkgs_N` and `hyperhive_N` nodes into
/// a single meta-level reference.
pub const CANONICAL_INPUTS: &[&str] = &["nixpkgs", "hyperhive"];
/// Env vars the meta flake sets in every agent container, from the
/// `(name, value)` pairs [`render_flake`] is given. A caller passes only the
/// names it has a non-empty value for, so absent options render no no-op
/// `FOO = ""` lines.
pub const FORWARDED_VARS: &[&str] = &[
// Where the swarm secret store listens. The hive's own `BAO_ADDR` value,
// forwarded under a separate name so the container never inherits the
// hive's store environment wholesale — the address is the only part of it
// an agent may have, the certificate beside it being the hive's own.
"HIVE_AGENT_BAO_ADDR",
// The mount path of the store's services `pki` engine, whose root an agent
// fetches to trust the swarm. Forwarded with the address, under the same gate.
"HIVE_AGENT_BAO_SERVICES_PKI_MOUNT",
// Where an agent's `swarm-logs` queries, computed by the host: it is a
// gateway address, and a container cannot derive it from anything it holds.
"HIVE_AGENT_LOGS_QUERY_URL",
// The metrics store's origin, for `swarm-metrics`, for the same reason.
"HIVE_AGENT_METRICS_URL",
"HIVE_FORGE_URL",
"HIVE_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 an agent config declares, in its
/// `flake.lock` text `lock` or its `flake.nix` text `flake_nix`. A repo that
/// was only just seeded has no lock yet, so its `flake.nix` counts too: a line
/// starting `<input>.url` or `inputs.<input>.url`.
///
/// A `hyperhive` node in a lock does not count: it can be a leftover from an
/// input the config dropped from its `flake.nix`, and a `follows` for an input
/// the flake does not declare makes nix warn.
#[must_use]
pub fn canonical_inputs_declared(lock: Option<&str>, flake_nix: Option<&str>) -> Vec<&'static str> {
let locked = lock.map(canonical_inputs_in_lock).unwrap_or_default();
CANONICAL_INPUTS
.iter()
.copied()
.filter(|canon| {
(*canon != "hyperhive" && locked.contains(canon))
|| flake_nix.is_some_and(|text| {
text.lines().map(str::trim_start).any(|l| {
l.starts_with(&format!("{canon}.url"))
|| l.starts_with(&format!("inputs.{canon}.url"))
})
})
})
.collect()
}
/// 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`].
pub fn render_flake(
hyperhive_url: &str,
agents: &[AgentSpec],
vars: &[(&'static str, String)],
github_disabled: bool,
) -> Result<String> {
let mut out = String::new();
push_inputs(&mut out, hyperhive_url, agents)?;
push_mk_agent_open(&mut out, vars);
push_agent_service_options(&mut out, vars, github_disabled);
push_agent_environment(&mut out);
push_agent_entries(&mut out, agents);
Ok(out)
}
/// The flake header and `inputs` block: `hyperhive`, `nixpkgs`, and one input
/// per config-repo agent.
fn push_inputs(out: &mut String, hyperhive_url: &str, agents: &[AgentSpec]) -> Result<()> {
use std::fmt::Write as _;
out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n");
// `hyperhive` is pinned by forge URL and rev, so the flake resolves on
// any machine that can reach the forge. `nixpkgs` follows hyperhive's
// own pin, which cascades to every agent because
// `agent-<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,
);
}
}
Ok(())
}
/// Closes `inputs`, opens `outputs` and the `mkAgent` function up to the
/// start of the per-agent module. Binds `agentEnv`, the one attrset the
/// container-wide, login-shell and harness-service environments all take.
fn push_mk_agent_open(out: &mut String, vars: &[(&'static str, String)]) {
use std::fmt::Write as _;
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";
# One attrset for `environment.variables` (login shells),
# `systemd.globalEnvironment` (every unit) and the harness service,
# so the three cannot disagree. The forwarded vars come from the host.
agentEnv = {
HIVE_LABEL = name;
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness";
"#,
);
for (var, val) in vars {
let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {var} = \"{escaped}\";");
}
out.push_str(
r" };
in
base.extendModules {
modules = [
agentModule
{
",
);
}
/// Service URL options and the GitHub override inside the per-agent module.
fn push_agent_service_options(
out: &mut String,
vars: &[(&'static str, String)],
github_disabled: bool,
) {
// Agent-facing service URLs (`services.hyperhive.agent.forge.url`, `services.hyperhive.agent.matrix.url`):
// emit the host's real values as build-time agent config, the same
// host-state -> agent-module shape as the CA block above.
//
// These options exist already, but until now *nothing set them*, so every
// agent silently fell back to their `localhost:<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(out, vars);
// GitHub integration is on by default in every agent
// (`services.hyperhive.agent.github.enable`); the host turns it off swarm-wide via
// `services.hyperhive.swarm.github.enable = false`, passed here as
// `github_disabled`. Only the OFF
// override is propagated — the enabled default needs no per-agent line.
if github_disabled {
out.push_str(" services.hyperhive.agent.github.enable = false;\n");
}
}
/// The rest of the per-agent module: user, hostname, git identity, and the
/// container-wide and harness-service environments.
fn push_agent_environment(out: &mut String) {
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, all from `agentEnv`: every unit and daemon
# (the bash-task runner where `hive-forge` and `git` run included)
# resolves the state dir and the forwarded service URLs without
# redeclaring them. `environment.variables` is what login shells
# read; `systemd.globalEnvironment` is the analogue for units.
environment.variables = agentEnv;
systemd.globalEnvironment = agentEnv;
systemd.services.${service}.environment = agentEnv // {
HIVE_PORT = toString port;
};
}
# Tells the harness the agent has no config repo of its own.
(if archetype == null then { } else {
systemd.services.${service}.environment.HIVE_ARCHETYPE = archetype.name;
})
];
};
in
{
nixosConfigurations = {
"#,
);
}
/// One `mkAgent` line per agent, closing `outputs`.
fn push_agent_entries(out: &mut String, agents: &[AgentSpec]) {
for spec in agents {
out.push_str(&agent_entry(&spec.name, spec.port, spec.archetype.as_ref()));
out.push('\n');
}
out.push_str(" };\n };\n}\n");
}
/// One agent's `mkAgent` line in `nixosConfigurations`, without its newline.
fn agent_entry(name: &str, port: u16, archetype: Option<&ArchetypeRef>) -> String {
// `source` and `name` are identifiers (`archetypes::store_read`), so
// they need no escaping.
let archetype = archetype.map_or(String::new(), |a| {
format!(
" archetype = {{ source = \"{}\"; name = \"{}\"; }};",
a.source, a.name
)
});
format!(" {name} = mkAgent {{ name = \"{name}\"; port = {port};{archetype} }};")
}
/// Web UI port range, shared by every agent including the manager.
/// Deterministic from the agent's name (FNV-1a hash mod range size), so the
/// dashboard can compute the same port without asking hive-c0re.
const WEB_PORT_BASE: u16 = 8100;
const WEB_PORT_RANGE: u16 = 900;
/// FNV-1a hash of a string — used by `agent_web_port`.
fn fnv1a(s: &str) -> u32 {
let mut hash: u32 = 2_166_136_261;
for b in s.bytes() {
hash ^= u32::from(b);
hash = hash.wrapping_mul(16_777_619);
}
hash
}
/// Per-agent web UI port — `WEB_PORT_BASE + FNV-1a(name) %
/// WEB_PORT_RANGE` for every agent including the manager. The port
/// allocation rule reads the same for every name; collisions are
/// possible (birthday paradox at ~30 agents) and the operator
/// resolves them by renaming an agent (different hash → different
/// port). Stable across hosts, restarts, and dashboard renders —
/// no state-file dance.
#[must_use]
pub fn agent_web_port(name: &str) -> u16 {
// Modulo of a u32 by a u16's value is guaranteed < u16::MAX, so try_from never fails.
WEB_PORT_BASE + u16::try_from(fnv1a(name) % u32::from(WEB_PORT_RANGE)).unwrap_or(0)
}
/// Whether `rev` is a full git commit id: 40 lowercase hex digits.
#[must_use]
pub fn is_rev(rev: &str) -> bool {
rev.len() == 40 && rev.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// The URL of `input` in a flake [`render_flake`] rendered: the text of its
/// `<input>.url = "<url>";` line. `None` when the flake has no such input.
#[must_use]
pub fn input_url<'a>(flake_nix: &'a str, input: &str) -> Option<&'a str> {
let prefix = format!(" {input}.url = \"");
Some(
flake_nix
.lines()
.find_map(|l| l.strip_prefix(prefix.as_str()))?
.split_once('"')?
.0,
)
}
/// The commit `input` is pinned to in a flake [`render_flake`] rendered:
/// the `<rev>` of its `<input>.url = "git+<url>?rev=<rev>";` line. `None` when
/// the flake has no such input, or its rev is not a commit id.
#[must_use]
pub fn pinned_rev<'a>(flake_nix: &'a str, input: &str) -> Option<&'a str> {
let url = input_url(flake_nix, input)?;
let (_, rev) = url.split_once("?rev=")?;
let rev = rev.split('&').next()?;
is_rev(rev).then_some(rev)
}
#[cfg(test)]
mod tests {
use super::*;
const HYPERHIVE: &str = "1111111111111111111111111111111111111111";
const ARGUS: &str = "2222222222222222222222222222222222222222";
fn spec(name: &str, archetype: Option<ArchetypeRef>, pin: Option<AgentPin>) -> AgentSpec {
AgentSpec {
name: name.to_owned(),
port: agent_web_port(name),
archetype,
pin,
}
}
fn rendered() -> String {
let url = |repo: &str, rev: &str| {
pinned_git_url(&format!("https://f.t/{repo}.git"), Some(rev)).unwrap()
};
render_flake(
&url("hyperhive/hyperhive", HYPERHIVE),
&[
spec(
"argus",
None,
Some(AgentPin {
url: url("agent-configs/argus", ARGUS),
canonical_inputs: vec![],
}),
),
spec(
"rev",
Some(ArchetypeRef {
source: "hyperhive".to_owned(),
name: "reviewer".to_owned(),
}),
None,
),
],
&[],
false,
)
.unwrap()
}
/// `testdata/meta-flake.nix.in` is the flake `nix/vm-tests/swarm-builder-offline.nix`
/// evaluates and serves, with `@HYPERHIVE_URL@` where the pin goes. It must be
/// what [`render_flake`] renders for one archetype agent.
#[test]
fn the_vm_test_fixture_is_what_render_flake_renders() {
let archetype = ArchetypeRef {
source: "hyperhive".to_owned(),
name: "reviewer".to_owned(),
};
let flake = render_flake(
"@HYPERHIVE_URL@",
&[spec("rev1", Some(archetype), None)],
&[],
false,
)
.unwrap();
assert_eq!(
flake,
include_str!("../testdata/meta-flake.nix.in"),
"update the fixture to the rendered flake"
);
}
/// Login shells, every unit and the harness service take the forwarded
/// vars from the one `agentEnv` attrset, so none can lack a value another has.
#[test]
fn forwarded_vars_reach_login_shells_units_and_the_harness_from_one_attrset() {
let vars = [
("HIVE_FORGE_URL", "http://forge.t.local".to_owned()),
("HYPERHIVE_SWARM_NAME", "t".to_owned()),
];
let archetype = ArchetypeRef {
source: "hyperhive".to_owned(),
name: "reviewer".to_owned(),
};
let flake = render_flake("u", &[spec("rev", Some(archetype), None)], &vars, false).unwrap();
assert_eq!(
flake
.matches("HIVE_FORGE_URL = \"http://forge.t.local\";")
.count(),
1,
"the value is written once, in agentEnv"
);
assert!(flake.contains("agentEnv = {\n HIVE_LABEL = name;"));
for sink in [
"environment.variables = agentEnv;",
"systemd.globalEnvironment = agentEnv;",
"systemd.services.${service}.environment = agentEnv // {",
] {
assert!(flake.contains(sink), "missing `{sink}`");
}
}
/// `testdata/meta-flake-swarm.nix.in` is the flake `nix/vm-tests/full-setup.nix`
/// evaluates and serves: two archetype agents, the seven forwarded values a
/// single-host swarm without a display name sets, each as `@<NAME>@`, and
/// `@HYPERHIVE_URL@` where the pin goes. It must be what [`render_flake`]
/// renders for them.
#[test]
fn the_full_setup_fixture_is_what_render_flake_renders() {
let archetype = || {
Some(ArchetypeRef {
source: "hyperhive".to_owned(),
name: "reviewer".to_owned(),
})
};
let vars: Vec<(&'static str, String)> = FORWARDED_VARS
.iter()
.filter(|name| **name != "HYPERHIVE_SWARM_NAME")
.map(|&name| (name, format!("@{name}@")))
.collect();
let flake = render_flake(
"@HYPERHIVE_URL@",
&[
spec("iris", archetype(), None),
spec("ruth", archetype(), None),
],
&vars,
false,
)
.unwrap();
assert_eq!(
flake,
include_str!("../testdata/meta-flake-swarm.nix.in"),
"update the fixture to the rendered flake"
);
}
#[test]
fn a_rendered_flake_names_the_commit_each_input_is_pinned_to() {
let flake = rendered();
assert_eq!(pinned_rev(&flake, "hyperhive"), Some(HYPERHIVE));
assert_eq!(pinned_rev(&flake, "agent-argus"), Some(ARGUS));
assert_eq!(
pinned_rev(&flake, "agent-rev"),
None,
"an archetype has no input"
);
assert_eq!(pinned_rev(&flake, "agent-ghost"), None);
}
#[test]
fn a_pin_that_is_not_a_commit_id_is_no_pin() {
let flake = " hyperhive.url = \"git+https://f.t/h.git?rev=main\";\n";
assert_eq!(pinned_rev(flake, "hyperhive"), None);
assert_eq!(
pinned_rev(
" hyperhive.url = \"git+https://f.t/h.git\";\n",
"hyperhive"
),
None
);
}
/// The text of `mkAgent`'s `let` binding `name`: from `name =` to the
/// next binding at the same indent (`;` ends it at line end).
fn binding<'a>(flake: &'a str, name: &str) -> &'a str {
let from = flake
.find(&format!("\n {name} =\n"))
.or_else(|| flake.find(&format!("\n {name} = ")))
.unwrap_or_else(|| panic!("no `{name}` binding in mkAgent"));
let rest = &flake[from + 1..];
let end = rest
.find("\n service =")
.unwrap_or_else(|| panic!("`{name}` is the last binding"));
&rest[..end]
}
#[test]
fn an_archetype_agent_s_module_is_the_archetype_source_s_module() {
let flake = rendered();
let module = binding(&flake, "agentModule");
assert!(
module.contains("archetypeSources.${archetype.source}"),
"the archetype branch picks its source by name: {module}"
);
assert!(
module.contains(".hyperhiveArchetypes.${archetype.name}.module"),
"the archetype branch imports the archetype's module: {module}"
);
// The source it picks from must offer the hyperhive input.
assert!(
binding(&flake, "archetypeSources").contains("inherit hyperhive;"),
"the `hyperhive` archetype source"
);
// The module reaches the agent's system.
let modules = &flake[flake.find("modules = [").expect("a module list")..];
assert!(
modules
.trim_start_matches("modules = [")
.trim_start()
.starts_with("agentModule"),
"agentModule heads the extendModules list"
);
// The entry names the source and archetype the import reads.
assert!(
flake.contains("rev = mkAgent { name = \"rev\"; port = ")
&& flake
.contains(" archetype = { source = \"hyperhive\"; name = \"reviewer\"; }; };"),
"the archetype agent's entry"
);
}
/// An agent that declares `hyperhive` follows meta's own pin, so the
/// lock holds one hyperhive; one that declares nothing gets no line (the
/// control that the line is the declared input's, not every agent's).
#[test]
fn a_declared_hyperhive_input_follows_meta_s_own() {
let url = |repo: &str, rev: &str| {
pinned_git_url(&format!("https://f.t/{repo}.git"), Some(rev)).unwrap()
};
let agents = |canonical_inputs: Vec<&'static str>| {
render_flake(
&url("hyperhive/hyperhive", HYPERHIVE),
&[spec(
"argus",
None,
Some(AgentPin {
url: url("agent-configs/argus", ARGUS),
canonical_inputs,
}),
)],
&[],
false,
)
.unwrap()
};
let line = "agent-argus.inputs.hyperhive.follows = \"hyperhive\";";
assert!(agents(vec!["hyperhive"]).contains(line));
assert!(!agents(vec![]).contains(line));
}
/// A seeded repo has only a `flake.nix`; its input counts, from either
/// spelling, and a lock-declared input still does.
#[test]
fn canonical_inputs_are_read_from_the_flake_text_and_the_lock() {
let flake = "{\n inputs = {\n hyperhive.url = \"git+x\";\n };\n}\n";
assert_eq!(canonical_inputs_declared(None, Some(flake)), ["hyperhive"]);
let dotted = "{\n inputs.nixpkgs.url = \"x\";\n}\n";
assert_eq!(canonical_inputs_declared(None, Some(dotted)), ["nixpkgs"]);
let lock = r#"{"root":"root","nodes":{"root":{"inputs":{"nixpkgs":"n"}}}}"#;
assert_eq!(canonical_inputs_declared(Some(lock), None), ["nixpkgs"]);
assert!(canonical_inputs_declared(None, Some("{ inputs = { }; }")).is_empty());
// A leftover `hyperhive` node in a lock declares nothing.
let stale = r#"{"root":"root","nodes":{"root":{"inputs":{"hyperhive":"hyperhive"}}}}"#;
assert!(canonical_inputs_declared(Some(stale), Some("{ inputs = { }; }")).is_empty());
// Another archetype source is not canonical: meta does not follow it.
let other = "{\n inputs = {\n extra.url = \"git+x\";\n };\n}\n";
assert!(canonical_inputs_declared(None, Some(other)).is_empty());
}
}