diff --git a/flake.nix b/flake.nix index 7623611f..ade38c7e 100644 --- a/flake.nix +++ b/flake.nix @@ -138,6 +138,7 @@ cargo-outdated cacert cargo-audit + cargo-watch nixpkgs-fmt check.check-rustfmt check.check-spelling @@ -149,6 +150,7 @@ ++ lib.optionals (pkgs.stdenv.isDarwin) (with pkgs; [ libiconv darwin.apple_sdk.frameworks.Security + darwin.apple_sdk.frameworks.SystemConfiguration ]) ++ lib.optionals (pkgs.stdenv.isLinux) (with pkgs; [ checkpolicy diff --git a/src/action/common/configure_init_service.rs b/src/action/common/configure_init_service.rs index 71a3536d..1997abcc 100644 --- a/src/action/common/configure_init_service.rs +++ b/src/action/common/configure_init_service.rs @@ -182,7 +182,7 @@ impl Action for ConfigureInitService { execute_command( Command::new("launchctl") .process_group(0) - .args(&["load", "-w"]) + .args(["load", "-w"]) .arg(DARWIN_NIX_DAEMON_DEST) .stdin(std::process::Stdio::null()), ) @@ -192,7 +192,7 @@ impl Action for ConfigureInitService { let domain = "system"; let service = "org.nixos.nix-daemon"; - let is_disabled = crate::action::macos::service_is_disabled(&domain, &service) + let is_disabled = crate::action::macos::service_is_disabled(domain, service) .await .map_err(Self::error)?; if is_disabled { @@ -395,7 +395,7 @@ impl Action for ConfigureInitService { .arg(DARWIN_NIX_DAEMON_DEST), ) .await - .map_err(|e| Self::error(e))?; + .map_err(Self::error)?; }, #[cfg(target_os = "linux")] InitSystem::Systemd => { diff --git a/src/action/macos/create_nix_hook_service.rs b/src/action/macos/create_nix_hook_service.rs new file mode 100644 index 00000000..6ae5d1d3 --- /dev/null +++ b/src/action/macos/create_nix_hook_service.rs @@ -0,0 +1,226 @@ +use serde::{Deserialize, Serialize}; +use tracing::{span, Span}; + +use std::path::PathBuf; +use tokio::{ + fs::{remove_file, OpenOptions}, + io::AsyncWriteExt, + process::Command, +}; + +use crate::{ + action::{Action, ActionDescription, ActionError, ActionErrorKind, ActionTag, StatefulAction}, + execute_command, +}; + +/** Create a plist for a `launchctl` service to re-add Nix to the zshrc after upgrades. + */ +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] +pub struct CreateNixHookService { + path: PathBuf, + service_label: String, + needs_bootout: bool, +} + +impl CreateNixHookService { + #[tracing::instrument(level = "debug", skip_all)] + pub async fn plan() -> Result, ActionError> { + let mut this = Self { + path: PathBuf::from( + "/Library/LaunchDaemons/systems.determinate.nix-installer.nix-hook.plist", + ), + service_label: "systems.determinate.nix-installer.nix-hook".into(), + needs_bootout: false, + }; + + // If the service is currently loaded or running, we need to unload it during execute (since we will then recreate it and reload it) + // This `launchctl` command may fail if the service isn't loaded + let mut check_loaded_command = Command::new("launchctl"); + check_loaded_command.process_group(0); + check_loaded_command.arg("print"); + check_loaded_command.arg(format!("system/{}", this.service_label)); + tracing::trace!( + command = format!("{:?}", check_loaded_command.as_std()), + "Executing" + ); + let check_loaded_output = check_loaded_command + .output() + .await + .map_err(|e| ActionErrorKind::command(&check_loaded_command, e)) + .map_err(Self::error)?; + this.needs_bootout = check_loaded_output.status.success(); + if this.needs_bootout { + tracing::debug!( + "Detected loaded service `{}` which needs unload before replacing `{}`", + this.service_label, + this.path.display(), + ); + } + + if this.path.exists() { + let discovered_plist: LaunchctlHookPlist = + plist::from_file(&this.path).map_err(Self::error)?; + let expected_plist = generate_plist(&this.service_label) + .await + .map_err(Self::error)?; + if discovered_plist != expected_plist { + tracing::trace!( + ?discovered_plist, + ?expected_plist, + "Parsed plists not equal" + ); + return Err(Self::error(CreateNixHookServiceError::DifferentPlist { + expected: expected_plist, + discovered: discovered_plist, + path: this.path.clone(), + })); + } + + tracing::debug!("Creating file `{}` already complete", this.path.display()); + return Ok(StatefulAction::completed(this)); + } + + Ok(StatefulAction::uncompleted(this)) + } +} + +#[async_trait::async_trait] +#[typetag::serde(name = "create_nix_hook_service")] +impl Action for CreateNixHookService { + fn action_tag() -> ActionTag { + ActionTag("create_nix_hook_service") + } + fn tracing_synopsis(&self) -> String { + format!( + "{maybe_unload} a `launchctl` plist to put Nix into your PATH", + maybe_unload = if self.needs_bootout { + "Unload, then recreate" + } else { + "Create" + } + ) + } + + fn tracing_span(&self) -> Span { + let span = span!( + tracing::Level::DEBUG, + "create_nix_hook_service", + path = tracing::field::display(self.path.display()), + buf = tracing::field::Empty, + ); + + span + } + + fn execute_description(&self) -> Vec { + vec![ActionDescription::new(self.tracing_synopsis(), vec![])] + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn execute(&mut self) -> Result<(), ActionError> { + let Self { + path, + service_label, + needs_bootout, + } = self; + + if *needs_bootout { + execute_command( + Command::new("launchctl") + .process_group(0) + .arg("bootout") + .arg(format!("system/{service_label}")), + ) + .await + .map_err(Self::error)?; + } + + let generated_plist = generate_plist(service_label).await.map_err(Self::error)?; + + let mut options = OpenOptions::new(); + options.create(true).write(true).read(true); + + let mut file = options + .open(&path) + .await + .map_err(|e| Self::error(ActionErrorKind::Open(path.to_owned(), e)))?; + + let mut buf = Vec::new(); + plist::to_writer_xml(&mut buf, &generated_plist).map_err(Self::error)?; + file.write_all(&buf) + .await + .map_err(|e| Self::error(ActionErrorKind::Write(path.to_owned(), e)))?; + + Ok(()) + } + + fn revert_description(&self) -> Vec { + vec![ActionDescription::new( + format!("Delete file `{}`", self.path.display()), + vec![format!("Delete file `{}`", self.path.display())], + )] + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn revert(&mut self) -> Result<(), ActionError> { + remove_file(&self.path) + .await + .map_err(|e| Self::error(ActionErrorKind::Remove(self.path.to_owned(), e)))?; + + Ok(()) + } +} + +/// This function must be able to operate at both plan and execute time. +async fn generate_plist(service_label: &str) -> Result { + let plist = LaunchctlHookPlist { + keep_alive: KeepAliveOpts { + successful_exit: false, + }, + label: service_label.into(), + program_arguments: vec![ + "/bin/sh".into(), + "-c".into(), + "/bin/wait4path /nix/nix-installer && /nix/nix-installer repair".into(), + ], + standard_error_path: "/nix/.nix-installer-hook.err.log".into(), + standard_out_path: "/nix/.nix-installer-hook.out.log".into(), + }; + + Ok(plist) +} + +#[derive(Deserialize, Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct LaunchctlHookPlist { + label: String, + program_arguments: Vec, + keep_alive: KeepAliveOpts, + standard_error_path: String, + standard_out_path: String, +} + +#[derive(Deserialize, Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct KeepAliveOpts { + successful_exit: bool, +} + +#[non_exhaustive] +#[derive(Debug, thiserror::Error)] +pub enum CreateNixHookServiceError { + #[error( + "`{path}` exists and contains content different than expected. Consider removing the file." + )] + DifferentPlist { + expected: LaunchctlHookPlist, + discovered: LaunchctlHookPlist, + path: PathBuf, + }, +} + +impl From for ActionErrorKind { + fn from(val: CreateNixHookServiceError) -> Self { + ActionErrorKind::Custom(Box::new(val)) + } +} diff --git a/src/action/macos/mod.rs b/src/action/macos/mod.rs index d1e5ef48..4299d7fc 100644 --- a/src/action/macos/mod.rs +++ b/src/action/macos/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod bootstrap_launchctl_service; pub(crate) mod create_apfs_volume; pub(crate) mod create_fstab_entry; +pub(crate) mod create_nix_hook_service; pub(crate) mod create_nix_volume; pub(crate) mod create_synthetic_objects; pub(crate) mod create_volume_service; @@ -16,6 +17,7 @@ pub(crate) mod unmount_apfs_volume; pub use bootstrap_launchctl_service::BootstrapLaunchctlService; pub use create_apfs_volume::CreateApfsVolume; +pub use create_nix_hook_service::CreateNixHookService; pub use create_nix_volume::{CreateNixVolume, NIX_VOLUME_MOUNTD_DEST}; pub use create_synthetic_objects::CreateSyntheticObjects; pub use create_volume_service::CreateVolumeService; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 22ffe704..37ded730 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -47,6 +47,7 @@ impl CommandExecute for NixInstallerCli { NixInstallerSubcommand::Plan(plan) => plan.execute().await, NixInstallerSubcommand::SelfTest(self_test) => self_test.execute().await, NixInstallerSubcommand::Install(install) => install.execute().await, + NixInstallerSubcommand::Repair(restore_shell) => restore_shell.execute().await, NixInstallerSubcommand::Uninstall(revert) => revert.execute().await, } } diff --git a/src/cli/subcommand/mod.rs b/src/cli/subcommand/mod.rs index 41f4ef73..ce8f4243 100644 --- a/src/cli/subcommand/mod.rs +++ b/src/cli/subcommand/mod.rs @@ -2,6 +2,8 @@ mod plan; use plan::Plan; mod install; use install::Install; +mod repair; +use repair::Repair; mod uninstall; use uninstall::Uninstall; mod self_test; @@ -11,6 +13,7 @@ use self_test::SelfTest; #[derive(Debug, clap::Subcommand)] pub enum NixInstallerSubcommand { Install(Install), + Repair(Repair), Uninstall(Uninstall), SelfTest(SelfTest), Plan(Plan), diff --git a/src/cli/subcommand/repair.rs b/src/cli/subcommand/repair.rs new file mode 100644 index 00000000..3b4fb1e3 --- /dev/null +++ b/src/cli/subcommand/repair.rs @@ -0,0 +1,46 @@ +use std::process::ExitCode; + +use crate::{ + action::common::ConfigureShellProfile, + cli::{ensure_root, CommandExecute}, + planner::{PlannerError, ShellProfileLocations}, +}; +use clap::{ArgAction, Parser}; + +/** +Update the shell profiles to make Nix usable after system upgrades. +*/ +#[derive(Debug, Parser)] +#[command(args_conflicts_with_subcommands = true)] +pub struct Repair { + #[clap( + long, + env = "NIX_INSTALLER_NO_CONFIRM", + action(ArgAction::SetTrue), + default_value = "false", + global = true + )] + pub no_confirm: bool, +} + +#[async_trait::async_trait] +impl CommandExecute for Repair { + #[tracing::instrument(level = "trace", skip_all)] + async fn execute(self) -> eyre::Result { + let Self { no_confirm: _ } = self; + + ensure_root()?; + + let mut reconfigure = ConfigureShellProfile::plan(ShellProfileLocations::default()) + .await + .map_err(PlannerError::Action)? + .boxed(); + + if let Err(err) = reconfigure.try_execute().await { + println!("{:#?}", err); + Ok(ExitCode::FAILURE) + } else { + Ok(ExitCode::SUCCESS) + } + } +} diff --git a/src/planner/macos.rs b/src/planner/macos.rs index 72d16b69..28398d5c 100644 --- a/src/planner/macos.rs +++ b/src/planner/macos.rs @@ -12,7 +12,7 @@ use crate::{ action::{ base::RemoveDirectory, common::{ConfigureInitService, ConfigureNix, CreateUsersAndGroups, ProvisionNix}, - macos::{CreateNixVolume, SetTmutilExclusions}, + macos::{CreateNixHookService, CreateNixVolume, SetTmutilExclusions}, StatefulAction, }, execute_command, @@ -122,15 +122,14 @@ impl Planner for Macos { let stdout = String::from_utf8_lossy(&output.stdout); let stdout_trimmed = stdout.trim(); - if stdout_trimmed == "true" { - true - } else { - false - } + + stdout_trimmed == "true" }, }; - Ok(vec![ + let mut plan = vec![]; + + plan.push( CreateNixVolume::plan( root_disk.unwrap(), /* We just ensured it was populated */ self.volume_label.clone(), @@ -140,33 +139,57 @@ impl Planner for Macos { .await .map_err(PlannerError::Action)? .boxed(), + ); + plan.push( ProvisionNix::plan(&self.settings) .await .map_err(PlannerError::Action)? .boxed(), - // Auto-allocate uids is broken on Mac. Tools like `whoami` don't work. - // e.g. https://github.com/NixOS/nix/issues/8444 + ); + // Auto-allocate uids is broken on Mac. Tools like `whoami` don't work. + // e.g. https://github.com/NixOS/nix/issues/8444 + plan.push( CreateUsersAndGroups::plan(self.settings.clone()) .await .map_err(PlannerError::Action)? .boxed(), + ); + plan.push( SetTmutilExclusions::plan(vec![PathBuf::from("/nix/store"), PathBuf::from("/nix/var")]) .await .map_err(PlannerError::Action)? .boxed(), + ); + plan.push( ConfigureNix::plan(ShellProfileLocations::default(), &self.settings) .await .map_err(PlannerError::Action)? .boxed(), + ); + + if self.settings.modify_profile { + plan.push( + CreateNixHookService::plan() + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + } + + plan.push( ConfigureInitService::plan(InitSystem::Launchd, true) .await .map_err(PlannerError::Action)? .boxed(), + ); + plan.push( RemoveDirectory::plan(crate::settings::SCRATCH_DIR) .await .map_err(PlannerError::Action)? .boxed(), - ]) + ); + + Ok(plan) } fn settings(&self) -> Result, InstallSettingsError> { @@ -179,7 +202,7 @@ impl Planner for Macos { } = self; let mut map = HashMap::default(); - map.extend(settings.settings()?.into_iter()); + map.extend(settings.settings()?); map.insert("volume_encrypt".into(), serde_json::to_value(encrypt)?); map.insert("volume_label".into(), serde_json::to_value(volume_label)?); map.insert("root_disk".into(), serde_json::to_value(root_disk)?); @@ -234,9 +257,9 @@ impl Planner for Macos { } } -impl Into for Macos { - fn into(self) -> BuiltinPlanner { - BuiltinPlanner::Macos(self) +impl From for BuiltinPlanner { + fn from(val: Macos) -> Self { + BuiltinPlanner::Macos(val) } }