read the running rev and the hyperhive flake from /etc files, not the unit
This commit is contained in:
parent
32af7e0639
commit
aed7c8451a
11 changed files with 416 additions and 85 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
159
swarm-builder/src/hyperhive_flake.rs
Normal file
159
swarm-builder/src/hyperhive_flake.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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:#}"))?;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue