diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index bd2d5d00..5c1cca40 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -173,13 +173,16 @@ older or newer than the controller keeps building. - **Choosing the commit.** The rebuild's `MetaSync` node reads the repo's default-branch head with the hive's read token (`forge::reader_token()`) - and takes it when its `flake.nix` lists the agent and, for a custom agent, - pins the config commit at the head of `agent-configs/` `main` now. + and takes it when its `flake.nix` builds the agent the way the agent needs now: + a custom agent from the config commit at the head of `agent-configs/` + `main`, an archetype agent from the archetype it has now. An agent switched + between custom and archetype, or to another archetype, fails that test. A hive without the `core` token file that nix's git reads, a repo with no commit, a head that lacks the agent and a head behind the config all select - the hive's own flake. `MetaSync` records the commit under the agent's - runtime directory; `Prebuild` and `Swap` read that record, so - both build one flake. A first deploy resolves the commit in its `Create` + the hive's own flake. `MetaSync` records the commit in `hive-c0re`'s own state, + `/var/lib/hyperhive/meta-rev/`, which the agent can't write (the container mounts its runtime + directory read-write); `Prebuild` and `Swap` read that + record, so both build one flake. A first deploy resolves the commit in its `Create` node, since it has no `MetaSync`. - **The build.** `hive-c0re` prebuilds `git+/internal/meta.git?rev=#nixosConfigurations.…` and diff --git a/hive-c0re/src/meta_rev.rs b/hive-c0re/src/meta_rev.rs index 65b47abd..2ee47cc1 100644 --- a/hive-c0re/src/meta_rev.rs +++ b/hive-c0re/src/meta_rev.rs @@ -10,6 +10,7 @@ use anyhow::{Context, Result}; use hive_priv_sock::{meta_flake_url, valid_meta_rev}; +use swarm_queue_client::agent_archetype::ArchetypeRef; /// What the forge says about `internal/meta` and an agent's config repo. pub(crate) trait MetaSource { @@ -30,18 +31,18 @@ pub(crate) trait MetaSource { /// /// `internal/meta` is private, so a hive whose nix cannot authenticate /// ([`MetaSource::nix_can_fetch`]) builds from its own flake. The head is -/// taken only when its `flake.nix` builds the agent from the -/// config commit the forge's `main` is at now (`archetype`: when it lists the -/// agent at all). A head that is behind (the swarm's update has not run, or -/// failed) or lacks the agent would build an older config than the one being -/// deployed. +/// taken only when its `flake.nix` builds the agent the way it is wanted now: +/// from `archetype` when that is set, otherwise from the config commit the +/// forge's `main` is at. A head that is behind (the swarm's update has not +/// run, or failed), lacks the agent or lists it under another archetype would +/// build an older module than the one being deployed. /// /// # Errors /// A forge read that failed. pub(crate) async fn choose( source: &impl MetaSource, agent: &str, - archetype: bool, + archetype: Option<&ArchetypeRef>, ) -> Result> { if !source.nix_can_fetch() { return Ok(None); @@ -53,7 +54,7 @@ pub(crate) async fn choose( return Ok(None); } let flake = source.flake_at(&rev).await?; - let config_head = if archetype { + let config_head = if archetype.is_some() { None } else { let Some(head) = source.config_head(agent).await? else { @@ -61,14 +62,17 @@ pub(crate) async fn choose( }; Some(head) }; - Ok(hive_meta_flake::builds_agent_at(&flake, agent, config_head.as_deref()).then_some(rev)) + Ok( + hive_meta_flake::builds_agent_at(&flake, agent, archetype, config_head.as_deref()) + .then_some(rev), + ) } /// [`choose`] against the forge this hive reads. `None`, logged, whenever the /// hive cannot tell: no forge credential, a failed read, no usable commit. pub(crate) async fn resolve(agent: &str) -> Option { let archetype = match crate::archetypes::of(agent) { - Ok(archetype) => archetype.is_some(), + Ok(archetype) => archetype, Err(e) => { tracing::warn!(%agent, error = ?e, "internal/meta: cannot read the agent's archetype; building from the hive's own flake"); return None; @@ -78,7 +82,7 @@ pub(crate) async fn resolve(agent: &str) -> Option { tracing::info!(%agent, "internal/meta: no forge read token; building from the hive's own flake"); return None; }; - match choose(&source, agent, archetype).await { + match choose(&source, agent, archetype.as_ref()).await { Ok(Some(rev)) => { tracing::info!(%agent, %rev, "internal/meta: building from the swarm's meta flake"); Some(rev) @@ -175,8 +179,7 @@ mod tests { assert_eq!(Some(rev), self.head, "the flake is read at the head"); Ok(self.flake.clone()) } - async fn config_head(&self, agent: &str) -> Result> { - assert_eq!(agent, "argus"); + async fn config_head(&self, _: &str) -> Result> { Ok(self.config_head.map(str::to_owned)) } } @@ -233,7 +236,7 @@ mod tests { #[tokio::test] async fn the_head_is_taken_when_it_pins_the_agents_current_config() { assert_eq!( - choose(&forge(Some(ARGUS_HEAD)), "argus", false) + choose(&forge(Some(ARGUS_HEAD)), "argus", None) .await .unwrap(), Some(META.to_owned()) @@ -243,7 +246,7 @@ mod tests { #[tokio::test] async fn a_head_behind_the_agents_config_is_not_taken() { assert_eq!( - choose(&forge(Some(STALE)), "argus", false).await.unwrap(), + choose(&forge(Some(STALE)), "argus", None).await.unwrap(), None ); } @@ -252,11 +255,18 @@ mod tests { async fn a_head_without_the_agent_is_not_taken() { let mut source = forge(Some(ARGUS_HEAD)); source.flake = source.flake.replace("argus", "someone"); - assert_eq!(choose(&source, "argus", false).await.unwrap(), None); + assert_eq!(choose(&source, "argus", None).await.unwrap(), None); + } + + fn reviewer() -> ArchetypeRef { + ArchetypeRef { + source: "hyperhive".to_owned(), + name: "reviewer".to_owned(), + } } #[tokio::test] - async fn an_archetype_agent_needs_only_to_be_listed() { + async fn an_archetype_agent_is_taken_when_the_head_lists_that_archetype() { struct Archetypes(Forge); impl MetaSource for Archetypes { fn nix_can_fetch(&self) -> bool { @@ -274,10 +284,39 @@ mod tests { } let source = Archetypes(forge(None)); assert_eq!( - choose(&source, "rev", true).await.unwrap(), + choose(&source, "rev", Some(&reviewer())).await.unwrap(), Some(META.to_owned()) ); - assert_eq!(choose(&source, "ghost", true).await.unwrap(), None); + let other = ArchetypeRef { + name: "planner".to_owned(), + ..reviewer() + }; + assert_eq!( + choose(&source, "rev", Some(&other)).await.unwrap(), + None, + "the head lists another archetype" + ); + assert_eq!( + choose(&source, "ghost", Some(&reviewer())).await.unwrap(), + None + ); + } + + /// The head lists argus as a custom agent and rev as an archetype agent; + /// an agent switched either way is not what the head builds. + #[tokio::test] + async fn an_agent_switched_between_custom_and_archetype_is_not_taken() { + let source = forge(Some(ARGUS_HEAD)); + assert_eq!( + choose(&source, "argus", Some(&reviewer())).await.unwrap(), + None, + "custom in the head, archetype now" + ); + assert_eq!( + choose(&source, "rev", None).await.unwrap(), + None, + "archetype in the head, custom now" + ); } /// A hive without the credential nix's git reads builds from its own flake diff --git a/hive-meta-flake/src/lib.rs b/hive-meta-flake/src/lib.rs index d2615401..6dbc6c29 100644 --- a/hive-meta-flake/src/lib.rs +++ b/hive-meta-flake/src/lib.rs @@ -6,6 +6,7 @@ //! builds has one renderer. use anyhow::{Context, Result}; +use swarm_wire::agent_archetype::ArchetypeRef; /// One agent of the meta flake. #[derive(Debug, Clone)] @@ -14,7 +15,7 @@ pub struct AgentSpec { pub port: u16, /// The archetype the agent is built from, `None` /// for a custom agent built from its own config repo. - pub archetype: Option, + pub archetype: Option, /// 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, @@ -404,25 +405,26 @@ fn push_agent_environment(out: &mut String, vars: &[(&'static str, String)]) { /// One `mkAgent` line per agent, closing `outputs`. fn push_agent_entries(out: &mut String, agents: &[AgentSpec]) { - use std::fmt::Write as _; 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(&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. @@ -474,22 +476,29 @@ pub fn pinned_rev<'a>(flake_nix: &'a str, input: &str) -> Option<&'a str> { is_rev(rev).then_some(rev) } -/// Whether a flake [`render_flake`] rendered builds agent `name` from the -/// config commit `config_head`. `None` is an archetype agent, which has no -/// config input to compare. +/// 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, config_head: Option<&str>) -> bool { - let attr = format!("\n {name} = mkAgent {{ name = \"{name}\";"); - if !flake_nix.contains(&attr) { +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; } - config_head.is_none_or(|head| pinned_rev(flake_nix, &format!("agent-{name}")) == Some(head)) + archetype.is_some() + || config_head + .is_some_and(|head| pinned_rev(flake_nix, &format!("agent-{name}")) == Some(head)) } #[cfg(test)] mod tests { - use swarm_wire::agent_archetype::ArchetypeRef; - use super::*; const HYPERHIVE: &str = "1111111111111111111111111111111111111111"; @@ -560,26 +569,58 @@ mod tests { ); } + fn reviewer() -> ArchetypeRef { + ArchetypeRef { + source: "hyperhive".to_owned(), + name: "reviewer".to_owned(), + } + } + #[test] - fn an_agent_is_built_only_at_the_config_head_the_flake_pins() { + fn a_custom_agent_is_built_only_at_the_config_head_the_flake_pins() { let flake = rendered(); - assert!(builds_agent_at(&flake, "argus", Some(ARGUS))); + assert!(builds_agent_at(&flake, "argus", None, Some(ARGUS))); assert!( - !builds_agent_at(&flake, "argus", Some(HYPERHIVE)), + !builds_agent_at(&flake, "argus", None, Some(HYPERHIVE)), "a stale pin" ); assert!( - !builds_agent_at(&flake, "ghost", Some(ARGUS)), + !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" ); - assert!(builds_agent_at(&flake, "rev", None)); - assert!(!builds_agent_at(&flake, "ghost", None)); + } + + #[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", Some(ARGUS))); - assert!(!builds_agent_at(&flake, "v", None)); + assert!(!builds_agent_at(&flake, "gus", None, Some(ARGUS))); + assert!(!builds_agent_at(&flake, "v", Some(&reviewer()), None)); } }