Watch
0
0
Fork
You've already forked hyperhive
0

read the running rev and the hyperhive flake from /etc files, not the unit

This commit is contained in:
atlas 2026-11-06 04:44:27 +01:00 • committed by mara
commit aed7c8451a
11 changed files with 416 additions and 85 deletions

View file

@ -33,7 +33,10 @@ Where it runs and how it's enabled: [`services.md`](services.md#builder).
with every input overridden:
- `hyperhive` and `nixpkgs`: the sources the builder's own host was
deployed with (`deploy.hyperhiveFlake`, `deploy.builder.nixpkgsFlake`). A hive's meta
flake pins `hyperhive` by forge URL and rev instead.
flake pins `hyperhive` by forge URL and rev instead. The builder reads the
hyperhive path from `/etc/hyperhive/swarm-builder.json` in its container at
the start of each build, so a deploy that changes only that source doesn't
restart it or end a build in progress.
- `agent`: `git+https://<forge>/agent-configs/<agent>?ref=refs/pull/<pr>/head&rev=<head>`.
A build has one hour.
4. The reply is the system closure's store path, or the last lines `nix`
@ -160,6 +163,10 @@ running rev (`services.hyperhive.deploy.hive-controller.hyperhiveRev`, from `sel
tree or a forge that can't answer leaves the pin. A merge to hyperhive's
`main` moves nothing.
The controller reads its rev from `/etc/hyperhive/swarm-controller.json` at each
render, not from its unit, so a deploy that changes no Rust doesn't restart it.
A missing file, or one with no rev, counts as a controller without one.
The controller commits `flake.nix` and the returned `flake.lock` to
`internal/meta` in one commit, and gives each hive's forge account read on the
repo. It re-renders when it starts, and again every ten minutes while the latest

View file

@ -258,6 +258,14 @@ in
};
systemd.services.${forwarderUnit}.serviceConfig.LoadCredential = lib.attrNames storeFiles;
# The hyperhive source every build is made on. The store path changes
# with any edit outside `scripts/` and root-level `*.md`, so it is a
# file the daemon re-reads for each build, not part of the unit: a
# changed unit restarts the daemon and kills the build in progress.
environment.etc."hyperhive/swarm-builder.json".text = builtins.toJSON {
hyperhive_flake = hyperhiveFlake;
};
systemd.services.${unit} = {
description = "build agent configs for swarm-controller";
wantedBy = [ "multi-user.target" ];
@ -281,7 +289,6 @@ in
SWARM_BUILDER_NIX = "${nix}/bin/nix";
SWARM_BUILDER_CHECK_FLAKE = "${../builder/check-flake}";
SWARM_BUILDER_SYSTEM = pkgs.stdenv.hostPlatform.system;
SWARM_BUILDER_HYPERHIVE_FLAKE = hyperhiveFlake;
SWARM_BUILDER_NIXPKGS_FLAKE = nixpkgsFlake;
SWARM_BUILDER_FORGE_URL = "https://${forgeCfg.domain}";
SWARM_BUILDER_CONFIG_ORG = "agent-configs";

View file

@ -286,12 +286,18 @@ let
# helper answers only `https` for the forge.
git_base = "https://${forgeCfg.domain}";
github_disabled = !swarmCfg.github.enable;
# The rev this controller runs, which `internal/meta` pins hyperhive
# to once it descends from the committed pin; null on a dirty tree.
hyperhive_rev = config.services.hyperhive.deploy.hive-controller.hyperhiveRev;
};
};
# The rev this controller runs, which `internal/meta` pins hyperhive to once
# it descends from the committed pin; null on a dirty tree. It is a file the
# daemon re-reads on every meta update, not part of the unit: the rev differs
# on every commit, and a changed unit restarts the controller and drops its
# job graph.
runningRevFile = builtins.toJSON {
hyperhive_rev = deployCfg.hive-controller.hyperhiveRev;
};
# The swarm's own display name, for `GET /api/swarm` (swarm-ui's chrome).
# Gated on the option resolving, not defaulted to an empty string: an
# operator who never named the swarm gets `name: null` from the
@ -692,6 +698,9 @@ in
serviceConfig.SupplementaryGroups = [ clientsGroup ];
};
# Read by `meta_flake::running_rev`.
environment.etc."hyperhive/swarm-controller.json".text = runningRevFile;
# Installed host-wide, not into the daemon's unit: `swarmctl` is run
# by a human on this box and acts as root, so the daemon's sandbox is
# exactly what it must not inherit.

View file

@ -127,7 +127,7 @@ let
builderCheck =
agentModule:
let
src = env.SWARM_BUILDER_HYPERHIVE_FLAKE;
src = withBuilder.services.hyperhive.deploy.hyperhiveFlake;
hyperhive = (import "${src}/flake.nix").outputs (
self.inputs
// {
@ -262,10 +262,15 @@ let
}
{
# The check builds on the sources this host was deployed with, so a green check means
# the same hyperhive and nixpkgs base.
# the same hyperhive and nixpkgs base. The hyperhive source is a file the
# daemon re-reads, not part of its unit.
name = "the builder builds on the hyperhive and nixpkgs this host was deployed with";
ok =
env.SWARM_BUILDER_HYPERHIVE_FLAKE == withBuilder.services.hyperhive.deploy.hyperhiveFlake
(builtins.fromJSON (
builtins.unsafeDiscardStringContext inner.environment.etc."hyperhive/swarm-builder.json".text
)).hyperhive_flake
== builtins.unsafeDiscardStringContext withBuilder.services.hyperhive.deploy.hyperhiveFlake
&& !(env ? SWARM_BUILDER_HYPERHIVE_FLAKE)
&& env.SWARM_BUILDER_NIXPKGS_FLAKE == withBuilder.services.hyperhive.deploy.builder.nixpkgsFlake
&& lib.hasSuffix "-check-flake" env.SWARM_BUILDER_CHECK_FLAKE
&& env.SWARM_BUILDER_SYSTEM == pkgs.stdenv.hostPlatform.system

View file

@ -33,6 +33,14 @@ let
inherit (shared) allLocal;
# `allLocal` on another hyperhive commit and source, which differ from its
# own in nothing else.
allLocalMoved = hiveWithController {
deploy.singleHostSwarm = true;
deploy.hyperhiveFlake = "/nix/store/00000000000000000000000000000000-hyperhive-moved";
deploy.hive-controller.hyperhiveRev = lib.fixedWidthString 40 "b" "";
};
bare = shared.bareWithController;
# `lib.hasInfix` matches by regex, which refuses a needle that refers to a
@ -308,18 +316,68 @@ let
&& !((env "hive-priv") ? HIVE_PRIV_FORGE_URL);
}
{
# `hyperhive_rev` defaults to none on the controller, so a settings JSON
# `hyperhive_rev` defaults to none on the controller, so a rev file
# without the key is accepted and the pin would never move: the key has
# to be there, carrying the option it is wired from.
name = "the controller hands its running rev to the meta flake settings";
# to be there, carrying the option it is wired from. The settings JSON
# must not carry it: the daemon refuses a key it does not know, and a
# rev in the unit restarts the controller on every commit.
name = "the controller hands its running rev to the meta flake through its rev file";
ok =
let
revFile = builtins.fromJSON (
builtins.unsafeDiscardStringContext allLocal.environment.etc."hyperhive/swarm-controller.json".text
);
settings = builtins.fromJSON allLocal.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_META_FLAKE;
in
settings ? hyperhive_rev
&& settings.hyperhive_rev == allLocal.services.hyperhive.deploy.hive-controller.hyperhiveRev
revFile ? hyperhive_rev
&& revFile.hyperhive_rev == allLocal.services.hyperhive.deploy.hive-controller.hyperhiveRev
&& !(settings ? hyperhive_rev)
&& settings ? git_base;
}
{
# A deploy that changes only the commit and the hyperhive source must
# leave both long-running units as they are, or systemd restarts them:
# the controller drops its job graph and the builder its build. The
# moved host differs in exactly those two options, and its files carry
# them, which is the control that the comparison reads what changed.
name = "a new hyperhive commit and source leave the controller's and the builder's units unchanged";
ok =
let
unit = m: m.systemd.services.swarm-controller;
builderUnit = m: m.containers.swarm-builder.config.systemd.services.swarm-builder;
same =
f:
let
a = f allLocal;
b = f allLocalMoved;
in
a.environment == b.environment
&& a.serviceConfig == b.serviceConfig
&& map toString a.path == map toString b.path;
etcText = m: name: builtins.unsafeDiscardStringContext m.environment.etc.${name}.text;
builderEtc = m: m.containers.swarm-builder.config;
movedRev = allLocalMoved.services.hyperhive.deploy.hive-controller.hyperhiveRev;
movedFlake = allLocalMoved.services.hyperhive.deploy.hyperhiveFlake;
in
same unit
&& same builderUnit
&& movedRev != allLocal.services.hyperhive.deploy.hive-controller.hyperhiveRev
&& movedFlake != allLocal.services.hyperhive.deploy.hyperhiveFlake
&&
(builtins.fromJSON (etcText allLocalMoved "hyperhive/swarm-controller.json")).hyperhive_rev
== movedRev
&&
etcText allLocal "hyperhive/swarm-controller.json"
!= etcText allLocalMoved "hyperhive/swarm-controller.json"
&&
(builtins.fromJSON (etcText (builderEtc allLocalMoved) "hyperhive/swarm-builder.json"))
.hyperhive_flake == builtins.unsafeDiscardStringContext movedFlake
&&
etcText (builderEtc allLocal) "hyperhive/swarm-builder.json"
!= etcText (builderEtc allLocalMoved) "hyperhive/swarm-builder.json"
&& !((builderUnit allLocal).environment ? SWARM_BUILDER_HYPERHIVE_FLAKE)
&& (builderUnit allLocal).environment ? SWARM_BUILDER_NIXPKGS_FLAKE;
}
{
# An agent connects to the queue with its own secret, so the hive hands
# its agents no shared credential and no token endpoint to mint one at.

View file

@ -10,6 +10,7 @@ use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::hyperhive_flake::HyperhiveFlake;
use swarm_wire::builder::ArchetypesReply;
use tokio::io::AsyncReadExt as _;
@ -17,8 +18,9 @@ use tokio::io::AsyncReadExt as _;
pub struct Evaluator {
/// The `nix` binary.
pub nix: PathBuf,
/// The hyperhive source this host was deployed from.
pub hyperhive: String,
/// The hyperhive source this host was deployed from, read for each
/// evaluation.
pub hyperhive: HyperhiveFlake,
/// The nixpkgs source this host was deployed from, which the hyperhive
/// source's own `nixpkgs` input is pointed at, as a hive's meta flake does.
pub nixpkgs: String,
@ -30,16 +32,17 @@ impl Evaluator {
/// Evaluate the archetypes' `meta`.
pub async fn eval(&self) -> ArchetypesReply {
let mut cmd = tokio::process::Command::new(&self.nix);
cmd.args(self.args())
let hyperhive = self.hyperhive.current();
cmd.args(self.args(&hyperhive))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
run(cmd, &self.hyperhive, self.deadline).await
run(cmd, &hyperhive, self.deadline).await
}
/// The `nix eval` arguments.
fn args(&self) -> Vec<String> {
/// The `nix eval` arguments, on the hyperhive source at `hyperhive`.
fn args(&self, hyperhive: &str) -> Vec<String> {
[
"eval",
"--json",
@ -51,7 +54,7 @@ impl Evaluator {
.into_iter()
.map(str::to_owned)
.chain([
format!("{}#hyperhiveArchetypes", self.hyperhive),
format!("{hyperhive}#hyperhiveArchetypes"),
"--override-input".to_owned(),
"nixpkgs".to_owned(),
self.nixpkgs.clone(),
@ -165,7 +168,7 @@ mod tests {
fn evaluator() -> Evaluator {
Evaluator {
nix: PathBuf::from("nix"),
hyperhive: "/nix/store/h-source".to_owned(),
hyperhive: HyperhiveFlake::fixed("/nix/store/h-source"),
nixpkgs: "/nix/store/n-source".to_owned(),
deadline: swarm_wire::builder::ARCHETYPES_DEADLINE,
}
@ -173,7 +176,7 @@ mod tests {
#[test]
fn only_meta_is_evaluated_on_this_hosts_sources() {
let args = evaluator().args();
let args = evaluator().args("/nix/store/h-source");
assert!(
args.iter()
.any(|a| a == "/nix/store/h-source#hyperhiveArchetypes")

View file

@ -0,0 +1,159 @@
//! The hyperhive source builds are made on, read from the file
//! `swarm-builder.nix` writes it to.
//!
//! It is a file, not part of the unit: the store path changes with almost
//! any commit, and a changed unit restarts the daemon and ends the build in
//! progress. [`HyperhiveFlake::current`] reads the file again for each build
//! and evaluation.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result, bail};
/// Where `swarm-builder.nix` writes the file, in the container's `/etc`.
pub const FILE: &str = "/etc/hyperhive/swarm-builder.json";
/// The hyperhive flake's store path, as the file last named it.
#[derive(Clone)]
pub struct HyperhiveFlake(Arc<Inner>);
struct Inner {
file: PathBuf,
/// The last value read, which [`HyperhiveFlake::current`] falls back to
/// when the file cannot be read.
last: Mutex<String>,
}
impl HyperhiveFlake {
/// [`FILE`] as it is now.
///
/// # Errors
/// When the file is missing, unreadable, not JSON, or names no path: the
/// daemon cannot build without a source.
pub fn load() -> Result<Self> {
Self::load_from(Path::new(FILE))
}
fn load_from(file: &Path) -> Result<Self> {
let initial = read(file)?;
Ok(Self::new(file.to_owned(), initial))
}
/// `initial` until `file` can be read.
pub fn new(file: PathBuf, initial: String) -> Self {
Self(Arc::new(Inner {
file,
last: Mutex::new(initial),
}))
}
/// `path`, with a file that does not exist: what a test hands code that
/// reads the flake.
#[cfg(test)]
pub fn fixed(path: &str) -> Self {
Self::new(
PathBuf::from("/nonexistent/swarm-builder.json"),
path.to_owned(),
)
}
/// The path the file names now. A file that cannot be read keeps the last
/// path read, and is logged.
///
/// # Panics
/// When another thread panicked while it held the stored value.
#[must_use]
pub fn current(&self) -> String {
let mut last = self.0.last.lock().expect("unpoisoned");
match read(&self.0.file) {
Ok(path) => *last = path,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "hyperhive flake file not read; using the last path");
}
}
last.clone()
}
}
fn read(file: &Path) -> Result<String> {
let raw = std::fs::read_to_string(file).with_context(|| format!("read {}", file.display()))?;
let contents: serde_json::Value =
serde_json::from_str(&raw).with_context(|| format!("parse {}", file.display()))?;
match contents["hyperhive_flake"].as_str() {
Some(path) if !path.is_empty() => Ok(path.to_owned()),
_ => bail!("{}: hyperhive_flake is absent or empty", file.display()),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A fresh directory for one test, removed on drop.
struct Dir(PathBuf);
impl Dir {
fn new(name: &str) -> Self {
let dir =
std::env::temp_dir().join(format!("hyperhive-flake-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn file(&self) -> PathBuf {
self.0.join("swarm-builder.json")
}
fn write(&self, content: &str) {
std::fs::write(self.file(), content).unwrap();
}
}
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn the_path_is_read_again_each_time() {
let dir = Dir::new("reread");
dir.write(r#"{"hyperhive_flake":"/nix/store/a-source"}"#);
let flake = HyperhiveFlake::load_from(&dir.file()).unwrap();
assert_eq!(flake.current(), "/nix/store/a-source");
dir.write(r#"{"hyperhive_flake":"/nix/store/b-source"}"#);
assert_eq!(flake.current(), "/nix/store/b-source");
}
#[test]
fn a_file_that_cannot_be_used_keeps_the_last_path() {
let dir = Dir::new("keeps");
dir.write(r#"{"hyperhive_flake":"/nix/store/a-source"}"#);
let flake = HyperhiveFlake::load_from(&dir.file()).unwrap();
// Control: `the_path_is_read_again_each_time` shows a good file
// replacing the path.
for (name, content) in [
("empty value", Some(r#"{"hyperhive_flake":""}"#)),
("no key", Some("{}")),
("not JSON", Some("not json")),
("missing file", None),
] {
match content {
Some(content) => dir.write(content),
None => std::fs::remove_file(dir.file()).unwrap(),
}
assert_eq!(flake.current(), "/nix/store/a-source", "{name}");
}
}
#[test]
fn start_fails_without_a_usable_file() {
let dir = Dir::new("start");
assert!(HyperhiveFlake::load_from(&dir.file()).is_err(), "missing");
dir.write(r#"{"hyperhive_flake":""}"#);
assert!(HyperhiveFlake::load_from(&dir.file()).is_err(), "empty");
dir.write(r#"{"hyperhive_flake":"/nix/store/a-source"}"#);
assert!(HyperhiveFlake::load_from(&dir.file()).is_ok(), "control");
}
}

View file

@ -25,6 +25,7 @@
//! The store identity comes from the `BAO_*` environment. The queue client
//! secret is read at start and again when authelia refuses it, the forge token
//! once per build, both from the store under that identity.
//! The hyperhive source is read again for each build ([`hyperhive_flake`]).
use std::future::Future;
use std::path::PathBuf;
@ -45,6 +46,7 @@ use swarm_wire::builder::{
};
mod archetypes;
mod hyperhive_flake;
mod metrics;
mod nix_build;
mod push;
@ -96,7 +98,7 @@ async fn main() -> Result<()> {
let builder = Arc::new(nix_build::Builder {
nix: PathBuf::from(var("NIX")?),
check_flake: var("CHECK_FLAKE")?,
hyperhive: var("HYPERHIVE_FLAKE")?,
hyperhive: hyperhive_flake::HyperhiveFlake::load()?,
nixpkgs: var("NIXPKGS_FLAKE")?,
forge_url: forge_url()?,
config_org: var("CONFIG_ORG")?,
@ -1005,7 +1007,7 @@ mod tests {
Arc::new(nix_build::Builder {
nix: self.0.join("nix"),
check_flake: "/nix/store/c-check".to_owned(),
hyperhive: "/nix/store/h-source".to_owned(),
hyperhive: hyperhive_flake::HyperhiveFlake::fixed("/nix/store/h-source"),
nixpkgs: "/nix/store/n-source".to_owned(),
forge_url: "https://forge.example".to_owned(),
config_org: "agent-configs".to_owned(),
@ -1694,7 +1696,7 @@ mod tests {
fn evaluator(fake: &Fake) -> archetypes::Evaluator {
archetypes::Evaluator {
nix: fake.0.join("nix"),
hyperhive: "/nix/store/h-source".to_owned(),
hyperhive: hyperhive_flake::HyperhiveFlake::fixed("/nix/store/h-source"),
nixpkgs: "/nix/store/n-source".to_owned(),
deadline: Duration::from_secs(30),
}

View file

@ -18,6 +18,7 @@ use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use crate::hyperhive_flake::HyperhiveFlake;
use hive_types::NixBuilds;
use swarm_wire::builder::{
BuildAgent, BuildFromMeta, FromMetaReply, LockMeta, LockReply, META_REPO_PATH, Reply,
@ -46,8 +47,8 @@ pub struct Builder {
pub nix: PathBuf,
/// The check flake's directory in the store.
pub check_flake: String,
/// The hyperhive source this host was deployed from.
pub hyperhive: String,
/// The hyperhive source this host was deployed from, read for each build.
pub hyperhive: HyperhiveFlake,
/// The nixpkgs source this host was deployed from.
pub nixpkgs: String,
/// `https://<forge>`, with no trailing slash and no path.
@ -159,7 +160,7 @@ impl Builder {
format!("{}#check", self.check_flake),
"--override-input".to_owned(),
"hyperhive".to_owned(),
self.hyperhive.clone(),
self.hyperhive.current(),
"--override-input".to_owned(),
"nixpkgs".to_owned(),
self.nixpkgs.clone(),
@ -525,7 +526,7 @@ mod tests {
fn builder() -> Builder {
Builder {
check_flake: "/nix/store/c-check".to_owned(),
hyperhive: "/nix/store/h-source".to_owned(),
hyperhive: HyperhiveFlake::fixed("/nix/store/h-source"),
nixpkgs: "/nix/store/n-source".to_owned(),
forge_url: "https://forge.example".to_owned(),
config_org: "agent-configs".to_owned(),

View file

@ -792,7 +792,14 @@ async fn update_meta(
.map_err(|e| format!("{e:#}"))?;
let report = forge_node(
"update_meta",
meta_flake::update(client.as_ref(), queue, &meta.settings, &agents, &meta.hives),
meta_flake::update(
client.as_ref(),
queue,
&meta.settings,
meta_flake::running_rev().as_deref(),
&agents,
&meta.hives,
),
)
.await
.map_err(|e| format!("{e:#}"))?;

View file

@ -13,6 +13,7 @@
use std::collections::BTreeMap;
use std::future::Future;
use std::path::Path;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
@ -30,6 +31,11 @@ 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";
/// The file `swarm-controller.nix` writes the running rev to, which every
/// [`update`] reads again through [`running_rev`]. The unit does not carry the
/// rev: it differs on every commit, and a changed unit restarts the controller.
const REV_FILE: &str = "/etc/hyperhive/swarm-controller.json";
/// Where hyperhive itself lives on the forge.
const HYPERHIVE_OWNER: &str = "hyperhive";
const HYPERHIVE_REPO: &str = "hyperhive";
@ -46,12 +52,49 @@ pub(crate) struct Settings {
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.
}
/// The contents of [`REV_FILE`].
#[derive(Deserialize)]
struct RevFile {
/// The hyperhive commit this controller runs (`self.rev`); `null` for a
/// dirty tree.
#[serde(default)]
hyperhive_rev: Option<String>,
}
/// The hyperhive commit this controller runs, from [`REV_FILE`]; `None` when
/// the file or the value is absent, empty or not a full commit id, which leaves
/// the hyperhive pin where it is.
pub(crate) fn running_rev() -> Option<String> {
read_rev_file(Path::new(REV_FILE))
}
fn read_rev_file(path: &Path) -> Option<String> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "meta flake: cannot read the running rev file");
return None;
}
};
let file: RevFile = match serde_json::from_str(&raw) {
Ok(file) => file,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "meta flake: the running rev file does not parse");
return None;
}
};
let rev = file.hyperhive_rev.filter(|r| !r.is_empty())?;
if hive_meta_flake::is_rev(&rev) {
Some(rev)
} else {
tracing::warn!(path = %path.display(), %rev, "meta flake: the running rev is not a full commit id");
None
}
}
impl Settings {
/// [`Settings`] from [`SETTINGS_ENV`], `None` when it is unset.
pub(crate) fn from_env() -> Result<Option<Self>> {
@ -82,13 +125,6 @@ impl Settings {
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)
}
@ -216,6 +252,9 @@ pub(crate) struct Report {
/// Bring `internal/meta` up to the forge's current heads for `agents`, and
/// give each of `hives`' forge accounts, and the swarm builder's, read on it.
///
/// `running_rev` is the rev this controller runs ([`running_rev`]), read by the
/// caller for each update; `None` leaves the hyperhive pin.
///
/// # 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
@ -224,6 +263,7 @@ pub(crate) async fn update(
forge: &impl MetaForge,
locker: &impl LockSink,
settings: &Settings,
running_rev: Option<&str>,
agents: &[Placed],
hives: &[String],
) -> Result<Report> {
@ -253,7 +293,7 @@ pub(crate) async fn update(
let flake_file = sha_of("flake.nix");
let pin = hyperhive_pin(
forge,
settings,
running_rev,
flake_file
.map(|f| String::from_utf8_lossy(&f.bytes))
.as_deref(),
@ -319,7 +359,7 @@ struct HyperhivePin {
/// comparison leaves it too, and the next update asks again.
async fn hyperhive_pin(
forge: &impl MetaForge,
settings: &Settings,
running: Option<&str>,
committed_flake: Option<&str>,
) -> Result<HyperhivePin> {
let Some(flake) = committed_flake else {
@ -338,7 +378,7 @@ async fn hyperhive_pin(
rev: pinned.to_owned(),
moved: false,
};
let Some(running) = settings.hyperhive_rev.as_deref() else {
let Some(running) = running else {
tracing::info!(%pinned, "meta flake: this controller has no rev; keeping the hyperhive pin");
return Ok(keep());
};
@ -488,18 +528,13 @@ mod tests {
.collect()
}
fn settings_running(running: Option<&str>) -> Settings {
let running = running.map_or("null".to_owned(), |r| format!("\"{r}\""));
fn settings() -> Settings {
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}}}"#
r#"{{"vars":{{"HIVE_FORGE_URL":"http://forge.t.local","HYPERHIVE_SWARM_NAME":"t"}},"git_base":"{FORGE}/","github_disabled":true}}"#
))
.unwrap()
}
fn settings() -> Settings {
settings_running(None)
}
fn head(commit: &str, files: &[(&str, &str)]) -> DefaultBranchFiles {
DefaultBranchFiles {
branch: "main".to_owned(),
@ -700,10 +735,17 @@ mod tests {
.to_owned()
}
async fn run(forge: &FakeForge, locker: &FakeLocker, settings: &Settings) -> Report {
update(forge, locker, settings, &argus_and_reviewer(), &[])
.await
.unwrap()
async fn run(forge: &FakeForge, locker: &FakeLocker, running: Option<&str>) -> Report {
update(
forge,
locker,
&settings(),
running,
&argus_and_reviewer(),
&[],
)
.await
.unwrap()
}
#[tokio::test]
@ -714,6 +756,7 @@ mod tests {
&forge,
&locker,
&settings(),
None,
&argus_and_reviewer(),
&["pr1ma".to_owned()],
)
@ -769,7 +812,7 @@ mod tests {
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;
let got = run(&forge, &locker, None).await;
assert_eq!(got.updated, Updated::Unchanged);
assert_eq!(got.pin, rev('1'));
assert!(!got.hyperhive_moved);
@ -786,7 +829,7 @@ mod tests {
head("m1", &[("flake.nix", &old), ("flake.lock", "{}")]),
)]);
let locker = FakeLocker::locked();
run(&forge, &locker, &settings()).await;
run(&forge, &locker, None).await;
let commits = forge.commits.lock().unwrap();
let shas: Vec<_> = commits[0].1.iter().map(|(_, _, sha)| sha.clone()).collect();
assert_eq!(
@ -807,9 +850,16 @@ mod tests {
}),
sent: Mutex::default(),
};
let err = update(&forge, &locker, &settings(), &argus_and_reviewer(), &[])
.await
.unwrap_err();
let err = update(
&forge,
&locker,
&settings(),
None,
&argus_and_reviewer(),
&[],
)
.await
.unwrap_err();
assert!(format!("{err:#}").contains("no such input"));
assert!(forge.commits.lock().unwrap().is_empty());
}
@ -823,7 +873,7 @@ mod tests {
archetype: None,
});
let locker = FakeLocker::locked();
let got = update(&forge, &locker, &settings(), &agents, &[])
let got = update(&forge, &locker, &settings(), None, &agents, &[])
.await
.unwrap();
assert_eq!(got.updated, Updated::Committed);
@ -842,7 +892,7 @@ mod tests {
#[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;
run(&forge, &FakeLocker::locked(), None).await;
let reads = forge.head_reads.lock().unwrap();
assert!(
reads.contains(&("agent-configs/argus".to_owned(), Some("main".to_owned()))),
@ -858,7 +908,7 @@ mod tests {
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;
let got = run(&forge, &locker, Some(&rev('3'))).await;
assert_eq!(locked_pin(&locker), rev('1'));
assert!(
!got.hyperhive_moved,
@ -874,7 +924,7 @@ mod tests {
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;
let got = run(&forge, &locker, Some(&rev('3'))).await;
assert_eq!(locked_pin(&locker), rev('3'));
assert_eq!(
got,
@ -899,7 +949,7 @@ mod tests {
] {
let forge = forge_pinned_at(&pin).await;
let locker = FakeLocker::locked();
let got = run(&forge, &locker, &settings_running(Some(&running))).await;
let got = run(&forge, &locker, Some(&running)).await;
assert_eq!(got.updated, Updated::Unchanged, "{name}");
assert!(!got.hyperhive_moved, "{name}");
assert!(locker.sent.lock().unwrap().is_empty(), "{name}");
@ -914,7 +964,7 @@ mod tests {
.repos
.insert("hyperhive/hyperhive".to_owned(), head(&rev('3'), &[]));
let locker = FakeLocker::locked();
let got = run(&forge, &locker, &settings_running(Some(&rev('2')))).await;
let got = run(&forge, &locker, Some(&rev('2'))).await;
assert_eq!(got.updated, Updated::Unchanged);
assert!(forge.commits.lock().unwrap().is_empty());
}
@ -923,7 +973,7 @@ mod tests {
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;
let got = run(&forge, &locker, None).await;
assert_eq!(got.updated, Updated::Unchanged);
assert!(forge.compares.lock().unwrap().is_empty());
}
@ -942,15 +992,9 @@ mod tests {
}),
});
let locker = FakeLocker::locked();
let got = update(
&forge,
&locker,
&settings_running(Some(&rev('3'))),
&agents,
&[],
)
.await
.unwrap();
let got = update(&forge, &locker, &settings(), Some(&rev('3')), &agents, &[])
.await
.unwrap();
assert_eq!(locked_pin(&locker), rev('2'));
assert!(!got.hyperhive_moved);
assert_eq!(got.updated, Updated::Committed);
@ -962,7 +1006,7 @@ mod tests {
"internal/meta",
head("m1", &[("flake.nix", "{ }"), ("flake.lock", "{}")]),
)]);
let err = update(&forge, &FakeLocker::locked(), &settings(), &[], &[])
let err = update(&forge, &FakeLocker::locked(), &settings(), None, &[], &[])
.await
.unwrap_err();
assert!(format!("{err:#}").contains("pins no hyperhive commit"));
@ -1009,25 +1053,54 @@ mod tests {
}
#[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| {
fn settings_refuse_an_unknown_value_a_missing_forge_and_a_plain_http_base() {
let parse = |vars: &str, git_base: &str| {
Settings::parse(&format!(
r#"{{"vars":{vars},"git_base":"{git_base}","github_disabled":false,"hyperhive_rev":{rev}}}"#
r#"{{"vars":{vars},"git_base":"{git_base}","github_disabled":false}}"#
))
};
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());
assert!(parse(ok, "https://f").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());
assert!(parse("{}", "https://f").is_err());
assert!(parse(ok, "http://f").is_err());
}
/// The rev file's content as `read_rev_file` sees it; `None` writes no file.
fn rev_from(content: Option<&str>) -> Option<String> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("swarm-controller.json");
if let Some(content) = content {
std::fs::write(&path, content).unwrap();
}
read_rev_file(&path)
}
#[test]
fn the_running_rev_is_the_files_full_commit_id() {
let json = format!(r#"{{"hyperhive_rev":"{}"}}"#, rev('a'));
assert_eq!(rev_from(Some(&json)), Some(rev('a')));
}
#[test]
fn no_file_no_value_an_empty_value_or_a_bad_value_is_no_rev() {
// Control: `the_running_rev_is_the_files_full_commit_id` reads a rev
// through this same function.
for (name, content) in [
("missing file", None),
("null", Some(r#"{"hyperhive_rev":null}"#)),
("no key", Some("{}")),
("empty value", Some(r#"{"hyperhive_rev":""}"#)),
("short rev", Some(r#"{"hyperhive_rev":"abc123"}"#)),
("a branch", Some(r#"{"hyperhive_rev":"main"}"#)),
("not JSON", Some("not json")),
] {
assert_eq!(rev_from(content), None, "{name}");
}
}
}