Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-builder/src/push.rs

500 lines
18 KiB
Rust

//! The push of a built closure to the binary cache, with `attic push`.
//!
//! The cache is an attic server: the swarm's own or an external instance. Its push token is read
//! from the secret store per push and written to a file in a
//! directory only the builder's unit can enter, beside a config that names it with
//! `token-file`. `attic` finds the config 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_domain = !domain.is_empty()
&& domain
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':'));
if !plain_domain {
bail!("the cache domain {domain:?} is not a host name with an optional port");
}
if !cache_name_ok(&cache) {
bail!("the cache name {cache:?} is not one attic accepts");
}
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 a file beside it 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
}
}
/// Whether `name` is a cache name attic accepts, which is
/// `^[A-Za-z0-9][A-Za-z0-9-_+]{0,49}$`. `services.hyperhive.swarm.attic.cache`
/// states the same rule in nix.
fn cache_name_ok(name: &str) -> bool {
let mut bytes = name.bytes();
bytes.next().is_some_and(|b| b.is_ascii_alphanumeric())
&& name.len() <= 50
&& bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'+'))
}
/// 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`, which names `token` beside it, both
/// 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 _};
if token.is_empty() || token.bytes().any(|b| b < b' ') {
return Err(std::io::Error::other(
"the push token is empty or holds a control character",
));
}
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");
let token_file = dir.join("token");
let token_path = token_file.to_string_lossy();
// A quote or backslash in the path would end the TOML string early.
if token_path.contains(['"', '\\']) {
return Err(std::io::Error::other(
"the runtime directory's path holds a quote or a backslash",
));
}
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&dir)?;
let private = |name: &str| {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(dir.join(name))
};
private("token")?.write_all(token.as_bytes())?;
write!(
private("config.toml")?,
"default-server = \"{SERVER}\"\n\n[servers.{SERVER}]\n\
endpoint = \"https://{domain}\"\ntoken-file = \"{token_path}\"\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_file_the_config_names_and_both_are_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");
let token_file = home.0.join("attic/token");
assert!(
text.contains(&format!("token-file = \"{}\"", token_file.display())),
"{text}"
);
assert!(!text.contains(TOKEN), "{text}");
assert_eq!(std::fs::read_to_string(&token_file).expect("reads"), TOKEN);
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(&token_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 an_empty_token_or_one_with_a_control_character_is_refused() {
let parent = std::env::temp_dir();
for bad in ["", "a\nb", "a\0b"] {
assert!(ConfigHome::write(&parent, "h", bad).is_err(), "{bad:?}");
}
}
#[test]
fn a_path_that_would_end_the_config_string_is_refused() {
let parent = std::env::temp_dir().join("a\"b");
assert!(ConfigHome::write(&parent, "h", TOKEN).is_err());
assert!(!parent.exists());
}
#[test]
fn the_cache_name_rule_is_attics() {
let fifty = "a".repeat(50);
for ok in ["grid", "a", "A-b_c+d", "0", fifty.as_str()] {
assert!(cache_name_ok(ok), "{ok:?}");
}
let fifty_one = "a".repeat(51);
for bad in [
"",
"grid.v2",
"-x",
"_x",
"+x",
"a:b",
"a b",
"a/b",
fifty_one.as_str(),
] {
assert!(!cache_name_ok(bad), "{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.v2"), state()).is_err());
assert!(Remote::from_settings(some("cache"), some("-grid"), state()).is_err());
assert!(Remote::from_settings(some("cache"), some("g_r+i-d"), state()).is_ok());
assert!(Remote::from_settings(some("cache"), some("grid"), None).is_err());
}
}