1025 lines
36 KiB
Rust
1025 lines
36 KiB
Rust
//! `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/<agent>` `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<String, String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
impl Settings {
|
|
/// [`Settings`] from [`SETTINGS_ENV`], `None` when it is unset.
|
|
pub(crate) fn from_env() -> Result<Option<Self>> {
|
|
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<Self> {
|
|
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<ArchetypeRef>,
|
|
}
|
|
|
|
/// 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<u8>,
|
|
pub(crate) sha: Option<String>,
|
|
}
|
|
|
|
/// 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<Output = Result<Option<DefaultBranchFiles>>> + 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<Output = Result<Option<u64>>> + Send;
|
|
fn ensure_meta_repo(&self) -> impl Future<Output = Result<()>> + Send;
|
|
fn grant_meta_read(&self, login: &str) -> impl Future<Output = Result<()>> + Send;
|
|
fn commit_meta(
|
|
&self,
|
|
message: &str,
|
|
files: Vec<MetaFile>,
|
|
) -> impl Future<Output = Result<()>> + 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<u8>) -> impl Future<Output = Result<LockReply, String>> + Send;
|
|
}
|
|
|
|
impl LockSink for async_nats::Client {
|
|
async fn lock(&self, request: Vec<u8>) -> Result<LockReply, String> {
|
|
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<Report> {
|
|
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<HyperhivePin> {
|
|
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<bool> {
|
|
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<String> {
|
|
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<String, Vec<String>> {
|
|
[
|
|
('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<String>)>);
|
|
|
|
#[derive(Default)]
|
|
struct FakeForge {
|
|
repos: BTreeMap<String, DefaultBranchFiles>,
|
|
commits: Mutex<Vec<Commit>>,
|
|
grants: Mutex<Vec<String>>,
|
|
/// Hyperhive's commits and their parents.
|
|
history: BTreeMap<String, Vec<String>>,
|
|
/// Every compare asked, as `(base, head)`.
|
|
compares: Mutex<Vec<(String, String)>>,
|
|
/// Every compare fails, as a forge that is down does.
|
|
compare_fails: bool,
|
|
/// Every `head_files` read, as `("owner/repo", branch)`.
|
|
head_reads: Mutex<Vec<(String, Option<String>)>>,
|
|
}
|
|
|
|
impl FakeForge {
|
|
/// `commit` and everything it descends from; `None` for a commit not
|
|
/// on the forge.
|
|
fn ancestry(&self, commit: &str) -> Option<BTreeSet<String>> {
|
|
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<Option<DefaultBranchFiles>> {
|
|
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<Option<u64>> {
|
|
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<MetaFile>) -> 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<LockReply, String>,
|
|
sent: Mutex<Vec<String>>,
|
|
}
|
|
|
|
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<u8>) -> Result<LockReply, String> {
|
|
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<Placed> {
|
|
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());
|
|
}
|
|
}
|