diff --git a/docs/swarm/builder.md b/docs/swarm/builder.md index e2ca37e1..d690aef0 100644 --- a/docs/swarm/builder.md +++ b/docs/swarm/builder.md @@ -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:///agent-configs/?ref=refs/pull//head&rev=`. 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 diff --git a/nix/host-modules/swarm-builder.nix b/nix/host-modules/swarm-builder.nix index 4bc4d218..5b09c7e9 100644 --- a/nix/host-modules/swarm-builder.nix +++ b/nix/host-modules/swarm-builder.nix @@ -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"; diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index db81e0b7..c87ad023 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -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. diff --git a/nix/module-eval/builder.nix b/nix/module-eval/builder.nix index ec48573b..d6bfcb2c 100644 --- a/nix/module-eval/builder.nix +++ b/nix/module-eval/builder.nix @@ -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 diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index b25685e9..4b0f5c41 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -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. diff --git a/swarm-builder/src/archetypes.rs b/swarm-builder/src/archetypes.rs index 1a240bd4..8d7722d9 100644 --- a/swarm-builder/src/archetypes.rs +++ b/swarm-builder/src/archetypes.rs @@ -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 { + /// The `nix eval` arguments, on the hyperhive source at `hyperhive`. + fn args(&self, hyperhive: &str) -> Vec { [ "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") diff --git a/swarm-builder/src/hyperhive_flake.rs b/swarm-builder/src/hyperhive_flake.rs new file mode 100644 index 00000000..cc87c0a1 --- /dev/null +++ b/swarm-builder/src/hyperhive_flake.rs @@ -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); + +struct Inner { + file: PathBuf, + /// The last value read, which [`HyperhiveFlake::current`] falls back to + /// when the file cannot be read. + last: Mutex, +} + +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::load_from(Path::new(FILE)) + } + + fn load_from(file: &Path) -> Result { + 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 { + 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"); + } +} diff --git a/swarm-builder/src/main.rs b/swarm-builder/src/main.rs index f12e123f..660f4dad 100644 --- a/swarm-builder/src/main.rs +++ b/swarm-builder/src/main.rs @@ -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), } diff --git a/swarm-builder/src/nix_build.rs b/swarm-builder/src/nix_build.rs index f7c2b4df..9b5d1f31 100644 --- a/swarm-builder/src/nix_build.rs +++ b/swarm-builder/src/nix_build.rs @@ -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://`, 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(), diff --git a/swarm-controller/src/jobq.rs b/swarm-controller/src/jobq.rs index 5663dff2..0358b394 100644 --- a/swarm-controller/src/jobq.rs +++ b/swarm-controller/src/jobq.rs @@ -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:#}"))?; diff --git a/swarm-controller/src/meta_flake.rs b/swarm-controller/src/meta_flake.rs index 1ad2d913..4c036778 100644 --- a/swarm-controller/src/meta_flake.rs +++ b/swarm-controller/src/meta_flake.rs @@ -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, } +/// 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 { + read_rev_file(Path::new(REV_FILE)) +} + +fn read_rev_file(path: &Path) -> Option { + 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> { @@ -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 { @@ -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 { 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 { + 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}"); + } } }