Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-builder/src/nix_build.rs
atlas cafa120d77 swarm-builder: scope the forge token to the forge, one build at a time, opt-in
argus's review on #5177: the git credential helper answered every `get`,
so a config PR's own flake inputs could have git send the builder's forge
token to any host. The helper is now configured under
`credential.<forge-url>.helper` and itself answers only an https request
whose host is the forge's; a test runs it as git does and checks that
other hosts, http and non-get operations get nothing.

Builds are bounded: every `build_agent` node needs the one `builder` jobq
resource, so one runs at a time and the rest wait pending, and a newer
head cancels its PR's pending build (a running one finishes and writes on
its own commit). The builder also holds a one-permit semaphore around each
build.

`builder` and `review-classifier`, the forge logins swarm-controller owns,
join nix/reserved-names.nix, with a test tying the list to both constants.

The builder is now opt-in like the CI runner: `allSwarmServices` no longer
turns it on, since it runs unreviewed config PRs through the host's nix
daemon. mara had not answered atlas's question on the PR.
2026-10-14 03:30:46 +02:00

413 lines
14 KiB
Rust

//! 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.
//!
//! 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
//! from the child's environment: it is in no argument, no URL and no lock, so
//! no error line `nix` prints can carry it into the reply.
//!
//! ⚠️ A config's own flake inputs are fetched by the same `git`, from hosts the
//! config names. The helper is configured for the forge's URL only, and itself
//! answers only an `https` request for the forge's host, so the token never
//! goes anywhere else.
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use swarm_queue_client::builder::{BuildAgent, Reply};
use tokio::io::AsyncReadExt as _;
/// The most of `nix`'s stderr a failed build's reply carries, from its end.
const TAIL_LINES: usize = 30;
const TAIL_BYTES: usize = 4000;
/// The variable the credential helper reads the token from, in the child only.
const TOKEN_VAR: &str = "SWARM_BUILDER_GIT_TOKEN";
/// The variable holding the one host the helper answers for, in the child
/// only.
const HOST_VAR: &str = "SWARM_BUILDER_GIT_HOST";
/// What every build is run with, fixed at start.
pub struct Builder {
/// The `nix` binary.
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 nixpkgs source this host was deployed from.
pub nixpkgs: String,
/// `https://<forge>`, with no trailing slash and no path.
pub forge_url: String,
/// The org the config repos are in.
pub config_org: String,
/// How long one build may run.
pub deadline: Duration,
}
impl Builder {
/// Build `job` with `token` as the forge credential.
pub async fn build(&self, job: &BuildAgent, token: &str) -> Reply {
if let Err(error) = validate(job) {
return Reply::Failed { error_tail: error };
}
let mut cmd = tokio::process::Command::new(&self.nix);
cmd.args(self.args(job))
.env(TOKEN_VAR, token)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_CONFIG_COUNT", "1")
.env(HOST_VAR, forge_host(&self.forge_url))
.env(
"GIT_CONFIG_KEY_0",
format!("credential.{}.helper", self.forge_url),
)
.env("GIT_CONFIG_VALUE_0", credential_helper())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
run(cmd, self.deadline).await
}
/// The `nix build` arguments for `job`.
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([
format!("{}#check", self.check_flake),
"--override-input".to_owned(),
"hyperhive".to_owned(),
self.hyperhive.clone(),
"--override-input".to_owned(),
"nixpkgs".to_owned(),
self.nixpkgs.clone(),
"--override-input".to_owned(),
"agent".to_owned(),
self.agent_url(job),
])
.collect()
}
/// The config commit as a flake reference, fetched through the PR's head
/// ref.
fn agent_url(&self, job: &BuildAgent) -> String {
format!(
"git+{}/{}/{}?ref=refs/pull/{}/head&rev={}",
self.forge_url, self.config_org, job.agent, job.pr, job.rev
)
}
}
/// The host part of `forge_url` (`https://<host>[:port]`), as `git` names it
/// to a credential helper.
fn forge_host(forge_url: &str) -> &str {
let rest = forge_url.strip_prefix("https://").unwrap_or(forge_url);
rest.split('/').next().unwrap_or(rest)
}
/// A `git` credential helper that answers a `get` with the builder's login and
/// the token from [`TOKEN_VAR`] only when the request is `https` to the host in
/// [`HOST_VAR`], and with nothing otherwise.
fn credential_helper() -> String {
format!(
"!f() {{ test \"$1\" = get || return 0; p=; h=; \
while IFS='=' read -r k v; do case \"$k\" in '') break ;; protocol) p=$v ;; host) h=$v ;; esac; done; \
test -n \"${HOST_VAR}\" && test \"$p\" = https && test \"$h\" = \"${HOST_VAR}\" \
&& printf 'username=builder\\npassword=%s\\n' \"${TOKEN_VAR}\"; return 0; }}; f"
)
}
/// 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
.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));
}
let rev_ok = job.rev.len() == 40
&& job
.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());
}
Ok(())
}
/// Run `cmd` to its end or `deadline`, and read the store path it printed or
/// the tail of what it said.
async fn run(mut cmd: tokio::process::Command, deadline: Duration) -> Reply {
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) => {
return Reply::Failed {
error_tail: format!("starting nix: {e}"),
};
}
};
let mut stdout = child.stdout.take().expect("piped");
let mut stderr = child.stderr.take().expect("piped");
let work = async {
let (mut out, mut err) = (Vec::new(), Vec::new());
let (o, e, status) = tokio::join!(
stdout.read_to_end(&mut out),
stderr.read_to_end(&mut err),
child.wait()
);
o.and(e).map(|_| ())?;
status.map(|s| (s, out, err))
};
match tokio::time::timeout(deadline, work).await {
Err(_) => Reply::Failed {
error_tail: format!(
"the build did not finish within {} minutes",
deadline.as_secs() / 60
),
},
Ok(Err(e)) => Reply::Failed {
error_tail: format!("waiting for nix: {e}"),
},
Ok(Ok((status, out, err))) => {
let out_path = String::from_utf8_lossy(&out)
.lines()
.rfind(|l| !l.trim().is_empty())
.map(|l| l.trim().to_owned());
match out_path {
Some(out_path) if status.success() => Reply::Built { out_path },
_ => Reply::Failed {
error_tail: tail(&String::from_utf8_lossy(&err), status),
},
}
}
}
}
/// The last [`TAIL_LINES`] lines of `stderr`, at most [`TAIL_BYTES`] bytes,
/// or the exit status when it said nothing.
fn tail(stderr: &str, status: std::process::ExitStatus) -> String {
let lines: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.is_empty() {
return format!("nix exited with {status} and printed nothing");
}
let joined = lines[lines.len().saturating_sub(TAIL_LINES)..].join("\n");
let mut start = joined.len().saturating_sub(TAIL_BYTES);
while !joined.is_char_boundary(start) {
start += 1;
}
joined[start..].to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use swarm_queue_client::builder::BUILD_DEADLINE;
fn job() -> BuildAgent {
BuildAgent {
agent: "atlas".to_owned(),
rev: "0123456789abcdef0123456789abcdef01234567".to_owned(),
pr: 7,
}
}
fn builder() -> Builder {
Builder {
check_flake: "/nix/store/c-check".to_owned(),
hyperhive: "/nix/store/h-source".to_owned(),
nixpkgs: "/nix/store/n-source".to_owned(),
forge_url: "https://forge.example".to_owned(),
config_org: "agent-configs".to_owned(),
nix: PathBuf::from("nix"),
deadline: BUILD_DEADLINE,
}
}
#[test]
fn the_agent_is_fetched_at_its_commit_through_the_prs_ref() {
assert_eq!(
builder().agent_url(&job()),
"git+https://forge.example/agent-configs/atlas?ref=refs/pull/7/head\
&rev=0123456789abcdef0123456789abcdef01234567"
);
}
#[test]
fn every_input_is_overridden_and_no_lock_is_written() {
let args = builder().args(&job());
let overridden: Vec<&str> = args
.windows(2)
.filter(|w| w[0] == "--override-input")
.map(|w| w[1].as_str())
.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"));
}
/// What the helper prints for `op` and a request with `protocol` and
/// `host`, run the way `git` runs a `!` helper.
fn helper_answer(op: &str, protocol: &str, host: &str) -> String {
use std::io::Write as _;
let helper = credential_helper();
let script = format!("{} {op}", helper.trim_start_matches('!'));
let mut child = std::process::Command::new("sh")
.args(["-c", &script])
.env(TOKEN_VAR, "s3cret")
.env(HOST_VAR, "forge.example:3000")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("sh runs");
write!(
child.stdin.take().expect("piped"),
"protocol={protocol}\nhost={host}\npath=agent-configs/atlas\n\n"
)
.expect("the helper reads its request");
let out = child.wait_with_output().expect("the helper exits");
assert!(out.status.success());
String::from_utf8(out.stdout).expect("utf-8")
}
#[test]
fn the_helper_gives_the_token_to_the_forge_alone() {
assert_eq!(
helper_answer("get", "https", "forge.example:3000"),
"username=builder\npassword=s3cret\n"
);
for (op, protocol, host) in [
("get", "https", "github.com"),
("get", "https", "forge.example"),
("get", "https", "forge.example:3000.evil.example"),
("get", "http", "forge.example:3000"),
("store", "https", "forge.example:3000"),
] {
assert_eq!(
helper_answer(op, protocol, host),
"",
"{op} {protocol}://{host}"
);
}
}
#[test]
fn the_helper_is_configured_for_the_forge_url_only() {
assert_eq!(
forge_host("https://forge.example:3000"),
"forge.example:3000"
);
assert_eq!(forge_host("https://forge.example/sub"), "forge.example");
}
#[test]
fn the_token_is_in_no_argument() {
let args = builder().args(&job()).join(" ");
assert!(!args.contains("password"));
assert!(credential_helper().contains(&format!("${TOKEN_VAR}")));
}
#[test]
fn a_job_that_would_bend_the_reference_is_refused() {
for bad in [
BuildAgent {
agent: "a?rev=x".to_owned(),
..job()
},
BuildAgent {
agent: String::new(),
..job()
},
BuildAgent {
rev: "main".to_owned(),
..job()
},
BuildAgent { pr: 0, ..job() },
] {
assert!(validate(&bad).is_err(), "{bad:?}");
}
assert!(validate(&job()).is_ok());
}
#[test]
fn the_tail_keeps_the_end_of_a_long_log() {
use std::os::unix::process::ExitStatusExt as _;
let log = (0..100)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let t = tail(&log, std::process::ExitStatus::from_raw(256));
assert!(t.ends_with("line 99"));
assert!(!t.contains("line 69\n"));
assert!(t.starts_with("line 70"));
let quiet = tail("", std::process::ExitStatus::from_raw(256));
assert!(quiet.contains("printed nothing"));
}
fn sh(script: &str, deadline: Duration) -> impl Future<Output = Reply> {
let mut cmd = tokio::process::Command::new("sh");
cmd.args(["-c", script])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
run(cmd, deadline)
}
#[tokio::test]
async fn a_success_replies_with_the_last_path_printed() {
let reply = sh(
"echo warming >&2; echo /nix/store/abc-nixos-system",
Duration::from_secs(30),
)
.await;
assert_eq!(
reply,
Reply::Built {
out_path: "/nix/store/abc-nixos-system".to_owned()
}
);
}
#[tokio::test]
async fn a_failure_replies_with_what_nix_said_last() {
let reply = sh(
"echo first >&2; echo 'error: attribute missing' >&2; exit 1",
Duration::from_secs(30),
)
.await;
let Reply::Failed { error_tail } = reply else {
panic!("{reply:?}")
};
assert!(error_tail.ends_with("error: attribute missing"));
}
#[tokio::test]
async fn a_build_past_its_deadline_is_stopped() {
let reply = sh("sleep 30", Duration::from_millis(200)).await;
assert!(
matches!(&reply, Reply::Failed { error_tail } if error_tail.contains("did not finish")),
"{reply:?}"
);
}
}