on deploy, hive-c0re takes the internal/meta head for an archetype agent only when the head lists that same archetype, and for a custom agent only when the head lists it as custom, so a switch between the two, or to another archetype, builds from the hive's own flake until the swarm commits the new flake. the record location is described in the same docs paragraph.
626 lines
26 KiB
Rust
626 lines
26 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. hive-c0re and
|
|
//! swarm-controller both render through [`render_flake`], so the flake a hive
|
|
//! builds has one renderer.
|
|
|
|
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.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 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_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`].
|
|
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);
|
|
push_agent_service_options(&mut out, vars, github_disabled);
|
|
push_agent_environment(&mut out, vars);
|
|
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.
|
|
fn push_mk_agent_open(out: &mut String) {
|
|
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
|
|
{
|
|
"#,
|
|
);
|
|
}
|
|
|
|
/// 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, vars: &[(&'static str, String)]) {
|
|
use std::fmt::Write as _;
|
|
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 = {
|
|
"#,
|
|
);
|
|
}
|
|
|
|
/// 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 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 prefix = format!(" {input}.url = \"");
|
|
let url = flake_nix
|
|
.lines()
|
|
.find_map(|l| l.strip_prefix(prefix.as_str()))?
|
|
.split_once('"')?
|
|
.0;
|
|
let (_, rev) = url.split_once("?rev=")?;
|
|
let rev = rev.split('&').next()?;
|
|
is_rev(rev).then_some(rev)
|
|
}
|
|
|
|
/// Whether a flake [`render_flake`] rendered builds agent `name` the way the
|
|
/// caller wants it built: from `archetype` when that is set, otherwise from
|
|
/// its own config repo at the commit `config_head`. A flake that lists the
|
|
/// agent under another archetype, or as custom where `archetype` is set (or
|
|
/// the reverse), does not; nor does a custom agent with no `config_head`.
|
|
#[must_use]
|
|
pub fn builds_agent_at(
|
|
flake_nix: &str,
|
|
name: &str,
|
|
archetype: Option<&ArchetypeRef>,
|
|
config_head: Option<&str>,
|
|
) -> bool {
|
|
let entry = format!("\n{}\n", agent_entry(name, agent_web_port(name), archetype));
|
|
if !flake_nix.contains(&entry) {
|
|
return false;
|
|
}
|
|
archetype.is_some()
|
|
|| config_head
|
|
.is_some_and(|head| pinned_rev(flake_nix, &format!("agent-{name}")) == Some(head))
|
|
}
|
|
|
|
#[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()
|
|
}
|
|
|
|
#[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
|
|
);
|
|
}
|
|
|
|
fn reviewer() -> ArchetypeRef {
|
|
ArchetypeRef {
|
|
source: "hyperhive".to_owned(),
|
|
name: "reviewer".to_owned(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_custom_agent_is_built_only_at_the_config_head_the_flake_pins() {
|
|
let flake = rendered();
|
|
assert!(builds_agent_at(&flake, "argus", None, Some(ARGUS)));
|
|
assert!(
|
|
!builds_agent_at(&flake, "argus", None, Some(HYPERHIVE)),
|
|
"a stale pin"
|
|
);
|
|
assert!(
|
|
!builds_agent_at(&flake, "argus", None, None),
|
|
"a custom agent with no config commit"
|
|
);
|
|
assert!(
|
|
!builds_agent_at(&flake, "ghost", None, Some(ARGUS)),
|
|
"an agent the flake lacks"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_archetype_agent_is_built_only_from_the_archetype_the_flake_lists() {
|
|
let flake = rendered();
|
|
assert!(builds_agent_at(&flake, "rev", Some(&reviewer()), None));
|
|
let other = ArchetypeRef {
|
|
name: "planner".to_owned(),
|
|
..reviewer()
|
|
};
|
|
assert!(
|
|
!builds_agent_at(&flake, "rev", Some(&other), None),
|
|
"another archetype of the same source"
|
|
);
|
|
assert!(
|
|
!builds_agent_at(&flake, "rev", None, Some(ARGUS)),
|
|
"listed as an archetype agent, wanted as a custom one"
|
|
);
|
|
assert!(
|
|
!builds_agent_at(&flake, "argus", Some(&reviewer()), None),
|
|
"listed as a custom agent, wanted as an archetype one"
|
|
);
|
|
assert!(!builds_agent_at(&flake, "ghost", Some(&reviewer()), None));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_whose_name_ends_another_s_is_not_that_agent() {
|
|
let flake = rendered();
|
|
assert!(!builds_agent_at(&flake, "gus", None, Some(ARGUS)));
|
|
assert!(!builds_agent_at(&flake, "v", Some(&reviewer()), None));
|
|
}
|
|
}
|