464 lines
17 KiB
Rust
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}");
|
|
}
|
|
}
|
|
}
|