diff options
| author | seth <[email protected]> | 2024-09-08 23:39:48 -0400 |
|---|---|---|
| committer | seth <[email protected]> | 2024-09-13 17:03:00 -0400 |
| commit | cc183fccca73df619c78dd0ca2567ac547c56ad2 (patch) | |
| tree | a06a87049cd90e877e626b8ff31e27a373df8f39 /src | |
feat: initial commit
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.rs | 26 | ||||
| -rw-r--r-- | src/command.rs | 123 | ||||
| -rw-r--r-- | src/http.rs | 77 | ||||
| -rw-r--r-- | src/main.rs | 34 | ||||
| -rw-r--r-- | src/nix.rs | 153 |
5 files changed, 413 insertions, 0 deletions
diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..073162c --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,26 @@ +use clap::Parser; + +#[derive(Clone, Debug, Parser)] +#[command(version, about, long_about = None)] +pub struct Cli { + /// A list of Nix installables to look for. If not given, all paths in nixpkgs are checked + #[arg(required_unless_present("configuration"))] + pub installables: Option<Vec<String>>, + + /// Flake reference pointing to a NixOS or nix-darwin configuration + #[allow(clippy::doc_markdown)] // Why does "NixOS" trigger this??? + #[arg(short, long, conflicts_with("installables"))] + pub configuration: Option<String>, + + /// URL of the substituter to check + #[arg(short, long, default_value = "https://cache.nixos.org")] + pub binary_cache: String, + + /// Flake reference of nixpkgs (or other package repository) + #[arg(short, long, default_value = "nixpkgs")] + pub flake: String, + + /// Show a list of store paths not found in the substituter + #[arg(short, long)] + pub show_missing: bool, +} diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..ef3461e --- /dev/null +++ b/src/command.rs @@ -0,0 +1,123 @@ +use crate::{ + http::{self, Ext}, + nix, +}; + +use anyhow::Result; +use futures::{stream, StreamExt, TryStreamExt}; +use indicatif::{ProgressBar, ProgressStyle}; +use tracing::instrument; + +pub trait Run { + async fn run(&self) -> Result<()>; +} + +impl Run for crate::Cli { + #[instrument(skip(self))] + async fn run(&self) -> Result<()> { + let store_paths = if let Some(installables) = self.installables.clone() { + installables_paths(installables).await? + } else if let Some(configuration) = &self.configuration { + configuration_paths(configuration)? + } else { + nix::all_flake_installables(&self.flake)? + }; + + check_store_paths(&self.binary_cache, &store_paths, self.show_missing).await?; + + Ok(()) + } +} + +#[instrument(skip(installables))] +async fn installables_paths(installables: Vec<String>) -> Result<Vec<String>> { + println!( + "š Attempting to evaluating {} installable(s)", + installables.len() + ); + + // Find our outputs concurrently + let progress_bar = ProgressBar::new(installables.len() as u64).with_style(progress_style()?); + let out_paths: Vec<String> = stream::iter(&installables) + .map(|installable| { + let progress_bar = &progress_bar; + async move { + progress_bar.inc(1); + let out_path = nix::out_path(installable)?; + + anyhow::Ok(out_path) + } + }) + .buffer_unordered(num_cpus::get()) // try not to explode computers + .try_collect() + .await?; + + println!("ā
Evaluated {} installable(s)!", out_paths.len()); + Ok(out_paths) +} + +#[instrument(skip(configuration_ref))] +fn configuration_paths(configuration_ref: &str) -> Result<Vec<String>> { + println!("ā Indexing requisites of configuration closure"); + let closure_paths = nix::configuration_closure_paths(configuration_ref)?; + Ok(closure_paths) +} + +#[allow(clippy::cast_precision_loss)] +#[instrument(skip(store_paths))] +async fn check_store_paths( + binary_cache: &str, + store_paths: &Vec<String>, + show_missing: bool, +) -> Result<()> { + let num_store_paths = store_paths.len(); + println!("š”ļø Checking for {num_store_paths} store path(s) in {binary_cache}",); + + let http = <http::Client as http::Ext>::default(); + let progress_bar = ProgressBar::new(num_store_paths as u64).with_style(progress_style()?); + let uncached_paths: Vec<&str> = stream::iter(store_paths) + // Check the cache for all of our paths + .map(|store_path| { + let http = &http; + let progress_bar = &progress_bar; + async move { + let has_store_path = http.has_store_path(binary_cache, store_path).await?; + progress_bar.inc(1); + + anyhow::Ok((has_store_path, store_path.as_str())) + } + }) + .buffer_unordered(100) + // Filter out misses + .try_filter_map(|(has_store_path, store_path)| async move { + Ok((!has_store_path).then_some(store_path)) + }) + .try_collect() + .await?; + + let num_uncached = uncached_paths.len(); + let num_cached = num_store_paths - num_uncached; + + println!( + "āļø {:.2}% of paths available ({} out of {})", + (num_cached as f32 / num_store_paths as f32) * 100.0, + num_cached, + num_store_paths, + ); + + if show_missing { + println!( + "\nāļø Found {num_uncached} uncached paths:\n{}", + uncached_paths.join("\n") + ); + } + + Ok(()) +} + +pub fn progress_style() -> Result<ProgressStyle> { + Ok( + ProgressStyle::with_template("[{elapsed_precise}] {bar:40} {pos:>7}/{len:7} {msg}")? + .progress_chars("##-"), + ) +} diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..1b37d4c --- /dev/null +++ b/src/http.rs @@ -0,0 +1,77 @@ +/// HTTP interfaces +use crate::Error; + +use anyhow::Result; +use reqwest::{Method, StatusCode}; +use tracing::{event, instrument, Level}; + +// https://nix.dev/manual/nix/2.24/store/store-path +const STORE_DIRECTORY: &str = "/nix/store"; +const DIGEST_SIZE: usize = 32; + +pub type Client = reqwest::Client; + +pub trait Ext { + fn default() -> Self; + async fn has_store_path(&self, binary_cache_url: &str, store_path: &str) -> Result<bool>; +} + +impl Ext for Client { + /// Create a client with our user agent + fn default() -> Self { + Client::builder() + .user_agent(concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION") + )) + .build() + .unwrap() + } + + /// Check if a store path is available in the given binary cache + #[instrument(skip(self, binary_cache_url, store_path))] + async fn has_store_path(&self, binary_cache_url: &str, store_path: &str) -> Result<bool> { + let url = format!( + "{binary_cache_url}/{}.narinfo", + hash_from_store_path(store_path) + ); + + let request = self.request(Method::HEAD, url).build()?; + event!( + Level::TRACE, + "Checking for store path {store_path} in binary cache at {}", + request.url() + ); + let response = self.execute(request).await?; + + match response.status() { + StatusCode::OK => { + event!( + Level::TRACE, + "Found store path `{store_path}` in binary cache" + ); + Ok(true) + } + StatusCode::NOT_FOUND => { + event!( + Level::TRACE, + "Did not find store path `{store_path}` in binary cache" + ); + Ok(false) + } + status_code => Err(Error::HTTPFailed(status_code).into()), + } + } +} + +/// Strip the <hash> from /nix/store/<hash>-<name> +fn hash_from_store_path(store_path: &str) -> &str { + // Store paths will always start with the store directory, followed by a path separator. See + // the above link + let start_index = STORE_DIRECTORY.len() + 1; + // The next DIGEST_SIZE characters will then be the digest + let end_index = start_index + DIGEST_SIZE; + + &store_path[start_index..end_index] +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6d3fe4d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,34 @@ +#![allow(clippy::multiple_crate_versions)] +use anyhow::Result; +use clap::Parser; +use reqwest::StatusCode; +use tracing::instrument; + +mod cli; +mod command; +mod http; +mod nix; + +use cli::Cli; +use command::Run; + +#[derive(Clone, Debug, thiserror::Error)] +enum Error { + #[error("Unstable to complete HTTP request: {0}")] + HTTPFailed(StatusCode), + #[error("Nix exited with code {code}: {stderr}")] + Nix { code: i32, stderr: String }, +} + +#[tokio::main] +#[instrument] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let cli = Cli::parse(); + if let Err(why) = cli.run().await { + eprintln!("{why}"); + } + + Ok(()) +} diff --git a/src/nix.rs b/src/nix.rs new file mode 100644 index 0000000..a2268a2 --- /dev/null +++ b/src/nix.rs @@ -0,0 +1,153 @@ +/// Abstractions over Nix's CLI +use crate::Error; + +use std::{collections::HashMap, process::Command}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tracing::{event, instrument, Level}; + +/// JSON output of `nix path-info` +#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct PathInfo { + path: String, +} + +/// JSON output of `nix build` +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct Build { + drv_path: String, + /// Derivation output names and their path + outputs: HashMap<String, String>, +} + +#[instrument(skip(installable))] +pub fn dry_build_output(installable: &str) -> Result<Vec<u8>> { + event!(Level::TRACE, "Running command `nix build --extra-experimental-features 'nix-command flakes' --dry-run --json {installable}`"); + let output = Command::new("nix") + .args([ + "--extra-experimental-features", + "nix-command flakes", + "build", + "--dry-run", + "--json", + installable, + ]) + .output()?; + + if output.status.success() { + Ok(output.stdout) + } else { + let code = output.status.code().unwrap_or(1); + let stderr = String::from_utf8(output.stderr.clone()).unwrap_or_default(); + + Err(Error::Nix { code, stderr }.into()) + } +} + +/// Get the `outPath` (store path) of an installable +#[instrument(skip(installable))] +pub fn out_path(installable: &str) -> Result<String> { + let dry_build_output = dry_build_output(installable)?; + let data: Vec<Build> = serde_json::from_slice(&dry_build_output)?; + + let out_path = data + .first() + .context("Unable to parse `nix build` output!")? + .outputs + .get("out") + .with_context(|| format!("Unable to find output `out` for installable {installable}"))?; + + Ok(out_path.to_string()) +} + +/// Get the `drvPath` (derivation path) of an installable +#[instrument(skip(installable))] +pub fn drv_path(installable: &str) -> Result<String> { + let dry_build_output = dry_build_output(installable)?; + let data: Vec<Build> = serde_json::from_slice(&dry_build_output)?; + + let drv_path = &data + .first() + .context("Unable to parse `nix build` output!")? + .drv_path; + + Ok(drv_path.to_string()) +} + +/// Get all paths in a closure at the given store path +#[instrument(skip(store_path))] +pub fn closure_paths(store_path: &str) -> Result<Vec<String>> { + event!(Level::TRACE, "Running command `nix --extra-experimental-features 'nix-command flakes' path-info --json --recursive {store_path}`"); + let output = Command::new("nix") + .args([ + "--extra-experimental-features", + "nix-command flakes", + "path-info", + "--json", + "--recursive", + store_path, + ]) + .output()?; + + if output.status.success() { + let path_infos: Vec<PathInfo> = serde_json::from_slice(&output.stdout)?; + let paths = path_infos + .into_iter() + .map(|path_info| path_info.path) + .collect(); + Ok(paths) + } else { + let code = output.status.code().unwrap_or(1); + let stderr = String::from_utf8(output.stderr.clone()).unwrap_or_default(); + + Err(Error::Nix { code, stderr }.into()) + } +} + +/// Get all paths in a NixOS or nix-darwin configuration's closure +#[instrument(skip(configuration_ref))] +pub fn configuration_closure_paths(configuration_ref: &str) -> Result<Vec<String>> { + let installable = format!("{configuration_ref}.config.system.build.toplevel"); + let store_path = drv_path(&installable)?; + let paths = closure_paths(&store_path)?; + + Ok(paths) +} + +/// Get all installables available in a given Flake +#[instrument(skip(flake_ref))] +pub fn all_flake_installables(flake_ref: &str) -> Result<Vec<String>> { + event!( + Level::TRACE, + "Running command `nix --extra-experimental-features 'nix-command flakes' search --json {flake_ref} .`" + ); + let output = Command::new("nix") + .args([ + "--extra-experimental-features", + "nix-command flakes", + "search", + "--json", + flake_ref, + ".", + ]) + .output()?; + + if output.status.success() { + let search_results: HashMap<String, Value> = serde_json::from_slice(&output.stdout)?; + let package_names = search_results + .keys() + .map(|name| format!("{flake_ref}#{name}")) + .collect::<Vec<_>>(); + + Ok(package_names) + } else { + let code = output.status.code().unwrap_or(1); + let stderr = String::from_utf8(output.stderr.clone()).unwrap_or_default(); + + Err(Error::Nix { code, stderr }.into()) + } +} |
