//! `internal/meta`: the swarm meta flake hives build from. //! //! [`update`] renders `flake.nix` with `hive_meta_flake::render_flake`: hyperhive //! pinned by [`hyperhive_pin`], each custom agent's config input pinned to its //! `agent-configs/` `main`, every agent placed on some hive. The swarm //! builder locks it (`swarm_wire::builder::LockMeta`), and the flake and the //! lock are committed together in one commit. A render equal to the committed //! `flake.nix` sends no lock and commits nothing. //! //! The hyperhive pin is a ratchet on this controller's running rev: it moves //! only to a rev that descends from the committed one, so deploying an older //! controller never rolls the agents back. use std::collections::BTreeMap; use std::future::Future; use anyhow::{Context, Result, bail}; use serde::Deserialize; use swarm_wire::agent_archetype::ArchetypeRef; use swarm_wire::builder::{LOCK_REPLY_DEADLINE, LOCK_SUBJECT, LockMeta, LockReply}; use crate::forge::{CONFIG_ORG, DefaultBranchFiles}; /// The branch an agent's config is deployed from, which hive-c0re's /// `internal_meta` reads the config head from too. const CONFIG_BRANCH: &str = "main"; /// The repo in `internal` the flake is committed to. pub(crate) const META_REPO: &str = "meta"; /// The env var holding [`Settings`] as JSON, set by `swarm-controller.nix`. pub(crate) const SETTINGS_ENV: &str = "SWARM_CONTROLLER_META_FLAKE"; /// Where hyperhive itself lives on the forge. const HYPERHIVE_OWNER: &str = "hyperhive"; const HYPERHIVE_REPO: &str = "hyperhive"; /// What every agent of the swarm gets, the same on every hive. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct Settings { /// Values by `hive_meta_flake::FORWARDED_VARS` name. `HIVE_FORGE_URL` is /// required. vars: BTreeMap, /// The forge's `https` origin, which every pinned input is fetched from: /// the swarm builder's credential helper answers only `https` for it. git_base: String, /// The swarm turned the agents' GitHub integration off. github_disabled: bool, /// The hyperhive commit this controller runs (`self.rev`); `None` for a /// dirty tree, which leaves the pin where it is. #[serde(default)] hyperhive_rev: Option, } impl Settings { /// [`Settings`] from [`SETTINGS_ENV`], `None` when it is unset. pub(crate) fn from_env() -> Result> { match std::env::var(SETTINGS_ENV) { Ok(raw) => Self::parse(&raw).map(Some), Err(std::env::VarError::NotPresent) => Ok(None), Err(e) => Err(e).with_context(|| format!("read {SETTINGS_ENV}")), } } fn parse(raw: &str) -> Result { let settings: Self = serde_json::from_str(raw).with_context(|| format!("parse {SETTINGS_ENV}"))?; if let Some(unknown) = settings .vars .keys() .find(|k| !hive_meta_flake::FORWARDED_VARS.contains(&k.as_str())) { bail!("{SETTINGS_ENV}: {unknown} is not a value the meta flake forwards"); } if settings .vars .get("HIVE_FORGE_URL") .is_none_or(String::is_empty) { bail!("{SETTINGS_ENV}: HIVE_FORGE_URL is unset"); } if !settings.git_base.starts_with("https://") { bail!("{SETTINGS_ENV}: git_base must be an https origin"); } if let Some(rev) = settings .hyperhive_rev .as_deref() .filter(|r| !hive_meta_flake::is_rev(r)) { bail!("{SETTINGS_ENV}: hyperhive_rev {rev:?} is not a full commit id"); } Ok(settings) } /// The forwarded pairs with a value, in `FORWARDED_VARS` order. fn forwarded(&self) -> Vec<(&'static str, String)> { hive_meta_flake::FORWARDED_VARS .iter() .filter_map(|&name| { self.vars .get(name) .filter(|v| !v.is_empty()) .map(|v| (name, v.clone())) }) .collect() } fn git_url(&self, owner: &str, repo: &str) -> String { format!("{}/{owner}/{repo}.git", self.git_base.trim_end_matches('/')) } } /// One agent some hive places, with the archetype it is built from. #[derive(Debug, Clone)] pub(crate) struct Placed { pub(crate) name: String, pub(crate) archetype: Option, } /// One file of a meta commit: a blob sha replaces that blob, `None` creates it. pub(crate) struct MetaFile { pub(crate) path: &'static str, pub(crate) bytes: Vec, pub(crate) sha: Option, } /// The forge, as [`update`] reads and writes it. pub(crate) trait MetaForge { /// The `paths` of `owner/repo` at the head of `branch`, the default /// branch when it is `None`; `None` when the branch has no commit. fn head_files( &self, owner: &str, repo: &str, branch: Option<&str>, paths: &[&str], ) -> impl Future>> + Send; /// The number of commits `head` has that `base` lacks, as the forge's /// compare endpoint counts them: `GET /repos/{owner}/{repo}/compare/ /// {base}...{head}`, whose `total_commits` is the commits from the merge /// base of the two to `head`. `None` when either commit is not on the /// forge. /// /// ⚠️ Two histories with no common ancestor count 0, the same as a /// `head` already contained in `base`. [`is_strict_descendant`] is the /// reading that tells them apart. fn commits_ahead( &self, owner: &str, repo: &str, base: &str, head: &str, ) -> impl Future>> + Send; fn ensure_meta_repo(&self) -> impl Future> + Send; fn grant_meta_read(&self, login: &str) -> impl Future> + Send; fn commit_meta( &self, message: &str, files: Vec, ) -> impl Future> + Send; } /// Where a lock is sent: the queue in production, a fake in the tests. `Err` /// is a lock that never reached a builder or never came back. pub(crate) trait LockSink { fn lock(&self, request: Vec) -> impl Future> + Send; } impl LockSink for async_nats::Client { async fn lock(&self, request: Vec) -> Result { use async_nats::RequestErrorKind; let sent = async_nats::Request::new() .payload(request.into()) .timeout(Some(LOCK_REPLY_DEADLINE)); match self.send_request(LOCK_SUBJECT, sent).await { Ok(msg) => serde_json::from_slice(&msg.payload) .map_err(|e| format!("the builder's lock reply did not decode: {e}")), Err(e) => Err(match e.kind() { RequestErrorKind::NoResponders => "no swarm builder is listening for locks: \ deploy.builder.enable is set on no host, or its builder predates the lock \ request" .to_owned(), RequestErrorKind::TimedOut => format!( "the swarm builder sent no lock within {} minutes", LOCK_REPLY_DEADLINE.as_secs() / 60 ), _ => format!("sending the lock to the swarm builder failed: {e}"), }), } } } /// What [`update`] did. #[derive(Debug, PartialEq, Eq)] pub(crate) enum Updated { /// The committed flake already is the render. Unchanged, /// A new flake and lock were committed. Committed, } /// What [`update`] reports. #[derive(Debug, PartialEq, Eq)] pub(crate) struct Report { pub(crate) updated: Updated, /// The hyperhive rev `internal/meta` pins once the update has run. pub(crate) pin: String, /// The commit moved the hyperhive pin from one rev to another. The first /// commit moves nothing. The placed agents are rebuilt for the pin /// `meta_fanout` has not recorded; this decides only while that record /// cannot be read. pub(crate) hyperhive_moved: bool, } /// Bring `internal/meta` up to the forge's current heads for `agents`, and /// give each of `hives`' forge accounts read on it. /// /// # Errors /// A head that cannot be read, a lock the builder refuses, or a refused commit. /// A failed read grant is logged, not returned: the commit is what hives wait /// on. pub(crate) async fn update( forge: &impl MetaForge, locker: &impl LockSink, settings: &Settings, agents: &[Placed], hives: &[String], ) -> Result { forge.ensure_meta_repo().await?; for hive in hives { let login = swarm_secret_client::forge::hive_login(hive); if let Err(e) = forge.grant_meta_read(&login).await { tracing::warn!(%hive, error = %format!("{e:#}"), "meta flake: read grant on internal/meta failed"); } } let committed = forge .head_files( crate::forge::objects::SHARED_ORG, META_REPO, None, &["flake.nix", "flake.lock"], ) .await?; let sha_of = |path: &str| { committed .as_ref() .and_then(|c| c.files.iter().find(|f| f.path == path)) }; let flake_file = sha_of("flake.nix"); let pin = hyperhive_pin( forge, settings, flake_file .map(|f| String::from_utf8_lossy(&f.bytes)) .as_deref(), ) .await?; let flake = render(forge, settings, &pin.rev, agents).await?; if flake_file.is_some_and(|f| f.bytes == flake.as_bytes()) && sha_of("flake.lock").is_some() { return Ok(Report { updated: Updated::Unchanged, pin: pin.rev, hyperhive_moved: false, }); } let request = serde_json::to_vec(&LockMeta { flake_nix: flake.clone(), }) .context("encode the lock request")?; let flake_lock = match locker.lock(request).await { Ok(LockReply::Locked { flake_lock }) => flake_lock, Ok(LockReply::Failed { error_tail }) => bail!("the builder could not lock: {error_tail}"), Err(e) => bail!("{e}"), }; forge .commit_meta( &format!("meta: pin {} agent(s)", agents.len()), vec![ MetaFile { path: "flake.nix", bytes: flake.into_bytes(), sha: flake_file.map(|f| f.sha.clone()), }, MetaFile { path: "flake.lock", bytes: flake_lock.into_bytes(), sha: sha_of("flake.lock").map(|f| f.sha.clone()), }, ], ) .await?; Ok(Report { updated: Updated::Committed, pin: pin.rev, hyperhive_moved: pin.moved, }) } /// The hyperhive commit the flake pins, and whether that is a move off the /// committed pin. struct HyperhivePin { rev: String, moved: bool, } /// The hyperhive pin for the next render, given the committed `flake.nix` /// (`None` before the first commit). /// /// - No committed flake: the forge's `main` head, the one time the pin follows /// `main`. /// - Otherwise the committed pin, unless this controller's running rev is a /// strict descendant of it ([`is_strict_descendant`]), which becomes the /// pin. A merge to `main` moves nothing; a controller older than, or on /// another line from, the pin leaves it. A forge that cannot answer the /// comparison leaves it too, and the next update asks again. async fn hyperhive_pin( forge: &impl MetaForge, settings: &Settings, committed_flake: Option<&str>, ) -> Result { let Some(flake) = committed_flake else { let head = forge .head_files(HYPERHIVE_OWNER, HYPERHIVE_REPO, None, &[]) .await? .with_context(|| format!("{HYPERHIVE_OWNER}/{HYPERHIVE_REPO} is empty or absent"))?; return Ok(HyperhivePin { rev: head.commit, moved: false, }); }; let pinned = hive_meta_flake::pinned_rev(flake, "hyperhive") .context("the committed internal/meta/flake.nix pins no hyperhive commit")?; let keep = || HyperhivePin { rev: pinned.to_owned(), moved: false, }; let Some(running) = settings.hyperhive_rev.as_deref() else { tracing::info!(%pinned, "meta flake: this controller has no rev; keeping the hyperhive pin"); return Ok(keep()); }; match is_strict_descendant(forge, HYPERHIVE_OWNER, HYPERHIVE_REPO, pinned, running).await { Ok(true) => { tracing::info!(%pinned, %running, "meta flake: moving the hyperhive pin to this controller's rev"); Ok(HyperhivePin { rev: running.to_owned(), moved: true, }) } Ok(false) => { tracing::info!(%pinned, %running, "meta flake: this controller's rev is not newer than the hyperhive pin; keeping it"); Ok(keep()) } Err(e) => { tracing::warn!(%pinned, %running, error = %format!("{e:#}"), "meta flake: cannot compare this controller's rev with the hyperhive pin; keeping it"); Ok(keep()) } } } /// Whether `descendant` is a commit of `owner/repo` strictly after `ancestor`: /// it has commits `ancestor` lacks ([`MetaForge::commits_ahead`]) and /// `ancestor` has none it lacks. Equal commits, a `descendant` that is behind, /// histories that diverged and histories with nothing in common are all /// `false`; a commit the forge does not have is too. async fn is_strict_descendant( forge: &impl MetaForge, owner: &str, repo: &str, ancestor: &str, descendant: &str, ) -> Result { if ancestor == descendant { return Ok(false); } // Unrelated histories also count 0, so a zero here is `false` whatever // the other side says. let Some(ahead) = forge .commits_ahead(owner, repo, ancestor, descendant) .await? else { return Ok(false); }; if ahead == 0 { return Ok(false); } let behind = forge .commits_ahead(owner, repo, descendant, ancestor) .await?; Ok(behind == Some(0)) } /// The `flake.nix` for `agents` at the forge's current config heads, with /// hyperhive at `hyperhive_rev`. A custom agent whose config repo has no /// commit on [`CONFIG_BRANCH`] is left out, so a hive builds it from its own /// flake and no other agent waits on it. async fn render( forge: &impl MetaForge, settings: &Settings, hyperhive_rev: &str, agents: &[Placed], ) -> Result { let hyperhive_url = hive_meta_flake::pinned_git_url( &settings.git_url(HYPERHIVE_OWNER, HYPERHIVE_REPO), Some(hyperhive_rev), )?; let mut specs = Vec::with_capacity(agents.len()); for agent in agents { let pin = if agent.archetype.is_some() { None } else { let Some(head) = forge .head_files( CONFIG_ORG, &agent.name, Some(CONFIG_BRANCH), &["flake.lock"], ) .await? else { tracing::warn!(agent = %agent.name, "meta flake: its config repo has no commit on {CONFIG_BRANCH}; leaving the agent out"); continue; }; let lock = head .files .first() .map(|f| String::from_utf8_lossy(&f.bytes).into_owned()); Some(hive_meta_flake::AgentPin { url: hive_meta_flake::pinned_git_url( &settings.git_url(CONFIG_ORG, &agent.name), Some(&head.commit), )?, canonical_inputs: lock .as_deref() .map(hive_meta_flake::canonical_inputs_in_lock) .unwrap_or_default(), }) }; specs.push(hive_meta_flake::AgentSpec { name: agent.name.clone(), port: hive_meta_flake::agent_web_port(&agent.name), archetype: agent.archetype.clone(), pin, }); } specs.sort_by(|a, b| a.name.cmp(&b.name)); hive_meta_flake::render_flake( &hyperhive_url, &specs, &settings.forwarded(), settings.github_disabled, ) } #[cfg(test)] mod tests { use std::collections::BTreeSet; use std::sync::Mutex; use super::*; use crate::forge::BranchFile; const FORGE: &str = "https://forge.t.local"; /// A full commit id made of one hex digit. fn rev(digit: char) -> String { digit.to_string().repeat(40) } /// The hyperhive history the fake forge knows: `1 <- 2 <- 3`, `2 <- 4` /// (4 diverged from 3) and `9` on its own. `8` is not on the forge. fn history() -> BTreeMap> { [ ('1', vec![]), ('2', vec!['1']), ('3', vec!['2']), ('4', vec!['2']), ('9', vec![]), ] .into_iter() .map(|(c, parents)| (rev(c), parents.into_iter().map(rev).collect())) .collect() } fn settings_running(running: Option<&str>) -> Settings { let running = running.map_or("null".to_owned(), |r| format!("\"{r}\"")); Settings::parse(&format!( r#"{{"vars":{{"HIVE_FORGE_URL":"http://forge.t.local","HYPERHIVE_SWARM_NAME":"t"}},"git_base":"{FORGE}/","github_disabled":true,"hyperhive_rev":{running}}}"# )) .unwrap() } fn settings() -> Settings { settings_running(None) } fn head(commit: &str, files: &[(&str, &str)]) -> DefaultBranchFiles { DefaultBranchFiles { branch: "main".to_owned(), commit: commit.to_owned(), web_url: None, files: files .iter() .map(|(path, body)| BranchFile { path: (*path).to_owned(), sha: format!("blob-{path}"), bytes: body.as_bytes().to_vec(), }) .collect(), } } /// One recorded commit: its message, and each file's path, text and sha. type Commit = (String, Vec<(&'static str, String, Option)>); #[derive(Default)] struct FakeForge { repos: BTreeMap, commits: Mutex>, grants: Mutex>, /// Hyperhive's commits and their parents. history: BTreeMap>, /// Every compare asked, as `(base, head)`. compares: Mutex>, /// Every compare fails, as a forge that is down does. compare_fails: bool, /// Every `head_files` read, as `("owner/repo", branch)`. head_reads: Mutex)>>, } impl FakeForge { /// `commit` and everything it descends from; `None` for a commit not /// on the forge. fn ancestry(&self, commit: &str) -> Option> { self.history.get(commit)?; let mut seen = BTreeSet::new(); let mut todo = vec![commit.to_owned()]; while let Some(c) = todo.pop() { if seen.insert(c.clone()) { todo.extend(self.history.get(&c).cloned().unwrap_or_default()); } } Some(seen) } } impl MetaForge for FakeForge { async fn head_files( &self, owner: &str, repo: &str, branch: Option<&str>, paths: &[&str], ) -> Result> { self.head_reads .lock() .unwrap() .push((format!("{owner}/{repo}"), branch.map(str::to_owned))); Ok(self.repos.get(&format!("{owner}/{repo}")).map(|h| { let mut h = h.clone(); h.files.retain(|f| paths.contains(&f.path.as_str())); h })) } /// Forgejo's `GetCompareInfo` for `base...head`: the commits `head` /// has beyond the merge base, and none at all when the two share no /// ancestor. async fn commits_ahead( &self, _owner: &str, _repo: &str, base: &str, head: &str, ) -> Result> { self.compares .lock() .unwrap() .push((base.to_owned(), head.to_owned())); if self.compare_fails { bail!("the forge is down"); } let (Some(base), Some(head)) = (self.ancestry(base), self.ancestry(head)) else { return Ok(None); }; if base.is_disjoint(&head) { return Ok(Some(0)); } Ok(Some(head.difference(&base).count() as u64)) } async fn ensure_meta_repo(&self) -> Result<()> { Ok(()) } async fn grant_meta_read(&self, login: &str) -> Result<()> { self.grants.lock().unwrap().push(login.to_owned()); Ok(()) } async fn commit_meta(&self, message: &str, files: Vec) -> Result<()> { self.commits.lock().unwrap().push(( message.to_owned(), files .into_iter() .map(|f| (f.path, String::from_utf8(f.bytes).unwrap(), f.sha)) .collect(), )); Ok(()) } } /// Answers every lock with `reply`, recording each flake it was sent. struct FakeLocker { reply: Result, sent: Mutex>, } impl FakeLocker { fn locked() -> Self { Self { reply: Ok(LockReply::Locked { flake_lock: "{\"version\":7}".to_owned(), }), sent: Mutex::default(), } } } impl LockSink for FakeLocker { async fn lock(&self, request: Vec) -> Result { let req: LockMeta = serde_json::from_slice(&request).unwrap(); self.sent.lock().unwrap().push(req.flake_nix); self.reply.clone() } } /// A forge whose hyperhive `main` is at commit `1`, with no `internal/meta`. fn forge_with(extra: &[(&str, DefaultBranchFiles)]) -> FakeForge { let mut repos = BTreeMap::new(); repos.insert("hyperhive/hyperhive".to_owned(), head(&rev('1'), &[])); repos.insert( "agent-configs/argus".to_owned(), head( "a1", &[( "flake.lock", r#"{"root":"root","nodes":{"root":{"inputs":{"nixpkgs":"nixpkgs"}}}}"#, )], ), ); for (name, h) in extra { repos.insert((*name).to_owned(), h.clone()); } FakeForge { repos, history: history(), ..FakeForge::default() } } /// A forge with `internal/meta` committed, pinning hyperhive at `pin`. async fn forge_pinned_at(pin: &str) -> FakeForge { let base = forge_with(&[]); let flake = render(&base, &settings(), pin, &argus_and_reviewer()) .await .unwrap(); forge_with(&[( "internal/meta", head("m1", &[("flake.nix", &flake), ("flake.lock", "{}")]), )]) } fn argus_and_reviewer() -> Vec { vec![ Placed { name: "argus".to_owned(), archetype: None, }, Placed { name: "rev".to_owned(), archetype: Some(ArchetypeRef { source: "hyperhive".to_owned(), name: "reviewer".to_owned(), }), }, ] } /// The hyperhive commit the one lock sent to `locker` pins. fn locked_pin(locker: &FakeLocker) -> String { let sent = locker.sent.lock().unwrap(); let [flake] = sent.as_slice() else { panic!("one lock: {sent:?}") }; hive_meta_flake::pinned_rev(flake, "hyperhive") .expect("a hyperhive pin") .to_owned() } async fn run(forge: &FakeForge, locker: &FakeLocker, settings: &Settings) -> Report { update(forge, locker, settings, &argus_and_reviewer(), &[]) .await .unwrap() } #[tokio::test] async fn a_new_render_is_locked_and_committed_with_its_lock_in_one_commit() { let forge = forge_with(&[]); let locker = FakeLocker::locked(); let got = update( &forge, &locker, &settings(), &argus_and_reviewer(), &["pr1ma".to_owned()], ) .await .unwrap(); assert_eq!( got, Report { updated: Updated::Committed, pin: rev('1'), hyperhive_moved: false } ); let sent = locker.sent.lock().unwrap(); let [flake] = sent.as_slice() else { panic!("one lock: {sent:?}") }; assert!(flake.contains(&format!( "hyperhive.url = \"git+{FORGE}/hyperhive/hyperhive.git?rev={}\";", rev('1') ))); assert!(flake.contains(&format!( "agent-argus.url = \"git+{FORGE}/agent-configs/argus.git?rev=a1\";" ))); assert!(flake.contains("agent-argus.inputs.nixpkgs.follows = \"nixpkgs\";")); assert!( !flake.contains("agent-rev.url"), "an archetype agent has no input" ); assert!(flake.contains("services.hyperhive.agent.github.enable = false;")); assert!(flake.contains("HYPERHIVE_SWARM_NAME = \"t\";")); let commits = forge.commits.lock().unwrap(); let [(_, files)] = commits.as_slice() else { panic!("one commit: {commits:?}") }; assert_eq!( files, &vec![ ("flake.nix", flake.clone(), None), ("flake.lock", "{\"version\":7}".to_owned(), None), ] ); assert_eq!( *forge.grants.lock().unwrap(), vec![swarm_secret_client::forge::hive_login("pr1ma")] ); } #[tokio::test] async fn an_unchanged_render_sends_no_lock_and_commits_nothing() { let forge = forge_pinned_at(&rev('1')).await; let locker = FakeLocker::locked(); let got = run(&forge, &locker, &settings()).await; assert_eq!(got.updated, Updated::Unchanged); assert_eq!(got.pin, rev('1')); assert!(!got.hyperhive_moved); assert!(locker.sent.lock().unwrap().is_empty()); assert!(forge.commits.lock().unwrap().is_empty()); } #[tokio::test] async fn a_moved_config_head_replaces_both_committed_blobs() { let base = forge_with(&[]); let old = render(&base, &settings(), &rev('1'), &[]).await.unwrap(); let forge = forge_with(&[( "internal/meta", head("m1", &[("flake.nix", &old), ("flake.lock", "{}")]), )]); let locker = FakeLocker::locked(); run(&forge, &locker, &settings()).await; let commits = forge.commits.lock().unwrap(); let shas: Vec<_> = commits[0].1.iter().map(|(_, _, sha)| sha.clone()).collect(); assert_eq!( shas, vec![ Some("blob-flake.nix".to_owned()), Some("blob-flake.lock".to_owned()) ] ); } #[tokio::test] async fn a_refused_lock_commits_nothing() { let forge = forge_with(&[]); let locker = FakeLocker { reply: Ok(LockReply::Failed { error_tail: "no such input".to_owned(), }), sent: Mutex::default(), }; let err = update(&forge, &locker, &settings(), &argus_and_reviewer(), &[]) .await .unwrap_err(); assert!(format!("{err:#}").contains("no such input")); assert!(forge.commits.lock().unwrap().is_empty()); } #[tokio::test] async fn a_custom_agent_without_a_config_commit_is_left_out_of_the_render() { let forge = forge_with(&[]); let mut agents = argus_and_reviewer(); agents.push(Placed { name: "ghost".to_owned(), archetype: None, }); let locker = FakeLocker::locked(); let got = update(&forge, &locker, &settings(), &agents, &[]) .await .unwrap(); assert_eq!(got.updated, Updated::Committed); let sent = locker.sent.lock().unwrap(); let [flake] = sent.as_slice() else { panic!("one lock: {sent:?}") }; assert!( flake.contains("\n argus = mkAgent"), "the agents with a commit are still rendered" ); assert!(flake.contains("\n rev = mkAgent")); assert!(!flake.contains("ghost")); } #[tokio::test] async fn a_config_head_is_read_from_main_not_the_default_branch() { let forge = forge_with(&[]); run(&forge, &FakeLocker::locked(), &settings()).await; let reads = forge.head_reads.lock().unwrap(); assert!( reads.contains(&("agent-configs/argus".to_owned(), Some("main".to_owned()))), "{reads:?}" ); assert!( reads.contains(&("internal/meta".to_owned(), None)), "the meta repo is read on its default branch, as hive-c0re reads it" ); } #[tokio::test] async fn the_first_commit_pins_the_forge_main_head_whatever_the_controller_runs() { let forge = forge_with(&[]); let locker = FakeLocker::locked(); let got = run(&forge, &locker, &settings_running(Some(&rev('3')))).await; assert_eq!(locked_pin(&locker), rev('1')); assert!( !got.hyperhive_moved, "no agent was told to build from it yet" ); assert!( forge.compares.lock().unwrap().is_empty(), "nothing to compare with before the first commit" ); } #[tokio::test] async fn a_controller_on_a_descendant_moves_the_pin_and_says_so() { let forge = forge_pinned_at(&rev('2')).await; let locker = FakeLocker::locked(); let got = run(&forge, &locker, &settings_running(Some(&rev('3')))).await; assert_eq!(locked_pin(&locker), rev('3')); assert_eq!( got, Report { updated: Updated::Committed, pin: rev('3'), hyperhive_moved: true } ); } /// Each running rev that must not move its pin; the control is the test /// above, where a descendant does. #[tokio::test] async fn a_controller_that_is_not_strictly_newer_leaves_the_pin() { for (name, pin, running) in [ ("the same commit", rev('2'), rev('2')), ("an ancestor", rev('2'), rev('1')), ("a diverged commit", rev('3'), rev('4')), ("an unrelated history", rev('2'), rev('9')), ("a commit the forge lacks", rev('2'), rev('8')), ] { let forge = forge_pinned_at(&pin).await; let locker = FakeLocker::locked(); let got = run(&forge, &locker, &settings_running(Some(&running))).await; assert_eq!(got.updated, Updated::Unchanged, "{name}"); assert!(!got.hyperhive_moved, "{name}"); assert!(locker.sent.lock().unwrap().is_empty(), "{name}"); assert!(forge.commits.lock().unwrap().is_empty(), "{name}"); } } #[tokio::test] async fn a_merge_to_hyperhive_main_does_not_move_the_pin() { let mut forge = forge_pinned_at(&rev('2')).await; forge .repos .insert("hyperhive/hyperhive".to_owned(), head(&rev('3'), &[])); let locker = FakeLocker::locked(); let got = run(&forge, &locker, &settings_running(Some(&rev('2')))).await; assert_eq!(got.updated, Updated::Unchanged); assert!(forge.commits.lock().unwrap().is_empty()); } #[tokio::test] async fn a_controller_without_a_rev_keeps_the_pin_and_asks_nothing() { let forge = forge_pinned_at(&rev('2')).await; let locker = FakeLocker::locked(); let got = run(&forge, &locker, &settings()).await; assert_eq!(got.updated, Updated::Unchanged); assert!(forge.compares.lock().unwrap().is_empty()); } #[tokio::test] async fn a_forge_that_cannot_compare_keeps_the_pin_and_still_commits_the_rest() { let mut forge = forge_pinned_at(&rev('2')).await; forge.compare_fails = true; // A new agent makes the render differ from the committed flake. let mut agents = argus_and_reviewer(); agents.push(Placed { name: "newcomer".to_owned(), archetype: Some(ArchetypeRef { source: "hyperhive".to_owned(), name: "reviewer".to_owned(), }), }); let locker = FakeLocker::locked(); let got = update( &forge, &locker, &settings_running(Some(&rev('3'))), &agents, &[], ) .await .unwrap(); assert_eq!(locked_pin(&locker), rev('2')); assert!(!got.hyperhive_moved); assert_eq!(got.updated, Updated::Committed); } #[tokio::test] async fn a_committed_flake_without_a_hyperhive_pin_is_an_error_not_a_first_commit() { let forge = forge_with(&[( "internal/meta", head("m1", &[("flake.nix", "{ }"), ("flake.lock", "{}")]), )]); let err = update(&forge, &FakeLocker::locked(), &settings(), &[], &[]) .await .unwrap_err(); assert!(format!("{err:#}").contains("pins no hyperhive commit")); } /// The fake is Forgejo's `GetCompareInfo`, so the descendant test rests /// on these: a `base...head` count of 0 is both "head is contained in /// base" and "no common ancestor", and only the reverse count tells a /// divergence from a descendant. #[tokio::test] async fn descent_needs_commits_ahead_one_way_and_none_the_other() { let forge = forge_with(&[]); let ahead = |base: char, head: char| { let forge = &forge; async move { forge .commits_ahead("o", "r", &rev(base), &rev(head)) .await .unwrap() } }; assert_eq!(ahead('2', '3').await, Some(1)); assert_eq!(ahead('1', '3').await, Some(2)); assert_eq!(ahead('3', '2').await, Some(0), "contained in base"); assert_eq!(ahead('2', '9').await, Some(0), "no common ancestor"); assert_eq!(ahead('3', '4').await, Some(1), "diverged: both ways"); assert_eq!(ahead('4', '3').await, Some(1), "diverged: both ways"); assert_eq!(ahead('2', '8').await, None); let descends = |ancestor: char, descendant: char| { let forge = &forge; async move { is_strict_descendant(forge, "o", "r", &rev(ancestor), &rev(descendant)) .await .unwrap() } }; assert!(descends('2', '3').await); assert!(descends('1', '3').await); assert!(!descends('3', '2').await); assert!(!descends('2', '2').await); assert!(!descends('3', '4').await); assert!(!descends('2', '9').await, "unrelated histories"); assert!(!descends('2', '8').await); } #[test] fn settings_refuse_an_unknown_value_a_missing_forge_a_plain_http_base_and_a_short_rev() { let parse = |vars: &str, git_base: &str, rev: &str| { Settings::parse(&format!( r#"{{"vars":{vars},"git_base":"{git_base}","github_disabled":false,"hyperhive_rev":{rev}}}"# )) }; let ok = r#"{"HIVE_FORGE_URL":"x"}"#; assert!(parse(ok, "https://f", "null").is_ok()); let full = format!("\"{}\"", rev('a')); assert!(parse(ok, "https://f", &full).is_ok()); let unknown = parse( r#"{"HIVE_FORGE_URL":"x","HIVE_AGENT_NATS_URL":"y"}"#, "https://f", "null", ) .unwrap_err(); assert!(format!("{unknown:#}").contains("HIVE_AGENT_NATS_URL")); assert!(parse("{}", "https://f", "null").is_err()); assert!(parse(ok, "http://f", "null").is_err()); assert!(parse(ok, "https://f", "\"main\"").is_err()); } }