diff --git a/src/darwin.rs b/src/darwin.rs index fb1b4427..b0c4fb97 100644 --- a/src/darwin.rs +++ b/src/darwin.rs @@ -1,15 +1,10 @@ -use std::env; +use color_eyre::eyre::Context; +use tracing::{debug, warn}; -use color_eyre::eyre::{bail, Context}; -use tracing::{debug, info, warn}; - -use crate::commands; use crate::commands::Command; -use crate::installable::Installable; use crate::interface::{DarwinArgs, DarwinRebuildArgs, DarwinReplArgs, DarwinSubcommand}; -use crate::nixos::toplevel_for; use crate::update::update; -use crate::util::get_hostname; +use crate::util::platform; use crate::Result; const SYSTEM_PROFILE: &str = "/nix/var/nix/profiles/system"; @@ -40,85 +35,65 @@ impl DarwinRebuildArgs { fn rebuild(self, variant: DarwinRebuildVariant) -> Result<()> { use DarwinRebuildVariant::{Build, Switch}; - if nix::unistd::Uid::effective().is_root() { - bail!("Don't run nh os as root. I will call sudo internally as needed"); - } + // Ensure we're not running as root + platform::check_not_root(false)?; if self.update_args.update { update(&self.common.installable, self.update_args.update_input)?; } - let hostname = self.hostname.ok_or(()).or_else(|()| get_hostname())?; - - let out_path: Box = match self.common.out_link { - Some(ref p) => Box::new(p.clone()), - None => Box::new({ - let dir = tempfile::Builder::new().prefix("nh-os").tempdir()?; - (dir.as_ref().join("result"), dir) - }), - }; + let hostname = self + .hostname + .ok_or(()) + .or_else(|()| crate::util::get_hostname())?; + // Set up temporary directory for build results + let out_path = platform::create_output_path(self.common.out_link, "nh-os")?; debug!(?out_path); - // Use NH_DARWIN_FLAKE if available, otherwise use the provided installable - let installable = if let Ok(darwin_flake) = env::var("NH_DARWIN_FLAKE") { - debug!("Using NH_DARWIN_FLAKE: {}", darwin_flake); - - let mut elems = darwin_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.common.installable.clone() - }; - - let mut processed_installable = installable; - if let Installable::Flake { - ref mut attribute, .. - } = processed_installable - { - // If user explicitly selects some other attribute, don't push darwinConfigurations - if attribute.is_empty() { - attribute.push(String::from("darwinConfigurations")); - attribute.push(hostname.clone()); - } - } - - let toplevel = toplevel_for(hostname, processed_installable, "toplevel"); - - commands::Build::new(toplevel) - .extra_arg("--out-link") - .extra_arg(out_path.get_path()) - .extra_args(&self.extra_args) - .message("Building Darwin configuration") - .nom(!self.common.no_nom) - .run()?; + // Check for environment variable override for flake path + let installable = + platform::resolve_env_installable("NH_DARWIN_FLAKE", self.common.installable.clone()); + + // Configure the installable for Darwin + let toplevel = platform::extend_installable_for_platform( + installable, + "darwinConfigurations", + &["toplevel"], + Some(hostname), + true, + &self + .extra_args + .iter() + .map(std::convert::Into::into) + .collect::>(), + )?; + + // Build the nix-darwin configuration + platform::build_configuration( + toplevel, + out_path.as_ref(), + &self.extra_args, + None, + "Building Darwin configuration", + self.common.no_nom, + )?; let target_profile = out_path.get_path().to_owned(); - target_profile.try_exists().context("Doesn't exist")?; - Command::new("nvd") - .arg("diff") - .arg(CURRENT_PROFILE) - .arg(&target_profile) - .message("Comparing changes") - .run()?; - - if self.common.ask && !self.common.dry && !matches!(variant, Build) { - info!("Apply the config?"); - let confirmation = dialoguer::Confirm::new().default(false).interact()?; + // Show diff between current and new configuration + platform::compare_configurations( + CURRENT_PROFILE, + &target_profile, + false, + "Comparing changes", + )?; - if !confirmation { - bail!("User rejected the new config"); - } + // Ask for confirmation if needed + if !platform::confirm_action(self.common.ask, self.common.dry)? && !matches!(variant, Build) + { + return Ok(()); } if matches!(variant, Switch) { @@ -159,46 +134,16 @@ impl DarwinRebuildArgs { impl DarwinReplArgs { fn run(self) -> Result<()> { - // Use NH_DARWIN_FLAKE if available, otherwise use the provided installable - let mut target_installable = if let Ok(darwin_flake) = env::var("NH_DARWIN_FLAKE") { - debug!("Using NH_DARWIN_FLAKE: {}", darwin_flake); - - let mut elems = darwin_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.installable - }; - - if matches!(target_installable, Installable::Store { .. }) { - bail!("Nix doesn't support nix store installables."); - } - - let hostname = self.hostname.ok_or(()).or_else(|()| get_hostname())?; - - if let Installable::Flake { - ref mut attribute, .. - } = target_installable - { - if attribute.is_empty() { - attribute.push(String::from("darwinConfigurations")); - attribute.push(hostname); - } - } - - Command::new("nix") - .arg("repl") - .args(target_installable.to_args()) - .run()?; - - Ok(()) + // Check for environment variable override for flake path + let installable = platform::resolve_env_installable("NH_DARWIN_FLAKE", self.installable); + + // Launch the nix REPL with the Darwin configuration + platform::run_repl( + installable, + "darwinConfigurations", + &["toplevel"], + self.hostname, + &[], + ) } } diff --git a/src/generations.rs b/src/generations.rs index 1e00f200..2431f1c9 100644 --- a/src/generations.rs +++ b/src/generations.rs @@ -43,7 +43,7 @@ pub fn from_dir(generation_dir: &Path) -> Option { }) } -pub fn describe(generation_dir: &Path, current_profile: &Path) -> Option { +pub fn describe(generation_dir: &Path, _current_profile: &Path) -> Option { let generation_number = from_dir(generation_dir)?; // Get metadata once and reuse for both date and existence checks diff --git a/src/home.rs b/src/home.rs index 1817ea9b..3babb62a 100644 --- a/src/home.rs +++ b/src/home.rs @@ -1,17 +1,13 @@ use std::env; -use std::ffi::OsString; use std::path::PathBuf; -use color_eyre::eyre::bail; use color_eyre::Result; use tracing::{debug, info, warn}; -use crate::commands; use crate::commands::Command; -use crate::installable::Installable; use crate::interface::{self, HomeRebuildArgs, HomeReplArgs, HomeSubcommand}; use crate::update::update; -use crate::util::get_hostname; +use crate::util::platform; impl interface::HomeArgs { pub fn run(self) -> Result<()> { @@ -43,50 +39,37 @@ impl HomeRebuildArgs { update(&self.common.installable, self.update_args.update_input)?; } - let out_path: Box = match self.common.out_link { - Some(ref p) => Box::new(p.clone()), - None => Box::new({ - let dir = tempfile::Builder::new().prefix("nh-home").tempdir()?; - (dir.as_ref().join("result"), dir) - }), - }; - + let out_path = platform::create_output_path(self.common.out_link, "nh-home")?; debug!(?out_path); - // Use NH_HOME_FLAKE if available, otherwise use the provided installable - let installable = if let Ok(home_flake) = env::var("NH_HOME_FLAKE") { - debug!("Using NH_HOME_FLAKE: {}", home_flake); - - let mut elems = home_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.common.installable.clone() - }; + // Check for environment variable override for flake path + let installable = + platform::resolve_env_installable("NH_HOME_FLAKE", self.common.installable.clone()); - let toplevel = toplevel_for( + // Set up the installable with the correct attribute path + let toplevel = platform::extend_installable_for_platform( installable, - true, - &self.extra_args, + "homeConfigurations", + &["config", "home", "activationPackage"], self.configuration.clone(), + true, + &self + .extra_args + .iter() + .map(std::convert::Into::into) + .collect::>(), )?; - commands::Build::new(toplevel) - .extra_arg("--out-link") - .extra_arg(out_path.get_path()) - .extra_args(&self.extra_args) - .message("Building Home-Manager configuration") - .nom(!self.common.no_nom) - .run()?; + platform::build_configuration( + toplevel, + out_path.as_ref(), + &self.extra_args, + None, + "Building Home-Manager configuration", + self.common.no_nom, + )?; + // Find the previous home-manager generation if it exists let prev_generation: Option = [ PathBuf::from("/nix/var/nix/profiles/per-user") .join(env::var("USER").expect("Couldn't get username")) @@ -99,34 +82,32 @@ impl HomeRebuildArgs { debug!(?prev_generation); + // Location where home-manager stores specialisation info let spec_location = PathBuf::from(std::env::var("HOME")?).join(".local/share/home-manager/specialisation"); - let current_specialisation = std::fs::read_to_string(spec_location.to_str().unwrap()).ok(); - - let target_specialisation = if self.no_specialisation { - None - } else { - current_specialisation.or(self.specialisation) - }; - - debug!("target_specialisation: {target_specialisation:?}"); + // Process any specialisations for home-manager + let target_specialisation = platform::process_specialisation( + self.no_specialisation, + self.specialisation, + spec_location.to_str().unwrap(), + )?; - let target_profile: Box = match &target_specialisation { - None => out_path, - Some(spec) => Box::new(out_path.get_path().join("specialisation").join(spec)), - }; + // Get final path considering specialisations + let target_profile = + platform::get_target_profile(out_path.as_ref(), &target_specialisation); - // just do nothing for None case (fresh installs) + // Skip comparison for fresh installs (no previous generation) if let Some(generation) = prev_generation { - Command::new("nvd") - .arg("diff") - .arg(generation) - .arg(target_profile.get_path()) - .message("Comparing changes") - .run()?; + platform::compare_configurations( + &generation.to_string_lossy(), + &target_profile, + false, + "Comparing changes", + )?; } + // Handle dry run or build-only mode if self.common.dry || matches!(variant, Build) { if self.common.ask { warn!("--ask has no effect as dry run was requested"); @@ -134,246 +115,43 @@ impl HomeRebuildArgs { return Ok(()); } - if self.common.ask { - info!("Apply the config?"); - let confirmation = dialoguer::Confirm::new().default(false).interact()?; - - if !confirmation { - bail!("User rejected the new config"); - } + // Ask for confirmation if needed + if !platform::confirm_action(self.common.ask, self.common.dry)? { + return Ok(()); } + // Configure backup extension if provided if let Some(ext) = &self.backup_extension { info!("Using {} as the backup extension", ext); env::set_var("HOME_MANAGER_BACKUP_EXT", ext); } - Command::new(target_profile.get_path().join("activate")) + // Run the activation script + Command::new(target_profile.join("activate")) .message("Activating configuration") .run()?; // Make sure out_path is not accidentally dropped // https://docs.rs/tempfile/3.12.0/tempfile/index.html#early-drop-pitfall drop(target_profile); + drop(out_path); Ok(()) } } -fn toplevel_for( - installable: Installable, - push_drv: bool, - extra_args: I, - configuration_name: Option, -) -> Result -where - I: IntoIterator, - S: AsRef, -{ - let mut res = installable; - let extra_args: Vec = { - let mut vec = Vec::new(); - for elem in extra_args { - vec.push(elem.as_ref().to_owned()); - } - vec - }; - - let toplevel = ["config", "home", "activationPackage"] - .into_iter() - .map(String::from); - - match res { - Installable::Flake { - ref reference, - ref mut attribute, - } => { - // If user explicitly selects some other attribute in the installable itself - // then don't push homeConfigurations - if !attribute.is_empty() { - debug!( - "Using explicit attribute path from installable: {:?}", - attribute - ); - return Ok(res); - } - - attribute.push(String::from("homeConfigurations")); - - let flake_reference = reference.clone(); - let mut found_config = false; - - // Check if an explicit configuration name was provided via the flag - if let Some(config_name) = configuration_name { - // Verify the provided configuration exists - let func = format!(r#" x: x ? "{config_name}" "#); - let check_res = commands::Command::new("nix") - .arg("eval") - .args(&extra_args) - .arg("--apply") - .arg(func) - .args( - (Installable::Flake { - reference: flake_reference.clone(), - attribute: attribute.clone(), - }) - .to_args(), - ) - .run_capture() - .map_err(|e| { - color_eyre::eyre::eyre!( - "Failed running nix eval to check for explicit configuration '{}': {}", - config_name, - e - ) - })?; - - if check_res.map(|s| s.trim().to_owned()).as_deref() == Some("true") { - debug!("Using explicit configuration from flag: {}", config_name); - attribute.push(config_name); - if push_drv { - attribute.extend(toplevel.clone()); - } - found_config = true; - } else { - // Explicit config provided but not found - let tried_attr_path = { - let mut attr_path = attribute.clone(); - attr_path.push(config_name); - Installable::Flake { - reference: flake_reference, - attribute: attr_path, - } - .to_args() - .join(" ") - }; - bail!("Explicitly specified home-manager configuration not found: {tried_attr_path}"); - } - } - - // If no explicit config was found via flag, try automatic detection - if !found_config { - let username = std::env::var("USER").expect("Couldn't get username"); - let hostname = get_hostname()?; - let mut tried = vec![]; - - for attr_name in [format!("{username}@{hostname}"), username] { - let func = format!(r#" x: x ? "{attr_name}" "#); - let check_res = commands::Command::new("nix") - .arg("eval") - .args(&extra_args) - .arg("--apply") - .arg(func) - .args( - (Installable::Flake { - reference: flake_reference.clone(), - attribute: attribute.clone(), - }) - .to_args(), - ) - .run_capture() - .map_err(|e| { - color_eyre::eyre::eyre!( - "Failed running nix eval to check for automatic configuration '{}': {}", - attr_name, - e - ) - })?; - - let current_try_attr = { - let mut attr_path = attribute.clone(); - attr_path.push(attr_name.clone()); - attr_path - }; - tried.push(current_try_attr.clone()); - - match check_res.map(|s| s.trim().to_owned()).as_deref() { - Some("true") => { - debug!("Using automatically detected configuration: {}", attr_name); - attribute.push(attr_name); - if push_drv { - attribute.extend(toplevel.clone()); - } - found_config = true; - break; - } - _ => { - continue; - } - } - } - - // If still not found after automatic detection, error out - if !found_config { - let tried_str = tried - .into_iter() - .map(|a| { - Installable::Flake { - reference: flake_reference.clone(), - attribute: a, - } - .to_args() - .join(" ") - }) - .collect::>() - .join(", "); - bail!("Couldn't find home-manager configuration automatically, tried: {tried_str}"); - } - } - } - Installable::File { - ref mut attribute, .. - } => { - if push_drv { - attribute.extend(toplevel); - } - } - Installable::Expression { - ref mut attribute, .. - } => { - if push_drv { - attribute.extend(toplevel); - } - } - Installable::Store { .. } => {} - } - - Ok(res) -} - impl HomeReplArgs { fn run(self) -> Result<()> { - // Use NH_HOME_FLAKE if available, otherwise use the provided installable - let installable = if let Ok(home_flake) = env::var("NH_HOME_FLAKE") { - debug!("Using NH_HOME_FLAKE: {}", home_flake); - - let mut elems = home_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.installable - }; + // Load flake from environment variable or use provided one + let installable = platform::resolve_env_installable("NH_HOME_FLAKE", self.installable); - let toplevel = toplevel_for( + // Launch the nix REPL with the home-manager configuration + platform::run_repl( installable, - false, + "homeConfigurations", + &["config", "home", "activationPackage"], + self.configuration, &self.extra_args, - self.configuration.clone(), - )?; - - Command::new("nix") - .arg("repl") - .args(toplevel.to_args()) - .run()?; - - Ok(()) + ) } } diff --git a/src/installable.rs b/src/installable.rs index e8d59e2a..8c38d2be 100644 --- a/src/installable.rs +++ b/src/installable.rs @@ -26,6 +26,16 @@ pub enum Installable { }, } +// Implement Default for Installable +impl Default for Installable { + fn default() -> Self { + Self::Flake { + reference: String::from("."), + attribute: Vec::new(), + } + } +} + impl FromArgMatches for Installable { fn from_arg_matches(matches: &clap::ArgMatches) -> Result { let mut matches = matches.clone(); diff --git a/src/interface.rs b/src/interface.rs index ea79d8f0..dc2c94ef 100644 --- a/src/interface.rs +++ b/src/interface.rs @@ -185,7 +185,7 @@ pub struct OsRollbackArgs { pub bypass_root_check: bool, } -#[derive(Debug, Args)] +#[derive(Debug, Args, Clone)] pub struct CommonRebuildArgs { /// Only print actions, without performing them #[arg(long, short = 'n')] @@ -435,7 +435,7 @@ pub struct DarwinReplArgs { pub hostname: Option, } -#[derive(Debug, Args)] +#[derive(Debug, Args, Clone)] pub struct UpdateArgs { #[arg(short = 'u', long = "update")] /// Update all flake inputs diff --git a/src/nixos.rs b/src/nixos.rs index 775828d7..f50b49e7 100644 --- a/src/nixos.rs +++ b/src/nixos.rs @@ -1,4 +1,3 @@ -use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -6,17 +5,15 @@ use color_eyre::eyre::{bail, Context}; use color_eyre::eyre::{eyre, Result}; use tracing::{debug, info, warn}; -use crate::commands; use crate::commands::Command; use crate::generations; use crate::installable::Installable; use crate::interface::OsSubcommand::{self}; -use crate::interface::{ - self, OsBuildVmArgs, OsGenerationsArgs, OsRebuildArgs, OsReplArgs, OsRollbackArgs, -}; +use crate::interface::{self, OsGenerationsArgs, OsRebuildArgs, OsReplArgs, OsRollbackArgs}; use crate::update::update; use crate::util::ensure_ssh_key_login; use crate::util::get_hostname; +use crate::util::platform; const SYSTEM_PROFILE: &str = "/nix/var/nix/profiles/system"; const CURRENT_PROFILE: &str = "/run/current-system"; @@ -26,18 +23,44 @@ const SPEC_LOCATION: &str = "/etc/specialisation"; impl interface::OsArgs { pub fn run(self) -> Result<()> { use OsRebuildVariant::{Boot, Build, Switch, Test}; + // Always resolve installable from env var at the top + let resolved_installable = platform::resolve_env_installable( + "NH_OS_FLAKE", + match &self.subcommand { + OsSubcommand::Boot(args) => args.common.installable.clone(), + OsSubcommand::Test(args) => args.common.installable.clone(), + OsSubcommand::Switch(args) => args.common.installable.clone(), + OsSubcommand::Build(args) => args.common.installable.clone(), + OsSubcommand::BuildVm(args) => args.common.common.installable.clone(), + OsSubcommand::Repl(args) => args.installable.clone(), + _ => Installable::default(), // fallback for Info/Rollback, not used + }, + ); match self.subcommand { - OsSubcommand::Boot(args) => args.rebuild(Boot, None), - OsSubcommand::Test(args) => args.rebuild(Test, None), - OsSubcommand::Switch(args) => args.rebuild(Switch, None), + OsSubcommand::Boot(args) => { + args.rebuild_with_installable(Boot, None, resolved_installable) + } + OsSubcommand::Test(args) => { + args.rebuild_with_installable(Test, None, resolved_installable) + } + OsSubcommand::Switch(args) => { + args.rebuild_with_installable(Switch, None, resolved_installable) + } OsSubcommand::Build(args) => { if args.common.ask || args.common.dry { warn!("`--ask` and `--dry` have no effect for `nh os build`"); } - args.rebuild(Build, None) + args.rebuild_with_installable(Build, None, resolved_installable) + } + OsSubcommand::BuildVm(args) => { + let final_attr = get_final_attr(true, args.with_bootloader); + args.common.rebuild_with_installable( + OsRebuildVariant::BuildVm, + Some(final_attr), + resolved_installable, + ) } - OsSubcommand::BuildVm(args) => args.build_vm(), - OsSubcommand::Repl(args) => args.run(), + OsSubcommand::Repl(args) => args.run_with_installable(resolved_installable), OsSubcommand::Info(args) => args.info(), OsSubcommand::Rollback(args) => args.rollback(), } @@ -53,33 +76,21 @@ enum OsRebuildVariant { BuildVm, } -impl OsBuildVmArgs { - fn build_vm(self) -> Result<()> { - let final_attr = get_final_attr(true, self.with_bootloader); - self.common - .rebuild(OsRebuildVariant::BuildVm, Some(final_attr)) - } -} - impl OsRebuildArgs { - // final_attr is the attribute of config.system.build.X to evaluate. - fn rebuild(self, variant: OsRebuildVariant, final_attr: Option) -> Result<()> { + // Accept the resolved installable as a parameter + fn rebuild_with_installable( + self, + variant: OsRebuildVariant, + final_attr: Option, + installable: Installable, + ) -> Result<()> { use OsRebuildVariant::{Boot, Build, BuildVm, Switch, Test}; - if self.build_host.is_some() || self.target_host.is_some() { - // if it fails its okay let _ = ensure_ssh_key_login(); } - let elevate = if self.bypass_root_check { - warn!("Bypassing root check, now running nix as root"); - false - } else { - if nix::unistd::Uid::effective().is_root() { - bail!("Don't run nh os as root. I will call sudo internally as needed"); - } - true - }; + // Check for root privileges and elevate if needed + let elevate = platform::check_not_root(self.bypass_root_check)?; if self.update_args.update { update(&self.common.installable, self.update_args.update_input)?; @@ -104,88 +115,74 @@ impl OsRebuildArgs { }, }; - let out_path: Box = match self.common.out_link { - Some(ref p) => Box::new(p.clone()), - None => Box::new({ - let dir = tempfile::Builder::new().prefix("nh-os").tempdir()?; - (dir.as_ref().join("result"), dir) - }), - }; - + // Create temporary output path for the build result + let out_path = platform::create_output_path(self.common.out_link, "nh-os")?; debug!(?out_path); - // Use NH_OS_FLAKE if available, otherwise use the provided installable - let installable = if let Ok(os_flake) = env::var("NH_OS_FLAKE") { - debug!("Using NH_OS_FLAKE: {}", os_flake); - - let mut elems = os_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.common.installable.clone() - }; - - let toplevel = toplevel_for( - &target_hostname, + // Configure installable with the right attributes for NixOS + let toplevel = platform::extend_installable_for_platform( installable, - final_attr.unwrap_or(String::from("toplevel")).as_str(), - ); + "nixosConfigurations", + &[ + "config", + "system", + "build", + final_attr.as_deref().unwrap_or("toplevel"), + ], + Some(target_hostname.clone()), + true, + &self + .extra_args + .iter() + .map(std::convert::Into::into) + .collect::>(), + )?; let message = match variant { BuildVm => "Building NixOS VM image", _ => "Building NixOS configuration", }; - commands::Build::new(toplevel) - .extra_arg("--out-link") - .extra_arg(out_path.get_path()) - .extra_args(&self.extra_args) - .builder(self.build_host.clone()) - .message(message) - .nom(!self.common.no_nom) - .run()?; - - let current_specialisation = std::fs::read_to_string(SPEC_LOCATION).ok(); - - let target_specialisation = if self.no_specialisation { - None - } else { - current_specialisation.or_else(|| self.specialisation.clone()) - }; - - debug!("target_specialisation: {target_specialisation:?}"); - - let target_profile = match &target_specialisation { - None => out_path.get_path().to_owned(), - Some(spec) => out_path.get_path().join("specialisation").join(spec), - }; + // Build the NixOS configuration + platform::build_configuration( + toplevel, + out_path.as_ref(), + &self.extra_args, + self.build_host.clone(), + message, + self.common.no_nom, + )?; + + // Process any system specialisations + let target_specialisation = platform::process_specialisation( + self.no_specialisation, + self.specialisation.clone(), + SPEC_LOCATION, + )?; + + // Get the target system profile path + let target_profile = + platform::get_target_profile(out_path.as_ref(), &target_specialisation); debug!("exists: {}", target_profile.exists()); - target_profile.try_exists().context("Doesn't exist")?; if self.build_host.is_none() && self.target_host.is_none() && system_hostname.map_or(true, |h| h == target_hostname) { - Command::new("nvd") - .arg("diff") - .arg(CURRENT_PROFILE) - .arg(&target_profile) - .message("Comparing changes") - .run()?; + // Show diff between current and new configuration + platform::compare_configurations( + CURRENT_PROFILE, + &target_profile, + false, + "Comparing changes", + )?; } else { debug!("Not running nvd as the target hostname is different from the system hostname."); } + // Handle dry run mode or check if confirmation is needed if self.common.dry || matches!(variant, Build | BuildVm) { if self.common.ask { warn!("--ask has no effect as dry run was requested"); @@ -193,13 +190,8 @@ impl OsRebuildArgs { return Ok(()); } - if self.common.ask { - info!("Apply the config?"); - let confirmation = dialoguer::Confirm::new().default(false).interact()?; - - if !confirmation { - bail!("User rejected the new config"); - } + if !platform::confirm_action(self.common.ask, self.common.dry)? { + return Ok(()); } if let Some(target_host) = &self.target_host { @@ -215,12 +207,10 @@ impl OsRebuildArgs { }; if let Test | Switch = variant { - // !! Use the target profile aka spec-namespaced let switch_to_configuration = target_profile.join("bin").join("switch-to-configuration"); let switch_to_configuration = switch_to_configuration.canonicalize().unwrap(); let switch_to_configuration = switch_to_configuration.to_str().unwrap(); - Command::new(switch_to_configuration) .arg("test") .ssh(self.target_host.clone()) @@ -236,15 +226,12 @@ impl OsRebuildArgs { .arg(out_path.get_path().canonicalize().unwrap()) .ssh(self.target_host.clone()) .run()?; - - // !! Use the base profile aka no spec-namespace let switch_to_configuration = out_path .get_path() .join("bin") .join("switch-to-configuration"); let switch_to_configuration = switch_to_configuration.canonicalize().unwrap(); let switch_to_configuration = switch_to_configuration.to_str().unwrap(); - Command::new(switch_to_configuration) .arg("boot") .ssh(self.target_host) @@ -252,26 +239,15 @@ impl OsRebuildArgs { .message("Adding configuration to bootloader") .run()?; } - - // Make sure out_path is not accidentally dropped - // https://docs.rs/tempfile/3.12.0/tempfile/index.html#early-drop-pitfall drop(out_path); - Ok(()) } } impl OsRollbackArgs { fn rollback(&self) -> Result<()> { - let elevate = if self.bypass_root_check { - warn!("Bypassing root check, now running nix as root"); - false - } else { - if nix::unistd::Uid::effective().is_root() { - bail!("Don't run nh os as root. I will call sudo internally as needed"); - } - true - }; + // Check if we need root permissions + let elevate = platform::check_not_root(self.bypass_root_check)?; // Find previous generation or specific generation let target_generation = if let Some(gen_number) = self.to { @@ -288,24 +264,20 @@ impl OsRollbackArgs { .unwrap_or(Path::new("/nix/var/nix/profiles")); let generation_link = profile_dir.join(format!("system-{}-link", target_generation.number)); - // Handle specialisations - let current_specialisation = fs::read_to_string(SPEC_LOCATION).ok(); - - let target_specialisation = if self.no_specialisation { - None - } else { - self.specialisation.clone().or(current_specialisation) - }; - - debug!("target_specialisation: {target_specialisation:?}"); - - // Compare changes between current and target generation - Command::new("nvd") - .arg("diff") - .arg(CURRENT_PROFILE) - .arg(&generation_link) - .message("Comparing changes") - .run()?; + // Handle any system specialisations + let target_specialisation = platform::process_specialisation( + self.no_specialisation, + self.specialisation.clone(), + SPEC_LOCATION, + )?; + + // Show diff between current and target configuration + platform::compare_configurations( + CURRENT_PROFILE, + &generation_link, + false, + "Comparing changes", + )?; if self.dry { info!( @@ -315,13 +287,9 @@ impl OsRollbackArgs { return Ok(()); } - if self.ask { - info!("Roll back to generation {}?", target_generation.number); - let confirmation = dialoguer::Confirm::new().default(false).interact()?; - - if !confirmation { - bail!("User rejected the rollback"); - } + // Ask for confirmation if needed + if !platform::confirm_action(self.ask, self.dry)? { + return Ok(()); } // Get current generation number for potential rollback @@ -346,25 +314,10 @@ impl OsRollbackArgs { .run()?; // Set up rollback protection flag - let mut rollback_profile = false; - - // Determine the correct profile to use with specialisations - let final_profile = match &target_specialisation { - None => generation_link, - Some(spec) => { - let spec_path = generation_link.join("specialisation").join(spec); - if !spec_path.exists() { - warn!( - "Specialisation '{}' does not exist in generation {}", - spec, target_generation.number - ); - warn!("Using base configuration without specialisations"); - generation_link - } else { - spec_path - } - } - }; + let mut _rollback_profile = false; + + // Get the final profile path with specialisation if any + let final_profile = platform::get_target_profile(&generation_link, &target_specialisation); // Activate the configuration info!("Activating..."); @@ -383,10 +336,10 @@ impl OsRollbackArgs { ); } Err(e) => { - rollback_profile = true; + _rollback_profile = true; // If activation fails, rollback the profile - if rollback_profile && current_gen_number > 0 { + if _rollback_profile && current_gen_number > 0 { let current_gen_link = profile_dir.join(format!("system-{current_gen_number}-link")); @@ -521,88 +474,16 @@ pub fn get_final_attr(build_vm: bool, with_bootloader: bool) -> String { String::from(attr) } -pub fn toplevel_for>( - hostname: S, - installable: Installable, - final_attr: &str, -) -> Installable { - let mut res = installable; - let hostname = hostname.as_ref().to_owned(); - - let toplevel = ["config", "system", "build", final_attr] - .into_iter() - .map(String::from); - - match res { - Installable::Flake { - ref mut attribute, .. - } => { - // If user explicitly selects some other attribute, don't push nixosConfigurations - if attribute.is_empty() { - attribute.push(String::from("nixosConfigurations")); - attribute.push(hostname); - } - attribute.extend(toplevel); - } - Installable::File { - ref mut attribute, .. - } => { - attribute.extend(toplevel); - } - Installable::Expression { - ref mut attribute, .. - } => { - attribute.extend(toplevel); - } - Installable::Store { .. } => {} - } - - res -} - impl OsReplArgs { - fn run(self) -> Result<()> { - // Use NH_OS_FLAKE if available, otherwise use the provided installable - let mut target_installable = if let Ok(os_flake) = env::var("NH_OS_FLAKE") { - debug!("Using NH_OS_FLAKE: {}", os_flake); - - let mut elems = os_flake.splitn(2, '#'); - let reference = elems.next().unwrap().to_owned(); - let attribute = elems - .next() - .map(crate::installable::parse_attribute) - .unwrap_or_default(); - - Installable::Flake { - reference, - attribute, - } - } else { - self.installable - }; - - if matches!(target_installable, Installable::Store { .. }) { - bail!("Nix doesn't support nix store installables."); - } - - let hostname = self.hostname.ok_or(()).or_else(|()| get_hostname())?; - - if let Installable::Flake { - ref mut attribute, .. - } = target_installable - { - if attribute.is_empty() { - attribute.push(String::from("nixosConfigurations")); - attribute.push(hostname); - } - } - - Command::new("nix") - .arg("repl") - .args(target_installable.to_args()) - .run()?; - - Ok(()) + fn run_with_installable(self, installable: Installable) -> Result<()> { + // Launch the nix REPL with the NixOS configuration + platform::run_repl( + installable, + "nixosConfigurations", + &["config", "system", "build", "toplevel"], + self.hostname, + &[], + ) } } diff --git a/src/util.rs b/src/util/mod.rs similarity index 99% rename from src/util.rs rename to src/util/mod.rs index 5a7aa4f5..11b6b0b5 100644 --- a/src/util.rs +++ b/src/util/mod.rs @@ -8,6 +8,8 @@ use tempfile::TempDir; use crate::commands::Command; +pub mod platform; + /// Retrieves the installed Nix version as a string. /// /// This function executes the `nix --version` command, parses the output to diff --git a/src/util/platform.rs b/src/util/platform.rs new file mode 100644 index 00000000..7dda9681 --- /dev/null +++ b/src/util/platform.rs @@ -0,0 +1,310 @@ +// Shared platform logic for nh +use std::env; +use std::ffi::OsString; +use std::path::PathBuf; + +use color_eyre::eyre::bail; +use color_eyre::Result; +use tracing::{debug, info, warn}; + +use crate::commands; +use crate::installable::Installable; + +/// Resolves an Installable from an environment variable, or falls back to the provided one. +pub fn resolve_env_installable(var: &str, fallback: Installable) -> Installable { + if let Ok(val) = env::var(var) { + let mut elems = val.splitn(2, '#'); + let reference = elems.next().unwrap().to_owned(); + let attribute = elems + .next() + .map(crate::installable::parse_attribute) + .unwrap_or_default(); + Installable::Flake { + reference, + attribute, + } + } else { + fallback + } +} + +/// Extends an Installable with the appropriate attribute path for a platform. +/// +/// - `config_type`: e.g. "homeConfigurations", "nixosConfigurations", "darwinConfigurations" +/// - `extra_path`: e.g. ["config", "home", "activationPackage"] +/// - `config_name`: Optional configuration name (e.g. username@hostname) +/// - `push_drv`: Whether to push the drv path (platform-specific) +/// - `extra_args`: Extra args for nix eval (for config detection) +pub fn extend_installable_for_platform( + mut installable: Installable, + config_type: &str, + extra_path: &[&str], + config_name: Option, + push_drv: bool, + extra_args: &[OsString], +) -> Result { + use tracing::debug; + + use crate::commands; + use crate::util::get_hostname; + match &mut installable { + Installable::Flake { + reference, + attribute, + } => { + if !attribute.is_empty() { + debug!( + "Using explicit attribute path from installable: {:?}", + attribute + ); + return Ok(installable); + } + attribute.push(config_type.to_string()); + let flake_reference = reference.clone(); + let mut found_config = false; + if let Some(config_name) = config_name { + let func = format!(r#"x: x ? "{config_name}""#); + let check_res = commands::Command::new("nix") + .arg("eval") + .args(extra_args) + .arg("--apply") + .arg(&func) + .args( + (Installable::Flake { + reference: flake_reference.clone(), + attribute: attribute.clone(), + }) + .to_args(), + ) + .run_capture(); + if let Ok(res) = check_res { + if res.map(|s| s.trim().to_owned()).as_deref() == Some("true") { + debug!("Using explicit configuration from flag: {}", config_name); + attribute.push(config_name); + if push_drv { + attribute.extend(extra_path.iter().map(|s| (*s).to_string())); + } + found_config = true; + } + } + if !found_config { + // If not found, error out + return Err(color_eyre::eyre::eyre!( + "Explicitly specified configuration not found in flake." + )); + } + } + if !found_config { + let username = std::env::var("USER").unwrap_or_else(|_| "user".to_string()); + let hostname = get_hostname().unwrap_or_else(|_| "host".to_string()); + for attr_name in [format!("{username}@{hostname}"), username] { + let func = format!(r#"x: x ? "{attr_name}""#); + let check_res = commands::Command::new("nix") + .arg("eval") + .args(extra_args) + .arg("--apply") + .arg(&func) + .args( + (Installable::Flake { + reference: flake_reference.clone(), + attribute: attribute.clone(), + }) + .to_args(), + ) + .run_capture(); + if let Ok(res) = check_res { + if res.map(|s| s.trim().to_owned()).as_deref() == Some("true") { + debug!("Using automatically detected configuration: {}", attr_name); + attribute.push(attr_name); + if push_drv { + attribute.extend(extra_path.iter().map(|s| (*s).to_string())); + } + found_config = true; + break; + } + } + } + if !found_config { + return Err(color_eyre::eyre::eyre!( + "Couldn't find configuration automatically in flake." + )); + } + } + } + Installable::File { attribute, .. } | Installable::Expression { attribute, .. } => { + if push_drv { + attribute.extend(extra_path.iter().map(|s| (*s).to_string())); + } + } + Installable::Store { .. } => {} + } + Ok(installable) +} + +/// Handles common specialisation logic for all platforms +pub fn handle_specialisation( + specialisation_path: &str, + no_specialisation: bool, + explicit_specialisation: Option, +) -> Option { + if no_specialisation { + None + } else { + let current_specialisation = std::fs::read_to_string(specialisation_path).ok(); + explicit_specialisation.or(current_specialisation) + } +} + +/// Checks if the user wants to proceed with applying the configuration +pub fn confirm_action(ask: bool, dry: bool) -> Result { + use tracing::{info, warn}; + + if dry { + if ask { + warn!("--ask has no effect as dry run was requested"); + } + return Ok(false); + } + + if ask { + info!("Apply the config?"); + let confirmation = dialoguer::Confirm::new().default(false).interact()?; + + if !confirmation { + bail!("User rejected the new config"); + } + } + + Ok(true) +} + +/// Common function to ensure we're not running as root +pub fn check_not_root(bypass_root_check: bool) -> Result { + use tracing::warn; + + if bypass_root_check { + warn!("Bypassing root check, now running nix as root"); + return Ok(false); + } + + if nix::unistd::Uid::effective().is_root() { + bail!("Don't run nh os as root. I will call sudo internally as needed"); + } + + Ok(true) +} + +/// Creates a temporary output path for build results +pub fn create_output_path( + out_link: Option>, + prefix: &str, +) -> Result> { + let out_path: Box = match out_link { + Some(ref p) => Box::new(std::path::PathBuf::from(p.as_ref())), + None => Box::new({ + let dir = tempfile::Builder::new().prefix(prefix).tempdir()?; + (dir.as_ref().join("result"), dir) + }), + }; + + Ok(out_path) +} + +/// Compare configurations using nvd diff +pub fn compare_configurations( + current_profile: &str, + target_profile: &std::path::Path, + skip_compare: bool, + message: &str, +) -> Result<()> { + if skip_compare { + debug!("Skipping configuration comparison"); + return Ok(()); + } + + commands::Command::new("nvd") + .arg("diff") + .arg(current_profile) + .arg(target_profile) + .message(message) + .run()?; + + Ok(()) +} + +/// Build a configuration using the nix build command +pub fn build_configuration( + installable: Installable, + out_path: &dyn crate::util::MaybeTempPath, + extra_args: &[impl AsRef], + builder: Option, + message: &str, + no_nom: bool, +) -> Result<()> { + commands::Build::new(installable) + .extra_arg("--out-link") + .extra_arg(out_path.get_path()) + .extra_args(extra_args) + .builder(builder) + .message(message) + .nom(!no_nom) + .run()?; + + Ok(()) +} + +/// Determine the target profile path considering specialisation +pub fn get_target_profile( + out_path: &dyn crate::util::MaybeTempPath, + target_specialisation: &Option, +) -> PathBuf { + match target_specialisation { + None => out_path.get_path().to_owned(), + Some(spec) => out_path.get_path().join("specialisation").join(spec), + } +} + +/// Common logic for handling REPL for different platforms +pub fn run_repl( + installable: Installable, + config_type: &str, + extra_path: &[&str], + config_name: Option, + extra_args: &[String], +) -> Result<()> { + // Handle store installables which don't work with repl + if let Installable::Store { .. } = installable { + bail!("Nix doesn't support nix store installables with repl."); + } + + let installable = extend_installable_for_platform( + installable, + config_type, + extra_path, + config_name, + false, + &[], + )?; + + commands::Command::new("nix") + .arg("repl") + .args(installable.to_args()) + .args(extra_args) + .run()?; + + Ok(()) +} + +/// Process the target specialisation based on common patterns +pub fn process_specialisation( + no_specialisation: bool, + specialisation: Option, + specialisation_path: &str, +) -> Result> { + let target_specialisation = + handle_specialisation(specialisation_path, no_specialisation, specialisation); + + debug!("target_specialisation: {target_specialisation:?}"); + + Ok(target_specialisation) +}