build agents from internal/meta when it pins their config
A rebuild's MetaSync resolves the head of the swarm's internal/meta once and records it; the prebuild and the swap build that flake instead of the hive's own, and a first deploy does the same in Create. hive-priv takes the commit as a validated meta_rev on UpdateContainer and CreateContainer and builds from its own forge URL (HIVE_PRIV_FORGE_URL). The head is used only when its flake lists the agent at the config commit the forge's main is at; otherwise, and when the repo has no commit or this hive has no core token file for nix's git, the hive renders locally as before. hive-c0re's and hive-priv's gitconfig now share the forge credential helper and rewrite the forge's https URLs to http, which hive-priv lacked.
This commit is contained in:
parent
22e2d30581
commit
7c0a622d41
1 changed files with 831 additions and 64 deletions
325
hive-c0re/src/meta_rev.rs
Normal file
325
hive-c0re/src/meta_rev.rs
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
//! The `internal/meta` commit a hive builds an agent from.
|
||||
//!
|
||||
//! `internal/meta` is the swarm's meta flake, committed by swarm-controller.
|
||||
//! A rebuild's `MetaSync` node resolves its head once ([`resolve`]) and
|
||||
//! records it ([`record`]); `Prebuild` and `Swap` read the record
|
||||
//! ([`recorded`]), so both build the same flake. A create resolves and uses
|
||||
//! the rev directly. No rev, whether because the repo or its commit is absent
|
||||
//! or because the commit does not pin this agent's current config, leaves the
|
||||
//! build on the hive's own meta flake.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use hive_priv_sock::{meta_flake_url, valid_meta_rev};
|
||||
|
||||
/// 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
|
||||
/// `core`, from the token file, whatever token this hive reads with.
|
||||
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<Option<String>>;
|
||||
/// `internal/meta`'s `flake.nix` at `rev`.
|
||||
async fn flake_at(&self, rev: &str) -> Result<String>;
|
||||
/// The head of `agent-configs/<agent>`'s `main`; `None` when it has none.
|
||||
async fn config_head(&self, agent: &str) -> Result<Option<String>>;
|
||||
}
|
||||
|
||||
/// The `internal/meta` commit to build `agent` from, or `None` for the hive's
|
||||
/// own flake.
|
||||
///
|
||||
/// `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.
|
||||
///
|
||||
/// # Errors
|
||||
/// A forge read that failed.
|
||||
pub(crate) async fn choose(
|
||||
source: &impl MetaSource,
|
||||
agent: &str,
|
||||
archetype: bool,
|
||||
) -> Result<Option<String>> {
|
||||
if !source.nix_can_fetch() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(rev) = source.meta_head().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !valid_meta_rev(&rev) {
|
||||
return Ok(None);
|
||||
}
|
||||
let flake = source.flake_at(&rev).await?;
|
||||
let config_head = if archetype {
|
||||
None
|
||||
} else {
|
||||
let Some(head) = source.config_head(agent).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Some(head)
|
||||
};
|
||||
Ok(hive_meta_flake::builds_agent_at(&flake, agent, 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<String> {
|
||||
let archetype = match crate::archetypes::of(agent) {
|
||||
Ok(archetype) => archetype.is_some(),
|
||||
Err(e) => {
|
||||
tracing::warn!(%agent, error = ?e, "internal/meta: cannot read the agent's archetype; building from the hive's own flake");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let Some(source) = crate::forge::internal_meta_source().await else {
|
||||
tracing::info!(%agent, "internal/meta: no forge read token; building from the hive's own flake");
|
||||
return None;
|
||||
};
|
||||
match choose(&source, agent, archetype).await {
|
||||
Ok(Some(rev)) => {
|
||||
tracing::info!(%agent, %rev, "internal/meta: building from the swarm's meta flake");
|
||||
Some(rev)
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::info!(%agent, "internal/meta: absent or not pinning this agent's config; building from the hive's own flake");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%agent, error = %format!("{e:#}"), "internal/meta: cannot read it; building from the hive's own flake");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The record file in `agent`'s runtime dir, which a reboot clears, so a
|
||||
/// record never outlives the boot it was made in.
|
||||
const RECORD_FILE: &str = "meta-rev";
|
||||
|
||||
/// Record `rev` as the commit `agent`'s build reads, or clear the record for
|
||||
/// `None`.
|
||||
///
|
||||
/// # Errors
|
||||
/// The record file could not be written or removed.
|
||||
pub(crate) fn record(agent: &str, rev: Option<&str>) -> Result<()> {
|
||||
record_in(&crate::paths::agent_runtime_dir(agent), rev)
|
||||
}
|
||||
|
||||
/// The commit [`record`] stored for `agent`, if it is a valid one.
|
||||
pub(crate) fn recorded(agent: &str) -> Option<String> {
|
||||
recorded_in(&crate::paths::agent_runtime_dir(agent))
|
||||
}
|
||||
|
||||
fn record_in(dir: &std::path::Path, rev: Option<&str>) -> Result<()> {
|
||||
let path = dir.join(RECORD_FILE);
|
||||
match rev {
|
||||
Some(rev) => {
|
||||
std::fs::write(&path, rev).with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
None => match std::fs::remove_file(&path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
|
||||
Err(e).with_context(|| format!("remove {}", path.display()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn recorded_in(dir: &std::path::Path) -> Option<String> {
|
||||
let rev = std::fs::read_to_string(dir.join(RECORD_FILE)).ok()?;
|
||||
valid_meta_rev(rev.trim()).then(|| rev.trim().to_owned())
|
||||
}
|
||||
|
||||
/// The `<flake>#<agent>` 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<Option<String>> {
|
||||
Ok(self.head.map(str::to_owned))
|
||||
}
|
||||
async fn flake_at(&self, rev: &str) -> Result<String> {
|
||||
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<Option<String>> {
|
||||
assert_eq!(agent, "argus");
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_head_is_taken_when_it_pins_the_agents_current_config() {
|
||||
assert_eq!(
|
||||
choose(&forge(Some(ARGUS_HEAD)), "argus", false)
|
||||
.await
|
||||
.unwrap(),
|
||||
Some(META.to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_head_behind_the_agents_config_is_not_taken() {
|
||||
assert_eq!(
|
||||
choose(&forge(Some(STALE)), "argus", false).await.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_archetype_agent_needs_only_to_be_listed() {
|
||||
struct Archetypes(Forge);
|
||||
impl MetaSource for Archetypes {
|
||||
fn nix_can_fetch(&self) -> bool {
|
||||
self.0.nix_can_fetch()
|
||||
}
|
||||
async fn meta_head(&self) -> Result<Option<String>> {
|
||||
self.0.meta_head().await
|
||||
}
|
||||
async fn flake_at(&self, rev: &str) -> Result<String> {
|
||||
self.0.flake_at(rev).await
|
||||
}
|
||||
async fn config_head(&self, _: &str) -> Result<Option<String>> {
|
||||
panic!("an archetype agent has no config repo to read")
|
||||
}
|
||||
}
|
||||
let source = Archetypes(forge(None));
|
||||
assert_eq!(
|
||||
choose(&source, "rev", true).await.unwrap(),
|
||||
Some(META.to_owned())
|
||||
);
|
||||
assert_eq!(choose(&source, "ghost", true).await.unwrap(), None);
|
||||
}
|
||||
|
||||
/// A hive without the credential nix's git reads builds from its own flake
|
||||
/// even though its own token reads the repo; the first test is the control.
|
||||
#[tokio::test]
|
||||
async fn a_hive_whose_nix_cannot_authenticate_builds_from_its_own_flake() {
|
||||
let mut source = forge(Some(ARGUS_HEAD));
|
||||
source.fetchable = false;
|
||||
assert_eq!(choose(&source, "argus", false).await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_repo_or_no_commit_is_the_hives_own_flake() {
|
||||
let mut source = forge(Some(ARGUS_HEAD));
|
||||
source.head = None;
|
||||
assert_eq!(choose(&source, "argus", false).await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_custom_agent_with_no_config_commit_is_the_hives_own_flake() {
|
||||
assert_eq!(choose(&forge(None), "argus", false).await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_is_read_back_and_cleared() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(recorded_in(dir.path()), None);
|
||||
record_in(dir.path(), Some(META)).unwrap();
|
||||
assert_eq!(recorded_in(dir.path()), Some(META.to_owned()));
|
||||
record_in(dir.path(), None).unwrap();
|
||||
assert_eq!(recorded_in(dir.path()), None);
|
||||
record_in(dir.path(), None).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_that_is_not_a_commit_id_is_no_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join(RECORD_FILE), "main\n").unwrap();
|
||||
assert_eq!(recorded_in(dir.path()), 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue