Watch
0
0
Fork
You've already forked hyperhive
0

gate each deploy on a swarm build of the agent from internal/meta

This commit is contained in:
atlas 2026-10-26 16:40:06 +01:00
commit 5e64048e53

View file

@ -1,7 +1,8 @@
//! One build: `nix build` of the check flake with its three inputs pointed at
//! this host's hyperhive and nixpkgs and at the agent's config
//! commit. One lock: `nix flake lock` of the swarm meta flake in a scratch
//! directory.
//! commit. One meta build: `nix build` of one agent's toplevel in
//! `internal/meta` at a commit, every input at the flake's own lock. One lock:
//! `nix flake lock` of the swarm meta flake in a scratch directory.
//!
//! The config repo is private, so `git` fetches it with the builder's forge
//! token. The token reaches `git` through a credential helper that reads it
@ -17,7 +18,9 @@ use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use swarm_wire::builder::{BuildAgent, LockMeta, LockReply, Reply};
use swarm_wire::builder::{
BuildAgent, BuildFromMeta, FromMetaReply, LockMeta, LockReply, META_REPO_PATH, Reply,
};
use tokio::io::AsyncReadExt as _;
/// The most of `nix`'s stderr a failed build's reply carries, from its end.
@ -54,6 +57,8 @@ pub struct Builder {
pub deadline: Duration,
/// How long one lock may run.
pub lock_deadline: Duration,
/// The platform this host builds for, reported with each meta build.
pub system: String,
}
impl Builder {
@ -63,10 +68,27 @@ impl Builder {
return Reply::Failed { error_tail: error };
}
let mut cmd = self.nix_command(token);
cmd.args(self.args(job));
cmd.args(BUILD_FLAGS).args(self.args(job));
run(cmd, self.deadline).await
}
/// Build `job`'s agent from `internal/meta` with `token` as the forge
/// credential.
pub async fn build_from_meta(&self, job: &BuildFromMeta, token: &str) -> FromMetaReply {
if let Err(error_tail) = validate_meta(job) {
return FromMetaReply::Failed { error_tail };
}
let mut cmd = self.nix_command(token);
cmd.args(BUILD_FLAGS).arg(self.meta_attr(job));
match run(cmd, self.deadline).await {
Reply::Built { out_path } => FromMetaReply::Built {
out_path,
system: self.system.clone(),
},
Reply::Failed { error_tail } => FromMetaReply::Failed { error_tail },
}
}
/// Lock `job`'s flake with `token` as the forge credential, in a scratch
/// directory removed afterwards, and answer with the lock `nix` wrote.
pub async fn lock(&self, job: &LockMeta, token: &str) -> LockReply {
@ -119,21 +141,9 @@ impl Builder {
cmd
}
/// The `nix build` arguments for `job`.
/// The `nix build` arguments for `job`, after [`BUILD_FLAGS`].
fn args(&self, job: &BuildAgent) -> Vec<String> {
[
"build",
"--no-link",
"--print-out-paths",
"--no-write-lock-file",
"--option",
"accept-flake-config",
"false",
"--print-build-logs",
]
.into_iter()
.map(str::to_owned)
.chain([
vec![
format!("{}#check", self.check_flake),
"--override-input".to_owned(),
"hyperhive".to_owned(),
@ -144,8 +154,17 @@ impl Builder {
"--override-input".to_owned(),
"agent".to_owned(),
self.agent_url(job),
])
.collect()
]
}
/// The agent's toplevel in `internal/meta` at the commit, the attribute
/// the hives build. No input is overridden: the committed `flake.lock`
/// decides all of them.
fn meta_attr(&self, job: &BuildFromMeta) -> String {
format!(
"git+{}/{META_REPO_PATH}.git?rev={}#nixosConfigurations.{}.config.system.build.toplevel",
self.forge_url, job.meta_rev, job.agent
)
}
/// The config commit as a flake reference, fetched through the PR's head
@ -158,6 +177,19 @@ impl Builder {
}
}
/// What every `nix build` starts with: nothing linked, nothing written to the
/// flake's lock, the flake's own `nix.conf` settings refused.
const BUILD_FLAGS: [&str; 8] = [
"build",
"--no-link",
"--print-out-paths",
"--no-write-lock-file",
"--option",
"accept-flake-config",
"false",
"--print-build-logs",
];
/// `nix flake lock` of the flake in the working directory. The flake's config
/// is not trusted, and nothing is built.
const LOCK_ARGS: &[&str] = &["flake", "lock", "--option", "accept-flake-config", "false"];
@ -207,24 +239,32 @@ fn credential_helper() -> String {
/// Refuse a job whose fields would change the reference's meaning: an agent
/// is one `[a-z0-9-]` segment, and a rev is a full commit id.
fn validate(job: &BuildAgent) -> Result<(), String> {
let agent_ok = !job.agent.is_empty()
&& job
.agent
check_agent_and_rev(&job.agent, &job.rev)?;
if job.pr == 0 {
return Err("PR 0 does not exist".to_owned());
}
Ok(())
}
/// [`validate`] for a meta build: the same agent and commit rules.
fn validate_meta(job: &BuildFromMeta) -> Result<(), String> {
check_agent_and_rev(&job.agent, &job.meta_rev)
}
fn check_agent_and_rev(agent: &str, rev: &str) -> Result<(), String> {
let agent_ok = !agent.is_empty()
&& agent
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !agent_ok {
return Err(format!("{:?} is not an agent name", job.agent));
return Err(format!("{agent:?} is not an agent name"));
}
let rev_ok = job.rev.len() == 40
&& job
.rev
let rev_ok = rev.len() == 40
&& rev
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if !rev_ok {
return Err(format!("{:?} is not a full commit id", job.rev));
}
if job.pr == 0 {
return Err("PR 0 does not exist".to_owned());
return Err(format!("{rev:?} is not a full commit id"));
}
Ok(())
}
@ -387,6 +427,7 @@ mod tests {
nix: PathBuf::from("nix"),
deadline: BUILD_DEADLINE,
lock_deadline: LOCK_DEADLINE,
system: "x86_64-linux".to_owned(),
}
}
@ -409,7 +450,88 @@ mod tests {
.collect();
assert_eq!(overridden, ["hyperhive", "nixpkgs", "agent"]);
assert!(args.iter().any(|a| a == "/nix/store/c-check#check"));
assert!(args.iter().any(|a| a == "--no-write-lock-file"));
assert!(BUILD_FLAGS.contains(&"--no-write-lock-file"));
}
fn meta_job() -> BuildFromMeta {
BuildFromMeta {
agent: "atlas".to_owned(),
meta_rev: "0123456789abcdef0123456789abcdef01234567".to_owned(),
}
}
#[test]
fn a_meta_build_is_the_agents_toplevel_in_internal_meta_at_the_commit() {
assert_eq!(
builder().meta_attr(&meta_job()),
"git+https://forge.example/internal/meta.git\
?rev=0123456789abcdef0123456789abcdef01234567\
#nixosConfigurations.atlas.config.system.build.toplevel"
);
}
#[test]
fn a_meta_job_that_would_bend_the_reference_is_refused() {
for bad in [
BuildFromMeta {
agent: "a#b".to_owned(),
..meta_job()
},
BuildFromMeta {
agent: String::new(),
..meta_job()
},
BuildFromMeta {
meta_rev: "main".to_owned(),
..meta_job()
},
] {
assert!(validate_meta(&bad).is_err(), "{bad:?}");
}
assert!(validate_meta(&meta_job()).is_ok());
}
#[tokio::test]
async fn a_meta_build_reports_the_path_and_the_hosts_system() {
// `echo` stands in for `nix`: it prints the arguments it was given, which
// are taken for the store path, and exits 0.
let b = Builder {
nix: PathBuf::from("echo"),
..builder()
};
let reply = b.build_from_meta(&meta_job(), "t").await;
assert_eq!(
reply,
FromMetaReply::Built {
out_path: format!("{} {}", BUILD_FLAGS.join(" "), b.meta_attr(&meta_job())),
system: "x86_64-linux".to_owned(),
}
);
}
#[tokio::test]
async fn a_failed_meta_build_is_a_failed_reply_and_a_refused_job_is_failed_without_nix() {
let b = Builder {
nix: PathBuf::from("false"),
..builder()
};
let failed = b.build_from_meta(&meta_job(), "t").await;
assert!(matches!(failed, FromMetaReply::Failed { .. }), "{failed:?}");
// Control: `a_meta_build_reports_the_path_and_the_hosts_system` builds this
// same job, so the refusal below is the validation, not the stub.
let refused = b
.build_from_meta(
&BuildFromMeta {
meta_rev: "main".to_owned(),
..meta_job()
},
"t",
)
.await;
assert!(
matches!(&refused, FromMetaReply::Failed { error_tail } if error_tail.contains("commit id")),
"{refused:?}"
);
}
/// What the helper prints for `op` and a request with `protocol` and