lix-installer/src/action/mod.rs
Graham Christensen 01a3447b83
Remove the writable flag, don't set too many permission bits (#718)
Making everything 0o555 is too much, since many files in the store
are not supposed to be executable. Those should be 0o444. Instead
of splatting 0o555 out, take a more measured approach and remove
the writable flag from the on-disk mode.
2023-11-14 08:26:23 -08:00

661 lines
23 KiB
Rust

/*! An executable or revertable step, possibly orchestrating sub-[`Action`]s using things like
[`JoinSet`](tokio::task::JoinSet)s
[`Action`]s should be considered an 'atom' of change. Typically they are either a 'base' or
a 'composite' [`Action`].
Base actions are things like:
* [`CreateDirectory`](base::CreateDirectory)
* [`CreateFile`](base::CreateFile)
* [`CreateGroup`](base::CreateGroup)
Composite actions are things like:
* [`CreateNixTree`](common::CreateNixTree)
* [`ConfigureShellProfile`](common::ConfigureShellProfile)
During their `plan` phase, [`Planner`](crate::planner::Planner)s call an [`Action`]s `plan` function, which may accept any
arguments. For example, several 'composite' actions accept a [`CommonSettings`](crate::settings::CommonSettings). Later, the
[`InstallPlan`](crate::InstallPlan) will call [`try_execute`](StatefulAction::try_execute) on the [`StatefulAction`].
You can manually plan, execute, then revert an [`Action`] like so:
```rust,no_run
# async fn wrapper() {
use nix_installer::action::base::CreateDirectory;
let mut action = CreateDirectory::plan("/nix", None, None, 0o0755, true).await.unwrap();
action.try_execute().await.unwrap();
action.try_revert().await.unwrap();
# }
```
A general guidance for what determines how fine-grained an [`Action`] should be is the unit of
reversion. The [`ConfigureInitService`](common::ConfigureInitService) action is a good
example of this, it takes several steps, such as running `systemd-tmpfiles`, and calling
`systemctl link` on some systemd units.
Where possible, tasks which could break during execution should be broken up, as uninstalling/installing
step detection is determined by the wrapping [`StatefulAction`]. If an [`Action`] is a 'composite'
its sub-[`Action`]s can be reverted piece-by-piece. So breaking up actions into faillable units is
ideal.
A custom [`Action`] can be created then used in a custom [`Planner`](crate::planner::Planner):
```rust,no_run
use std::{error::Error, collections::HashMap};
use tracing::{Span, span};
use nix_installer::{
InstallPlan,
settings::{CommonSettings, InstallSettingsError},
planner::{Planner, PlannerError},
action::{Action, ActionError, StatefulAction, ActionDescription},
};
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
pub struct MyAction {}
impl MyAction {
#[tracing::instrument(level = "debug", skip_all)]
pub async fn plan() -> Result<StatefulAction<Self>, ActionError> {
Ok(Self {}.into())
}
}
#[async_trait::async_trait]
#[typetag::serde(name = "my_action")]
impl Action for MyAction {
fn action_tag() -> nix_installer::action::ActionTag {
"my_action".into()
}
fn tracing_synopsis(&self) -> String {
"My action".to_string()
}
fn tracing_span(&self) -> Span {
span!(
tracing::Level::DEBUG,
"my_action",
// Tracing fields here ...
)
}
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 steps ...
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> {
// Revert steps...
Ok(())
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MyPlanner {
pub common: CommonSettings,
}
#[async_trait::async_trait]
#[typetag::serde(name = "my-planner")]
impl Planner for MyPlanner {
async fn default() -> Result<Self, PlannerError> {
Ok(Self {
common: CommonSettings::default().await?,
})
}
async fn plan(&self) -> Result<Vec<StatefulAction<Box<dyn Action>>>, PlannerError> {
Ok(vec![
// ...
MyAction::plan()
.await
.map_err(PlannerError::Action)?.boxed(),
])
}
fn settings(&self) -> Result<HashMap<String, serde_json::Value>, InstallSettingsError> {
let Self { common } = self;
let mut map = std::collections::HashMap::default();
map.extend(common.settings()?.into_iter());
Ok(map)
}
async fn configured_settings(
&self,
) -> Result<HashMap<String, serde_json::Value>, PlannerError> {
let default = Self::default().await?.settings()?;
let configured = self.settings()?;
let mut settings: HashMap<String, serde_json::Value> = HashMap::new();
for (key, value) in configured.iter() {
if default.get(key) != Some(value) {
settings.insert(key.clone(), value.clone());
}
}
Ok(settings)
}
#[cfg(feature = "diagnostics")]
async fn diagnostic_data(&self) -> Result<nix_installer::diagnostics::DiagnosticData, PlannerError> {
Ok(nix_installer::diagnostics::DiagnosticData::new(
self.common.diagnostic_attribution.clone(),
self.common.diagnostic_endpoint.clone(),
self.typetag_name().into(),
self.configured_settings()
.await?
.into_keys()
.collect::<Vec<_>>(),
self.common.ssl_cert_file.clone(),
)?)
}
}
# async fn custom_planner_install() -> color_eyre::Result<()> {
let planner = MyPlanner::default().await?;
let mut plan = InstallPlan::plan(planner).await?;
match plan.install(None).await {
Ok(()) => tracing::info!("Done"),
Err(e) => {
match e.source() {
Some(source) => tracing::error!("{e}: {}", source),
None => tracing::error!("{e}"),
};
plan.uninstall(None).await?;
},
};
# Ok(())
# }
```
*/
pub mod base;
pub mod common;
pub mod linux;
pub mod macos;
mod stateful;
pub use stateful::{ActionState, StatefulAction};
use std::{error::Error, process::Output};
use tokio::task::JoinError;
use tracing::Span;
use crate::{error::HasExpectedErrors, settings::UrlOrPathError, CertificateError};
/// An action which can be reverted or completed, with an action state
///
/// This trait interacts with [`StatefulAction`] which does the [`ActionState`] manipulation and provides some tracing facilities.
///
/// Instead of calling [`execute`][Action::execute] or [`revert`][Action::revert], you should prefer [`try_execute`][StatefulAction::try_execute] and [`try_revert`][StatefulAction::try_revert]
#[async_trait::async_trait]
#[typetag::serde(tag = "action")]
pub trait Action: Send + Sync + std::fmt::Debug + dyn_clone::DynClone {
fn action_tag() -> ActionTag
where
Self: Sized;
/// A synopsis of the action for tracing purposes
fn tracing_synopsis(&self) -> String;
/// A tracing span suitable for the action
///
/// It should be [`tracing::Level::DEBUG`] and contain the same name as the [`typetag::serde`] entry.
///
/// It may contain any fields, and will be attached in the [`StatefulAction::try_execute`] and [`StatefulAction::try_revert`] functions.
///
/// See [`tracing::Span`] for more details.
fn tracing_span(&self) -> Span;
/// A description of what this action would do during execution
///
/// If this action calls sub-[`Action`]s, care should be taken to use [`StatefulAction::describe_execute`] on those actions, not [`execute_description`][Action::execute_description].
///
/// This is called by [`InstallPlan::describe_install`](crate::InstallPlan::describe_install) through [`StatefulAction::describe_execute`] which will skip output if the action is completed.
fn execute_description(&self) -> Vec<ActionDescription>;
/// A description of what this action would do during revert
///
/// If this action calls sub-[`Action`]s, care should be taken to use [`StatefulAction::describe_revert`] on those actions, not [`revert_description`][Action::revert_description].
///
/// This is called by [`InstallPlan::describe_uninstall`](crate::InstallPlan::describe_uninstall) through [`StatefulAction::describe_revert`] which will skip output if the action is completed.
fn revert_description(&self) -> Vec<ActionDescription>;
/// Perform any execution steps
///
/// If this action calls sub-[`Action`]s, care should be taken to call [`try_execute`][StatefulAction::try_execute], not [`execute`][Action::execute], so that [`ActionState`] is handled correctly and tracing is done.
///
/// This is called by [`InstallPlan::install`](crate::InstallPlan::install) through [`StatefulAction::try_execute`] which handles tracing as well as if the action needs to execute based on its `action_state`.
async fn execute(&mut self) -> Result<(), ActionError>;
/// Perform any revert steps
///
/// If this action calls sub-[`Action`]s, care should be taken to call [`try_revert`][StatefulAction::try_revert], not [`revert`][Action::revert], so that [`ActionState`] is handled correctly and tracing is done.
///
/// This is called by [`InstallPlan::uninstall`](crate::InstallPlan::uninstall) through [`StatefulAction::try_revert`] which handles tracing as well as if the action needs to revert based on its `action_state`.
async fn revert(&mut self) -> Result<(), ActionError>;
fn stateful(self) -> StatefulAction<Self>
where
Self: Sized,
{
StatefulAction {
action: self,
state: ActionState::Uncompleted,
}
}
fn error(kind: impl Into<ActionErrorKind>) -> ActionError
where
Self: Sized,
{
ActionError::new(Self::action_tag(), kind)
}
// They should also have an `async fn plan(args...) -> Result<StatefulAction<Self>, ActionError>;`
}
dyn_clone::clone_trait_object!(Action);
/**
A description of an [`Action`](crate::action::Action), intended for humans to review
*/
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
pub struct ActionDescription {
pub description: String,
pub explanation: Vec<String>,
}
impl ActionDescription {
pub fn new(description: String, explanation: Vec<String>) -> Self {
Self {
description,
explanation,
}
}
}
/// A 'tag' name an action has that corresponds to the one we serialize in [`typetag]`
pub struct ActionTag(&'static str);
impl std::fmt::Display for ActionTag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::fmt::Debug for ActionTag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl From<&'static str> for ActionTag {
fn from(value: &'static str) -> Self {
Self(value)
}
}
#[derive(Debug)]
pub struct ActionError {
action_tag: ActionTag,
kind: ActionErrorKind,
}
impl ActionError {
pub fn new(action_tag: ActionTag, kind: impl Into<ActionErrorKind>) -> Self {
Self {
action_tag,
kind: kind.into(),
}
}
pub fn kind(&self) -> &ActionErrorKind {
&self.kind
}
pub fn action_tag(&self) -> &ActionTag {
&self.action_tag
}
#[cfg(feature = "diagnostics")]
pub fn diagnostic(&self) -> String {
use crate::diagnostics::ErrorDiagnostic;
self.kind.diagnostic()
}
}
impl std::fmt::Display for ActionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("Action `{}` errored", self.action_tag))
}
}
impl std::error::Error for ActionError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.kind)
}
}
impl From<ActionError> for ActionErrorKind {
fn from(value: ActionError) -> Self {
Self::Child(Box::new(value))
}
}
/// An error occurring during an action
#[non_exhaustive]
#[derive(thiserror::Error, Debug, strum::IntoStaticStr)]
pub enum ActionErrorKind {
/// A custom error
#[error(transparent)]
Custom(Box<dyn std::error::Error + Send + Sync>),
/// An error to do with certificates
#[error(transparent)]
Certificate(#[from] CertificateError),
/// A child error
#[error(transparent)]
Child(Box<ActionError>),
/// Several errors
#[error("Multiple child errors\n\n{}", .0.iter().map(|err| {
if let Some(source) = err.source() {
format!("{err}\n{source}\n")
} else {
format!("{err}\n")
}
}).collect::<Vec<_>>().join("\n"))]
MultipleChildren(Vec<ActionError>),
/// Several errors
#[error("Multiple errors\n\n{}", .0.iter().map(|err| {
if let Some(source) = err.source() {
format!("{err}\n{source}")
} else {
format!("{err}\n")
}
}).collect::<Vec<_>>().join("\n"))]
Multiple(Vec<ActionErrorKind>),
/// The path already exists with different content that expected
#[error(
"`{0}` exists with different content than planned, consider removing it with `rm {0}`"
)]
DifferentContent(std::path::PathBuf),
/// The file already exists
#[error("`{0}` already exists, consider removing it with `rm {0}`")]
FileExists(std::path::PathBuf),
/// The directory already exists
#[error("`{0}` already exists, consider removing it with `rm -r {0}`")]
DirExists(std::path::PathBuf),
/// The symlink already exists
#[error("`{0}` already exists, consider removing it with `rm {0}`")]
SymlinkExists(std::path::PathBuf),
#[error("`{0}` exists with a different uid ({1}) than planned ({2}), consider updating it with `chown {2} {0}`")]
PathUserMismatch(std::path::PathBuf, u32, u32),
#[error("`{0}` exists with a different gid ({1}) than planned ({2}), consider updating it with `chgrp {2} {0}`")]
PathGroupMismatch(std::path::PathBuf, u32, u32),
#[error("`{0}` exists with a different mode ({existing_mode:o}) than planned ({planned_mode:o}), consider updating it with `chmod {planned_mode:o} {0}`",
existing_mode = .1 & 0o777,
planned_mode = .2 & 0o777)]
PathModeMismatch(std::path::PathBuf, u32, u32),
#[error("Path `{0}` exists, but is not a file, consider removing it with `rm {0}`")]
PathWasNotFile(std::path::PathBuf),
#[error("Path `{0}` exists, but is not a directory, consider removing it with `rm {0}`")]
PathWasNotDirectory(std::path::PathBuf),
#[error("Getting metadata for {0}`")]
GettingMetadata(std::path::PathBuf, #[source] std::io::Error),
#[error("Creating directory `{0}`")]
CreateDirectory(std::path::PathBuf, #[source] std::io::Error),
#[error("Symlinking from `{0}` to `{1}`")]
Symlink(
std::path::PathBuf,
std::path::PathBuf,
#[source] std::io::Error,
),
#[error("Getting filesystem metadata for `{0}` on `{1}`")]
GetMetadata(std::path::PathBuf, #[source] std::io::Error),
#[error("Set mode `{0:#o}` on `{1}`")]
SetPermissions(u32, std::path::PathBuf, #[source] std::io::Error),
#[error("Remove file `{0}`")]
Remove(std::path::PathBuf, #[source] std::io::Error),
#[error("Copying file `{0}` to `{1}`")]
Copy(
std::path::PathBuf,
std::path::PathBuf,
#[source] std::io::Error,
),
#[error("Rename `{0}` to `{1}`")]
Rename(
std::path::PathBuf,
std::path::PathBuf,
#[source] std::io::Error,
),
#[error("Canonicalizing `{0}`")]
Canonicalize(std::path::PathBuf, #[source] std::io::Error),
#[error("Read path `{0}`")]
Read(std::path::PathBuf, #[source] std::io::Error),
#[error("Reading directory `{0}`")]
ReadDir(std::path::PathBuf, #[source] std::io::Error),
#[error("Reading symbolic link `{0}`")]
ReadSymlink(std::path::PathBuf, #[source] std::io::Error),
#[error("Open path `{0}`")]
Open(std::path::PathBuf, #[source] std::io::Error),
#[error("Write path `{0}`")]
Write(std::path::PathBuf, #[source] std::io::Error),
#[error("Sync path `{0}`")]
Sync(std::path::PathBuf, #[source] std::io::Error),
#[error("Seek path `{0}`")]
Seek(std::path::PathBuf, #[source] std::io::Error),
#[error("Flushing `{0}`")]
Flush(std::path::PathBuf, #[source] std::io::Error),
#[error("Truncating `{0}`")]
Truncate(std::path::PathBuf, #[source] std::io::Error),
#[error("Getting uid for user `{0}`")]
GettingUserId(String, #[source] nix::errno::Errno),
#[error("User `{0}` existed but had a different uid ({1}) than planned ({2})")]
UserUidMismatch(String, u32, u32),
#[error("User `{0}` existed but had a different gid ({1}) than planned ({2})")]
UserGidMismatch(String, u32, u32),
#[error("Getting user `{0}`")]
NoUser(String),
#[error("Getting gid for group `{0}`")]
GettingGroupId(String, #[source] nix::errno::Errno),
#[error("Group `{0}` existed but had a different gid ({1}) than planned ({2})")]
GroupGidMismatch(String, u32, u32),
#[error("Getting group `{0}`")]
NoGroup(String),
#[error("Chowning path `{0}`")]
Chown(std::path::PathBuf, #[source] nix::errno::Errno),
#[error("Glob globbing error")]
GlobGlobError(
#[from]
#[source]
glob::GlobError,
),
#[error("Glob pattern error")]
GlobPatternError(
#[from]
#[source]
glob::PatternError,
),
/// Failed to execute command
#[error("Failed to execute command `{command}`",
command = .command,
)]
Command {
#[cfg(feature = "diagnostics")]
program: String,
command: String,
#[source]
error: std::io::Error,
},
#[error(
"Failed to execute command{maybe_status} `{command}`, stdout: {stdout}\nstderr: {stderr}\n",
command = .command,
stdout = String::from_utf8_lossy(&.output.stdout),
stderr = String::from_utf8_lossy(&.output.stderr),
maybe_status = if let Some(status) = .output.status.code() {
format!(" with status {status}")
} else {
"".to_string()
}
)]
CommandOutput {
#[cfg(feature = "diagnostics")]
program: String,
command: String,
output: Output,
},
#[error("Joining spawned async task")]
Join(
#[source]
#[from]
JoinError,
),
#[error("String from UTF-8 error")]
FromUtf8(
#[source]
#[from]
std::string::FromUtf8Error,
),
#[error("Path `{}` could not be converted to valid UTF-8 string", .0.display())]
PathNoneString(std::path::PathBuf),
/// A MacOS (Darwin) plist related error
#[error(transparent)]
Plist(#[from] plist::Error),
#[error("Unexpected binary tarball contents found, the build result from `https://releases.nixos.org/?prefix=nix/` or `nix build nix#hydraJobs.binaryTarball.$SYSTEM` is expected")]
MalformedBinaryTarball,
#[error("Could not find `{0}` in PATH; This action only works on SteamOS, which should have this present in PATH.")]
MissingSteamosBinary(String),
#[error(
"Could not find a supported command to create users in PATH; please install `useradd` or `adduser`"
)]
MissingUserCreationCommand,
#[error("Could not find a supported command to create groups in PATH; please install `groupadd` or `addgroup`")]
MissingGroupCreationCommand,
#[error("Could not find a supported command to add users to groups in PATH; please install `gpasswd` or `addgroup`")]
MissingAddUserToGroupCommand,
#[error(
"Could not find a supported command to delete users in PATH; please install `userdel` or `deluser`"
)]
MissingUserDeletionCommand,
#[error("Could not find a supported command to delete groups in PATH; please install `groupdel` or `delgroup`")]
MissingGroupDeletionCommand,
#[error("Could not find a supported command to remove users from groups in PATH; please install `gpasswd` or `deluser`")]
MissingRemoveUserFromGroupCommand,
#[error("\
Could not detect systemd; you may be able to get up and running without systemd with `nix-installer install linux --init none`.\n\
See https://github.com/DeterminateSystems/nix-installer#without-systemd-linux-only for documentation on usage and drawbacks.\
")]
SystemdMissing,
#[error("`{command}` failed, message: {message}")]
DiskUtilInfoError { command: String, message: String },
#[error(transparent)]
UrlOrPathError(#[from] UrlOrPathError),
#[error("Request error")]
Reqwest(
#[from]
#[source]
reqwest::Error,
),
#[error("Unknown url scheme")]
UnknownUrlScheme,
}
impl ActionErrorKind {
pub fn command(command: &tokio::process::Command, error: std::io::Error) -> Self {
Self::Command {
#[cfg(feature = "diagnostics")]
program: command.as_std().get_program().to_string_lossy().into(),
command: format!("{:?}", command.as_std()),
error,
}
}
pub fn command_output(command: &tokio::process::Command, output: std::process::Output) -> Self {
Self::CommandOutput {
#[cfg(feature = "diagnostics")]
program: command.as_std().get_program().to_string_lossy().into(),
command: format!("{:?}", command.as_std()),
output,
}
}
}
impl HasExpectedErrors for ActionErrorKind {
fn expected<'a>(&'a self) -> Option<Box<dyn std::error::Error + 'a>> {
match self {
Self::PathUserMismatch(_, _, _)
| Self::PathGroupMismatch(_, _, _)
| Self::PathModeMismatch(_, _, _) => Some(Box::new(self)),
Self::SystemdMissing => Some(Box::new(self)),
_ => None,
}
}
}
#[cfg(feature = "diagnostics")]
impl crate::diagnostics::ErrorDiagnostic for ActionErrorKind {
fn diagnostic(&self) -> String {
let static_str: &'static str = (self).into();
let context = match self {
Self::Child(child) => vec![child.diagnostic()],
Self::MultipleChildren(children) => {
children.iter().map(|child| child.diagnostic()).collect()
},
Self::Read(path, _)
| Self::Open(path, _)
| Self::Write(path, _)
| Self::Flush(path, _)
| Self::SetPermissions(_, path, _)
| Self::GettingMetadata(path, _)
| Self::CreateDirectory(path, _)
| Self::PathWasNotFile(path)
| Self::Remove(path, _) => {
vec![path.to_string_lossy().to_string()]
},
Self::Rename(first_path, second_path, _)
| Self::Copy(first_path, second_path, _)
| Self::Symlink(first_path, second_path, _) => {
vec![
first_path.to_string_lossy().to_string(),
second_path.to_string_lossy().to_string(),
]
},
Self::NoGroup(name) | Self::NoUser(name) => {
vec![name.clone()]
},
Self::Command {
program,
command: _,
error: _,
}
| Self::CommandOutput {
program,
command: _,
output: _,
} => {
vec![program.clone()]
},
_ => vec![],
};
format!(
"{}({})",
static_str,
context
.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(", ")
)
}
}