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:
parent
de323a72dd
commit
ba97c64dda
13 changed files with 457 additions and 392 deletions
|
|
@ -94,6 +94,8 @@ Per-hive (host + in-container):
|
||||||
both (under a unit, stdout already is the journal). Do not grow it into a
|
both (under a unit, stdout already is the journal). Do not grow it into a
|
||||||
utility crate.
|
utility crate.
|
||||||
- **`hive-metric/`** — one-shot CLI pushing a labeled metric over OTLP.
|
- **`hive-metric/`** — one-shot CLI pushing a labeled metric over OTLP.
|
||||||
|
- **`hive-meta-flake/`** — the meta flake's `flake.nix` text, a pure
|
||||||
|
render. hive-c0re and swarm-controller both call it.
|
||||||
|
|
||||||
Wire types + socket plumbing:
|
Wire types + socket plumbing:
|
||||||
|
|
||||||
|
|
|
||||||
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -1781,6 +1781,7 @@ dependencies = [
|
||||||
"hive-jobq",
|
"hive-jobq",
|
||||||
"hive-jobq-wire",
|
"hive-jobq-wire",
|
||||||
"hive-log",
|
"hive-log",
|
||||||
|
"hive-meta-flake",
|
||||||
"hive-priv-sock",
|
"hive-priv-sock",
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
"hive-sock-client",
|
"hive-sock-client",
|
||||||
|
|
@ -1959,6 +1960,15 @@ dependencies = [
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hive-meta-flake"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"serde_json",
|
||||||
|
"swarm-wire",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hive-metric"
|
name = "hive-metric"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ members = [
|
||||||
"hive-jobq-metrics",
|
"hive-jobq-metrics",
|
||||||
"hive-jobq-wire",
|
"hive-jobq-wire",
|
||||||
"hive-log",
|
"hive-log",
|
||||||
|
"hive-meta-flake",
|
||||||
"hive-matrix-mcp",
|
"hive-matrix-mcp",
|
||||||
"hive-metric",
|
"hive-metric",
|
||||||
"hive-priv",
|
"hive-priv",
|
||||||
|
|
@ -107,6 +108,7 @@ hive-jobq = { path = "hive-jobq" }
|
||||||
hive-jobq-metrics = { path = "hive-jobq-metrics" }
|
hive-jobq-metrics = { path = "hive-jobq-metrics" }
|
||||||
hive-jobq-wire = { path = "hive-jobq-wire" }
|
hive-jobq-wire = { path = "hive-jobq-wire" }
|
||||||
hive-log = { path = "hive-log" }
|
hive-log = { path = "hive-log" }
|
||||||
|
hive-meta-flake = { path = "hive-meta-flake" }
|
||||||
hive-core-agent-sock = { path = "hive-core-agent-sock" }
|
hive-core-agent-sock = { path = "hive-core-agent-sock" }
|
||||||
hive-claude = "0.1.4"
|
hive-claude = "0.1.4"
|
||||||
hive-host-sock = { path = "hive-host-sock" }
|
hive-host-sock = { path = "hive-host-sock" }
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ hive-host-sock.workspace = true
|
||||||
hive-jobq.workspace = true
|
hive-jobq.workspace = true
|
||||||
hive-jobq-wire.workspace = true
|
hive-jobq-wire.workspace = true
|
||||||
hive-log.workspace = true
|
hive-log.workspace = true
|
||||||
|
hive-meta-flake.workspace = true
|
||||||
hive-priv-sock.workspace = true
|
hive-priv-sock.workspace = true
|
||||||
hive-sock-client.workspace = true
|
hive-sock-client.workspace = true
|
||||||
hive-types.workspace = true
|
hive-types.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -367,7 +367,7 @@ fn read_active_model(name: &hive_types::Ident) -> Option<String> {
|
||||||
/// The agent-side
|
/// The agent-side
|
||||||
/// `hive-agent::identity::{hive_name, swarm_name}` accessors read the
|
/// `hive-agent::identity::{hive_name, swarm_name}` accessors read the
|
||||||
/// same env vars after they're forwarded into each sub-agent's
|
/// same env vars after they're forwarded into each sub-agent's
|
||||||
/// harness service environment by `meta::render_flake`; surfacing
|
/// harness service environment by `hive_meta_flake::render_flake`; surfacing
|
||||||
/// them here from c0re's own env keeps the manager + agent
|
/// them here from c0re's own env keeps the manager + agent
|
||||||
/// `GetAgentMeta` paths consistent without a round-trip to the
|
/// `GetAgentMeta` paths consistent without a round-trip to the
|
||||||
/// target container.
|
/// target container.
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ fn extract_token(output: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonical email address for a hive agent's Forgejo account.
|
/// Canonical email address for a hive agent's Forgejo account.
|
||||||
/// Must match the `user.email` set by `meta::render_flake` so commits
|
/// Must match the `user.email` set by `hive_meta_flake::render_flake` so commits
|
||||||
/// by the agent link back to their Forgejo profile page.
|
/// by the agent link back to their Forgejo profile page.
|
||||||
fn agent_email(name: &str) -> String {
|
fn agent_email(name: &str) -> String {
|
||||||
format!("{name}@hyperhive.local")
|
format!("{name}@hyperhive.local")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ use tokio::process::Command;
|
||||||
use tokio::sync::{Mutex, MutexGuard};
|
use tokio::sync::{Mutex, MutexGuard};
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
pub use hive_meta_flake::{AgentPin, AgentSpec};
|
||||||
|
#[cfg(test)]
|
||||||
|
use hive_meta_flake::{FORWARDED_VAR_OPTIONS, push_forwarded_var_options};
|
||||||
|
use hive_meta_flake::{FORWARDED_VARS, canonical_inputs_in_lock, pinned_git_url};
|
||||||
|
|
||||||
use crate::coordinator::HiveEnv;
|
use crate::coordinator::HiveEnv;
|
||||||
use crate::lifecycle;
|
use crate::lifecycle;
|
||||||
|
|
||||||
|
|
@ -112,30 +117,6 @@ async fn lock_notifying<'a>(
|
||||||
/// Where the manager sees this directory inside its container (RO bind).
|
/// Where the manager sees this directory inside its container (RO bind).
|
||||||
pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta";
|
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`
|
/// Stage topology.json if it exists: it is regenerated by `reconcile`
|
||||||
/// whenever the agent set changed. Without staging, an
|
/// whenever the agent set changed. Without staging, an
|
||||||
/// existing-but-untracked file shows up as untracked in the meta repo,
|
/// existing-but-untracked file shows up as untracked in the meta repo,
|
||||||
|
|
@ -422,15 +403,6 @@ fn hyperhive_input_url(hive: &HiveEnv) -> Result<String> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 the hyperhive pin taken from `hive`, as
|
/// [`render_flake`] with the hyperhive pin taken from `hive`, as
|
||||||
/// [`sync_agents`] renders it. Nothing else in `hive` reaches the flake: the
|
/// [`sync_agents`] renders it. Nothing else in `hive` reaches the flake: the
|
||||||
/// values that differ per host go to the container at start
|
/// values that differ per host go to the container at start
|
||||||
|
|
@ -439,113 +411,15 @@ fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<String> {
|
||||||
render_flake(&hyperhive_input_url(hive)?, agents)
|
render_flake(&hyperhive_input_url(hive)?, agents)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonical inputs meta knows how to dedup. An agent that declares one
|
/// [`hive_meta_flake::render_flake`] with the forwarded values and the GitHub
|
||||||
/// of these as a top-level input in its own `flake.nix` will get a
|
/// switch read from this process's environment.
|
||||||
/// `follows = "<name>"` line emitted in meta — collapsing the
|
fn render_flake(hyperhive_url: &str, agents: &[AgentSpec]) -> Result<String> {
|
||||||
/// otherwise-separate-but-identical `nixpkgs_N` nodes into a single
|
hive_meta_flake::render_flake(
|
||||||
/// meta-level reference.
|
hyperhive_url,
|
||||||
const CANONICAL_INPUTS: &[&str] = &["nixpkgs"];
|
agents,
|
||||||
|
&forwarded_env_vars(),
|
||||||
/// Env vars hive-c0re forwards from its own systemd unit env into every
|
std::env::var_os("HYPERHIVE_GITHUB_DISABLED").is_some(),
|
||||||
/// 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, 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.
|
|
||||||
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 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.
|
/// Service URLs a running hive must always supply, checked before rendering.
|
||||||
|
|
@ -562,7 +436,7 @@ const REQUIRED_SERVICE_URL_VARS: &[&str] = &["HIVE_FORGE_URL"];
|
||||||
/// `vars`.
|
/// `vars`.
|
||||||
///
|
///
|
||||||
/// Pure over the already-collected pairs so it can be tested without touching
|
/// 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).
|
/// process env (the same reason [`hive_meta_flake::push_forwarded_var_options`] is split out).
|
||||||
///
|
///
|
||||||
/// Checked by `sync_agents` — the point at which the hive commits a rendered
|
/// 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
|
/// flake to disk — rather than inside the renderer. Rendering is a pure string
|
||||||
|
|
@ -693,252 +567,6 @@ where
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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, 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 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 `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;
|
|
||||||
",
|
|
||||||
);
|
|
||||||
// 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
|
/// 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
|
/// from HEAD). On the initial commit (`HEAD` doesn't exist yet) falls back
|
||||||
/// to `git diff --cached --name-only HEAD` failing gracefully by using
|
/// to `git diff --cached --name-only HEAD` failing gracefully by using
|
||||||
|
|
|
||||||
13
hive-meta-flake/Cargo.toml
Normal file
13
hive-meta-flake/Cargo.toml
Normal 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
10
hive-meta-flake/README.md
Normal 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
399
hive-meta-flake/src/lib.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -72,7 +72,7 @@ in
|
||||||
internal = true;
|
internal = true;
|
||||||
description = ''
|
description = ''
|
||||||
Human-readable swarm name, rendered per-agent by
|
Human-readable swarm name, rendered per-agent by
|
||||||
`meta.rs::render_flake` from the host's
|
`hive_meta_flake::render_flake` from the host's
|
||||||
`services.hyperhive.swarm.name`. Read during evaluation for the
|
`services.hyperhive.swarm.name`. Read during evaluation for the
|
||||||
OTEL `swarm` resource label; `HYPERHIVE_SWARM_NAME` carries the
|
OTEL `swarm` resource label; `HYPERHIVE_SWARM_NAME` carries the
|
||||||
same value at runtime.
|
same value at runtime.
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,7 @@ in
|
||||||
# homeserver is an account the daemon can never log in as, so declaring one
|
# homeserver is an account the daemon can never log in as, so declaring one
|
||||||
# unconditionally would enable matrix everywhere and inject a tool surface
|
# unconditionally would enable matrix everywhere and inject a tool surface
|
||||||
# backed by a permanently no-opping daemon. On a real hive hive-c0re renders
|
# backed by a permanently no-opping daemon. On a real hive hive-c0re renders
|
||||||
# this URL per agent (meta.rs's `FORWARDED_VAR_OPTIONS`), so the common case
|
# this URL per agent (hive-meta-flake's `FORWARDED_VAR_OPTIONS`), so the common case
|
||||||
# is still "every agent has `main`".
|
# is still "every agent has `main`".
|
||||||
services.hyperhive.agent.matrixAccounts =
|
services.hyperhive.agent.matrixAccounts =
|
||||||
lib.mkIf (config.services.hyperhive.agent.matrix.url != null)
|
lib.mkIf (config.services.hyperhive.agent.matrix.url != null)
|
||||||
|
|
|
||||||
|
|
@ -256,7 +256,7 @@ in
|
||||||
lib.optionalAttrs haveBaoClientIdentity {
|
lib.optionalAttrs haveBaoClientIdentity {
|
||||||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||||||
# The same address again under an agent-facing name, forwarded into every
|
# The same address again under an agent-facing name, forwarded into every
|
||||||
# container's module block by `meta::render_flake`. Deliberately not the
|
# container's module block by `hive_meta_flake::render_flake`. Deliberately not the
|
||||||
# `BAO_ADDR` line itself: a container that inherited this hive's store
|
# `BAO_ADDR` line itself: a container that inherited this hive's store
|
||||||
# environment wholesale would inherit the hive's certificate paths with it,
|
# environment wholesale would inherit the hive's certificate paths with it,
|
||||||
# and those name a credential no agent may present.
|
# and those name a credential no agent may present.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue