From 7ec5148e6da1c4921eb93c7b4699c45eb9043376 Mon Sep 17 00:00:00 2001 From: Ana Hobden Date: Wed, 31 May 2023 13:36:44 -0700 Subject: [PATCH] Add time machine exclusions for Mac (#480) * Add time machine exclusions for Mac * Prod CI * Stub out mac test for a remote build * Add plan changes * wip * Move the exclusions to be later * Fixups * Use pathbufs * Import pathbuf * Update test plans --- src/action/macos/mod.rs | 4 + src/action/macos/set_tmutil_exclusion.rs | 100 ++++++++++++++++ src/action/macos/set_tmutil_exclusions.rs | 132 ++++++++++++++++++++++ src/planner/macos.rs | 12 +- src/planner/mod.rs | 4 +- tests/fixtures/macos/macos.json | 26 ++++- tests/plan.rs | 16 +-- 7 files changed, 275 insertions(+), 19 deletions(-) create mode 100644 src/action/macos/set_tmutil_exclusion.rs create mode 100644 src/action/macos/set_tmutil_exclusions.rs diff --git a/src/action/macos/mod.rs b/src/action/macos/mod.rs index 350a4f8..c0447fe 100644 --- a/src/action/macos/mod.rs +++ b/src/action/macos/mod.rs @@ -10,6 +10,8 @@ pub(crate) mod create_volume_service; pub(crate) mod enable_ownership; pub(crate) mod encrypt_apfs_volume; pub(crate) mod kickstart_launchctl_service; +pub(crate) mod set_tmutil_exclusion; +pub(crate) mod set_tmutil_exclusions; pub(crate) mod unmount_apfs_volume; pub use bootstrap_launchctl_service::BootstrapLaunchctlService; @@ -21,6 +23,8 @@ pub use enable_ownership::{EnableOwnership, EnableOwnershipError}; pub use encrypt_apfs_volume::EncryptApfsVolume; pub use kickstart_launchctl_service::KickstartLaunchctlService; use serde::Deserialize; +pub use set_tmutil_exclusion::SetTmutilExclusion; +pub use set_tmutil_exclusions::SetTmutilExclusions; use tokio::process::Command; pub use unmount_apfs_volume::UnmountApfsVolume; use uuid::Uuid; diff --git a/src/action/macos/set_tmutil_exclusion.rs b/src/action/macos/set_tmutil_exclusion.rs new file mode 100644 index 0000000..5c94dc0 --- /dev/null +++ b/src/action/macos/set_tmutil_exclusion.rs @@ -0,0 +1,100 @@ +use std::path::{Path, PathBuf}; + +use tokio::process::Command; +use tracing::{span, Span}; + +use crate::action::{ActionError, ActionTag, StatefulAction}; +use crate::execute_command; + +use crate::action::{Action, ActionDescription}; + +/** +Set a time machine exclusion on a path. + +Note, this cannot be used on Volumes easily: + +```bash,no_run +% sudo tmutil addexclusion -v "Nix Store" +tmutil: addexclusion requires Full Disk Access privileges. +To allow this operation, select Full Disk Access in the Privacy +tab of the Security & Privacy preference pane, and add Terminal +to the list of applications which are allowed Full Disk Access. +% sudo tmutil addexclusion /nix +/nix: The operation couldn’t be completed. Invalid argument +``` + + */ +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] +pub struct SetTmutilExclusion { + path: PathBuf, +} + +impl SetTmutilExclusion { + #[tracing::instrument(level = "debug", skip_all)] + pub async fn plan(path: impl AsRef) -> Result, ActionError> { + Ok(Self { + path: path.as_ref().to_path_buf(), + } + .into()) + } +} + +#[async_trait::async_trait] +#[typetag::serde(name = "set_tmutil_exclusion")] +impl Action for SetTmutilExclusion { + fn action_tag() -> ActionTag { + ActionTag("set_tmutil_exclusion") + } + fn tracing_synopsis(&self) -> String { + format!( + "Configure Time Machine exclusion on `{}`", + self.path.display() + ) + } + + fn tracing_span(&self) -> Span { + span!( + tracing::Level::DEBUG, + "set_tmutil_exclusion", + path = %self.path.display(), + ) + } + + 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> { + execute_command( + Command::new("tmutil") + .process_group(0) + .arg("addexclusion") + .arg(&self.path) + .stdin(std::process::Stdio::null()), + ) + .await + .map_err(Self::error)?; + + Ok(()) + } + + fn revert_description(&self) -> Vec { + vec![ActionDescription::new(self.tracing_synopsis(), vec![])] + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn revert(&mut self) -> Result<(), ActionError> { + execute_command( + Command::new("tmutil") + .process_group(0) + .arg("removeexclusion") + .arg(&self.path) + .stdin(std::process::Stdio::null()), + ) + .await + .map_err(Self::error)?; + + Ok(()) + } +} diff --git a/src/action/macos/set_tmutil_exclusions.rs b/src/action/macos/set_tmutil_exclusions.rs new file mode 100644 index 0000000..66b787d --- /dev/null +++ b/src/action/macos/set_tmutil_exclusions.rs @@ -0,0 +1,132 @@ +use std::path::PathBuf; + +use tracing::{span, Span}; + +use crate::action::{ + Action, ActionDescription, ActionError, ActionErrorKind, ActionTag, StatefulAction, +}; + +use super::SetTmutilExclusion; + +/** +Set a time machine exclusion on several paths. + +Note, this cannot be used on Volumes easily: + +```bash,no_run +% sudo tmutil addexclusion -v "Nix Store" +tmutil: addexclusion requires Full Disk Access privileges. +To allow this operation, select Full Disk Access in the Privacy +tab of the Security & Privacy preference pane, and add Terminal +to the list of applications which are allowed Full Disk Access. +% sudo tmutil addexclusion /nix +/nix: The operation couldn’t be completed. Invalid argument +``` + + */ +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] +pub struct SetTmutilExclusions { + set_tmutil_exclusions: Vec>, +} + +impl SetTmutilExclusions { + #[tracing::instrument(level = "debug", skip_all)] + pub async fn plan(paths: Vec) -> Result, ActionError> { + /* Testing with `sudo tmutil addexclusion -p /nix` and `sudo tmutil addexclusion -v "Nix Store"` on DetSys's Macs + yielded this error: + + ``` + tmutil: addexclusion requires Full Disk Access privileges. + To allow this operation, select Full Disk Access in the Privacy + tab of the Security & Privacy preference pane, and add Terminal + to the list of applications which are allowed Full Disk Access. + ``` + + So we do these subdirectories instead. + */ + let mut set_tmutil_exclusions = Vec::new(); + for path in paths { + let set_tmutil_exclusion = SetTmutilExclusion::plan(path).await.map_err(Self::error)?; + set_tmutil_exclusions.push(set_tmutil_exclusion); + } + + Ok(Self { + set_tmutil_exclusions, + } + .into()) + } +} + +#[async_trait::async_trait] +#[typetag::serde(name = "set_tmutil_exclusions")] +impl Action for SetTmutilExclusions { + fn action_tag() -> ActionTag { + ActionTag("set_tmutil_exclusions") + } + fn tracing_synopsis(&self) -> String { + String::from("Configure Time Machine exclusions") + } + + fn tracing_span(&self) -> Span { + span!(tracing::Level::DEBUG, "set_tmutil_exclusions",) + } + + fn execute_description(&self) -> Vec { + let Self { + set_tmutil_exclusions, + } = &self; + + let mut set_tmutil_exclusion_descriptions = Vec::new(); + for set_tmutil_exclusion in set_tmutil_exclusions { + if let Some(val) = set_tmutil_exclusion.describe_execute().iter().next() { + set_tmutil_exclusion_descriptions.push(val.description.clone()) + } + } + vec![ActionDescription::new( + self.tracing_synopsis(), + set_tmutil_exclusion_descriptions, + )] + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn execute(&mut self) -> Result<(), ActionError> { + // Just do sequential since parallelizing this will have little benefit + for set_tmutil_exclusion in self.set_tmutil_exclusions.iter_mut() { + set_tmutil_exclusion + .try_execute() + .await + .map_err(Self::error)?; + } + + Ok(()) + } + + fn revert_description(&self) -> Vec { + vec![ActionDescription::new( + format!("Remove time machine exclusions"), + vec![], + )] + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn revert(&mut self) -> Result<(), ActionError> { + let mut errors = vec![]; + // Just do sequential since parallelizing this will have little benefit + for set_tmutil_exclusion in self.set_tmutil_exclusions.iter_mut().rev() { + if let Err(err) = set_tmutil_exclusion.try_revert().await { + errors.push(err); + } + } + + if errors.is_empty() { + Ok(()) + } else if errors.len() == 1 { + Err(errors + .into_iter() + .next() + .expect("Expected 1 len Vec to have at least 1 item")) + } else { + Err(Self::error(ActionErrorKind::MultipleChildren(errors))) + } + } +} diff --git a/src/planner/macos.rs b/src/planner/macos.rs index ce2c307..077bdbd 100644 --- a/src/planner/macos.rs +++ b/src/planner/macos.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, io::Cursor}; +use std::{collections::HashMap, io::Cursor, path::PathBuf}; #[cfg(feature = "cli")] use clap::ArgAction; @@ -10,7 +10,7 @@ use crate::{ action::{ base::RemoveDirectory, common::{ConfigureInitService, ConfigureNix, ProvisionNix}, - macos::CreateNixVolume, + macos::{CreateNixVolume, SetTmutilExclusions}, StatefulAction, }, execute_command, @@ -130,10 +130,6 @@ impl Planner for Macos { }; Ok(vec![ - // Create Volume step: - // - // setup_Synthetic -> create_synthetic_objects - // Unmount -> create_volume -> Setup_fstab -> maybe encrypt_volume -> launchctl bootstrap -> launchctl kickstart -> await_volume -> maybe enableOwnership CreateNixVolume::plan( root_disk.unwrap(), /* We just ensured it was populated */ self.volume_label.clone(), @@ -147,6 +143,10 @@ impl Planner for Macos { .await .map_err(PlannerError::Action)? .boxed(), + SetTmutilExclusions::plan(vec![PathBuf::from("/nix/store"), PathBuf::from("/nix/var")]) + .await + .map_err(PlannerError::Action)? + .boxed(), ConfigureNix::plan(ShellProfileLocations::default(), &self.settings) .await .map_err(PlannerError::Action)? diff --git a/src/planner/mod.rs b/src/planner/mod.rs index 3d2d9b0..64b1665 100644 --- a/src/planner/mod.rs +++ b/src/planner/mod.rs @@ -397,9 +397,9 @@ impl HasExpectedErrors for PlannerError { this @ PlannerError::RosettaDetected => Some(Box::new(this)), PlannerError::Utf8(_) => None, PlannerError::SelinuxRequirements => Some(Box::new(self)), - PlannerError::Custom(e) => { + PlannerError::Custom(_e) => { #[cfg(target_os = "linux")] - if let Some(err) = e.downcast_ref::() { + if let Some(err) = _e.downcast_ref::() { return err.expected(); } None diff --git a/tests/fixtures/macos/macos.json b/tests/fixtures/macos/macos.json index 7166f30..f14b8dc 100644 --- a/tests/fixtures/macos/macos.json +++ b/tests/fixtures/macos/macos.json @@ -93,7 +93,7 @@ }, "state": "Uncompleted" }, - "delete_users": [], + "delete_users_in_group": null, "create_group": { "action": { "name": "nixbld", @@ -247,6 +247,26 @@ }, "state": "Uncompleted" }, + { + "action": { + "action": "set_tmutil_exclusions", + "set_tmutil_exclusions": [ + { + "action": { + "path": "/nix/store" + }, + "state": "Uncompleted" + }, + { + "action": { + "path": "/nix/var" + }, + "state": "Uncompleted" + } + ] + }, + "state": "Uncompleted" + }, { "action": { "action": "configure_nix", @@ -339,12 +359,12 @@ "path": "/etc/nix/nix.conf", "pending_nix_config": { "settings": { - "experimental-features": "nix-command flakes auto-allocate-uids", "extra-nix-path": "nixpkgs=flake:nixpkgs", + "auto-allocate-uids": "true", "auto-optimise-store": "true", "build-users-group": "nixbld", "bash-prompt-prefix": "(nix:$name)\\040", - "auto-allocate-uids": "true" + "experimental-features": "nix-command flakes auto-allocate-uids" } } }, diff --git a/tests/plan.rs b/tests/plan.rs index e34ad0f..934d110 100644 --- a/tests/plan.rs +++ b/tests/plan.rs @@ -25,11 +25,11 @@ fn plan_compat_steam_deck() -> eyre::Result<()> { Ok(()) } -// Ensure existing plans still parse -// If this breaks and you need to update the fixture, disable these tests, bump `nix_installer` to a new version, and update the plans. -#[cfg(target_os = "macos")] -#[test] -fn plan_compat_macos() -> eyre::Result<()> { - let _: InstallPlan = serde_json::from_str(MACOS)?; - Ok(()) -} +// // Ensure existing plans still parse +// // If this breaks and you need to update the fixture, disable these tests, bump `nix_installer` to a new version, and update the plans. +// #[cfg(target_os = "macos")] +// #[test] +// fn plan_compat_macos() -> eyre::Result<()> { +// let _: InstallPlan = serde_json::from_str(MACOS)?; +// Ok(()) +// }