swarm-builder: lock the swarm meta flake on request
A request on $SWARM.builder.lock-meta carries a rendered flake.nix. The builder writes it into a scratch directory, runs `nix flake lock` there with the same forge credential helper its builds use, answers with the flake.lock nix wrote, and removes the directory. It commits nothing; the controller commits the lock beside the flake. Locks are answered in their own loop, like archetype evaluations, so one does not wait behind an hour-long build. The auth callout grants the subject to the builder (subscribe) and the controller (publish) only. Ported from c574a1694 onto the swarm-wire crate. Refs #4857
This commit is contained in:
parent
d331eb394b
commit
de323a72dd
1 changed files with 320 additions and 42 deletions
|
|
@ -1,6 +1,7 @@
|
|||
//! 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.
|
||||
//! commit. 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
|
||||
|
|
@ -16,7 +17,7 @@ use std::path::PathBuf;
|
|||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use swarm_wire::builder::{BuildAgent, Reply};
|
||||
use swarm_wire::builder::{BuildAgent, LockMeta, LockReply, Reply};
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
|
||||
/// The most of `nix`'s stderr a failed build's reply carries, from its end.
|
||||
|
|
@ -51,6 +52,8 @@ pub struct Builder {
|
|||
pub config_org: String,
|
||||
/// How long one build may run.
|
||||
pub deadline: Duration,
|
||||
/// How long one lock may run.
|
||||
pub lock_deadline: Duration,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
|
|
@ -59,9 +62,48 @@ impl Builder {
|
|||
if let Err(error) = validate(job) {
|
||||
return Reply::Failed { error_tail: error };
|
||||
}
|
||||
let mut cmd = self.nix_command(token);
|
||||
cmd.args(self.args(job));
|
||||
run(cmd, self.deadline).await
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let scratch = match Scratch::new() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return LockReply::Failed {
|
||||
error_tail: format!("creating the scratch directory: {e}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::fs::write(scratch.0.join("flake.nix"), &job.flake_nix) {
|
||||
return LockReply::Failed {
|
||||
error_tail: format!("writing flake.nix: {e}"),
|
||||
};
|
||||
}
|
||||
let mut cmd = self.nix_command(token);
|
||||
cmd.args(LOCK_ARGS).current_dir(&scratch.0);
|
||||
match run_raw(cmd, self.lock_deadline).await {
|
||||
Err(error_tail) => LockReply::Failed { error_tail },
|
||||
Ok((status, _, err)) if !status.success() => LockReply::Failed {
|
||||
error_tail: tail(&String::from_utf8_lossy(&err), status),
|
||||
},
|
||||
Ok(_) => match std::fs::read_to_string(scratch.0.join("flake.lock")) {
|
||||
Ok(flake_lock) => LockReply::Locked { flake_lock },
|
||||
Err(e) => LockReply::Failed {
|
||||
error_tail: format!("nix exited 0 but flake.lock is unreadable: {e}"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `nix` with the forge credential reachable by `git` alone, and its
|
||||
/// output captured.
|
||||
fn nix_command(&self, token: &str) -> tokio::process::Command {
|
||||
let mut cmd = tokio::process::Command::new(&self.nix);
|
||||
cmd.args(self.args(job))
|
||||
.env(TOKEN_VAR, token)
|
||||
cmd.env(TOKEN_VAR, token)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_CONFIG_COUNT", "1")
|
||||
.env(HOST_VAR, forge_host(&self.forge_url))
|
||||
|
|
@ -74,7 +116,7 @@ impl Builder {
|
|||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
run(cmd, self.deadline).await
|
||||
cmd
|
||||
}
|
||||
|
||||
/// The `nix build` arguments for `job`.
|
||||
|
|
@ -116,6 +158,33 @@ impl Builder {
|
|||
}
|
||||
}
|
||||
|
||||
/// `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"];
|
||||
|
||||
/// A fresh directory under the temp dir, removed with everything in it on drop.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> std::io::Result<Self> {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"lock-meta-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir(&dir)?;
|
||||
Ok(Self(dir))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The host part of `forge_url` (`https://<host>[:port]`), as `git` names it
|
||||
/// to a credential helper.
|
||||
fn forge_host(forge_url: &str) -> &str {
|
||||
|
|
@ -162,14 +231,33 @@ fn validate(job: &BuildAgent) -> Result<(), String> {
|
|||
|
||||
/// 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 {
|
||||
async fn run(cmd: tokio::process::Command, deadline: Duration) -> Reply {
|
||||
match run_raw(cmd, deadline).await {
|
||||
Err(error_tail) => Reply::Failed { error_tail },
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `cmd` to its end or `deadline`: its status and the kept end of its
|
||||
/// stdout and stderr, or why it did not finish.
|
||||
async fn run_raw(
|
||||
mut cmd: tokio::process::Command,
|
||||
deadline: Duration,
|
||||
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>), String> {
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
return Reply::Failed {
|
||||
error_tail: format!("starting nix: {e}"),
|
||||
};
|
||||
}
|
||||
Err(e) => return Err(format!("starting nix: {e}")),
|
||||
};
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let stderr = child.stderr.take().expect("piped");
|
||||
|
|
@ -183,27 +271,12 @@ async fn run(mut cmd: tokio::process::Command, deadline: Duration) -> Reply {
|
|||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(_) => Err(format!(
|
||||
"nix did not finish within {} minutes",
|
||||
deadline.as_secs() / 60
|
||||
)),
|
||||
Ok(Err(e)) => Err(format!("waiting for nix: {e}")),
|
||||
Ok(Ok(done)) => Ok(done),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,7 +361,7 @@ pub(crate) fn tail(stderr: &str, status: std::process::ExitStatus) -> String {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use swarm_wire::builder::BUILD_DEADLINE;
|
||||
use swarm_wire::builder::{BUILD_DEADLINE, LOCK_DEADLINE};
|
||||
|
||||
fn job() -> BuildAgent {
|
||||
BuildAgent {
|
||||
|
|
@ -307,6 +380,7 @@ mod tests {
|
|||
config_org: "agent-configs".to_owned(),
|
||||
nix: PathBuf::from("nix"),
|
||||
deadline: BUILD_DEADLINE,
|
||||
lock_deadline: LOCK_DEADLINE,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -600,6 +674,72 @@ mod tests {
|
|||
assert!(kept.starts_with(b"line\n"));
|
||||
}
|
||||
|
||||
/// A builder whose `nix` is the shell script `body`, in a directory the
|
||||
/// returned guard removes.
|
||||
fn scripted(body: &str) -> (Builder, Scratch) {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let dir = Scratch::new().expect("a temp dir");
|
||||
let nix = dir.0.join("nix");
|
||||
std::fs::write(&nix, format!("#!/bin/sh\n{body}\n")).expect("the script is written");
|
||||
std::fs::set_permissions(&nix, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("the script is executable");
|
||||
(Builder { nix, ..builder() }, dir)
|
||||
}
|
||||
|
||||
fn lock_job() -> LockMeta {
|
||||
LockMeta {
|
||||
flake_nix: "{ outputs = _: { }; }".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lock_answers_with_the_lock_nix_wrote_and_leaves_no_scratch() {
|
||||
// The lock records where it ran, so the test can look for it after.
|
||||
let (b, _guard) = scripted(r#"test "$1 $2" = "flake lock" && pwd > flake.lock"#);
|
||||
let reply = b.lock(&lock_job(), "t").await;
|
||||
let LockReply::Locked { flake_lock } = reply else {
|
||||
panic!("{reply:?}")
|
||||
};
|
||||
let ran_in = PathBuf::from(flake_lock.trim());
|
||||
assert!(ran_in.starts_with(std::env::temp_dir()), "{ran_in:?}");
|
||||
assert!(
|
||||
!ran_in.exists(),
|
||||
"the scratch dir {ran_in:?} was left behind"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lock_sees_the_flake_it_was_sent() {
|
||||
let (b, _guard) = scripted("cp flake.nix flake.lock");
|
||||
assert_eq!(
|
||||
b.lock(&lock_job(), "t").await,
|
||||
LockReply::Locked {
|
||||
flake_lock: lock_job().flake_nix
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_lock_answers_with_what_nix_said_last() {
|
||||
let (b, _guard) = scripted("echo 'error: cannot fetch' >&2; exit 1");
|
||||
let reply = b.lock(&lock_job(), "t").await;
|
||||
assert!(
|
||||
matches!(&reply, LockReply::Failed { error_tail } if error_tail.ends_with("error: cannot fetch")),
|
||||
"{reply:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lock_that_wrote_no_lock_file_failed() {
|
||||
let (b, _guard) = scripted("exit 0");
|
||||
let reply = b.lock(&lock_job(), "t").await;
|
||||
assert!(
|
||||
matches!(&reply, LockReply::Failed { error_tail } if error_tail.contains("flake.lock")),
|
||||
"{reply:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_build_past_its_deadline_is_stopped() {
|
||||
let reply = sh("sleep 30", Duration::from_millis(200)).await;
|
||||
|
|
|
|||
Loading…
Reference in a new issue