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:
parent
cea4827918
commit
4aa11b3a94
64 changed files with 1042 additions and 3793 deletions
164
hive-c0re/src/agent_set.rs
Normal file
164
hive-c0re/src/agent_set.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue