diff --git a/src/planner/mod.rs b/src/planner/mod.rs index 8db515a..0ddae72 100644 --- a/src/planner/mod.rs +++ b/src/planner/mod.rs @@ -107,6 +107,8 @@ pub mod linux; #[cfg(target_os = "macos")] pub mod macos; #[cfg(target_os = "linux")] +pub mod ostree; +#[cfg(target_os = "linux")] pub mod steam_deck; use std::{collections::HashMap, path::PathBuf, string::FromUtf8Error}; @@ -171,6 +173,9 @@ pub enum BuiltinPlanner { /// A planner suitable for the Valve Steam Deck running SteamOS #[cfg(target_os = "linux")] SteamDeck(steam_deck::SteamDeck), + /// A planner suitable for immutable distributions using ostree + #[cfg(target_os = "linux")] + Ostree(ostree::Ostree), } impl BuiltinPlanner { @@ -179,15 +184,7 @@ impl BuiltinPlanner { use target_lexicon::{Architecture, OperatingSystem}; match (Architecture::host(), OperatingSystem::host()) { #[cfg(target_os = "linux")] - (Architecture::X86_64, OperatingSystem::Linux) => { - let os_release = os_release::OsRelease::new().ok(); - match os_release { - Some(os_release) if os_release.id == "steamos" => { - Ok(Self::SteamDeck(steam_deck::SteamDeck::default().await?)) - }, - _ => Ok(Self::Linux(linux::Linux::default().await?)), - } - }, + (Architecture::X86_64, OperatingSystem::Linux) => Self::detect_linux_distro().await, #[cfg(target_os = "linux")] (Architecture::X86_32(_), OperatingSystem::Linux) => { Ok(Self::Linux(linux::Linux::default().await?)) @@ -210,6 +207,25 @@ impl BuiltinPlanner { } } + async fn detect_linux_distro() -> Result { + let is_steam_deck = + os_release::OsRelease::new().is_ok_and(|os_release| os_release.id == "steamos"); + if is_steam_deck { + return Ok(Self::SteamDeck(steam_deck::SteamDeck::default().await?)); + } + + let is_ostree = std::process::Command::new("ostree") + .arg("remote") + .arg("list") + .output() + .is_ok_and(|output| output.status.success()); + if is_ostree { + return Ok(Self::Ostree(ostree::Ostree::default().await?)); + } + + Ok(Self::Linux(linux::Linux::default().await?)) + } + pub async fn from_common_settings(settings: CommonSettings) -> Result { let mut built = Self::default().await?; match &mut built { @@ -217,6 +233,8 @@ impl BuiltinPlanner { BuiltinPlanner::Linux(inner) => inner.settings = settings, #[cfg(target_os = "linux")] BuiltinPlanner::SteamDeck(inner) => inner.settings = settings, + #[cfg(target_os = "linux")] + BuiltinPlanner::Ostree(inner) => inner.settings = settings, #[cfg(target_os = "macos")] BuiltinPlanner::Macos(inner) => inner.settings = settings, } @@ -231,6 +249,8 @@ impl BuiltinPlanner { BuiltinPlanner::Linux(inner) => inner.configured_settings().await, #[cfg(target_os = "linux")] BuiltinPlanner::SteamDeck(inner) => inner.configured_settings().await, + #[cfg(target_os = "linux")] + BuiltinPlanner::Ostree(inner) => inner.configured_settings().await, #[cfg(target_os = "macos")] BuiltinPlanner::Macos(inner) => inner.configured_settings().await, } @@ -242,6 +262,8 @@ impl BuiltinPlanner { BuiltinPlanner::Linux(planner) => InstallPlan::plan(planner).await, #[cfg(target_os = "linux")] BuiltinPlanner::SteamDeck(planner) => InstallPlan::plan(planner).await, + #[cfg(target_os = "linux")] + BuiltinPlanner::Ostree(planner) => InstallPlan::plan(planner).await, #[cfg(target_os = "macos")] BuiltinPlanner::Macos(planner) => InstallPlan::plan(planner).await, } @@ -252,6 +274,8 @@ impl BuiltinPlanner { BuiltinPlanner::Linux(i) => i.boxed(), #[cfg(target_os = "linux")] BuiltinPlanner::SteamDeck(i) => i.boxed(), + #[cfg(target_os = "linux")] + BuiltinPlanner::Ostree(i) => i.boxed(), #[cfg(target_os = "macos")] BuiltinPlanner::Macos(i) => i.boxed(), } @@ -263,6 +287,8 @@ impl BuiltinPlanner { BuiltinPlanner::Linux(i) => i.typetag_name(), #[cfg(target_os = "linux")] BuiltinPlanner::SteamDeck(i) => i.typetag_name(), + #[cfg(target_os = "linux")] + BuiltinPlanner::Ostree(i) => i.typetag_name(), #[cfg(target_os = "macos")] BuiltinPlanner::Macos(i) => i.typetag_name(), } @@ -274,6 +300,8 @@ impl BuiltinPlanner { BuiltinPlanner::Linux(i) => i.settings(), #[cfg(target_os = "linux")] BuiltinPlanner::SteamDeck(i) => i.settings(), + #[cfg(target_os = "linux")] + BuiltinPlanner::Ostree(i) => i.settings(), #[cfg(target_os = "macos")] BuiltinPlanner::Macos(i) => i.settings(), } @@ -288,6 +316,8 @@ impl BuiltinPlanner { BuiltinPlanner::Linux(i) => i.diagnostic_data().await, #[cfg(target_os = "linux")] BuiltinPlanner::SteamDeck(i) => i.diagnostic_data().await, + #[cfg(target_os = "linux")] + BuiltinPlanner::Ostree(i) => i.diagnostic_data().await, #[cfg(target_os = "macos")] BuiltinPlanner::Macos(i) => i.diagnostic_data().await, } diff --git a/src/planner/ostree.rs b/src/planner/ostree.rs new file mode 100644 index 0000000..7699b4f --- /dev/null +++ b/src/planner/ostree.rs @@ -0,0 +1,339 @@ +use crate::{ + action::{ + base::{CreateDirectory, CreateFile, RemoveDirectory}, + common::{ConfigureInitService, ConfigureNix, CreateUsersAndGroups, ProvisionNix}, + linux::{ProvisionSelinux, StartSystemdUnit, SystemctlDaemonReload}, + StatefulAction, + }, + error::HasExpectedErrors, + planner::{Planner, PlannerError}, + settings::CommonSettings, + settings::{InitSystem, InstallSettingsError}, + Action, BuiltinPlanner, +}; +use std::{collections::HashMap, path::PathBuf}; + +use super::{ + linux::{ + check_nix_not_already_installed, check_not_nixos, check_not_wsl1, check_systemd_active, + detect_selinux, + }, + ShellProfileLocations, +}; + +/// A planner for Linux installs +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "cli", derive(clap::Parser))] +pub struct Ostree { + /// Where `/nix` will be bind mounted to. + #[cfg_attr(feature = "cli", clap(long, default_value = "/var/home/nix"))] + persistence: PathBuf, + #[cfg_attr(feature = "cli", clap(flatten))] + pub settings: CommonSettings, +} + +#[async_trait::async_trait] +#[typetag::serde(name = "ostree")] +impl Planner for Ostree { + async fn default() -> Result { + Ok(Self { + persistence: PathBuf::from("/var/home/nix"), + settings: CommonSettings::default().await?, + }) + } + + async fn plan(&self) -> Result>>, PlannerError> { + let has_selinux = detect_selinux().await?; + let mut plan = vec![ + // Primarily for uninstall + SystemctlDaemonReload::plan() + .await + .map_err(PlannerError::Action)? + .boxed(), + ]; + + plan.push( + CreateDirectory::plan(&self.persistence, None, None, 0o0755, true) + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + + let nix_directory_buf = "\ + [Unit]\n\ + Description=Enable mount points in / for ostree\n\ + ConditionPathExists=!/nix\n\ + DefaultDependencies=no\n\ + Requires=local-fs-pre.target\n\ + After=local-fs-pre.target\n\ + [Service]\n\ + Type=oneshot\n\ + ExecStartPre=chattr -i /\n\ + ExecStart=mkdir -p /nix\n\ + ExecStopPost=chattr +i /\n\ + " + .to_string(); + let nix_directory_unit = CreateFile::plan( + "/etc/systemd/system/nix-directory.service", + None, + None, + 0o0644, + nix_directory_buf, + false, + ) + .await + .map_err(PlannerError::Action)?; + plan.push(nix_directory_unit.boxed()); + + let create_bind_mount_buf = format!( + "\ + [Unit]\n\ + Description=Mount `{persistence}` on `/nix`\n\ + PropagatesStopTo=nix-daemon.service\n\ + PropagatesStopTo=nix-directory.service\n\ + After=nix-directory.service\n\ + Requires=nix-directory.service\n\ + ConditionPathIsDirectory=/nix\n\ + DefaultDependencies=no\n\ + \n\ + [Mount]\n\ + What={persistence}\n\ + Where=/nix\n\ + Type=none\n\ + DirectoryMode=0755\n\ + Options=bind\n\ + \n\ + [Install]\n\ + RequiredBy=nix-daemon.service\n\ + RequiredBy=nix-daemon.socket\n + ", + persistence = self.persistence.display(), + ); + let create_bind_mount_unit = CreateFile::plan( + "/etc/systemd/system/nix.mount", + None, + None, + 0o0644, + create_bind_mount_buf, + false, + ) + .await + .map_err(PlannerError::Action)?; + plan.push(create_bind_mount_unit.boxed()); + + let ensure_symlinked_units_resolve_buf = "\ + [Unit]\n\ + Description=Ensure Nix related units which are symlinked resolve\n\ + After=nix.mount\n\ + Requires=nix.mount\n\ + DefaultDependencies=no\n\ + \n\ + [Service]\n\ + Type=oneshot\n\ + RemainAfterExit=yes\n\ + ExecStart=/usr/bin/systemctl daemon-reload\n\ + ExecStart=/usr/bin/systemctl restart --no-block nix-daemon.socket\n\ + \n\ + [Install]\n\ + WantedBy=sysinit.target\n\ + " + .to_string(); + let ensure_symlinked_units_resolve_unit = CreateFile::plan( + "/etc/systemd/system/ensure-symlinked-units-resolve.service", + None, + None, + 0o0644, + ensure_symlinked_units_resolve_buf, + false, + ) + .await + .map_err(PlannerError::Action)?; + plan.push(ensure_symlinked_units_resolve_unit.boxed()); + + // We need to remove this path since it's part of the read-only install. + let mut shell_profile_locations = ShellProfileLocations::default(); + if let Some(index) = shell_profile_locations + .fish + .vendor_confd_prefixes + .iter() + .position(|v| *v == PathBuf::from("/usr/share/fish/")) + { + shell_profile_locations + .fish + .vendor_confd_prefixes + .remove(index); + } + + plan.push( + StartSystemdUnit::plan("nix.mount".to_string(), false) + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + + plan.push( + ProvisionNix::plan(&self.settings.clone()) + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + plan.push( + CreateUsersAndGroups::plan(self.settings.clone()) + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + plan.push( + ConfigureNix::plan(shell_profile_locations, &self.settings) + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + + if has_selinux { + plan.push( + ProvisionSelinux::plan("/etc/nix-installer/selinux/packages/nix.pp".into()) + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + } + + plan.push( + ConfigureInitService::plan(InitSystem::Systemd, true) + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + plan.push( + StartSystemdUnit::plan("ensure-symlinked-units-resolve.service".to_string(), true) + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + plan.push( + RemoveDirectory::plan(crate::settings::SCRATCH_DIR) + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + plan.push( + SystemctlDaemonReload::plan() + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + + Ok(plan) + } + + fn settings(&self) -> Result, InstallSettingsError> { + let Self { + persistence, + settings, + } = self; + let mut map = HashMap::default(); + + map.extend(settings.settings()?.into_iter()); + map.insert( + "persistence".to_string(), + serde_json::to_value(persistence)?, + ); + + Ok(map) + } + + async fn configured_settings( + &self, + ) -> Result, PlannerError> { + let default = Self::default().await?.settings()?; + let configured = self.settings()?; + + let mut settings: HashMap = 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 { + Ok(crate::diagnostics::DiagnosticData::new( + self.settings.diagnostic_endpoint.clone(), + self.typetag_name().into(), + self.configured_settings() + .await? + .into_keys() + .collect::>(), + self.settings.ssl_cert_file.clone(), + )?) + } + async fn pre_uninstall_check(&self) -> Result<(), PlannerError> { + check_not_wsl1()?; + + check_systemd_active()?; + + Ok(()) + } + + async fn pre_install_check(&self) -> Result<(), PlannerError> { + check_not_nixos()?; + + check_nix_not_already_installed().await?; + + check_not_wsl1()?; + + check_systemd_active()?; + + Ok(()) + } +} + +impl From for BuiltinPlanner { + fn from(val: Ostree) -> Self { + BuiltinPlanner::Ostree(val) + } +} + +#[non_exhaustive] +#[derive(Debug, thiserror::Error)] +pub enum OstreeError { + #[error( + "\ + systemd was not active.\n\ + \n\ + If it will be started later consider, passing `--no-start-daemon`.\n\ + \n\ + To use a `root`-only Nix install, consider passing `--init none`." + )] + SystemdNotActive, + #[error( + "\ + systemd was not active.\n\ + \n\ + On WSL2, systemd is not enabled by default. Consider enabling it by adding it to your `/etc/wsl.conf` with `echo -e '[boot]\\nsystemd=true'` then restarting WSL2 with `wsl.exe --shutdown` and re-entering the WSL shell. For more information, see https://devblogs.microsoft.com/commandline/systemd-support-is-now-available-in-wsl/.\n\ + \n\ + If it will be started later consider, passing `--no-start-daemon`.\n\ + \n\ + To use a `root`-only Nix install, consider passing `--init none`." + )] + Wsl2SystemdNotActive, +} + +impl HasExpectedErrors for OstreeError { + fn expected<'a>(&'a self) -> Option> { + match self { + OstreeError::SystemdNotActive => Some(Box::new(self)), + OstreeError::Wsl2SystemdNotActive => Some(Box::new(self)), + } + } +} + +impl From for PlannerError { + fn from(v: OstreeError) -> PlannerError { + PlannerError::Custom(Box::new(v)) + } +}