Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/builder.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

361 lines
13 KiB
Rust

//! The `BuildAgent` node: send a config PR's head to the swarm builder over the
//! queue, and turn its answer into the [`CONTEXT`] commit status on that head.
//!
//! The status is the check's only record besides the node itself: nothing is
//! pushed, and the closure the builder made stays in its host's store. A red
//! status blocks nothing; the merge deploys as before.
//!
//! Builds share one `SwarmResourceKind::Builder` slot, so one runs at a time,
//! and a newer push replaces its PR's build while that build is still
//! pending (`queue_build`).
use std::future::Future;
use forgejo_api::structs::CommitStatusState;
use hive_jobq::scheduler::Outcome;
use swarm_queue_client::builder::{BuildAgent, REPLY_DEADLINE, Reply, SUBJECT};
use crate::forge::{self, CONFIG_ORG};
/// The commit status context the check writes.
pub const CONTEXT: &str = "swarm-builder";
/// Where a build is sent: the queue in production, a fake in the tests.
/// `Err` is a build that never reached a builder or never came back.
pub(crate) trait BuildSink {
fn build(&self, request: Vec<u8>) -> impl Future<Output = Result<Reply, String>> + Send;
}
impl BuildSink for async_nats::Client {
async fn build(&self, request: Vec<u8>) -> Result<Reply, String> {
use async_nats::RequestErrorKind;
let sent = async_nats::Request::new()
.payload(request.into())
.timeout(Some(REPLY_DEADLINE));
match self.send_request(SUBJECT, sent).await {
Ok(msg) => serde_json::from_slice(&msg.payload)
.map_err(|e| format!("the builder's reply did not decode: {e}")),
Err(e) => Err(match e.kind() {
RequestErrorKind::NoResponders => "no swarm builder is listening: \
deploy.builder.enable is set on no host, or its service is not connected \
to the swarm queue"
.to_owned(),
RequestErrorKind::TimedOut => format!(
"the swarm builder sent no answer within {} minutes",
REPLY_DEADLINE.as_secs() / 60
),
_ => format!("sending the build to the swarm builder failed: {e}"),
}),
}
}
}
/// Where a status is written: the forge in production, a fake in the tests.
pub(crate) trait StatusSink {
fn set(
&self,
agent: &str,
rev: &str,
state: CommitStatusState,
description: &str,
) -> impl Future<Output = anyhow::Result<()>> + Send;
}
impl StatusSink for forge::Client {
async fn set(
&self,
agent: &str,
rev: &str,
state: CommitStatusState,
description: &str,
) -> anyhow::Result<()> {
forge::within_forge_deadline(
"build_agent status",
self.create_commit_status(CONFIG_ORG, agent, rev, CONTEXT, state, description),
)
.await
.unwrap_or_else(|| Err(anyhow::anyhow!("no answer from the forge")))
}
}
/// Build `job` and write its status. The node fails when the build does, so
/// the Jobs page shows red where the PR does.
pub(crate) async fn run(
queue: Option<&impl BuildSink>,
statuses: Option<&impl StatusSink>,
job: BuildAgent,
) -> Outcome {
let Some(statuses) = statuses else {
return Outcome::Failed(
"no forge access here (SWARM_CONTROLLER_FORGE_URL unset, or its token not in the \
swarm secret store), so no build status can be written"
.to_owned(),
);
};
let set = |state, description: String| {
let job = &job;
async move {
statuses
.set(&job.agent, &job.rev, state, &description)
.await
.map_err(|e| format!("{e:#}"))
}
};
let Some(queue) = queue else {
let error = "no swarm queue is configured on this host, so no build can be sent";
return settle(set(CommitStatusState::Error, error.to_owned()).await, error);
};
if let Err(e) = set(
CommitStatusState::Pending,
"building on the swarm builder".to_owned(),
)
.await
{
tracing::warn!(agent = %job.agent, rev = %job.rev, error = %e, "build_agent: the pending status was not written");
}
let request = match serde_json::to_vec(&job) {
Ok(request) => request,
Err(e) => return Outcome::Failed(format!("encoding the build: {e}")),
};
match queue.build(request).await {
Ok(Reply::Built { out_path }) => {
tracing::info!(agent = %job.agent, rev = %job.rev, %out_path, "build_agent: built");
match set(CommitStatusState::Success, out_path).await {
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("built, but the status was not written: {e}")),
}
}
Ok(Reply::Failed { error_tail }) => {
let last = last_line(&error_tail);
settle(
set(CommitStatusState::Failure, last.to_owned()).await,
&format!("the build failed: {error_tail}"),
)
}
Err(error) => settle(set(CommitStatusState::Error, error.clone()).await, &error),
}
}
/// A failed node carrying `error`, and the status write's own failure when
/// there was one.
fn settle(written: Result<(), String>, error: &str) -> Outcome {
match written {
Ok(()) => Outcome::Failed(error.to_owned()),
Err(e) => Outcome::Failed(format!("{error} (and the status was not written: {e})")),
}
}
/// The last non-blank line of `tail`, which for `nix` is the error itself.
fn last_line(tail: &str) -> &str {
tail.lines()
.rev()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("the build failed")
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
struct FakeQueue(Result<Reply, String>);
impl BuildSink for FakeQueue {
async fn build(&self, request: Vec<u8>) -> Result<Reply, String> {
let sent: BuildAgent = serde_json::from_slice(&request).expect("a build request");
assert_eq!(sent, job());
self.0.clone()
}
}
#[derive(Default)]
struct FakeStatuses(Mutex<Vec<(CommitStatusState, String)>>);
impl StatusSink for FakeStatuses {
async fn set(
&self,
agent: &str,
rev: &str,
state: CommitStatusState,
description: &str,
) -> anyhow::Result<()> {
assert_eq!((agent, rev), ("atlas", "abc"));
self.0.lock().unwrap().push((state, description.to_owned()));
Ok(())
}
}
fn job() -> BuildAgent {
BuildAgent {
agent: "atlas".to_owned(),
rev: "abc".to_owned(),
pr: 3,
}
}
async fn statuses_after(queue: Option<FakeQueue>) -> (Outcome, Vec<CommitStatusState>) {
let statuses = FakeStatuses::default();
let outcome = run(queue.as_ref(), Some(&statuses), job()).await;
let states = statuses.0.into_inner().unwrap();
(outcome, states.into_iter().map(|(s, _)| s).collect())
}
#[tokio::test]
async fn a_built_config_goes_pending_then_green() {
let (outcome, states) = statuses_after(Some(FakeQueue(Ok(Reply::Built {
out_path: "/nix/store/x".to_owned(),
}))))
.await;
assert!(matches!(outcome, Outcome::Done));
assert_eq!(
states,
[CommitStatusState::Pending, CommitStatusState::Success]
);
}
#[tokio::test]
async fn a_failed_build_goes_red_with_its_last_line_and_fails_the_node() {
let statuses = FakeStatuses::default();
let queue = FakeQueue(Ok(Reply::Failed {
error_tail: "evaluating\nerror: boom\n".to_owned(),
}));
let outcome = run(Some(&queue), Some(&statuses), job()).await;
assert!(matches!(&outcome, Outcome::Failed(e) if e.contains("error: boom")));
let written = statuses.0.into_inner().unwrap();
assert_eq!(
written.last(),
Some(&(CommitStatusState::Failure, "error: boom".to_owned()))
);
}
/// A superseded build that finishes after the newer head's writes only on
/// its own commit, so the newer head keeps its status.
#[tokio::test]
async fn a_late_superseded_build_writes_only_on_its_own_commit() {
struct ByRevQueue;
impl BuildSink for ByRevQueue {
async fn build(&self, request: Vec<u8>) -> Result<Reply, String> {
let sent: BuildAgent = serde_json::from_slice(&request).expect("a build request");
Ok(if sent.rev == "old" {
Reply::Failed {
error_tail: "error: boom".to_owned(),
}
} else {
Reply::Built {
out_path: "/nix/store/new".to_owned(),
}
})
}
}
#[derive(Default)]
struct ByRev(Mutex<Vec<(String, CommitStatusState)>>);
impl StatusSink for ByRev {
async fn set(
&self,
_: &str,
rev: &str,
state: CommitStatusState,
_: &str,
) -> anyhow::Result<()> {
self.0.lock().unwrap().push((rev.to_owned(), state));
Ok(())
}
}
let statuses = ByRev::default();
let at = |rev: &str| BuildAgent {
rev: rev.to_owned(),
..job()
};
run(Some(&ByRevQueue), Some(&statuses), at("new")).await;
run(Some(&ByRevQueue), Some(&statuses), at("old")).await;
let written = statuses.0.into_inner().unwrap();
let last_on = |rev: &str| {
written
.iter()
.rev()
.find(|(r, _)| r == rev)
.map(|(_, s)| *s)
};
assert_eq!(last_on("new"), Some(CommitStatusState::Success));
assert_eq!(last_on("old"), Some(CommitStatusState::Failure));
}
#[tokio::test]
async fn no_builder_is_an_error_status_not_a_failure() {
let (outcome, states) =
statuses_after(Some(FakeQueue(Err("no swarm builder".to_owned())))).await;
assert!(matches!(outcome, Outcome::Failed(_)));
assert_eq!(
states,
[CommitStatusState::Pending, CommitStatusState::Error]
);
}
#[tokio::test]
async fn no_queue_is_an_error_status() {
let (outcome, states) = statuses_after(None).await;
assert!(matches!(&outcome, Outcome::Failed(e) if e.contains("no swarm queue")));
assert_eq!(states, [CommitStatusState::Error]);
}
#[tokio::test]
async fn no_forge_fails_the_node_without_sending_the_build() {
struct Unreachable;
impl BuildSink for Unreachable {
async fn build(&self, _: Vec<u8>) -> Result<Reply, String> {
panic!("a build with nowhere to report must not be sent")
}
}
let outcome = run(Some(&Unreachable), None::<&FakeStatuses>, job()).await;
assert!(matches!(&outcome, Outcome::Failed(e) if e.contains("no forge access")));
}
/// The queue half against a real server: no builder listening names the
/// option, and a listener's reply comes back decoded.
#[tokio::test]
async fn a_build_reaches_a_listening_builder_and_fails_without_one() {
use futures_util::StreamExt as _;
let nats = crate::test_nats::TestNats::start();
let client = nats.client().await;
let unanswered = client.build(b"{}".to_vec()).await;
assert!(
matches!(&unanswered, Err(e) if e.contains("deploy.builder.enable")),
"{unanswered:?}"
);
let builder = nats.client().await;
let mut requests = builder
.queue_subscribe(SUBJECT, swarm_queue_client::builder::QUEUE_GROUP.to_owned())
.await
.expect("subscribe");
// A message the builder sends itself and receives back proves the
// server processed the subscription before it.
let mut probe = builder
.subscribe("builder-test.probe")
.await
.expect("subscribe");
builder
.publish("builder-test.probe", "".into())
.await
.expect("publish");
probe.next().await.expect("the probe comes back");
let built = Reply::Built {
out_path: "/nix/store/x".to_owned(),
};
let answer = built.clone();
tokio::spawn(async move {
let msg = requests.next().await.expect("a request");
let reply = serde_json::to_vec(&answer).expect("encodes");
builder
.publish(msg.reply.expect("a reply subject"), reply.into())
.await
.expect("publish");
});
assert_eq!(client.build(b"{}".to_vec()).await, Ok(built));
}
}