summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorseth <[email protected]>2024-09-08 23:39:48 -0400
committerseth <[email protected]>2024-09-13 17:03:00 -0400
commitcc183fccca73df619c78dd0ca2567ac547c56ad2 (patch)
treea06a87049cd90e877e626b8ff31e27a373df8f39 /src
feat: initial commit
Diffstat (limited to 'src')
-rw-r--r--src/cli.rs26
-rw-r--r--src/command.rs123
-rw-r--r--src/http.rs77
-rw-r--r--src/main.rs34
-rw-r--r--src/nix.rs153
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())
+ }
+}