Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/cache_pull.rs

464 lines
17 KiB
Rust

//! Pull the swarm's closure into the store through the host's nix daemon.
//!
//! hive-c0re is not root and does not need to be: `nix-store --realise` of a
//! store path is a request to the daemon, which substitutes each path of the
//! closure from the swarm cache or from its other substituters (cache.nixos.org),
//! whichever has it. The swarm cache leaves out the paths cache.nixos.org
//! serves, so a client that fetched from the cache alone could not complete a
//! closure. The root unit `hive-priv-cache-facts` writes a nix.conf fragment
//! ([`CONF_FRAGMENT`]) that gives the daemon the cache's address, the swarm's
//! key and the netrc, which only root reads. The daemon stores a path only if a
//! key in its `trusted-public-keys` signed it, and hive-priv then applies the
//! toplevel only if the swarm's key signed it.
//!
//! [`pull`] first asks hive-priv to run the facts unit when the fragment is not
//! on the host yet, which is how the swarm's copy of the facts reaches the host
//! without waiting for the unit's timer.
use std::time::Duration;
use anyhow::Result;
use hive_priv_sock::valid_closure_path;
/// The nix.conf fragment `hive-priv-cache-facts` writes (root-owned, readable).
pub const CONF_FRAGMENT: &str = "/run/hyperhive-nix/cache.conf";
/// The fragment's lines the daemon needs to substitute from the swarm cache and
/// to trust what it gets.
const REQUIRED_SETTINGS: [&str; 3] = [
"extra-substituters",
"extra-trusted-public-keys",
"netrc-file",
];
/// Wall-clock bound on one pull. A stalled transfer is bounded by nix's own
/// `stalled-download-timeout`; this covers the rest.
const PULL_TIMEOUT: Duration = Duration::from_mins(30);
/// Why a closure was not pulled.
#[derive(Debug, PartialEq, Eq)]
pub enum PullError {
/// This host has no cache facts: no fragment, or one that lacks a setting
/// the daemon needs. Waiting for the facts unit fixes it.
FactsMissing(String),
/// The daemon refused the path: no signature by a key it trusts.
SignatureRefused(String),
/// No substituter has the path.
NotFound(String),
/// Anything else nix said, or could not do.
Failed(String),
/// Nix was still running at [`PULL_TIMEOUT`].
TimedOut,
}
impl std::fmt::Display for PullError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FactsMissing(why) => write!(
f,
"this hive has no cache facts yet ({why}); a later deploy retries"
),
Self::SignatureRefused(last) => write!(
f,
"the nix daemon refused the closure, which carries no signature by a key this host trusts: {last}"
),
Self::NotFound(last) => write!(
f,
"no substituter of the nix daemon has the closure: {last}"
),
Self::Failed(last) => f.write_str(last),
Self::TimedOut => write!(f, "nix-store timed out after {}s", PULL_TIMEOUT.as_secs()),
}
}
}
impl std::error::Error for PullError {}
/// What a `nix-store` run came to.
pub enum Run {
Exited { success: bool, stderr: String },
TimedOut,
}
/// What [`pull`] does on the host, so a test can stand in for it.
pub trait Effects: Sync {
/// The fragment's text; `None` when it cannot be read.
async fn fragment(&self) -> Option<String>;
/// Run the facts unit through hive-priv and wait for it.
async fn refresh(&self) -> Result<()>;
/// Run `nix-store` with `args`, bounded by [`PULL_TIMEOUT`].
async fn nix_store(&self, args: Vec<String>) -> std::io::Result<Run>;
}
/// The name of the first setting in [`REQUIRED_SETTINGS`] that `fragment` gives
/// no value, or `None` when it gives all of them.
#[must_use]
pub fn missing_setting(fragment: &str) -> Option<&'static str> {
REQUIRED_SETTINGS.into_iter().find(|wanted| {
!fragment.lines().any(|line| {
line.split_once('=')
.is_some_and(|(name, value)| name.trim() == *wanted && !value.trim().is_empty())
})
})
}
/// The `nix-store` arguments that fetch `store_path` and nothing else.
///
/// - `--realise <path>` asks the daemon to substitute the path and every path
/// it references that is not yet valid, from the substituters the daemon is
/// configured with.
/// - `--max-jobs 0` forbids a local build, so a path no substituter has fails.
/// - No `substituters`, `trusted-public-keys`, `require-sigs` or `netrc-file`:
/// the daemon ignores them from an unprivileged client and applies its own.
#[must_use]
pub fn pull_args(store_path: &str) -> Vec<String> {
["--realise", store_path, "--max-jobs", "0"]
.map(str::to_owned)
.into()
}
/// Name nix's refusal from its stderr. A refusal for want of a signature is
/// told apart from a path no substituter has and from a cache that is down.
fn classify(stderr: &str) -> PullError {
let last = stderr
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("nix-store failed with no message")
.trim()
.to_owned();
let lower = stderr.to_lowercase();
if lower.contains("signature") || lower.contains("not signed by any of the keys") {
PullError::SignatureRefused(last)
} else if lower.contains("don't know how to build")
|| lower.contains("no substituter that can build")
{
PullError::NotFound(last)
} else {
PullError::Failed(last)
}
}
/// Pull `store_path` into the store.
///
/// # Errors
/// [`PullError`]: no cache facts on this host (after one refresh), nix
/// refusing the signature, no substituter having the path, nix failing
/// otherwise, or a timeout.
pub async fn pull(store_path: &str) -> Result<(), PullError> {
pull_with(&Real, store_path).await
}
async fn pull_with(fx: &impl Effects, store_path: &str) -> Result<(), PullError> {
if !valid_closure_path(store_path) {
return Err(PullError::Failed(format!(
"{store_path:?} is not a store path"
)));
}
ensure_facts(fx).await?;
match fx
.nix_store(pull_args(store_path))
.await
.map_err(|e| PullError::Failed(format!("running nix-store: {e}")))?
{
Run::Exited { success: true, .. } => Ok(()),
Run::Exited { stderr, .. } => Err(classify(&stderr)),
Run::TimedOut => Err(PullError::TimedOut),
}
}
/// What the fragment lacks, or `None` when the daemon has what it needs.
async fn facts_lacking(fx: &impl Effects) -> Option<String> {
match fx.fragment().await {
None => Some("no fragment".to_owned()),
Some(text) => missing_setting(&text).map(|name| format!("the fragment has no {name}")),
}
}
/// The facts are on the host, after one refresh when they are not.
async fn ensure_facts(fx: &impl Effects) -> Result<(), PullError> {
if facts_lacking(fx).await.is_none() {
return Ok(());
}
let refreshed = fx.refresh().await;
match facts_lacking(fx).await {
None => Ok(()),
Some(lacking) => Err(PullError::FactsMissing(match refreshed {
Ok(()) => format!("the facts unit ran and {lacking}"),
Err(e) => format!("refreshing them failed: {e:#}"),
})),
}
}
/// The host: the fragment on disk, hive-priv for the refresh, `nix-store` from
/// the system profile.
struct Real;
impl Effects for Real {
async fn fragment(&self) -> Option<String> {
tokio::fs::read_to_string(CONF_FRAGMENT).await.ok()
}
async fn refresh(&self) -> Result<()> {
crate::priv_client::refresh_cache_facts().await
}
async fn nix_store(&self, args: Vec<String>) -> std::io::Result<Run> {
let mut cmd = tokio::process::Command::new("nix-store");
// Explicit, so the store is the daemon's whatever the environment.
cmd.args(args)
.env("NIX_REMOTE", "daemon")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.kill_on_drop(true);
match tokio::time::timeout(PULL_TIMEOUT, cmd.output()).await {
Ok(out) => {
let out = out?;
Ok(Run::Exited {
success: out.status.success(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
Err(_) => Ok(Run::TimedOut),
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
const PATH: &str = "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-nixos-system-atlas";
const FRAGMENT: &str = "extra-substituters = https://cache.example/swarm\nextra-trusted-public-keys = swarm:AAAA\nnetrc-file = /run/hyperhive-priv/attic/netrc";
/// A host that answers from a script: the fragment now (and after a
/// refresh), what `nix-store` comes to, and a log of calls.
struct Fake {
fragment: Mutex<Option<&'static str>>,
fragment_after_refresh: Option<&'static str>,
refresh_fails: bool,
nix: Mutex<Option<Run>>,
calls: Mutex<Vec<String>>,
}
impl Fake {
fn new(fragment: Option<&'static str>, nix: Run) -> Self {
Self {
fragment: Mutex::new(fragment),
fragment_after_refresh: Some(FRAGMENT),
refresh_fails: false,
nix: Mutex::new(Some(nix)),
calls: Mutex::new(Vec::new()),
}
}
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
}
impl Effects for Fake {
async fn fragment(&self) -> Option<String> {
self.calls.lock().unwrap().push("fragment".to_owned());
self.fragment.lock().unwrap().map(str::to_owned)
}
async fn refresh(&self) -> Result<()> {
self.calls.lock().unwrap().push("refresh".to_owned());
if self.refresh_fails {
anyhow::bail!("hive-priv unreachable");
}
*self.fragment.lock().unwrap() = self.fragment_after_refresh;
Ok(())
}
async fn nix_store(&self, args: Vec<String>) -> std::io::Result<Run> {
self.calls
.lock()
.unwrap()
.push(format!("nix-store {}", args.join(" ")));
Ok(self.nix.lock().unwrap().take().expect("one run"))
}
}
fn exited(success: bool, stderr: &str) -> Run {
Run::Exited {
success,
stderr: stderr.to_owned(),
}
}
/// The daemon substitutes: `--realise <path>`, no source named, no client
/// setting, nothing that widens trust, and nothing can build.
#[test]
fn the_command_realises_the_path_through_the_daemon_without_naming_a_source() {
assert_eq!(
pull_args(PATH),
["--realise", PATH, "--max-jobs", "0"].map(str::to_owned)
);
}
#[test]
fn the_fragment_is_complete_when_every_setting_has_a_value() {
assert_eq!(missing_setting(FRAGMENT), None);
for (bad, want) in [
("", "extra-substituters"),
(
"extra-substituters =\nextra-trusted-public-keys = k\nnetrc-file = n",
"extra-substituters",
),
(
"extra-substituters = u\nextra-trusted-public-keys\nnetrc-file = n",
"extra-trusted-public-keys",
),
(
"extra-substituters = u\nextra-trusted-public-keys = k",
"netrc-file",
),
(
"extra-trusted-substituters = u\nextra-trusted-public-keys = k\nnetrc-file = n",
"extra-substituters",
),
(
"extra-substituters = u\ntrusted-public-keys = k\nnetrc-file = n",
"extra-trusted-public-keys",
),
] {
assert_eq!(missing_setting(bad), Some(want), "{bad:?}");
}
}
#[tokio::test]
async fn with_the_facts_in_place_it_pulls_without_a_refresh() {
let fx = Fake::new(Some(FRAGMENT), exited(true, ""));
pull_with(&fx, PATH).await.expect("pulled");
assert_eq!(
fx.calls(),
[
"fragment".to_owned(),
format!("nix-store {}", pull_args(PATH).join(" "))
]
);
}
/// Missing facts are refreshed once, and the pull then goes ahead.
#[tokio::test]
async fn missing_facts_are_refreshed_before_the_pull() {
let fx = Fake::new(None, exited(true, ""));
pull_with(&fx, PATH).await.expect("pulled");
assert_eq!(
fx.calls(),
[
"fragment".to_owned(),
"refresh".to_owned(),
"fragment".to_owned(),
format!("nix-store {}", pull_args(PATH).join(" "))
]
);
}
/// Facts still absent after the refresh, whether the unit ran or hive-priv
/// could not start it, stop the pull before nix is asked. A fragment that
/// lacks a setting counts as absent.
#[tokio::test]
async fn facts_still_missing_after_the_refresh_are_reported_and_nix_is_not_run() {
for (case, fragment, refresh_fails, want) in [
("no fragment", None, false, "no fragment"),
(
"no substituter",
Some("extra-trusted-public-keys = k\nnetrc-file = n"),
false,
"has no extra-substituters",
),
("refresh failed", None, true, "hive-priv unreachable"),
] {
let mut fx = Fake::new(None, exited(true, ""));
fx.refresh_fails = refresh_fails;
fx.fragment_after_refresh = fragment;
let err = pull_with(&fx, PATH).await.expect_err(case);
let PullError::FactsMissing(why) = &err else {
panic!("{case}: {err:?}");
};
assert!(why.contains(want), "{case}: {why}");
assert!(
format!("{err}").contains("a later deploy retries"),
"{case}"
);
assert!(
!fx.calls().iter().any(|c| c.starts_with("nix-store")),
"{case}"
);
}
}
#[test]
fn nixs_refusals_are_told_apart() {
for (stderr, want) in [
(
"error: cannot add path '/nix/store/x' because it lacks a signature by a trusted key",
"signature",
),
(
"don't know how to build these paths:\n /nix/store/x\nerror: path '/nix/store/x' is required, but there is no substituter that can build it",
"not found",
),
(
"error: path '/nix/store/x' is required, but there is no substituter that can build it",
"not found",
),
(
"error: unable to download 'https://c/nix-cache-info': HTTP error 401",
"other",
),
(
"error: unable to download 'https://c/x.narinfo': Couldn't connect to server",
"other",
),
] {
let kind = match classify(stderr) {
PullError::SignatureRefused(_) => "signature",
PullError::NotFound(_) => "not found",
PullError::Failed(_) => "other",
PullError::FactsMissing(_) | PullError::TimedOut => "unexpected",
};
assert_eq!(kind, want, "{stderr}");
}
// The last line is what the operator reads.
let PullError::Failed(last) = classify("warning: a\n\nerror: Couldn't connect\n\n") else {
panic!("not a plain failure");
};
assert_eq!(last, "error: Couldn't connect");
}
#[tokio::test]
async fn a_failed_run_is_mapped_and_a_timeout_is_a_timeout() {
let fx = Fake::new(
Some(FRAGMENT),
exited(
false,
"error: cannot add path 'x' because it lacks a signature by a trusted key",
),
);
assert!(matches!(
pull_with(&fx, PATH).await,
Err(PullError::SignatureRefused(_))
));
let fx = Fake::new(Some(FRAGMENT), Run::TimedOut);
assert_eq!(pull_with(&fx, PATH).await, Err(PullError::TimedOut));
}
/// Nothing but a store path is ever handed to nix.
#[tokio::test]
async fn only_a_store_path_is_pulled() {
for bad in ["", "main", "/etc/passwd", "--help", "/nix/store/x"] {
let fx = Fake::new(Some(FRAGMENT), exited(true, ""));
assert!(
matches!(pull_with(&fx, bad).await, Err(PullError::Failed(_))),
"{bad}"
);
assert!(fx.calls().is_empty(), "{bad}");
}
}
}