diff --git a/.github/workflows/flakehub-cache.yml b/.github/workflows/flakehub-cache.yml index 363fb8e8..c7545b25 100644 --- a/.github/workflows/flakehub-cache.yml +++ b/.github/workflows/flakehub-cache.yml @@ -20,7 +20,7 @@ jobs: - nix-system: "x86_64-linux" runner: "ubuntu-22.04" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/magic-nix-cache-action@main with: diff --git a/.github/workflows/flakehub.yml b/.github/workflows/flakehub.yml index 7976cf20..783c4770 100644 --- a/.github/workflows/flakehub.yml +++ b/.github/workflows/flakehub.yml @@ -18,7 +18,7 @@ jobs: id-token: "write" contents: "read" steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@v4" with: ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}" - uses: "DeterminateSystems/nix-installer-action@main" diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 6d0f0ed2..c7b13089 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -9,7 +9,7 @@ jobs: flake-check: runs-on: UbuntuLatest32Cores128G steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: DeterminateSystems/flake-checker-action@main with: diff --git a/.github/workflows/release-branches.yml b/.github/workflows/release-branches.yml index b8a839fe..28fdec9a 100644 --- a/.github/workflows/release-branches.yml +++ b/.github/workflows/release-branches.yml @@ -21,7 +21,7 @@ jobs: id-token: write # In order to request a JWT for AWS auth steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v2 with: diff --git a/.github/workflows/release-prs.yml b/.github/workflows/release-prs.yml index 03bfbd69..31083664 100644 --- a/.github/workflows/release-prs.yml +++ b/.github/workflows/release-prs.yml @@ -30,7 +30,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Create the artifacts directory run: rm -rf ./artifacts && mkdir ./artifacts diff --git a/.github/workflows/release-tags.yml b/.github/workflows/release-tags.yml index 2e510044..cc326f45 100644 --- a/.github/workflows/release-tags.yml +++ b/.github/workflows/release-tags.yml @@ -19,7 +19,7 @@ jobs: id-token: write # In order to request a JWT for AWS auth steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Create the artifacts directory run: rm -rf ./artifacts && mkdir ./artifacts diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index f03e669d..725a7b6b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -9,7 +9,7 @@ jobs: rust-fmt-and-clippy: runs-on: UbuntuLatest32Cores128G steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: DeterminateSystems/nix-installer-action@v4 - uses: DeterminateSystems/magic-nix-cache-action@main @@ -19,3 +19,6 @@ jobs: - name: Clippy run: nix develop --command cargo clippy --all-targets --all-features -- -Dwarnings + + - name: Test + run: nix develop --command cargo test --all-features diff --git a/Cargo.lock b/Cargo.lock index 271d5594..c75de347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -331,6 +331,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.31" @@ -569,6 +575,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "eyre" version = "0.6.8" @@ -579,9 +595,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + [[package]] name = "fh" -version = "0.1.13" +version = "0.1.14" dependencies = [ "async-trait", "axum", @@ -595,6 +617,7 @@ dependencies = [ "handlebars", "indicatif", "inquire", + "nix", "nix-config-parser", "nixel", "once_cell", @@ -605,6 +628,7 @@ dependencies = [ "serde", "serde_json", "tabled", + "tempfile", "thiserror", "tokio", "tracing", @@ -612,6 +636,7 @@ dependencies = [ "tracing-subscriber", "url", "urlencoding", + "whoami", "xdg", ] @@ -956,9 +981,15 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.153" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "lock_api" @@ -1039,6 +1070,18 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.4.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nix-config-parser" version = "0.2.0" @@ -1171,7 +1214,7 @@ checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.3.5", "smallvec", "windows-targets 0.48.5", ] @@ -1322,6 +1365,15 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "regex" version = "1.9.5" @@ -1442,6 +1494,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags 2.4.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.21.12" @@ -1758,6 +1823,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "thiserror" version = "1.0.55" @@ -2115,6 +2192,12 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.87" @@ -2191,6 +2274,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "whoami" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9" +dependencies = [ + "redox_syscall 0.4.1", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index d658eff3..ce0ff846 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fh" -version = "0.1.13" +version = "0.1.14" authors = ["Determinate Systems "] edition = "2021" license = "Apache 2.0" @@ -22,10 +22,11 @@ color-eyre = { version = "0.6.2", default-features = false, features = [ "issue-url", ] } csv = "1.3.0" -gethostname = "0.4.3" +gethostname = { version = "0.4.3", default-features = false } handlebars = "4.4.0" indicatif = { version = "0.17.6", default-features = false } inquire = "0.6.2" +nix = { version = "0.29.0", default-features = false, features = ["user"] } nix-config-parser = "0.2.0" nixel = "5.2.0" once_cell = "1.18.0" @@ -39,6 +40,7 @@ semver = { version = "1.0.18", default-features = false, features = ["serde"] } serde = { version = "1.0.188", default-features = false, features = ["derive"] } serde_json = "1.0.105" tabled = { version = "0.14.0", features = ["color"] } +tempfile = "3.10.1" thiserror = { version = "1.0.44", default-features = false } tokio = { version = "1.30.0", default-features = false, features = ["full"] } tracing = "0.1.37" @@ -50,6 +52,7 @@ tracing-subscriber = { version = "0.3.17", default-features = false, features = ] } url = { version = "2.4.0", default-features = false, features = ["serde"] } urlencoding = "2.1.3" +whoami = { version = "1.5.1", default-features = false } xdg = "2.5.2" [dev-dependencies] diff --git a/README.md b/README.md index 3b56c5aa..093030dc 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,76 @@ nix build \ /nix/var/nix/profiles/system/bin/switch-to-configuration switch ``` +### Apply configurations to the current system + +The `fh apply` command enables you to apply a configuration for one of the following systems to the current host: + +- [NixOS](#nixos) +- [Home Manager](#home-manager) +- [nix-darwin](#nix-darwin) + +For all three systems, you only need to supply a flake output reference for the configuration and `fh` does the rest. + +#### NixOS + +On a [NixOS] system, you can use `fh apply nixos` to apply a configuration from an output path: + +```shell +fh apply nixos "my-org/system-configs/0.1#nixosConfigurations.staging-box" +``` + +If you don't specify a flake output path, `fh apply nixos` defaults to `nixosConfigurations.$(hostname)`. +These two commands are thus equivalent: + +```shell +fh apply nixos "my-org/system-configs/0.1#nixosConfigurations.$(hostname)" +fh apply nixos "my-org/system-configs/0.1" +``` + +`fh apply nixos` first resolves the supplied output reference to a store path, builds the `switch-to-configuration` script for that path, and then runs `switch-to-configuration switch` by default. +You can also supply a different command from `switch` (`boot`, `test`, or `dry-activate`). +Here's an example: + +```shell +fh apply nixos "my-org/system-configs/0.1" boot +``` + +#### Home Manager + +If you're on a system that uses [Home Manager][hm], you can use `fh apply home-manager` to apply a configuration from an output path: + +```shell +fh apply home-manager "my-org/home-configs/0.1#homeConfigurations.standard-home-config" +``` + +If you don't specify a flake output path, `fh apply home-manager` defaults to `homeConfigurations.$(whoami)`. +These two commands are thus equivalent: + +```shell +fh apply home-manager "my-org/home-configs/0.1#homeConfigurations.$(whoami)" +fh apply home-manager "my-org/home-configs/0.1" +``` + +`fh apply home-manager` first resolves the supplied output reference to a store path, builds the `activate` script for that path, and then runs it. + +#### nix-darwin + +If you're on a macOS system that uses [nix-darwin], you can use `fh apply nix-darwin` to apply a configuration from an output path: + +```shell +fh apply nix-darwin "my-org/macos-configs/0.1#darwinConfigurations.justme-aarch64-darwin" +``` + +If you don't specify a flake output path, `fh apply nix-darwin` defaults to `darwinConfigurations.${devicename}.system`, where `devicename` is the output of `scutil --get LocalHostName`. +These two commands are thus equivalent: + +```shell +fh apply nix-darwin "my-org/macos-configs/0.1#darwinConfigurations.$(scutil --get LocalHostName)" +fh apply nix-darwin "my-org/macos-configs/0.1" +``` + +`fh apply nix-darwin` first resolves the supplied output reference to a store path, builds the `darwin-rebuild` script for that path, and then runs `darwin-rebuild activate`. + ### Searching published flakes You can search publicly listed flakes using the `fh search` command and passing in a search query. @@ -336,9 +406,11 @@ For support, email support@flakehub.com or [join our Discord](https://discord.gg [flakehub-push-params]: https://github.com/determinateSystems/flakehub-push?tab=readme-ov-file#available-parameters [flakes]: https://flakehub.com/flakes [go]: https://golang.org +[hm]: https://github.com/nix-community/home-manager [inputs]: https://zero-to-nix.com/concepts/flakes#inputs [java]: https://java.com [javascript]: https://javascript.info +[nix-darwin]: https://github.com/LnL7/nix-darwin [nix-flakes]: https://zero-to-nix.com/concepts/flakes [nixos]: https://zero-to-nix.com/concepts/nixos [nixpkgs]: https://zero-to-nix.com/concepts/nixpkgs diff --git a/flake.nix b/flake.nix index 8941980d..b1fe27e7 100644 --- a/flake.nix +++ b/flake.nix @@ -69,6 +69,7 @@ ] ++ lib.optionals (stdenv.isDarwin) (with darwin.apple_sdk.frameworks; [ Security + SystemConfiguration ]); postInstall = '' @@ -96,6 +97,7 @@ default = pkgs.mkShell { packages = with pkgs; [ (fenixToolchain system) + bacon cargo-watch rust-analyzer nixpkgs-fmt @@ -107,6 +109,7 @@ ] ++ lib.optionals (stdenv.isDarwin) ([ libiconv ] ++ (with darwin.apple_sdk.frameworks; [ Security + SystemConfiguration ])); env = { diff --git a/src/cli/cmd/apply/home_manager.rs b/src/cli/cmd/apply/home_manager.rs new file mode 100644 index 00000000..55d2dc03 --- /dev/null +++ b/src/cli/cmd/apply/home_manager.rs @@ -0,0 +1,35 @@ +use clap::Parser; + +#[derive(Parser)] +pub(super) struct HomeManager { + /// The FlakeHub output reference for the Home Manager configuration. + /// References must take one of two forms: {org}/{flake}/{version_req}#{attr_path} or {org}/{flake}/{version_req}. + /// If the latter, the attribute path defaults to homeConfigurations.{whoami}. + pub(super) output_ref: String, +} + +impl super::ApplyType for HomeManager { + fn get_ref(&self) -> &str { + &self.output_ref + } + + fn default_ref(&self) -> String { + format!("homeConfigurations.{}", whoami::username()) + } + + fn profile_path(&self) -> Option<&std::path::Path> { + None + } + + fn requires_root(&self) -> bool { + false + } + + fn relative_path(&self) -> &std::path::Path { + std::path::Path::new("activate") + } + + fn action(&self) -> Option { + None + } +} diff --git a/src/cli/cmd/apply/mod.rs b/src/cli/cmd/apply/mod.rs new file mode 100644 index 00000000..ada2951b --- /dev/null +++ b/src/cli/cmd/apply/mod.rs @@ -0,0 +1,277 @@ +mod home_manager; +mod nix_darwin; +mod nixos; + +use std::{ + os::unix::prelude::PermissionsExt, + path::{Path, PathBuf}, + process::{ExitCode, Stdio}, +}; + +use clap::{Parser, Subcommand}; +use color_eyre::eyre::Context; +use tempfile::{tempdir, TempDir}; + +use crate::cli::{cmd::nix_command, error::FhError}; + +use self::{home_manager::HomeManager, nix_darwin::NixDarwin, nixos::NixOs}; + +use super::{CommandExecute, FlakeHubClient}; + +/// Apply the configuration at the specified FlakeHub output reference to the current system +#[derive(Parser)] +pub(crate) struct ApplySubcommand { + #[clap(subcommand)] + system: System, + + #[clap(from_global)] + api_addr: url::Url, + + #[clap(from_global)] + frontend_addr: url::Url, +} + +#[derive(Subcommand)] +enum System { + /// Resolve the store path for a Home Manager configuration and run its activation script + HomeManager(HomeManager), + + /// Resolve the store path for a nix-darwin configuration and run its activation script + NixDarwin(NixDarwin), + + /// Apply the resolved store path on a NixOS system + #[clap(name = "nixos")] + NixOs(NixOs), +} + +pub trait ApplyType { + fn get_ref(&self) -> &str; + + fn default_ref(&self) -> String; + + fn profile_path(&self) -> Option<&Path>; + + fn requires_root(&self) -> bool; + + fn relative_path(&self) -> &Path; + + fn action(&self) -> Option; +} + +#[async_trait::async_trait] +impl CommandExecute for ApplySubcommand { + async fn execute(self) -> color_eyre::Result { + let applyer: Box<&(dyn ApplyType + Send + Sync)> = match &self.system { + System::HomeManager(home_manager) => Box::new(home_manager), + System::NixOs(nixos) => Box::new(nixos), + System::NixDarwin(nix_darwin) => Box::new(nix_darwin), + }; + + let output_ref = { + parse_output_ref( + &self.frontend_addr, + applyer.get_ref(), + &applyer.default_ref(), + )? + }; + + tracing::info!("Resolving {}", output_ref); + + let resolved_path = FlakeHubClient::resolve(self.api_addr.as_ref(), &output_ref).await?; + + tracing::debug!( + "Successfully resolved reference {} to path {}", + &output_ref, + &resolved_path.store_path + ); + + let (profile_path, _tempdir) = apply_path_to_profile( + applyer.profile_path(), + &resolved_path.store_path, + applyer.requires_root(), + ) + .await?; + + let script_path = profile_path.join(applyer.relative_path()); + + run_script( + script_path, + applyer.action(), + &applyer + .relative_path() + .file_name() + .expect("The apply type should absolutely have a file name.") + .to_string_lossy(), + ) + .await?; + + Ok(ExitCode::SUCCESS) + } +} + +// This function enables you to provide simplified paths: +// +// fh apply nixos omnicorp/systems/0.1 +// +// Here, `omnicorp/systems/0.1` resolves to `omnicorp/systems/0.1#nixosConfigurations.$(hostname)`. +// If you need to apply a configuration at a path that doesn't conform to this pattern, you +// can still provide an explicit path. +fn parse_output_ref( + frontend_addr: &url::Url, + output_ref: &str, + default_path: &str, +) -> Result { + let with_default_output_path = match output_ref.split('#').collect::>()[..] { + [_release, _output_path] => output_ref.to_string(), + [_release] => format!("{}#{}", output_ref, default_path), + _ => return Err(FhError::MalformedOutputRef(output_ref.to_string())), + }; + + let output_ref = + super::parse_flake_output_ref(frontend_addr, &with_default_output_path)?.to_string(); + + let parsed = super::parse_release_ref(&output_ref)?; + + parsed.try_into() +} + +async fn run_script( + script_path: PathBuf, + action: Option, + script_name: &str, +) -> Result<(), FhError> { + tracing::debug!( + "Checking for {} script at {}", + script_name, + &script_path.display().to_string(), + ); + + if script_path.exists() && script_path.is_file() { + tracing::debug!( + "Found {} script at {}", + script_name, + &script_path.display().to_string(), + ); + + if let Ok(script_path_metadata) = tokio::fs::metadata(&script_path).await { + // The expected application script exists + let permissions = script_path_metadata.permissions(); + if permissions.mode() & 0o111 != 0 { + // The expected application script is executable + if let Some(action) = &action { + tracing::info!("{} {}", &script_path.display().to_string(), action); + } else { + tracing::info!("{}", &script_path.display().to_string()); + } + + let mut cmd = tokio::process::Command::new(&script_path); + if let Some(action) = action { + cmd.arg(action); + } + + let output = cmd + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .wrap_err("failed to spawn Nix command")? + .wait_with_output() + .await + .wrap_err(format!("failed to run {script_name} script"))?; + + println!("{}", String::from_utf8_lossy(&output.stdout)); + } + } + } + + Ok(()) +} + +async fn apply_path_to_profile( + input_profile_path: Option<&Path>, + store_path: &str, + sudo_if_necessary: bool, +) -> Result<(PathBuf, Option), FhError> { + let temp_handle: Option; + + let profile_path: PathBuf = if let Some(profile_path) = input_profile_path { + temp_handle = None; + tracing::info!( + "Applying resolved store path {} to profile at {}", + store_path, + profile_path.display() + ); + + profile_path.into() + } else { + let dir = tempdir()?; + let profile_path = dir.path().join("profile"); + + temp_handle = Some(dir); + profile_path + }; + + nix_command( + &[ + "build", + "--print-build-logs", + // `--max-jobs 0` ensures that `nix build` doesn't really *build* anything + // and acts more as a fetch operation + "--max-jobs", + "0", + "--profile", + profile_path.to_str().ok_or(FhError::InvalidProfile)?, + store_path, + ], + sudo_if_necessary, + ) + .await + .wrap_err("failed to build resolved store path with Nix")?; + + if input_profile_path.is_some() { + tracing::info!( + "Successfully applied resolved path {} to profile at {}", + store_path, + profile_path.display() + ); + } + + Ok((profile_path, temp_handle)) +} + +#[cfg(test)] +mod tests { + use super::parse_output_ref; + + #[test] + fn test_parse_output_ref() { + let cases: Vec<(&str, &str)> = vec![ + ("foo/bar/*", "foo/bar/*#DefaultFooBar"), + ("foo/bar/0.1.*", "foo/bar/0.1.*#DefaultFooBar"), + ( + "omnicorp/web/0.1.2#homeConfigurations.my-config", + "omnicorp/web/0.1.2#homeConfigurations.my-config", + ), + ( + "omnicorp/web/0.1.2#packages.x86_64-linux.default", + "omnicorp/web/0.1.2#packages.x86_64-linux.default", + ), + ( + "https://flakehub.com/f/omnicorp/web/0.1.2#packages.x86_64-linux.default", + "omnicorp/web/0.1.2#packages.x86_64-linux.default", + ), + ]; + + for (input, expect) in cases { + assert_eq!( + &parse_output_ref( + &url::Url::parse("https://flakehub.com/f").unwrap(), + input, + "DefaultFooBar" + ) + .unwrap_or_else(|_| panic!("failing case: {input}")) + .to_string(), + expect, + ); + } + } +} diff --git a/src/cli/cmd/apply/nix_darwin.rs b/src/cli/cmd/apply/nix_darwin.rs new file mode 100644 index 00000000..de8115a4 --- /dev/null +++ b/src/cli/cmd/apply/nix_darwin.rs @@ -0,0 +1,44 @@ +use clap::Parser; + +#[derive(Parser)] +pub(super) struct NixDarwin { + /// The FlakeHub output reference for the nix-darwin configuration. + /// References must take one of two forms: {org}/{flake}/{version_req}#{attr_path} or {org}/{flake}/{version_req}. + /// If the latter, the attribute path defaults to darwinConfigurations.{devicename}.system, where devicename + /// is the output of scutil --get LocalHostName. + pub(super) output_ref: String, + + #[arg( + long, + short, + env = "FH_APPLY_PROFILE", + default_value = "/nix/var/nix/profiles/system" + )] + pub(super) profile: std::path::PathBuf, +} + +impl super::ApplyType for NixDarwin { + fn get_ref(&self) -> &str { + &self.output_ref + } + + fn default_ref(&self) -> String { + format!("darwinConfigurations.{}", whoami::devicename()) + } + + fn profile_path(&self) -> Option<&std::path::Path> { + Some(&self.profile) + } + + fn requires_root(&self) -> bool { + true + } + + fn relative_path(&self) -> &std::path::Path { + std::path::Path::new("sw/bin/darwin-rebuild") + } + + fn action(&self) -> Option { + Some("activate".to_string()) + } +} diff --git a/src/cli/cmd/apply/nixos.rs b/src/cli/cmd/apply/nixos.rs new file mode 100644 index 00000000..155cce87 --- /dev/null +++ b/src/cli/cmd/apply/nixos.rs @@ -0,0 +1,70 @@ +use std::fmt::Display; + +use clap::{Parser, ValueEnum}; + +#[derive(Parser)] +pub(super) struct NixOs { + /// The FlakeHub output reference to apply to the system profile. + /// References must take one of two forms: {org}/{flake}/{version_req}#{attr_path} or {org}/{flake}/{version_req}. + /// If the latter, the attribute path defaults to nixosConfigurations.{hostname}. + pub(super) output_ref: String, + + /// The command to run from the profile's switch-to-configuration script. + /// Takes the form: switch-to-configuration . + #[clap(name = "ACTION", default_value = "switch")] + pub(super) action: NixOsAction, +} + +impl super::ApplyType for NixOs { + fn get_ref(&self) -> &str { + &self.output_ref + } + + fn default_ref(&self) -> String { + format!( + "nixosConfigurations.{}", + gethostname::gethostname().to_string_lossy() + ) + } + + fn profile_path(&self) -> Option<&std::path::Path> { + Some(std::path::Path::new("/nix/var/nix/profiles/system")) + } + + fn requires_root(&self) -> bool { + true + } + + fn relative_path(&self) -> &std::path::Path { + std::path::Path::new("bin/switch-to-configuration") + } + + fn action(&self) -> Option { + Some(self.action.to_string()) + } +} + +// For available commands, see +// https://github.com/NixOS/nixpkgs/blob/12100837a815473e96c9c86fdacf6e88d0e6b113/nixos/modules/system/activation/switch-to-configuration.pl#L85-L88 +#[derive(Clone, Debug, ValueEnum)] +pub enum NixOsAction { + Switch, + Boot, + Test, + DryActivate, +} + +impl Display for NixOsAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Switch => "switch", + Self::Boot => "boot", + Self::Test => "test", + Self::DryActivate => "dry-activate", + } + ) + } +} diff --git a/src/cli/cmd/convert.rs b/src/cli/cmd/convert.rs index 185a8e03..a5283ab0 100644 --- a/src/cli/cmd/convert.rs +++ b/src/cli/cmd/convert.rs @@ -3,10 +3,11 @@ use std::path::PathBuf; use std::process::{ExitCode, Stdio}; use clap::Parser; +use color_eyre::eyre::Context; use once_cell::sync::Lazy; use tracing::{span, Level}; -use super::CommandExecute; +use super::{nix_command, CommandExecute}; // match {nixos,nixpkgs,release}-YY.MM branches static RELEASE_BRANCH_REGEX: Lazy = Lazy::new(|| { @@ -81,12 +82,12 @@ impl CommandExecute for ConvertSubcommand { println!("{new_flake_contents}"); } else { tokio::fs::write(self.flake_path, new_flake_contents).await?; - tokio::process::Command::new("nix") - .args(["--extra-experimental-features", "nix-command flakes"]) - .arg("flake") - .arg("lock") - .status() - .await?; + + tracing::debug!("Running: nix flake lock"); + + nix_command(&["flake", "lock"], false) + .await + .wrap_err("failed to create missing lock file entries")?; } Ok(ExitCode::SUCCESS) diff --git a/src/cli/cmd/init/mod.rs b/src/cli/cmd/init/mod.rs index 8e9ee143..34f7da8c 100644 --- a/src/cli/cmd/init/mod.rs +++ b/src/cli/cmd/init/mod.rs @@ -240,7 +240,7 @@ impl CommandExecute for InitSubcommand { write(self.output, flake_string)?; if project.has_directory(".git") - && command_exists("git") + && command_exists("git")? && Prompt::bool(&format!( "Would you like to add your new Nix {} to Git?", if use_flake_compat { "files" } else { "file" } @@ -263,7 +263,7 @@ impl CommandExecute for InitSubcommand { write(PathBuf::from(".envrc"), String::from("use flake"))?; if Prompt::bool("You'll need to run `direnv allow` to activate direnv in this project. Would you like to do that now?") { - if command_exists("direnv") { + if command_exists("direnv")? { Command::new("direnv").arg("allow").output()?; } else { println!("It looks like direnv isn't installed."); @@ -280,8 +280,12 @@ impl CommandExecute for InitSubcommand { } } -fn command_exists(cmd: &str) -> bool { - Command::new(cmd).output().is_ok() +pub(super) fn command_exists(cmd: &str) -> Result { + if Command::new(cmd).output().is_err() { + return Err(FhError::MissingExecutable(String::from(cmd))); + } + + Ok(true) } async fn select_nixpkgs(api_addr: &str) -> Result { diff --git a/src/cli/cmd/list.rs b/src/cli/cmd/list.rs index ee8d8a11..8cf91534 100644 --- a/src/cli/cmd/list.rs +++ b/src/cli/cmd/list.rs @@ -27,7 +27,7 @@ pub(crate) struct ListSubcommand { cmd: Subcommands, /// Output results as JSON. - #[arg(long, global = true)] + #[arg(long, global = true, env = "FH_JSON_OUTPUT")] json: bool, #[arg(from_global)] diff --git a/src/cli/cmd/mod.rs b/src/cli/cmd/mod.rs index 38daee5b..8863317a 100644 --- a/src/cli/cmd/mod.rs +++ b/src/cli/cmd/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod add; +pub(crate) mod apply; pub(crate) mod completion; pub(crate) mod convert; pub(crate) mod eject; @@ -9,6 +10,8 @@ pub(crate) mod resolve; pub(crate) mod search; pub(crate) mod status; +use std::{fmt::Display, process::Stdio}; + use color_eyre::eyre::WrapErr; use once_cell::sync::Lazy; use reqwest::{ @@ -23,6 +26,7 @@ use tabled::settings::{ use url::Url; use self::{ + init::command_exists, list::{Flake, Org, Release, Version}, resolve::ResolvedPath, search::SearchResult, @@ -61,15 +65,16 @@ pub trait CommandExecute { #[derive(clap::Subcommand)] pub(crate) enum FhSubcommands { Add(add::AddSubcommand), + Apply(apply::ApplySubcommand), Completion(completion::CompletionSubcommand), + Convert(convert::ConvertSubcommand), + Eject(eject::EjectSubcommand), Init(init::InitSubcommand), List(list::ListSubcommand), - Search(search::SearchSubcommand), - Convert(convert::ConvertSubcommand), Login(login::LoginSubcommand), - Status(status::StatusSubcommand), - Eject(eject::EjectSubcommand), Resolve(resolve::ResolveSubcommand), + Search(search::SearchSubcommand), + Status(status::StatusSubcommand), } #[derive(Debug, Deserialize)] @@ -152,13 +157,13 @@ impl FlakeHubClient { Ok(res) } - async fn resolve(api_addr: &str, flake_ref: String) -> Result { + async fn resolve(api_addr: &str, output_ref: &FlakeOutputRef) -> Result { let FlakeOutputRef { ref org, - ref flake, + project: ref flake, ref version_constraint, ref attr_path, - } = flake_ref.try_into()?; + } = output_ref; let url = flakehub_url!( api_addr, @@ -257,11 +262,21 @@ pub(crate) fn print_json(value: T) -> Result<(), FhError> { // https://api.flakehub.com/f/{org}/{flake}/{version_constraint}/output/{attr_path} struct FlakeOutputRef { org: String, - flake: String, + project: String, version_constraint: String, attr_path: String, } +impl Display for FlakeOutputRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}/{}/{}#{}", + self.org, self.project, self.version_constraint, self.attr_path + ) + } +} + impl TryFrom for FlakeOutputRef { type Error = FhError; @@ -299,7 +314,7 @@ impl TryFrom for FlakeOutputRef { Ok(FlakeOutputRef { org: org.to_string(), - flake: flake.to_string(), + project: flake.to_string(), version_constraint: version.to_string(), attr_path: attr_path.to_string(), }) @@ -367,6 +382,93 @@ macro_rules! flakehub_url { }}; } +fn is_root_user() -> bool { + nix::unistd::getuid().is_root() +} + +async fn nix_command(args: &[&str], sudo_if_necessary: bool) -> Result<(), FhError> { + command_exists("nix")?; + + let use_sudo = sudo_if_necessary && !is_root_user(); + + let mut cmd = if use_sudo { + tracing::warn!( + "Current user is {} rather than root; running Nix command using sudo", + whoami::username() + ); + + let mut cmd = tokio::process::Command::new("sudo"); + cmd.arg("nix"); + cmd + } else { + tokio::process::Command::new("nix") + }; + + cmd.args(["--extra-experimental-features", "nix-command flakes"]); + cmd.args(args); + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + let cmd_str = format!("{:?}", cmd.as_std()); + tracing::debug!("Running: {:?}", cmd_str); + + let output = cmd + .spawn() + .wrap_err("failed to spawn Nix command")? + .wait_with_output() + .await + .wrap_err("failed to wait for Nix command output")?; + + if output.status.success() { + Ok(()) + } else { + Err(FhError::FailedNixCommand(cmd_str)) + } +} + +fn parse_flake_output_ref( + frontend_addr: &url::Url, + output_ref: &str, +) -> Result { + // Ensures that users can use both forms: + // 1. https://flakehub/f/{org}/{project}/{version_req}#{output} + // 2. {org}/{project}/{version_req}#{output} + let output_ref = String::from( + output_ref + .strip_prefix(frontend_addr.join("f/")?.as_str()) + .unwrap_or(output_ref), + ); + + output_ref.try_into() +} + +// Ensure that release refs are of the form {org}/{project}/{version_req} +fn parse_release_ref(flake_ref: &str) -> Result { + match flake_ref.to_string().split('/').collect::>()[..] { + [org, project, version_req] => { + validate_segment(org)?; + validate_segment(project)?; + validate_segment(version_req)?; + + Ok(flake_ref.to_string()) + } + _ => Err(FhError::FlakeParse(format!( + "flake ref {flake_ref} invalid; must be of the form {{org}}/{{project}}/{{version_req}}" + ))), + } +} + +// Ensure that orgs, project names, and the like don't contain whitespace +fn validate_segment(s: &str) -> Result<(), FhError> { + if s.chars().any(char::is_whitespace) { + return Err(FhError::FlakeParse(format!( + "path segment {} contains whitespace", + s + ))); + } + + Ok(()) +} + #[cfg(test)] mod tests { #[test] diff --git a/src/cli/cmd/resolve.rs b/src/cli/cmd/resolve.rs index 3aefd6e0..88853238 100644 --- a/src/cli/cmd/resolve.rs +++ b/src/cli/cmd/resolve.rs @@ -3,7 +3,7 @@ use std::process::ExitCode; use clap::Parser; use serde::{Deserialize, Serialize}; -use super::{print_json, CommandExecute, FlakeHubClient}; +use super::{parse_flake_output_ref, print_json, CommandExecute, FlakeHubClient}; /// Resolves a FlakeHub flake reference into a store path. #[derive(Debug, Parser)] @@ -13,11 +13,14 @@ pub(crate) struct ResolveSubcommand { flake_ref: String, /// Output the result as JSON displaying the store path plus the original attribute path. - #[arg(long)] + #[arg(long, env = "FH_OUTPUT_JSON")] json: bool, #[clap(from_global)] api_addr: url::Url, + + #[clap(from_global)] + frontend_addr: url::Url, } #[derive(Deserialize, Serialize)] @@ -25,25 +28,27 @@ pub(crate) struct ResolvedPath { // The original attribute path, i.e. attr_path in {org}/{flake}/{version}#{attr_path} attribute_path: String, // The resolved store path - store_path: String, + pub(crate) store_path: String, } #[async_trait::async_trait] impl CommandExecute for ResolveSubcommand { #[tracing::instrument(skip_all)] async fn execute(self) -> color_eyre::Result { - // Ensures that users can use otherwise-valid flake refs - let flake_ref = self - .flake_ref - .strip_prefix("https://flakehub.com/f/") - .unwrap_or(&self.flake_ref); + let output_ref = parse_flake_output_ref(&self.frontend_addr, &self.flake_ref)?; + + let resolved_path = FlakeHubClient::resolve(self.api_addr.as_ref(), &output_ref).await?; - let value = FlakeHubClient::resolve(self.api_addr.as_ref(), flake_ref.to_string()).await?; + tracing::debug!( + "Successfully resolved reference {} to path {}", + &output_ref, + &resolved_path.store_path + ); if self.json { - print_json(value)?; + print_json(resolved_path)?; } else { - println!("{}", value.store_path); + println!("{}", resolved_path.store_path); } Ok(ExitCode::SUCCESS) diff --git a/src/cli/cmd/search.rs b/src/cli/cmd/search.rs index 2b3126e8..f3312d0a 100644 --- a/src/cli/cmd/search.rs +++ b/src/cli/cmd/search.rs @@ -20,7 +20,7 @@ pub(crate) struct SearchSubcommand { max_results: usize, /// Output results as JSON. - #[clap(long)] + #[clap(long, env = "FH_OUTPUT_JSON")] json: bool, #[clap(from_global)] diff --git a/src/cli/error.rs b/src/cli/error.rs index 78259efd..94bd0c67 100644 --- a/src/cli/error.rs +++ b/src/cli/error.rs @@ -1,5 +1,8 @@ #[derive(Debug, thiserror::Error)] pub(crate) enum FhError { + #[error("Nix command `{0}` failed; check prior Nix output for details")] + FailedNixCommand(String), + #[error("file error: {0}")] Filesystem(#[from] std::io::Error), @@ -15,15 +18,24 @@ pub(crate) enum FhError { #[error("interactive initializer error: {0}")] Interactive(#[from] inquire::InquireError), + #[error("Profile path is not valid UTF-8")] + InvalidProfile, + #[error("json parsing error: {0}")] Json(#[from] serde_json::Error), #[error("label parsing error: {0}")] LabelParse(String), + #[error("malformed output reference: {0}")] + MalformedOutputRef(String), + #[error("malformed flake reference")] MalformedFlakeOutputRef, + #[error("{0} is not installed or not on the PATH")] + MissingExecutable(String), + #[error("missing from flake output reference: {0}")] MissingFromOutputRef(String), @@ -33,6 +45,9 @@ pub(crate) enum FhError { #[error("template error: {0}")] Render(#[from] handlebars::RenderError), + #[error(transparent)] + Report(#[from] color_eyre::Report), + #[error("template error: {0}")] Template(#[from] Box), diff --git a/src/main.rs b/src/main.rs index 04f64274..29261e57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,14 +30,15 @@ async fn main() -> color_eyre::Result { match cli.subcommand { FhSubcommands::Add(add) => add.execute().await, - FhSubcommands::Init(init) => init.execute().await, - FhSubcommands::List(list) => list.execute().await, - FhSubcommands::Search(search) => search.execute().await, + FhSubcommands::Apply(apply) => apply.execute().await, FhSubcommands::Completion(completion) => completion.execute().await, FhSubcommands::Convert(convert) => convert.execute().await, - FhSubcommands::Login(login) => login.execute().await, - FhSubcommands::Status(status) => status.execute().await, FhSubcommands::Eject(eject) => eject.execute().await, + FhSubcommands::Init(init) => init.execute().await, + FhSubcommands::List(list) => list.execute().await, + FhSubcommands::Login(login) => login.execute().await, FhSubcommands::Resolve(resolve) => resolve.execute().await, + FhSubcommands::Search(search) => search.execute().await, + FhSubcommands::Status(status) => status.execute().await, } }