Watch
0
0
Fork
You've already forked hyperhive
0

build agents only from internal/meta; delete the hive's local meta flake

The hive no longer renders, locks or commits a meta flake. A rebuild's
MetaSync node picks the internal/meta commit and records it with the
hyperhive and config pins it holds; Prebuild, Swap and Create build from it,
and a head that does not pin the agent's current config fails the build
naming the agent. hive-priv's meta_rev is required.

Removed: meta.rs sync/lock/render, the manual-rebuild relock, the boot
sweep's hyperhive bump (MetaLock), the update-meta-inputs form and its
panel, MetaWindow, the hive's hyperhive_rev setting.

Kept: agent_set::refresh holds what sync_agents also did (topology.json, the
gateway's agent-socket map and nginx include), called from spawn, destroy,
rebuild and the migration.

The boot reconcile and the stale-start upgrade compare the agent's marker
with the hyperhive pin of internal/meta; the dashboard's deployed chip reads
the config marker a successful build writes.
This commit is contained in:
atlas 2026-10-25 13:33:15 +01:00
commit 4aa11b3a94
64 changed files with 1042 additions and 3793 deletions

164
hive-c0re/src/agent_set.rs Normal file
View file

@ -0,0 +1,164 @@
//! The host-side files that follow the set of agents on this hive.
//!
//! [`refresh`] runs wherever the set changes (spawn, destroy, rebuild, the
//! startup migration): it brings `topology.json`, the gateway's agent-socket
//! map and its nginx include in line with the live agents.
use std::path::Path;
use anyhow::{Context, Result, bail};
use tokio::sync::Mutex;
/// Serialises [`refresh`]: `topology.json` is read, changed and written back,
/// so two concurrent refreshes would lose one of the changes.
static REFRESH: Mutex<()> = Mutex::const_new(());
/// Bring the host-side files in line with `agents`, the live agent set.
///
/// An empty `agents` is never intentional: it means `nixos-container list`
/// failed and the caller fell back to an empty list, and writing it would drop
/// every agent from the topology and the gateway. It is refused.
///
/// # Errors
/// `HIVE_FORGE_URL` is unset, or `topology.json` could not be reconciled. The
/// gateway files are best-effort: a failure there is logged, because the
/// 10-second poll in `workers::agent_sockets` writes them again.
pub async fn refresh(agents: &[hive_meta_flake::AgentSpec]) -> Result<()> {
let _guard = REFRESH.lock().await;
require_forge_url(std::env::var("HIVE_FORGE_URL").ok().as_deref())?;
let dir = crate::paths::meta_root();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let Some(agent_names) = live_names(agents) else {
tracing::warn!(
"agent_set: refusing to refresh with an empty agent list \
(nixos-container list may have failed)"
);
return Ok(());
};
remove_stale_ca_files(&dir);
let pending: Vec<String> = crate::coordinator::Coordinator::pending_init_names()
.into_iter()
.map(hive_types::Ident::into_string)
.collect();
crate::topology::reconcile(&agent_names, &pending)
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
// Every agent binds a unix socket (`HIVE_WEB_SOCKET`); the gateway routes
// via this map, falling back to a computed TCP loopback port
// (`lifecycle::agent_web_port`) only while an agent's socket marker is
// absent. See `docs/networking/gateway.md::Per-agent unix-socket upstream`.
if let Err(e) = crate::agent_sockets::write(&agent_names) {
tracing::warn!(error = ?e, "agent_sockets::write failed (non-fatal)");
}
// c0re is unprivileged, so the nginx reload goes through hive-priv.
if let Err(e) = crate::gateway_nginx::write(&agent_names).await {
tracing::warn!(error = ?e, "gateway_nginx::write failed (non-fatal)");
}
Ok(())
}
/// The names of `agents`, or `None` for an empty list, which [`refresh`]
/// refuses.
fn live_names(agents: &[hive_meta_flake::AgentSpec]) -> Option<Vec<String>> {
(!agents.is_empty()).then(|| agents.iter().map(|a| a.name.clone()).collect())
}
/// The forge is not optional on a real hive: the hyperhive NixOS module sets
/// `HIVE_FORGE_URL` unconditionally on the hive-c0re unit, so its absence
/// means this daemon was started outside it. The agent option is nullable
/// (`null` legitimately means "no forge" when the modules are evaluated on
/// their own), so nothing downstream would object: every agent would come up
/// with no forge login and no hint that was unintended.
fn require_forge_url(value: Option<&str>) -> Result<()> {
if value.is_none_or(str::is_empty) {
bail!(
"HIVE_FORGE_URL is unset — the hyperhive NixOS module sets it unconditionally on \
the hive-c0re unit, so this process was started outside that unit. Refusing to \
register agents that would every one of them have no forge configured."
);
}
Ok(())
}
/// Delete the CA files earlier renders left next to the meta flake
/// (`hive-ca.pem`, and the per-peer `peer-ca-<N>.pem` before it). Agents fetch
/// the swarm root from the store at boot, so nothing reads one.
fn remove_stale_ca_files(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let fname = e.file_name();
let Some(name) = fname.to_str() else { continue };
if is_stale_ca_name(name) {
let _ = std::fs::remove_file(dir.join(name));
}
}
}
/// True for the filenames [`remove_stale_ca_files`] owns.
fn is_stale_ca_name(name: &str) -> bool {
name == "hive-ca.pem"
|| (name.starts_with("peer-ca-")
&& Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("pem")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_empty_agent_list_is_refused_and_a_live_one_is_named() {
let spec = |name: &str| hive_meta_flake::AgentSpec {
name: name.to_owned(),
port: hive_meta_flake::agent_web_port(name),
archetype: None,
pin: None,
};
assert_eq!(live_names(&[]), None);
assert_eq!(
live_names(&[spec("alice"), spec("bob")]),
Some(vec!["alice".to_owned(), "bob".to_owned()])
);
}
#[test]
fn a_forge_url_is_required() {
require_forge_url(Some("http://forge.example.test"))
.expect("a forge URL satisfies the requirement");
for missing in [None, Some("")] {
let err = require_forge_url(missing).expect_err("no forge URL must stop the refresh");
assert!(
err.to_string().contains("HIVE_FORGE_URL is unset"),
"the error must name the missing variable: {err}"
);
}
}
#[test]
fn stale_ca_files_are_removed_and_nothing_else() {
let dir = tempfile::tempdir().expect("tempdir");
for name in [
"hive-ca.pem",
"peer-ca-0.pem",
"peer-ca-1.PEM",
"topology.json",
] {
std::fs::write(dir.path().join(name), "x").expect("write");
}
// Not CA files this machinery owns, though they look similar.
for name in ["peer-ca-0.txt", "other-ca.pem"] {
std::fs::write(dir.path().join(name), "x").expect("write");
}
remove_stale_ca_files(dir.path());
for gone in ["hive-ca.pem", "peer-ca-0.pem", "peer-ca-1.PEM"] {
assert!(!dir.path().join(gone).exists(), "{gone} must go");
}
for kept in ["topology.json", "peer-ca-0.txt", "other-ca.pem"] {
assert!(dir.path().join(kept).exists(), "{kept} must survive");
}
}
}