//! The `internal/meta` commit a hive builds an agent from. //! //! `internal/meta` is the swarm's meta flake, committed by swarm-controller; //! a hive has no meta flake of its own. A rebuild's `MetaSync` node resolves //! the head ([`resolve`]) and records it ([`record`]); `Prebuild` and `Swap` //! read the record ([`recorded`]), so both build the same flake. A create //! resolves and uses the commit directly. When the head does not pin the //! agent's current config, the build fails rather than falling back: the //! swarm re-pins before it asks a hive to deploy. //! //! The record also names the pins a successful build deploys. [`mark_deployed`] //! copies them to the agent's markers, which the boot reconcile //! ([`crate::auto_update`]) and the dashboard's update chip read. use anyhow::{Context, Result, bail}; use hive_priv_sock::{meta_flake_url, valid_meta_rev}; use serde::{Deserialize, Serialize}; use swarm_queue_client::agent_archetype::ArchetypeRef; /// What the forge says about `internal/meta` and an agent's config repo. pub(crate) trait MetaSource { /// Whether nix's own `git` can authenticate to the forge: it reads as this /// hive's account, from the credential file the source publishes. fn nix_can_fetch(&self) -> bool; /// The default branch's head commit of `internal/meta`; `None` when the /// repo is absent or has no commit. async fn meta_head(&self) -> Result>; /// `internal/meta`'s `flake.nix` at `rev`. async fn flake_at(&self, rev: &str) -> Result; /// The head of `agent-configs/`'s `main`; `None` when it has none. async fn config_head(&self, agent: &str) -> Result>; } /// An `internal/meta` commit and the pins it holds for one agent. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct MetaBuild { /// The `internal/meta` commit. pub rev: String, /// The hyperhive commit its flake pins. pub hyperhive: String, /// The agent's config commit its flake pins; none for an archetype agent. pub config: Option, } /// The `internal/meta` commit to build `agent` from. /// /// `internal/meta` is private, so a hive whose nix cannot authenticate /// ([`MetaSource::nix_can_fetch`]) cannot build. The head is 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 /// No usable head, a head that does not pin the agent's current config, or a /// forge read that failed. pub(crate) async fn choose( source: &impl MetaSource, agent: &str, archetype: Option<&ArchetypeRef>, ) -> Result { if !source.nix_can_fetch() { bail!("{agent}: this hive has no forge credential for nix to fetch internal/meta with"); } let Some(rev) = source.meta_head().await? else { bail!("{agent}: the swarm has not published internal/meta yet"); }; if !valid_meta_rev(&rev) { bail!("{agent}: internal/meta's head {rev:?} is not a commit id"); } let flake = source.flake_at(&rev).await?; let config = if archetype.is_some() { None } else { source.config_head(agent).await? }; if !hive_meta_flake::builds_agent_at(&flake, agent, archetype, config.as_deref()) { bail!("{agent}: the swarm meta flake does not pin its config yet (internal/meta at {rev})"); } let Some(hyperhive) = hive_meta_flake::pinned_rev(&flake, "hyperhive") else { bail!("{agent}: internal/meta at {rev} pins no hyperhive commit"); }; Ok(MetaBuild { rev, hyperhive: hyperhive.to_owned(), config, }) } /// The hyperhive commit `internal/meta`'s head pins; `None` when the repo or /// its commit is absent. /// /// # Errors /// A forge read that failed, or a head that pins no hyperhive commit. pub(crate) async fn hyperhive_pin(source: &impl MetaSource) -> Result> { let Some(rev) = source.meta_head().await? else { return Ok(None); }; let flake = source.flake_at(&rev).await?; let pin = hive_meta_flake::pinned_rev(&flake, "hyperhive") .with_context(|| format!("internal/meta at {rev} pins no hyperhive commit"))?; Ok(Some(pin.to_owned())) } /// The hyperhive commit `internal/meta` pins now, as this hive reads it: what /// an agent's marker is compared with to tell it is stale. `None`, logged, /// when the hive cannot tell (no forge token, a failed read, no commit yet), /// which callers treat as nothing to rebuild for. pub(crate) async fn current_hyperhive_pin() -> Option { let Some(source) = crate::forge::internal_meta_source().await else { tracing::info!("internal/meta: no forge read token; cannot tell which hyperhive it pins"); return None; }; match hyperhive_pin(&source).await { Ok(pin) => pin, Err(e) => { tracing::warn!(error = %format!("{e:#}"), "internal/meta: cannot read its hyperhive pin"); None } } } /// [`choose`] against the forge this hive reads. /// /// # Errors /// As [`choose`], or this hive holds no forge token or cannot read the /// agent's archetype. pub(crate) async fn resolve(agent: &str) -> Result { let archetype = crate::archetypes::of(agent) .with_context(|| format!("{agent}: cannot read the agent's archetype"))?; let Some(source) = crate::forge::internal_meta_source().await else { bail!("{agent}: this hive holds no forge token to read internal/meta with"); }; let build = choose(&source, agent, archetype.as_ref()).await?; tracing::info!(%agent, rev = %build.rev, "internal/meta: building from the swarm's meta flake"); Ok(build) } /// Record `build` as what `agent`'s rebuild builds from. The record is in /// hive-c0re's own state ([`crate::paths::meta_rev_dir`]), where the agent /// cannot write. /// /// # Errors /// The record file could not be written. pub(crate) fn record(agent: &str, build: &MetaBuild) -> Result<()> { record_in(&crate::paths::meta_rev_dir(), agent, build) } /// What [`record`] stored for `agent`, if it is a valid record. pub(crate) fn recorded(agent: &str) -> Option { recorded_in(&crate::paths::meta_rev_dir(), agent) } /// Written whole to a temp file beside the record and renamed over it, so a /// reader sees the old record or the new one and a crash leaves no partial /// file. fn record_in(dir: &std::path::Path, agent: &str, build: &MetaBuild) -> Result<()> { std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; let path = dir.join(agent); let tmp = dir.join(format!(".{agent}.tmp")); std::fs::write(&tmp, serde_json::to_vec(build)?) .with_context(|| format!("write {}", tmp.display()))?; std::fs::rename(&tmp, &path) .with_context(|| format!("rename {} to {}", tmp.display(), path.display())) } fn recorded_in(dir: &std::path::Path, agent: &str) -> Option { let build: MetaBuild = serde_json::from_slice(&std::fs::read(dir.join(agent)).ok()?).ok()?; let valid = valid_meta_rev(&build.rev) && valid_meta_rev(&build.hyperhive) && build.config.as_deref().is_none_or(valid_meta_rev); valid.then_some(build) } /// The record of `agent`'s build, or the error naming why a `Prebuild` or /// `Swap` has nothing to build from. /// /// # Errors /// No valid record: its `MetaSync` did not run, or ran in an older hive-c0re. pub(crate) fn recorded_or_err(agent: &str) -> Result { recorded(agent) .with_context(|| format!("{agent}: no internal/meta commit was recorded for this build")) } /// Mark `agent` as running what `build` pins: the hyperhive commit for the /// boot reconcile ([`crate::paths::applied_rev_marker`]), the config commit /// for the dashboard's deployed chip ([`crate::paths::applied_config_marker`]). /// /// # Errors /// A marker could not be written or removed. pub(crate) fn mark_deployed(agent: &str, build: &MetaBuild) -> Result<()> { mark_deployed_in( &crate::paths::applied_rev_marker(agent), &crate::paths::applied_config_marker(agent), build, ) } fn mark_deployed_in( hyperhive: &std::path::Path, config: &std::path::Path, build: &MetaBuild, ) -> Result<()> { std::fs::write(hyperhive, &build.hyperhive) .with_context(|| format!("write {}", hyperhive.display()))?; match &build.config { Some(rev) => { std::fs::write(config, rev).with_context(|| format!("write {}", config.display())) } None => match std::fs::remove_file(config) { Err(e) if e.kind() != std::io::ErrorKind::NotFound => { Err(e).with_context(|| format!("remove {}", config.display())) } _ => Ok(()), }, } } /// Whether an agent's `stored` marker says it runs the hyperhive commit `pin` /// names. With no `pin`, because the hive can't read `internal/meta`, nothing /// is stale: a rebuild would have no flake to build from either. pub(crate) fn marker_is_fresh(pin: Option<&str>, stored: Option<&str>) -> bool { pin.is_none_or(|pin| stored == Some(pin)) } /// The `#` ref [`crate::lifecycle::prebuild_toplevel`] builds /// for `rev`: the same flake hive-priv builds, from the same forge URL. pub(crate) fn flake_ref(agent: &str, rev: &str) -> String { flake_ref_at(crate::forge::forge_http_base(), agent, rev) } fn flake_ref_at(forge_base: &str, agent: &str, rev: &str) -> String { format!("{}#{agent}", meta_flake_url(forge_base, rev)) } #[cfg(test)] mod tests { use super::*; const HYPERHIVE: &str = "1111111111111111111111111111111111111111"; const META: &str = "3333333333333333333333333333333333333333"; const ARGUS_HEAD: &str = "2222222222222222222222222222222222222222"; const STALE: &str = "4444444444444444444444444444444444444444"; /// A forge holding `internal/meta` at [`META`] with the given flake, and /// `agent-configs/argus` at `config_head`. struct Forge { head: Option<&'static str>, flake: String, config_head: Option<&'static str>, fetchable: bool, } impl MetaSource for Forge { fn nix_can_fetch(&self) -> bool { self.fetchable } async fn meta_head(&self) -> Result> { Ok(self.head.map(str::to_owned)) } async fn flake_at(&self, rev: &str) -> Result { assert_eq!(Some(rev), self.head, "the flake is read at the head"); Ok(self.flake.clone()) } async fn config_head(&self, _: &str) -> Result> { Ok(self.config_head.map(str::to_owned)) } } /// `internal/meta`'s flake as swarm-controller renders it: argus pinned at /// [`ARGUS_HEAD`], and a reviewer from an archetype. fn flake() -> String { use swarm_wire::agent_archetype::ArchetypeRef; let url = |repo: &str, rev: &str| { hive_meta_flake::pinned_git_url(&format!("https://f.t/{repo}.git"), Some(rev)).unwrap() }; let spec = |name: &str, archetype, pin| hive_meta_flake::AgentSpec { name: name.to_owned(), port: hive_meta_flake::agent_web_port(name), archetype, pin, }; hive_meta_flake::render_flake( &url("hyperhive/hyperhive", HYPERHIVE), &[ spec( "argus", None, Some(hive_meta_flake::AgentPin { url: url("agent-configs/argus", ARGUS_HEAD), canonical_inputs: vec![], }), ), spec( "rev", Some(ArchetypeRef { source: "hyperhive".to_owned(), name: "reviewer".to_owned(), }), None, ), ], &[], false, ) .unwrap() } fn forge(config_head: Option<&'static str>) -> Forge { Forge { head: Some(META), flake: flake(), config_head, fetchable: true, } } fn reviewer() -> ArchetypeRef { ArchetypeRef { source: "hyperhive".to_owned(), name: "reviewer".to_owned(), } } /// The error a build with no usable pin ends in: it names the agent and /// says the swarm meta flake does not pin its config yet. fn assert_unpinned(err: &anyhow::Error, agent: &str) { let msg = format!("{err:#}"); assert!( msg.starts_with(&format!("{agent}: ")) && msg.contains("swarm meta flake does not pin its config yet"), "{msg}" ); } #[tokio::test] async fn the_head_is_taken_when_it_pins_the_agents_current_config() { assert_eq!( choose(&forge(Some(ARGUS_HEAD)), "argus", None) .await .unwrap(), MetaBuild { rev: META.to_owned(), hyperhive: HYPERHIVE.to_owned(), config: Some(ARGUS_HEAD.to_owned()), } ); } #[tokio::test] async fn a_head_behind_the_agents_config_is_an_error() { let err = choose(&forge(Some(STALE)), "argus", None) .await .unwrap_err(); assert_unpinned(&err, "argus"); } #[tokio::test] async fn a_head_without_the_agent_is_an_error() { let mut source = forge(Some(ARGUS_HEAD)); source.flake = source.flake.replace("argus", "someone"); let err = choose(&source, "argus", None).await.unwrap_err(); assert_unpinned(&err, "argus"); } #[tokio::test] 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 { self.0.nix_can_fetch() } async fn meta_head(&self) -> Result> { self.0.meta_head().await } async fn flake_at(&self, rev: &str) -> Result { self.0.flake_at(rev).await } async fn config_head(&self, _: &str) -> Result> { panic!("an archetype agent has no config repo to read") } } let source = Archetypes(forge(None)); assert_eq!( choose(&source, "rev", Some(&reviewer())).await.unwrap(), MetaBuild { rev: META.to_owned(), hyperhive: HYPERHIVE.to_owned(), config: None, }, "an archetype agent pins no config" ); let other = ArchetypeRef { name: "planner".to_owned(), ..reviewer() }; assert_unpinned( &choose(&source, "rev", Some(&other)).await.unwrap_err(), "rev", ); assert_unpinned( &choose(&source, "ghost", Some(&reviewer())) .await .unwrap_err(), "ghost", ); } /// 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_an_error() { let source = forge(Some(ARGUS_HEAD)); assert_unpinned( &choose(&source, "argus", Some(&reviewer())) .await .unwrap_err(), "argus", ); assert_unpinned(&choose(&source, "rev", None).await.unwrap_err(), "rev"); } /// A hive without the credential nix's git reads cannot build from the /// repo even though its own token reads it; the first test is the control. #[tokio::test] async fn a_hive_whose_nix_cannot_authenticate_cannot_build() { let mut source = forge(Some(ARGUS_HEAD)); source.fetchable = false; let err = choose(&source, "argus", None).await.unwrap_err(); assert!( format!("{err:#}").contains("no forge credential"), "{err:#}" ); } #[tokio::test] async fn no_repo_or_no_commit_is_an_error_naming_the_agent() { let mut source = forge(Some(ARGUS_HEAD)); source.head = None; let err = choose(&source, "argus", None).await.unwrap_err(); assert!( format!("{err:#}").starts_with("argus: the swarm has not published internal/meta"), "{err:#}" ); } #[tokio::test] async fn a_custom_agent_with_no_config_commit_is_an_error() { let err = choose(&forge(None), "argus", None).await.unwrap_err(); assert_unpinned(&err, "argus"); } #[tokio::test] async fn the_hyperhive_pin_is_the_one_in_the_head() { assert_eq!( hyperhive_pin(&forge(None)).await.unwrap().as_deref(), Some(HYPERHIVE) ); let mut source = forge(None); source.head = None; assert_eq!(hyperhive_pin(&source).await.unwrap(), None); let mut source = forge(None); source.flake = source.flake.replace("hyperhive.url", "other.url"); assert!(hyperhive_pin(&source).await.is_err(), "a head with no pin"); } fn build() -> MetaBuild { MetaBuild { rev: META.to_owned(), hyperhive: HYPERHIVE.to_owned(), config: Some(ARGUS_HEAD.to_owned()), } } #[test] fn a_record_is_read_back_and_replaced() { let dir = tempfile::tempdir().unwrap(); assert_eq!(recorded_in(dir.path(), "argus"), None); record_in(dir.path(), "argus", &build()).unwrap(); assert_eq!(recorded_in(dir.path(), "argus"), Some(build())); assert_eq!( recorded_in(dir.path(), "rev"), None, "a record is one agent's" ); let next = MetaBuild { rev: STALE.to_owned(), config: None, ..build() }; record_in(dir.path(), "argus", &next).unwrap(); assert_eq!(recorded_in(dir.path(), "argus"), Some(next)); } #[test] fn a_record_leaves_no_temp_file_and_creates_its_dir() { let dir = tempfile::tempdir().unwrap(); let records = dir.path().join("meta-rev"); record_in(&records, "argus", &build()).unwrap(); let names: Vec<_> = std::fs::read_dir(&records) .unwrap() .map(|e| e.unwrap().file_name()) .collect(); assert_eq!(names, ["argus"]); } /// Anything but a record naming commit ids is no record: a plain commit id /// (the older format), a branch name, a bad pin. #[test] fn a_record_that_does_not_name_commit_ids_is_no_record() { let dir = tempfile::tempdir().unwrap(); for bad in [ META.to_owned(), "main\n".to_owned(), serde_json::to_string(&MetaBuild { rev: "main".to_owned(), ..build() }) .unwrap(), serde_json::to_string(&MetaBuild { hyperhive: "main".to_owned(), ..build() }) .unwrap(), serde_json::to_string(&MetaBuild { config: Some("main".to_owned()), ..build() }) .unwrap(), ] { std::fs::write(dir.path().join("argus"), &bad).unwrap(); assert_eq!(recorded_in(dir.path(), "argus"), None, "{bad}"); } } /// The record is hive-c0re's own state, never the agent-writable runtime /// dir bind-mounted into the container. #[test] fn the_record_is_outside_the_agents_runtime_dir() { assert!(!crate::paths::meta_rev_dir().starts_with(crate::paths::runtime_root())); assert!(crate::paths::meta_rev_dir().starts_with(crate::paths::state_root())); } #[test] fn marking_deployed_writes_both_pins_and_clears_the_config_of_an_archetype_agent() { let dir = tempfile::tempdir().unwrap(); let hyperhive = dir.path().join("hyperhive"); let config = dir.path().join("config"); mark_deployed_in(&hyperhive, &config, &build()).unwrap(); assert_eq!(std::fs::read_to_string(&hyperhive).unwrap(), HYPERHIVE); assert_eq!(std::fs::read_to_string(&config).unwrap(), ARGUS_HEAD); let archetype = MetaBuild { config: None, ..build() }; mark_deployed_in(&hyperhive, &config, &archetype).unwrap(); assert!(!config.exists(), "an archetype agent pins no config"); mark_deployed_in(&hyperhive, &config, &archetype).unwrap(); } #[test] fn a_marker_is_fresh_only_at_the_pin() { assert!(marker_is_fresh(Some(HYPERHIVE), Some(HYPERHIVE))); assert!(!marker_is_fresh(Some(HYPERHIVE), Some(STALE)), "behind"); assert!(!marker_is_fresh(Some(HYPERHIVE), None), "never built"); assert!(marker_is_fresh(None, Some(STALE)), "no pin to compare with"); assert!(marker_is_fresh(None, None)); } #[test] fn the_prebuild_ref_is_the_flake_hive_priv_builds() { assert_eq!( flake_ref_at("http://forge.t.local", "argus", META), format!("git+http://forge.t.local/internal/meta.git?rev={META}#argus") ); } }