swarm-controller: record builder runs with nix's tail and link the status to them
This commit is contained in:
parent
70a0435814
commit
d4714072a9
11 changed files with 833 additions and 48 deletions
|
|
@ -1,21 +1,26 @@
|
|||
//! 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.
|
||||
//! queue, turn its answer into the [`CONTEXT`] commit status on that head, and
|
||||
//! record the build, `nix`'s tail with it, in [`runs`], whose
|
||||
//! `GET /api/builder/runs/{id}` the status links to.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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`).
|
||||
|
||||
pub(crate) mod runs;
|
||||
|
||||
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::agent_renewal::unix_now;
|
||||
use crate::forge::{self, CONFIG_ORG};
|
||||
use runs::{BuildOutcome, BuildRecord};
|
||||
|
||||
/// The commit status context the check writes.
|
||||
pub const CONTEXT: &str = "swarm-builder";
|
||||
|
|
@ -59,6 +64,7 @@ pub(crate) trait StatusSink {
|
|||
rev: &str,
|
||||
state: CommitStatusState,
|
||||
description: &str,
|
||||
target_url: Option<&str>,
|
||||
) -> impl Future<Output = anyhow::Result<()>> + Send;
|
||||
}
|
||||
|
||||
|
|
@ -69,21 +75,36 @@ impl StatusSink for forge::Client {
|
|||
rev: &str,
|
||||
state: CommitStatusState,
|
||||
description: &str,
|
||||
target_url: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
forge::within_forge_deadline(
|
||||
"build_agent status",
|
||||
self.create_commit_status(CONFIG_ORG, agent, rev, CONTEXT, state, description),
|
||||
self.create_commit_status(
|
||||
CONFIG_ORG,
|
||||
agent,
|
||||
rev,
|
||||
forge::CommitStatus {
|
||||
context: CONTEXT,
|
||||
state,
|
||||
description,
|
||||
target_url,
|
||||
},
|
||||
),
|
||||
)
|
||||
.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.
|
||||
/// Build `job`, record it in `builds`, and write its status, linking to the
|
||||
/// recorded build under `link_base` (the swarm vhost's public URL) when there is
|
||||
/// one. 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>,
|
||||
builds: &runs::Builds,
|
||||
link_base: Option<&str>,
|
||||
job: BuildAgent,
|
||||
) -> Outcome {
|
||||
let Some(statuses) = statuses else {
|
||||
|
|
@ -93,22 +114,30 @@ pub(crate) async fn run(
|
|||
.to_owned(),
|
||||
);
|
||||
};
|
||||
let set = |state, description: String| {
|
||||
let started_unix = unix_now();
|
||||
let set = |state, description: String, link: Option<String>| {
|
||||
let job = &job;
|
||||
async move {
|
||||
statuses
|
||||
.set(&job.agent, &job.rev, state, &description)
|
||||
.set(&job.agent, &job.rev, state, &description, link.as_deref())
|
||||
.await
|
||||
.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
};
|
||||
let finish =
|
||||
|outcome, text: String| record(builds, link_base, &job, started_unix, outcome, text);
|
||||
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);
|
||||
let link = finish(BuildOutcome::Error, error.to_owned()).await;
|
||||
return settle(
|
||||
set(CommitStatusState::Error, error.to_owned(), link).await,
|
||||
error,
|
||||
);
|
||||
};
|
||||
if let Err(e) = set(
|
||||
CommitStatusState::Pending,
|
||||
"building on the swarm builder".to_owned(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -121,22 +150,74 @@ pub(crate) async fn run(
|
|||
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 {
|
||||
let link = finish(BuildOutcome::Built, out_path.clone()).await;
|
||||
match set(CommitStatusState::Success, out_path, link).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);
|
||||
let last = last_line(&error_tail).to_owned();
|
||||
let link = finish(BuildOutcome::Failed, error_tail.clone()).await;
|
||||
settle(
|
||||
set(CommitStatusState::Failure, last.to_owned()).await,
|
||||
set(CommitStatusState::Failure, last, link).await,
|
||||
&format!("the build failed: {error_tail}"),
|
||||
)
|
||||
}
|
||||
Err(error) => settle(set(CommitStatusState::Error, error.clone()).await, &error),
|
||||
Err(error) => {
|
||||
let link = finish(BuildOutcome::Error, error.clone()).await;
|
||||
settle(
|
||||
set(CommitStatusState::Error, error.clone(), link).await,
|
||||
&error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the build of `job` that ended as `outcome`, `text` being its store
|
||||
/// path, `nix`'s tail or the reason. The link to it, when it was
|
||||
/// stored and `link_base` is known.
|
||||
async fn record(
|
||||
builds: &runs::Builds,
|
||||
link_base: Option<&str>,
|
||||
job: &BuildAgent,
|
||||
started_unix: i64,
|
||||
outcome: BuildOutcome,
|
||||
text: String,
|
||||
) -> Option<String> {
|
||||
let mut build = BuildRecord {
|
||||
id: format!(
|
||||
"{}-{}-{started_unix}",
|
||||
job.agent,
|
||||
job.rev.get(..12).unwrap_or(&job.rev)
|
||||
),
|
||||
repo: format!("{CONFIG_ORG}/{}", job.agent),
|
||||
pr: job.pr,
|
||||
head_sha: job.rev.clone(),
|
||||
started_unix,
|
||||
finished_unix: unix_now(),
|
||||
outcome,
|
||||
out_path: None,
|
||||
error_tail: None,
|
||||
detail: None,
|
||||
};
|
||||
*match outcome {
|
||||
BuildOutcome::Built => &mut build.out_path,
|
||||
BuildOutcome::Failed => &mut build.error_tail,
|
||||
BuildOutcome::Error => &mut build.detail,
|
||||
} = Some(text);
|
||||
if !builds.record(&build).await {
|
||||
return None;
|
||||
}
|
||||
link_base.map(|base| {
|
||||
format!(
|
||||
"{}/api/builder/runs/{}",
|
||||
base.trim_end_matches('/'),
|
||||
build.id
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// A failed node carrying `error`, and the status write's own failure when
|
||||
/// there was one.
|
||||
fn settle(written: Result<(), String>, error: &str) -> Outcome {
|
||||
|
|
@ -157,10 +238,13 @@ fn last_line(tail: &str) -> &str {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::runs::tests::FakeDoc;
|
||||
use super::*;
|
||||
|
||||
const BASE: &str = "https://swarm.example";
|
||||
|
||||
struct FakeQueue(Result<Reply, String>);
|
||||
|
||||
impl BuildSink for FakeQueue {
|
||||
|
|
@ -172,7 +256,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStatuses(Mutex<Vec<(CommitStatusState, String)>>);
|
||||
struct FakeStatuses(Mutex<Vec<(CommitStatusState, String, Option<String>)>>);
|
||||
|
||||
impl StatusSink for FakeStatuses {
|
||||
async fn set(
|
||||
|
|
@ -181,13 +265,22 @@ mod tests {
|
|||
rev: &str,
|
||||
state: CommitStatusState,
|
||||
description: &str,
|
||||
target_url: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
assert_eq!((agent, rev), ("atlas", "abc"));
|
||||
self.0.lock().unwrap().push((state, description.to_owned()));
|
||||
self.0.lock().unwrap().push((
|
||||
state,
|
||||
description.to_owned(),
|
||||
target_url.map(str::to_owned),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn store() -> runs::Builds {
|
||||
runs::Builds::Fake(Arc::new(FakeDoc::default()))
|
||||
}
|
||||
|
||||
fn job() -> BuildAgent {
|
||||
BuildAgent {
|
||||
agent: "atlas".to_owned(),
|
||||
|
|
@ -198,9 +291,9 @@ mod tests {
|
|||
|
||||
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 outcome = run(queue.as_ref(), Some(&statuses), &store(), Some(BASE), job()).await;
|
||||
let states = statuses.0.into_inner().unwrap();
|
||||
(outcome, states.into_iter().map(|(s, _)| s).collect())
|
||||
(outcome, states.into_iter().map(|(s, _, _)| s).collect())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -216,19 +309,114 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_build_goes_red_with_its_last_line_and_fails_the_node() {
|
||||
/// The build, the doc it was recorded in, and the statuses written.
|
||||
async fn recorded(
|
||||
reply: Result<Reply, String>,
|
||||
doc: FakeDoc,
|
||||
link_base: Option<&str>,
|
||||
) -> (
|
||||
Outcome,
|
||||
Vec<runs::BuildRecord>,
|
||||
Vec<(CommitStatusState, String, Option<String>)>,
|
||||
) {
|
||||
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;
|
||||
let doc = Arc::new(doc);
|
||||
let outcome = run(
|
||||
Some(&FakeQueue(reply)),
|
||||
Some(&statuses),
|
||||
&runs::Builds::Fake(Arc::clone(&doc)),
|
||||
link_base,
|
||||
job(),
|
||||
)
|
||||
.await;
|
||||
(outcome, doc.builds(), statuses.0.into_inner().unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_build_goes_red_with_its_last_line_and_links_to_its_recorded_tail() {
|
||||
let tail = "evaluating\nerror: boom\n";
|
||||
let (outcome, builds, written) = recorded(
|
||||
Ok(Reply::Failed {
|
||||
error_tail: tail.to_owned(),
|
||||
}),
|
||||
FakeDoc::default(),
|
||||
Some("https://swarm.example/"),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(&outcome, Outcome::Failed(e) if e.contains("error: boom")));
|
||||
let written = statuses.0.into_inner().unwrap();
|
||||
let [build] = builds.as_slice() else {
|
||||
panic!("one build recorded: {builds:?}")
|
||||
};
|
||||
assert_eq!(build.repo, "agent-configs/atlas");
|
||||
assert_eq!((build.pr, build.head_sha.as_str()), (3, "abc"));
|
||||
assert_eq!(build.outcome, BuildOutcome::Failed);
|
||||
assert_eq!(build.error_tail.as_deref(), Some(tail));
|
||||
assert_eq!((&build.out_path, &build.detail), (&None, &None));
|
||||
assert!(build.id.starts_with("atlas-abc-"), "{}", build.id);
|
||||
assert!(build.finished_unix >= build.started_unix);
|
||||
let link = format!("https://swarm.example/api/builder/runs/{}", build.id);
|
||||
assert_eq!(
|
||||
written,
|
||||
[
|
||||
(
|
||||
CommitStatusState::Pending,
|
||||
"building on the swarm builder".to_owned(),
|
||||
None
|
||||
),
|
||||
(
|
||||
CommitStatusState::Failure,
|
||||
"error: boom".to_owned(),
|
||||
Some(link)
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_built_config_and_a_missing_builder_are_recorded_and_linked_too() {
|
||||
let (_, builds, written) = recorded(
|
||||
Ok(Reply::Built {
|
||||
out_path: "/nix/store/x".to_owned(),
|
||||
}),
|
||||
FakeDoc::default(),
|
||||
Some(BASE),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(builds[0].outcome, BuildOutcome::Built);
|
||||
assert_eq!(builds[0].out_path.as_deref(), Some("/nix/store/x"));
|
||||
assert!(written[1].2.is_some(), "{written:?}");
|
||||
|
||||
let (_, builds, written) = recorded(
|
||||
Err("no swarm builder".to_owned()),
|
||||
FakeDoc::default(),
|
||||
Some(BASE),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(builds[0].outcome, BuildOutcome::Error);
|
||||
assert_eq!(builds[0].detail.as_deref(), Some("no swarm builder"));
|
||||
assert_eq!(written[1].0, CommitStatusState::Error);
|
||||
assert!(written[1].2.is_some(), "{written:?}");
|
||||
}
|
||||
|
||||
/// No link to a build that was not stored, nor without the swarm's URL;
|
||||
/// the status is written either way.
|
||||
#[tokio::test]
|
||||
async fn a_status_links_only_to_a_stored_build_under_a_known_url() {
|
||||
let failed = || {
|
||||
Ok(Reply::Failed {
|
||||
error_tail: "error: boom".to_owned(),
|
||||
})
|
||||
};
|
||||
let (_, builds, written) = recorded(failed(), FakeDoc::failing(), Some(BASE)).await;
|
||||
assert!(builds.is_empty());
|
||||
assert_eq!(
|
||||
written.last(),
|
||||
Some(&(CommitStatusState::Failure, "error: boom".to_owned()))
|
||||
Some(&(CommitStatusState::Failure, "error: boom".to_owned(), None))
|
||||
);
|
||||
|
||||
let (_, builds, written) = recorded(failed(), FakeDoc::default(), None).await;
|
||||
assert_eq!(builds.len(), 1, "recorded all the same");
|
||||
assert_eq!(written.last().map(|w| &w.2), Some(&None));
|
||||
}
|
||||
|
||||
/// A superseded build that finishes after the newer head's writes only on
|
||||
|
|
@ -259,6 +447,7 @@ mod tests {
|
|||
rev: &str,
|
||||
state: CommitStatusState,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
self.0.lock().unwrap().push((rev.to_owned(), state));
|
||||
Ok(())
|
||||
|
|
@ -270,8 +459,22 @@ mod tests {
|
|||
rev: rev.to_owned(),
|
||||
..job()
|
||||
};
|
||||
run(Some(&ByRevQueue), Some(&statuses), at("new")).await;
|
||||
run(Some(&ByRevQueue), Some(&statuses), at("old")).await;
|
||||
run(
|
||||
Some(&ByRevQueue),
|
||||
Some(&statuses),
|
||||
&store(),
|
||||
None,
|
||||
at("new"),
|
||||
)
|
||||
.await;
|
||||
run(
|
||||
Some(&ByRevQueue),
|
||||
Some(&statuses),
|
||||
&store(),
|
||||
None,
|
||||
at("old"),
|
||||
)
|
||||
.await;
|
||||
let written = statuses.0.into_inner().unwrap();
|
||||
let last_on = |rev: &str| {
|
||||
written
|
||||
|
|
@ -310,7 +513,14 @@ mod tests {
|
|||
panic!("a build with nowhere to report must not be sent")
|
||||
}
|
||||
}
|
||||
let outcome = run(Some(&Unreachable), None::<&FakeStatuses>, job()).await;
|
||||
let outcome = run(
|
||||
Some(&Unreachable),
|
||||
None::<&FakeStatuses>,
|
||||
&store(),
|
||||
None,
|
||||
job(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(&outcome, Outcome::Failed(e) if e.contains("no forge access")));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue