nix_installer/planner/
mod.rs

1/*! [`BuiltinPlanner`]s and traits to create new types which can be used to plan out an [`InstallPlan`]
2
3It's a [`Planner`]s job to construct (if possible) a valid [`InstallPlan`] for the host. Some planners are operating system specific, others are device specific.
4
5[`Planner`]s contain their planner specific settings, typically alongside a [`CommonSettings`].
6
7[`BuiltinPlanner::default()`] offers a way to get the default builtin planner for a given host.
8
9Custom Planners can also be used to create a platform, project, or organization specific install.
10
11A custom [`Planner`] can be created:
12
13```rust,no_run
14use std::{error::Error, collections::HashMap};
15use nix_installer::{
16    InstallPlan,
17    settings::{CommonSettings, InstallSettingsError},
18    planner::{Planner, PlannerError},
19    action::{Action, StatefulAction, base::CreateFile},
20};
21
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct MyPlanner {
24    pub common: CommonSettings,
25}
26
27
28#[async_trait::async_trait]
29#[typetag::serde(name = "my-planner")]
30impl Planner for MyPlanner {
31    async fn default() -> Result<Self, PlannerError> {
32        Ok(Self {
33            common: CommonSettings::default().await?,
34        })
35    }
36
37    async fn plan(&self) -> Result<Vec<StatefulAction<Box<dyn Action>>>, PlannerError> {
38        Ok(vec![
39            // ...
40
41                CreateFile::plan("/example", None, None, None, "Example".to_string(), false)
42                    .await
43                    .map_err(PlannerError::Action)?.boxed(),
44        ])
45    }
46
47    fn settings(&self) -> Result<HashMap<String, serde_json::Value>, InstallSettingsError> {
48        let Self { common } = self;
49        let mut map = std::collections::HashMap::default();
50
51        map.extend(common.settings()?.into_iter());
52
53        Ok(map)
54    }
55
56    async fn configured_settings(
57        &self,
58    ) -> Result<HashMap<String, serde_json::Value>, PlannerError> {
59        let default = Self::default().await?.settings()?;
60        let configured = self.settings()?;
61
62        let mut settings: HashMap<String, serde_json::Value> = HashMap::new();
63        for (key, value) in configured.iter() {
64            if default.get(key) != Some(value) {
65                settings.insert(key.clone(), value.clone());
66            }
67        }
68
69        Ok(settings)
70    }
71
72    #[cfg(feature = "diagnostics")]
73    async fn diagnostic_data(&self) -> Result<nix_installer::diagnostics::DiagnosticData, PlannerError> {
74        Ok(nix_installer::diagnostics::DiagnosticData::new(
75            self.common.diagnostic_attribution.clone(),
76            self.common.diagnostic_endpoint.clone(),
77            self.typetag_name().into(),
78            self.configured_settings()
79                .await?
80                .into_keys()
81                .collect::<Vec<_>>(),
82            self.common.ssl_cert_file.clone(),
83        )?)
84    }
85
86    async fn platform_check(&self) -> Result<(), PlannerError> {
87        use target_lexicon::OperatingSystem;
88        match target_lexicon::OperatingSystem::host() {
89            OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin => Ok(()),
90            host_os => Err(PlannerError::IncompatibleOperatingSystem {
91                planner: self.typetag_name(),
92                host_os,
93            }),
94        }
95    }
96}
97
98# async fn custom_planner_install() -> color_eyre::Result<()> {
99let planner = MyPlanner::default().await?;
100let mut plan = InstallPlan::plan(planner).await?;
101match plan.install(None).await {
102    Ok(()) => tracing::info!("Done"),
103    Err(e) => {
104        match e.source() {
105            Some(source) => tracing::error!("{e}: {}", source),
106            None => tracing::error!("{e}"),
107        };
108        plan.uninstall(None).await?;
109    },
110};
111
112#    Ok(())
113# }
114```
115
116*/
117pub mod linux;
118pub mod macos;
119pub mod ostree;
120pub mod steam_deck;
121
122use std::{collections::HashMap, path::PathBuf, string::FromUtf8Error};
123
124use serde::{Deserialize, Serialize};
125
126use crate::{
127    action::{ActionError, StatefulAction},
128    error::HasExpectedErrors,
129    settings::{CommonSettings, InstallSettingsError},
130    Action, InstallPlan, NixInstallerError,
131};
132
133/// Something which can be used to plan out an [`InstallPlan`]
134#[async_trait::async_trait]
135#[typetag::serde(tag = "planner")]
136pub trait Planner: std::fmt::Debug + Send + Sync + dyn_clone::DynClone {
137    /// Instantiate the planner with default settings, if possible
138    async fn default() -> Result<Self, PlannerError>
139    where
140        Self: Sized;
141    /// Plan out the [`Action`]s for an [`InstallPlan`]
142    async fn plan(&self) -> Result<Vec<StatefulAction<Box<dyn Action>>>, PlannerError>;
143    /// The settings being used by the planner
144    fn settings(&self) -> Result<HashMap<String, serde_json::Value>, InstallSettingsError>;
145
146    async fn configured_settings(&self)
147        -> Result<HashMap<String, serde_json::Value>, PlannerError>;
148
149    /// A boxed, type erased planner
150    fn boxed(self) -> Box<dyn Planner>
151    where
152        Self: Sized + 'static,
153    {
154        Box::new(self)
155    }
156
157    async fn platform_check(&self) -> Result<(), PlannerError>;
158
159    async fn pre_uninstall_check(&self) -> Result<(), PlannerError> {
160        Ok(())
161    }
162
163    async fn pre_install_check(&self) -> Result<(), PlannerError> {
164        Ok(())
165    }
166
167    #[cfg(feature = "diagnostics")]
168    async fn diagnostic_data(&self) -> Result<crate::diagnostics::DiagnosticData, PlannerError>;
169}
170
171dyn_clone::clone_trait_object!(Planner);
172
173/// Planners built into this crate
174#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
175#[cfg_attr(feature = "cli", derive(clap::Subcommand))]
176pub enum BuiltinPlanner {
177    #[cfg_attr(not(target_os = "linux"), clap(hide = true))]
178    /// A planner for traditional, mutable Linux systems like Debian, RHEL, or Arch
179    Linux(linux::Linux),
180    #[cfg_attr(not(target_os = "linux"), clap(hide = true))]
181    /// A planner for the Valve Steam Deck running SteamOS
182    SteamDeck(steam_deck::SteamDeck),
183    #[cfg_attr(not(target_os = "linux"), clap(hide = true))]
184    /// A planner suitable for immutable systems using ostree, such as Fedora Silverblue
185    Ostree(ostree::Ostree),
186    #[cfg_attr(not(target_os = "macos"), clap(hide = true))]
187    /// A planner for MacOS (Darwin) systems
188    Macos(macos::Macos),
189}
190
191impl BuiltinPlanner {
192    /// Heuristically determine the default planner for the target system
193    pub async fn default() -> Result<Self, PlannerError> {
194        use target_lexicon::{Architecture, OperatingSystem};
195        match (Architecture::host(), OperatingSystem::host()) {
196            (Architecture::X86_64, OperatingSystem::Linux) => Self::detect_linux_distro().await,
197            (Architecture::X86_32(_), OperatingSystem::Linux) => {
198                Ok(Self::Linux(linux::Linux::default().await?))
199            },
200            (Architecture::Aarch64(_), OperatingSystem::Linux) => {
201                Ok(Self::Linux(linux::Linux::default().await?))
202            },
203            (Architecture::X86_64, OperatingSystem::MacOSX { .. })
204            | (Architecture::X86_64, OperatingSystem::Darwin) => {
205                Ok(Self::Macos(macos::Macos::default().await?))
206            },
207            (Architecture::Aarch64(_), OperatingSystem::MacOSX { .. })
208            | (Architecture::Aarch64(_), OperatingSystem::Darwin) => {
209                Ok(Self::Macos(macos::Macos::default().await?))
210            },
211            _ => Err(PlannerError::UnsupportedArchitecture(target_lexicon::HOST)),
212        }
213    }
214
215    async fn detect_linux_distro() -> Result<Self, PlannerError> {
216        let is_steam_deck =
217            os_release::OsRelease::new().is_ok_and(|os_release| os_release.id == "steamos");
218        if is_steam_deck {
219            return Ok(Self::SteamDeck(steam_deck::SteamDeck::default().await?));
220        }
221
222        let is_ostree = std::process::Command::new("ostree")
223            .arg("remote")
224            .arg("list")
225            .output()
226            .is_ok_and(|output| output.status.success());
227        if is_ostree {
228            return Ok(Self::Ostree(ostree::Ostree::default().await?));
229        }
230
231        Ok(Self::Linux(linux::Linux::default().await?))
232    }
233
234    pub async fn from_common_settings(settings: CommonSettings) -> Result<Self, PlannerError> {
235        let mut built = Self::default().await?;
236        match &mut built {
237            BuiltinPlanner::Linux(inner) => inner.settings = settings,
238            BuiltinPlanner::SteamDeck(inner) => inner.settings = settings,
239            BuiltinPlanner::Ostree(inner) => inner.settings = settings,
240            BuiltinPlanner::Macos(inner) => inner.settings = settings,
241        }
242        Ok(built)
243    }
244
245    pub async fn configured_settings(
246        &self,
247    ) -> Result<HashMap<String, serde_json::Value>, PlannerError> {
248        match self {
249            BuiltinPlanner::Linux(inner) => inner.configured_settings().await,
250            BuiltinPlanner::SteamDeck(inner) => inner.configured_settings().await,
251            BuiltinPlanner::Ostree(inner) => inner.configured_settings().await,
252            BuiltinPlanner::Macos(inner) => inner.configured_settings().await,
253        }
254    }
255
256    pub async fn plan(self) -> Result<InstallPlan, NixInstallerError> {
257        match self {
258            BuiltinPlanner::Linux(planner) => InstallPlan::plan(planner).await,
259            BuiltinPlanner::SteamDeck(planner) => InstallPlan::plan(planner).await,
260            BuiltinPlanner::Ostree(planner) => InstallPlan::plan(planner).await,
261            BuiltinPlanner::Macos(planner) => InstallPlan::plan(planner).await,
262        }
263    }
264    pub fn boxed(self) -> Box<dyn Planner> {
265        match self {
266            BuiltinPlanner::Linux(i) => i.boxed(),
267            BuiltinPlanner::SteamDeck(i) => i.boxed(),
268            BuiltinPlanner::Ostree(i) => i.boxed(),
269            BuiltinPlanner::Macos(i) => i.boxed(),
270        }
271    }
272
273    pub fn typetag_name(&self) -> &'static str {
274        match self {
275            BuiltinPlanner::Linux(i) => i.typetag_name(),
276            BuiltinPlanner::SteamDeck(i) => i.typetag_name(),
277            BuiltinPlanner::Ostree(i) => i.typetag_name(),
278            BuiltinPlanner::Macos(i) => i.typetag_name(),
279        }
280    }
281
282    pub fn settings(&self) -> Result<HashMap<String, serde_json::Value>, InstallSettingsError> {
283        match self {
284            BuiltinPlanner::Linux(i) => i.settings(),
285            BuiltinPlanner::SteamDeck(i) => i.settings(),
286            BuiltinPlanner::Ostree(i) => i.settings(),
287            BuiltinPlanner::Macos(i) => i.settings(),
288        }
289    }
290
291    #[cfg(feature = "diagnostics")]
292    pub async fn diagnostic_data(
293        &self,
294    ) -> Result<crate::diagnostics::DiagnosticData, PlannerError> {
295        match self {
296            BuiltinPlanner::Linux(i) => i.diagnostic_data().await,
297            BuiltinPlanner::SteamDeck(i) => i.diagnostic_data().await,
298            BuiltinPlanner::Ostree(i) => i.diagnostic_data().await,
299            BuiltinPlanner::Macos(i) => i.diagnostic_data().await,
300        }
301    }
302}
303
304#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
305pub struct ShellProfileLocations {
306    pub fish: FishShellProfileLocations,
307    pub bash: Vec<PathBuf>,
308    pub zsh: Vec<PathBuf>,
309}
310
311impl Default for ShellProfileLocations {
312    fn default() -> Self {
313        Self {
314            fish: FishShellProfileLocations::default(),
315            bash: vec![
316                "/etc/bashrc".into(),
317                "/etc/profile.d/nix.sh".into(),
318                "/etc/bash.bashrc".into(),
319            ],
320            zsh: vec![
321                // https://zsh.sourceforge.io/Intro/intro_3.html
322                "/etc/zshrc".into(),
323                "/etc/zsh/zshrc".into(),
324            ],
325        }
326    }
327}
328
329#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
330pub struct FishShellProfileLocations {
331    pub confd_suffix: PathBuf,
332    /**
333     Each of these are common values of $__fish_sysconf_dir,
334    under which Fish will look for the file named by
335    `confd_suffix`.
336    */
337    pub confd_prefixes: Vec<PathBuf>,
338    /// Fish has different syntax than zsh/bash, treat it separate
339    pub vendor_confd_suffix: PathBuf,
340    /**
341    Each of these are common values of $__fish_vendor_confdir,
342    under which Fish will look for the file named by
343    `confd_suffix`.
344
345    More info: <https://fishshell.com/docs/3.3/index.html#configuration-files>
346    */
347    pub vendor_confd_prefixes: Vec<PathBuf>,
348}
349
350impl Default for FishShellProfileLocations {
351    fn default() -> Self {
352        Self {
353            confd_prefixes: vec![
354                "/etc/fish".into(),              // standard
355                "/usr/local/etc/fish".into(),    // their installer .pkg for macOS
356                "/opt/homebrew/etc/fish".into(), // homebrew
357                "/opt/local/etc/fish".into(),    // macports
358            ],
359            confd_suffix: "conf.d/nix.fish".into(),
360            vendor_confd_prefixes: vec!["/usr/share/fish/".into(), "/usr/local/share/fish/".into()],
361            vendor_confd_suffix: "vendor_conf.d/nix.fish".into(),
362        }
363    }
364}
365
366/// An error originating from a [`Planner`]
367#[non_exhaustive]
368#[derive(thiserror::Error, Debug, strum::IntoStaticStr)]
369pub enum PlannerError {
370    #[error("The selected planner (`{planner}`) does not support the host's operating system (`{host_os}`)")]
371    IncompatibleOperatingSystem {
372        planner: &'static str,
373        host_os: target_lexicon::OperatingSystem,
374    },
375    /// `nix-installer` does not have a default planner for the target architecture right now
376    #[error("`nix-installer` does not have a default planner for the `{0}` architecture right now, pass a specific archetype")]
377    UnsupportedArchitecture(target_lexicon::Triple),
378    /// Error executing action
379    #[error("Error executing action")]
380    Action(
381        #[source]
382        #[from]
383        ActionError,
384    ),
385    /// An [`InstallSettingsError`]
386    #[error(transparent)]
387    InstallSettings(#[from] InstallSettingsError),
388    /// An OS Release error
389    #[error("Fetching `/etc/os-release`")]
390    OsRelease(#[source] std::io::Error),
391    /// A MacOS (Darwin) plist related error
392    #[error(transparent)]
393    Plist(#[from] plist::Error),
394    #[error(transparent)]
395    Sysctl(#[from] sysctl::SysctlError),
396    #[error("Detected that this process is running under Rosetta, using Nix in Rosetta is not supported (Please open an issue with your use case)")]
397    RosettaDetected,
398    #[error("Determinate Nix is not available. See: https://determinate.systems/enterprise")]
399    DeterminateNixUnavailable,
400    /// A Linux SELinux related error
401    #[error("Unable to install on an SELinux system without common SELinux tooling, the binaries `restorecon`, and `semodule` are required")]
402    SelinuxRequirements,
403    /// A UTF-8 related error
404    #[error("UTF-8 error")]
405    Utf8(#[from] FromUtf8Error),
406    /// Custom planner error
407    #[error("Custom planner error")]
408    Custom(#[source] Box<dyn std::error::Error + Send + Sync>),
409    #[error("NixOS already has Nix installed")]
410    NixOs,
411    #[error("`nix` is already a valid command, so it is installed")]
412    NixExists,
413    #[error("WSL1 is not supported, please upgrade to WSL2: https://learn.microsoft.com/en-us/windows/wsl/install#upgrade-version-from-wsl-1-to-wsl-2")]
414    Wsl1,
415    /// Failed to execute command
416    #[error("Failed to execute command `{0}`")]
417    Command(String, #[source] std::io::Error),
418    #[cfg(feature = "diagnostics")]
419    #[error(transparent)]
420    Diagnostic(#[from] crate::diagnostics::DiagnosticError),
421}
422
423impl HasExpectedErrors for PlannerError {
424    fn expected<'a>(&'a self) -> Option<Box<dyn std::error::Error + 'a>> {
425        match self {
426            this @ PlannerError::UnsupportedArchitecture(_) => Some(Box::new(this)),
427            PlannerError::Action(_) => None,
428            PlannerError::InstallSettings(_) => None,
429            PlannerError::Plist(_) => None,
430            PlannerError::Sysctl(_) => None,
431            this @ PlannerError::IncompatibleOperatingSystem { .. } => Some(Box::new(this)),
432            this @ PlannerError::RosettaDetected => Some(Box::new(this)),
433            this @ PlannerError::DeterminateNixUnavailable => Some(Box::new(this)),
434            PlannerError::OsRelease(_) => None,
435            PlannerError::Utf8(_) => None,
436            PlannerError::SelinuxRequirements => Some(Box::new(self)),
437            PlannerError::Custom(_e) => {
438                #[cfg(target_os = "linux")]
439                if let Some(err) = _e.downcast_ref::<linux::LinuxErrorKind>() {
440                    return err.expected();
441                }
442                #[cfg(target_os = "macos")]
443                if let Some(err) = _e.downcast_ref::<macos::MacosError>() {
444                    return err.expected();
445                }
446                None
447            },
448            this @ PlannerError::NixOs => Some(Box::new(this)),
449            this @ PlannerError::NixExists => Some(Box::new(this)),
450            this @ PlannerError::Wsl1 => Some(Box::new(this)),
451            PlannerError::Command(_, _) => None,
452            #[cfg(feature = "diagnostics")]
453            PlannerError::Diagnostic(diagnostic_error) => Some(Box::new(diagnostic_error)),
454        }
455    }
456}
457
458#[cfg(feature = "diagnostics")]
459impl crate::diagnostics::ErrorDiagnostic for PlannerError {
460    fn diagnostic(&self) -> String {
461        let static_str: &'static str = (self).into();
462        static_str.to_string()
463    }
464}