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
This commit is contained in:
Ana Hobden 2023-05-31 13:36:44 -07:00 committed by GitHub
parent f91b93bdb0
commit 7ec5148e6d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 275 additions and 19 deletions

View file

@ -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;

View file

@ -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 couldnt 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<Path>) -> Result<StatefulAction<Self>, 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<ActionDescription> {
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<ActionDescription> {
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(())
}
}

View file

@ -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 couldnt be completed. Invalid argument
```
*/
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
pub struct SetTmutilExclusions {
set_tmutil_exclusions: Vec<StatefulAction<SetTmutilExclusion>>,
}
impl SetTmutilExclusions {
#[tracing::instrument(level = "debug", skip_all)]
pub async fn plan(paths: Vec<PathBuf>) -> Result<StatefulAction<Self>, 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<ActionDescription> {
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<ActionDescription> {
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)))
}
}
}

View file

@ -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)?

View file

@ -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::<linux::LinuxErrorKind>() {
if let Some(err) = _e.downcast_ref::<linux::LinuxErrorKind>() {
return err.expected();
}
None

View file

@ -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"
}
}
},

View file

@ -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(())
// }