//! 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; /// 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) -> std::io::Result; } /// 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 ` 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 { ["--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 { 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 { 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) -> std::io::Result { 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>, fragment_after_refresh: Option<&'static str>, refresh_fails: bool, nix: Mutex>, calls: Mutex>, } 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 { self.calls.lock().unwrap().clone() } } impl Effects for Fake { async fn fragment(&self) -> Option { 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) -> std::io::Result { 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 `, 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}"); } } }