push green meta builds to the attic cache and record cached
This commit is contained in:
parent
c4e8d5b540
commit
0849afec80
1 changed files with 779 additions and 31 deletions
437
swarm-builder/src/push.rs
Normal file
437
swarm-builder/src/push.rs
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
//! The push of a built closure to the binary cache, with `attic push`.
|
||||
//!
|
||||
//! The cache is an attic instance outside the swarm. Its push token is read
|
||||
//! from the secret store per push and written to an `attic` config file in a
|
||||
//! directory only the builder's unit can enter, which `attic` finds through
|
||||
//! `XDG_CONFIG_HOME`. `attic login <name> <url> <token>` is not used: it puts
|
||||
//! the token in the process list. `attic` verifies the server's certificate
|
||||
//! against the system roots.
|
||||
//!
|
||||
//! A push that does not happen or does not finish leaves the build green with
|
||||
//! `cached` false: the closure is still in the host's store, and the next build
|
||||
//! of the agent pushes again.
|
||||
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use swarm_wire::builder::FromMetaReply;
|
||||
|
||||
/// The longest one push may run, when the reply deadline leaves that much.
|
||||
pub const PUSH_DEADLINE: Duration = Duration::from_mins(10);
|
||||
|
||||
/// The server name in the config file, and in the cache argument of the push.
|
||||
const SERVER: &str = "swarm";
|
||||
|
||||
/// Where the builder pushes: one cache on one attic server.
|
||||
pub struct Remote {
|
||||
/// The `attic` binary: a name found in the unit's path in production.
|
||||
pub attic: PathBuf,
|
||||
/// The server's name, without scheme: `host` or `host:port`.
|
||||
pub domain: String,
|
||||
/// The cache's name on that server.
|
||||
pub cache: String,
|
||||
/// A directory only the builder can enter, which the config files go in.
|
||||
pub state_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// The cache the environment names: `None` when neither value is set, so
|
||||
/// the builder pushes nothing.
|
||||
///
|
||||
/// # Errors
|
||||
/// When only one of the two is set, or a value would bend the config file
|
||||
/// or the command.
|
||||
pub fn from_settings(
|
||||
domain: Option<String>,
|
||||
cache: Option<String>,
|
||||
state_dir: Option<String>,
|
||||
) -> Result<Option<Self>> {
|
||||
let (domain, cache) = match (domain, cache) {
|
||||
(None, None) => return Ok(None),
|
||||
(Some(domain), Some(cache)) => (domain, cache),
|
||||
_ => bail!("the cache domain and the cache name must be set together"),
|
||||
};
|
||||
let plain = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b':'))
|
||||
};
|
||||
if !plain(&domain) || !plain(&cache) {
|
||||
bail!("the cache domain {domain:?} and name {cache:?} must be plain names");
|
||||
}
|
||||
let Some(state_dir) = state_dir else {
|
||||
bail!("a cache is set, but the unit has no runtime directory to hold its config");
|
||||
};
|
||||
Ok(Some(Self {
|
||||
attic: PathBuf::from("attic"),
|
||||
domain,
|
||||
cache,
|
||||
state_dir: PathBuf::from(state_dir),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Push `out_path` with `token`. The result is what `attic` printed.
|
||||
///
|
||||
/// # Errors
|
||||
/// Why the push did not finish: the config could not be written, `attic`
|
||||
/// could not start, or it exited with an error.
|
||||
pub async fn push(&self, token: &str, out_path: &str) -> Result<String, String> {
|
||||
if !out_path.starts_with("/nix/store/") || out_path.contains(char::is_whitespace) {
|
||||
return Err(format!("{out_path:?} is not a store path"));
|
||||
}
|
||||
let home = ConfigHome::write(&self.state_dir, &self.domain, token)
|
||||
.map_err(|e| format!("writing the attic config: {e}"))?;
|
||||
let output = self
|
||||
.command(&home.0, out_path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("starting attic: {e}"))?;
|
||||
let said = |bytes: &[u8]| {
|
||||
crate::nix_build::last_lines(&String::from_utf8_lossy(bytes))
|
||||
.unwrap_or_default()
|
||||
.replace(token, "<redacted>")
|
||||
};
|
||||
if output.status.success() {
|
||||
Ok(said(&output.stdout))
|
||||
} else {
|
||||
Err(format!(
|
||||
"attic exited with {}: {}",
|
||||
output.status,
|
||||
said(&output.stderr)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// `attic push` of `out_path`, reading its config from `config_home`. The
|
||||
/// token is in the file there and in no argument or variable.
|
||||
fn command(&self, config_home: &Path, out_path: &str) -> tokio::process::Command {
|
||||
let mut cmd = tokio::process::Command::new(&self.attic);
|
||||
cmd.arg("push")
|
||||
.arg(format!("{SERVER}:{}", self.cache))
|
||||
.arg(out_path)
|
||||
.env("XDG_CONFIG_HOME", config_home)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
/// Numbers the config directories of one process's pushes.
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// The config directory of one push, removed with the token in it on drop.
|
||||
struct ConfigHome(PathBuf);
|
||||
|
||||
impl ConfigHome {
|
||||
/// `<parent>/push-<n>/attic/config.toml`, readable by this user alone.
|
||||
fn write(parent: &Path, domain: &str, token: &str) -> std::io::Result<Self> {
|
||||
use std::io::Write as _;
|
||||
use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _};
|
||||
// A quote or a control character would end the TOML string early.
|
||||
if token.is_empty() || token.bytes().any(|b| b == b'"' || b == b'\\' || b < b' ') {
|
||||
return Err(std::io::Error::other(
|
||||
"the push token is empty or holds a character the config cannot",
|
||||
));
|
||||
}
|
||||
let home = Self(parent.join(format!(
|
||||
"push-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
)));
|
||||
let dir = home.0.join("attic");
|
||||
std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(&dir)?;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(dir.join("config.toml"))?;
|
||||
write!(
|
||||
file,
|
||||
"default-server = \"{SERVER}\"\n\n[servers.{SERVER}]\n\
|
||||
endpoint = \"https://{domain}\"\ntoken = \"{token}\"\n"
|
||||
)?;
|
||||
Ok(home)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConfigHome {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `reply` with `cached` set by pushing its closure, when it is a built one and
|
||||
/// a cache is set. `read_token` is not polled without a cache. The token read
|
||||
/// and the push together get `budget`.
|
||||
///
|
||||
/// Whatever happens to the push, a built reply stays built: a failure is logged
|
||||
/// and leaves `cached` false.
|
||||
pub async fn cache_built(
|
||||
reply: FromMetaReply,
|
||||
cache: Option<&Remote>,
|
||||
read_token: impl Future<Output = Result<String>>,
|
||||
budget: Duration,
|
||||
agent: &str,
|
||||
) -> FromMetaReply {
|
||||
let (
|
||||
Some(cache),
|
||||
FromMetaReply::Built {
|
||||
out_path, system, ..
|
||||
},
|
||||
) = (cache, &reply)
|
||||
else {
|
||||
return reply;
|
||||
};
|
||||
let pushed = tokio::time::timeout(budget, async {
|
||||
let token = read_token
|
||||
.await
|
||||
.map_err(|e| format!("reading the push token: {e:#}"))?;
|
||||
cache.push(&token, out_path).await
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
Err(format!(
|
||||
"the push did not finish within {} s",
|
||||
budget.as_secs()
|
||||
))
|
||||
});
|
||||
match pushed {
|
||||
Ok(_) => {
|
||||
tracing::info!(%agent, %out_path, cache = %cache.cache, "closure pushed to the cache");
|
||||
FromMetaReply::Built {
|
||||
out_path: out_path.clone(),
|
||||
system: system.clone(),
|
||||
cached: true,
|
||||
}
|
||||
}
|
||||
Err(reason) => {
|
||||
tracing::warn!(%agent, %out_path, cache = %cache.cache, %reason, "closure not pushed to the cache");
|
||||
reply
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::*;
|
||||
|
||||
const TOKEN: &str = "eyJ0b2tlbiI6InNlY3JldCJ9.c2lnbmVk";
|
||||
const OUT: &str = "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-nixos-system-atlas";
|
||||
|
||||
fn built() -> FromMetaReply {
|
||||
FromMetaReply::Built {
|
||||
out_path: OUT.to_owned(),
|
||||
system: "x86_64-linux".to_owned(),
|
||||
cached: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn cached(reply: &FromMetaReply) -> bool {
|
||||
match reply {
|
||||
FromMetaReply::Built { cached, .. } => *cached,
|
||||
other => panic!("not built: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache whose `attic` is the program `attic`: `true` and `false` stand
|
||||
/// in for a push that works and one that fails, and `echo` for one that
|
||||
/// says what it was run with.
|
||||
fn cache_with(attic: &str) -> Remote {
|
||||
Remote {
|
||||
attic: PathBuf::from(attic),
|
||||
domain: "cache.example.org".to_owned(),
|
||||
cache: "grid".to_owned(),
|
||||
state_dir: std::env::temp_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
fn token() -> impl Future<Output = Result<String>> {
|
||||
std::future::ready(Ok(TOKEN.to_owned()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_push_that_works_marks_the_build_cached() {
|
||||
let cache = cache_with("true");
|
||||
let reply = cache_built(built(), Some(&cache), token(), PUSH_DEADLINE, "atlas").await;
|
||||
assert!(cached(&reply), "{reply:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_push_that_fails_leaves_the_build_green_and_not_cached() {
|
||||
let cache = cache_with("false");
|
||||
let reply = cache_built(built(), Some(&cache), token(), PUSH_DEADLINE, "atlas").await;
|
||||
assert_eq!(reply, built());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_push_that_cannot_start_leaves_the_build_green_and_not_cached() {
|
||||
let cache = cache_with("/nonexistent/attic");
|
||||
let reply = cache_built(built(), Some(&cache), token(), PUSH_DEADLINE, "atlas").await;
|
||||
assert_eq!(reply, built());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreadable_token_leaves_the_build_green_and_not_cached() {
|
||||
let cache = cache_with("true");
|
||||
let reply = cache_built(
|
||||
built(),
|
||||
Some(&cache),
|
||||
async { anyhow::bail!("nothing at the path") },
|
||||
PUSH_DEADLINE,
|
||||
"atlas",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(reply, built());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_token_read_that_outlasts_the_budget_leaves_the_build_green() {
|
||||
let cache = cache_with("true");
|
||||
let started = tokio::time::Instant::now();
|
||||
let reply = cache_built(
|
||||
built(),
|
||||
Some(&cache),
|
||||
std::future::pending(),
|
||||
Duration::from_secs(5),
|
||||
"atlas",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(reply, built());
|
||||
assert_eq!(started.elapsed(), Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_a_cache_nothing_is_read_or_pushed() {
|
||||
let polled = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let tracked = |polled: &std::sync::Arc<AtomicBool>| {
|
||||
let polled = std::sync::Arc::clone(polled);
|
||||
async move {
|
||||
polled.store(true, Ordering::SeqCst);
|
||||
token().await
|
||||
}
|
||||
};
|
||||
let reply = cache_built(built(), None, tracked(&polled), PUSH_DEADLINE, "atlas").await;
|
||||
assert_eq!(reply, built());
|
||||
assert!(!polled.load(Ordering::SeqCst), "the token was read");
|
||||
|
||||
// Control: with a cache the same future runs.
|
||||
let cache = cache_with("true");
|
||||
cache_built(
|
||||
built(),
|
||||
Some(&cache),
|
||||
tracked(&polled),
|
||||
PUSH_DEADLINE,
|
||||
"atlas",
|
||||
)
|
||||
.await;
|
||||
assert!(polled.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_build_that_is_not_built_is_not_pushed() {
|
||||
let cache = cache_with("true");
|
||||
let failed = FromMetaReply::Failed {
|
||||
error_tail: "e".to_owned(),
|
||||
};
|
||||
let reply = cache_built(
|
||||
failed.clone(),
|
||||
Some(&cache),
|
||||
token(),
|
||||
PUSH_DEADLINE,
|
||||
"atlas",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(reply, failed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_push_command_has_no_token_in_it() {
|
||||
// `echo` prints the arguments it is given.
|
||||
let cache = cache_with("echo");
|
||||
let said = cache.push(TOKEN, OUT).await.expect("echo succeeds");
|
||||
assert_eq!(said, format!("push swarm:grid {OUT}"));
|
||||
assert!(!said.contains(TOKEN));
|
||||
|
||||
let cmd = cache.command(Path::new("/run/home"), OUT);
|
||||
let cmd = cmd.as_std();
|
||||
let args: Vec<_> = cmd.get_args().collect();
|
||||
assert_eq!(args, ["push", "swarm:grid", OUT]);
|
||||
let env: Vec<_> = cmd.get_envs().collect();
|
||||
assert_eq!(
|
||||
env,
|
||||
[(
|
||||
std::ffi::OsStr::new("XDG_CONFIG_HOME"),
|
||||
Some(std::ffi::OsStr::new("/run/home"))
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_is_in_a_private_config_file_that_is_removed_after() {
|
||||
let parent = std::env::temp_dir().join(format!("push-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&parent).expect("parent");
|
||||
let home = ConfigHome::write(&parent, "cache.example.org", TOKEN).expect("written");
|
||||
let file = home.0.join("attic/config.toml");
|
||||
let text = std::fs::read_to_string(&file).expect("reads");
|
||||
assert!(text.contains(&format!("token = \"{TOKEN}\"")), "{text}");
|
||||
assert!(
|
||||
text.contains("endpoint = \"https://cache.example.org\""),
|
||||
"{text}"
|
||||
);
|
||||
let mode = |p: &Path| std::fs::metadata(p).expect("stat").permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&file), 0o600);
|
||||
assert_eq!(mode(&home.0), 0o700);
|
||||
let root = home.0.clone();
|
||||
drop(home);
|
||||
assert!(!root.exists());
|
||||
std::fs::remove_dir_all(&parent).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_that_would_end_the_config_string_is_refused() {
|
||||
let parent = std::env::temp_dir();
|
||||
for bad in ["", "a\"b", "a\\b", "a\nb"] {
|
||||
assert!(ConfigHome::write(&parent, "h", bad).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_path_that_is_not_a_store_path_is_not_pushed() {
|
||||
let cache = cache_with("true");
|
||||
assert!(cache.push(TOKEN, "--help").await.is_err());
|
||||
assert!(cache.push(TOKEN, "/nix/store/x y").await.is_err());
|
||||
assert!(cache.push(TOKEN, OUT).await.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_settings_name_a_cache_only_when_both_are_set() {
|
||||
let state = || Some("/run/swarm-builder".to_owned());
|
||||
let some = |s: &str| Some(s.to_owned());
|
||||
assert!(
|
||||
Remote::from_settings(None, None, None)
|
||||
.expect("ok")
|
||||
.is_none()
|
||||
);
|
||||
let cache = Remote::from_settings(some("cache.example.org"), some("grid"), state())
|
||||
.expect("ok")
|
||||
.expect("set");
|
||||
assert_eq!(cache.domain, "cache.example.org");
|
||||
assert_eq!(cache.cache, "grid");
|
||||
assert!(Remote::from_settings(some("cache.example.org"), None, state()).is_err());
|
||||
assert!(Remote::from_settings(None, some("grid"), state()).is_err());
|
||||
assert!(Remote::from_settings(some("https://cache"), some("grid"), state()).is_err());
|
||||
assert!(Remote::from_settings(some("cache"), some("a\"b"), state()).is_err());
|
||||
assert!(Remote::from_settings(some("cache"), some("grid"), None).is_err());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue