diff --git a/src/action/base/create_directory.rs b/src/action/base/create_directory.rs index 6fd7e9e..a16c12c 100644 --- a/src/action/base/create_directory.rs +++ b/src/action/base/create_directory.rs @@ -165,7 +165,7 @@ impl Action for CreateDirectory { None }; - create_dir(path.clone()) + create_dir(&path) .await .map_err(|e| ActionErrorKind::CreateDirectory(path.clone(), e)) .map_err(Self::error)?; diff --git a/src/action/linux/ensure_steamos_nix_directory.rs b/src/action/linux/ensure_steamos_nix_directory.rs new file mode 100644 index 0000000..ce39767 --- /dev/null +++ b/src/action/linux/ensure_steamos_nix_directory.rs @@ -0,0 +1,102 @@ +use std::path::{Path, PathBuf}; + +use tokio::fs::create_dir; +use tokio::process::Command; +use tracing::{span, Span}; + +use crate::action::{ActionError, ActionErrorKind, ActionTag}; +use crate::execute_command; + +use crate::action::{Action, ActionDescription, StatefulAction}; + +/** +Ensure SeamOS's `/nix` folder exists. + +In SteamOS build ID 20230522.1000 (and, presumably, later) a `/nix` directory and related units +exist. In previous versions of `nix-installer` the uninstall process would remove that directory. +This action ensures that the folder does indeed exist. +*/ +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] +pub struct EnsureSteamosNixDirectory; + +impl EnsureSteamosNixDirectory { + #[tracing::instrument(level = "debug", skip_all)] + pub async fn plan() -> Result, ActionError> { + if which::which("steamos-readonly").is_err() { + return Err(Self::error(ActionErrorKind::MissingSteamosBinary( + "steamos-readonly".into(), + ))); + } + if Path::new("/nix").exists() { + Ok(StatefulAction::completed(EnsureSteamosNixDirectory)) + } else { + Ok(StatefulAction::uncompleted(EnsureSteamosNixDirectory)) + } + } +} + +#[async_trait::async_trait] +#[typetag::serde(name = "ensure_steamos_nix_directory")] +impl Action for EnsureSteamosNixDirectory { + fn action_tag() -> ActionTag { + ActionTag("ensure_steamos_nix_directory") + } + fn tracing_synopsis(&self) -> String { + format!("Ensure SteamOS's `/nix` directory exists") + } + + fn tracing_span(&self) -> Span { + span!(tracing::Level::DEBUG, "ensure_steamos_nix_directory",) + } + + fn execute_description(&self) -> Vec { + vec![ActionDescription::new( + self.tracing_synopsis(), + vec![ + "On more recent versions of SteamOS, a `/nix` folder now exists on the base image.".to_string(), + "Previously, `nix-installer` created this directory through systemd units.".to_string(), + "It's likely you updated SteamOS, then ran `/nix/nix-installer uninstall`, which deleted the `/nix` directory.".to_string(), + ], + )] + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn execute(&mut self) -> Result<(), ActionError> { + execute_command( + Command::new("steamos-readonly") + .process_group(0) + .arg("disable") + .stdin(std::process::Stdio::null()), + ) + .await + .map_err(Self::error)?; + + let path = PathBuf::from("/nix"); + create_dir(&path) + .await + .map_err(|e| ActionErrorKind::CreateDirectory(path.clone(), e)) + .map_err(Self::error)?; + + execute_command( + Command::new("steamos-readonly") + .process_group(0) + .arg("enable") + .stdin(std::process::Stdio::null()), + ) + .await + .map_err(Self::error)?; + + Ok(()) + } + + fn revert_description(&self) -> Vec { + vec![] + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn revert(&mut self) -> Result<(), ActionError> { + // noop + + Ok(()) + } +} diff --git a/src/action/linux/mod.rs b/src/action/linux/mod.rs index 046133e..875e4be 100644 --- a/src/action/linux/mod.rs +++ b/src/action/linux/mod.rs @@ -1,5 +1,9 @@ +pub(crate) mod ensure_steamos_nix_directory; pub(crate) mod provision_selinux; pub(crate) mod start_systemd_unit; +pub(crate) mod systemctl_daemon_reload; +pub use ensure_steamos_nix_directory::EnsureSteamosNixDirectory; pub use provision_selinux::ProvisionSelinux; pub use start_systemd_unit::{StartSystemdUnit, StartSystemdUnitError}; +pub use systemctl_daemon_reload::SystemctlDaemonReload; diff --git a/src/action/linux/start_systemd_unit.rs b/src/action/linux/start_systemd_unit.rs index 70ca769..b1f9fa8 100644 --- a/src/action/linux/start_systemd_unit.rs +++ b/src/action/linux/start_systemd_unit.rs @@ -54,7 +54,7 @@ impl Action for StartSystemdUnit { ActionTag("start_systemd_unit") } fn tracing_synopsis(&self) -> String { - format!("Enable (and start) the systemd unit {}", self.unit) + format!("Enable (and start) the systemd unit `{}`", self.unit) } fn tracing_span(&self) -> Span { @@ -106,7 +106,7 @@ impl Action for StartSystemdUnit { fn revert_description(&self) -> Vec { vec![ActionDescription::new( - format!("Disable (and stop) the systemd unit {}", self.unit), + format!("Disable (and stop) the systemd unit `{}`", self.unit), vec![], )] } diff --git a/src/action/linux/systemctl_daemon_reload.rs b/src/action/linux/systemctl_daemon_reload.rs new file mode 100644 index 0000000..4286640 --- /dev/null +++ b/src/action/linux/systemctl_daemon_reload.rs @@ -0,0 +1,81 @@ +use std::path::Path; + +use tokio::process::Command; +use tracing::{span, Span}; + +use crate::action::{ActionError, ActionErrorKind, ActionTag}; +use crate::execute_command; + +use crate::action::{Action, ActionDescription, StatefulAction}; + +/** +Run `systemctl daemon-reload` (on both execute and revert) +*/ +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] +pub struct SystemctlDaemonReload; + +impl SystemctlDaemonReload { + #[tracing::instrument(level = "debug", skip_all)] + pub async fn plan() -> Result, ActionError> { + if !Path::new("/run/systemd/system").exists() { + return Err(Self::error(ActionErrorKind::SystemdMissing)); + } + + if which::which("systemctl").is_err() { + return Err(Self::error(ActionErrorKind::SystemdMissing)); + } + + Ok(StatefulAction::uncompleted(SystemctlDaemonReload)) + } +} + +#[async_trait::async_trait] +#[typetag::serde(name = "systemctl_daemon_reload")] +impl Action for SystemctlDaemonReload { + fn action_tag() -> ActionTag { + ActionTag("systemctl_daemon_reload") + } + fn tracing_synopsis(&self) -> String { + format!("Run `systemctl daemon-reload`") + } + + fn tracing_span(&self) -> Span { + span!(tracing::Level::DEBUG, "systemctl_daemon_reload",) + } + + 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("systemctl") + .process_group(0) + .arg("daemon-reload") + .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("systemctl") + .process_group(0) + .arg("daemon-reload") + .stdin(std::process::Stdio::null()), + ) + .await + .map_err(Self::error)?; + + Ok(()) + } +} diff --git a/src/action/mod.rs b/src/action/mod.rs index def8033..c33ba14 100644 --- a/src/action/mod.rs +++ b/src/action/mod.rs @@ -243,7 +243,7 @@ pub trait Action: Send + Sync + std::fmt::Debug + dyn_clone::DynClone { /// /// 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`. + /// 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 @@ -518,6 +518,8 @@ pub enum ActionErrorKind { 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`" )] diff --git a/src/planner/mod.rs b/src/planner/mod.rs index 64b1665..f6ff238 100644 --- a/src/planner/mod.rs +++ b/src/planner/mod.rs @@ -381,6 +381,9 @@ pub enum PlannerError { NixExists, #[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")] Wsl1, + /// Failed to execute command + #[error("Failed to execute command `{0}`")] + Command(String, #[source] std::io::Error), #[cfg(feature = "diagnostics")] #[error(transparent)] Diagnostic(#[from] crate::diagnostics::DiagnosticError), @@ -407,6 +410,7 @@ impl HasExpectedErrors for PlannerError { this @ PlannerError::NixOs => Some(Box::new(this)), this @ PlannerError::NixExists => Some(Box::new(this)), this @ PlannerError::Wsl1 => Some(Box::new(this)), + PlannerError::Command(_, _) => None, #[cfg(feature = "diagnostics")] PlannerError::Diagnostic(diagnostic_error) => Some(Box::new(diagnostic_error)), } diff --git a/src/planner/steam_deck.rs b/src/planner/steam_deck.rs index cfee8f3..e1242c5 100644 --- a/src/planner/steam_deck.rs +++ b/src/planner/steam_deck.rs @@ -39,6 +39,7 @@ One time step: 6. Run `sudo steamos-chroot --disk /dev/nvme0n1 --partset B` and inside run the same above commands 7. Safely turn off the VM! + Repeated step: 1. Create a snapshot of the base install to work on ```sh @@ -58,14 +59,52 @@ Repeated step: ``` 3. **Do your testing!** You can `ssh deck@localhost -p 2222` in and use `rsync -e 'ssh -p 2222' result/bin/nix-installer deck@localhost:nix-installer` to send a `nix-installer build. 4. Delete `steamos-hack.qcow2` + + +To test a specific channel of the Steam Deck: +1. Use `steamos-select-branch -l` to list possible branches. +2. Run `steamos-select-branch $BRANCH` to choose a branch +3. Run `steamos-update` +4. Run `sudo steamos-chroot --disk /dev/vda --partset A` and inside run this + ```sh + steamos-readonly disable + echo -e '[Autologin]\nSession=plasma.desktop' > /etc/sddm.conf.d/zz-steamos-autologin.conf + passwd deck + sudo systemctl enable sshd + steamos-readonly enable + exit + ``` +5. Run `sudo steamos-chroot --disk /dev/vda --partset B` and inside run the same above commands +6. Safely turn off the VM! + + +To test on a specific build id of the Steam Deck: +1. Determine the build id to be targeted. On a running system this is found in `/etc/os-release` under `BUILD_ID`. +2. Run `steamos-update-os now --update-version $BUILD_ID` + + If you can't access a specific build ID you may need to change branches, see above. + + Be patient, don't ctrl+C it, it breaks. Don't reboot yet! +4. Run `sudo steamos-chroot --disk /dev/vda --partset A` and inside run this + ```sh + steamos-readonly disable + echo -e '[Autologin]\nSession=plasma.desktop' > /etc/sddm.conf.d/zz-steamos-autologin.conf + passwd deck + sudo systemctl enable sshd + steamos-readonly enable + exit + ``` +5. Run `sudo steamos-chroot --disk /dev/vda --partset B` and inside run the same above commands +6. Safely turn off the VM! + */ -use std::{collections::HashMap, path::PathBuf}; +use std::{collections::HashMap, path::PathBuf, process::Output}; + +use tokio::process::Command; use crate::{ action::{ base::{CreateDirectory, CreateFile, RemoveDirectory}, common::{ConfigureInitService, ConfigureNix, ProvisionNix}, - linux::StartSystemdUnit, + linux::{EnsureSteamosNixDirectory, StartSystemdUnit, SystemctlDaemonReload}, Action, StatefulAction, }, planner::{Planner, PlannerError}, @@ -78,6 +117,7 @@ use super::ShellProfileLocations; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "cli", derive(clap::Parser))] pub struct SteamDeck { + /// Where `/nix` will be bind mounted to. Deprecated in SteamOS build ID 20230522.1000 or later #[cfg_attr( feature = "cli", clap( @@ -102,89 +142,127 @@ impl Planner for SteamDeck { } async fn plan(&self) -> Result>>, PlannerError> { - let persistence = &self.persistence; - if !persistence.is_absolute() { - return Err(PlannerError::Custom(Box::new( - SteamDeckError::AbsolutePathRequired(self.persistence.clone()), - ))); - }; + // Starting in roughly build ID `20230522.1000`, the Steam Deck has a `/home/.steamos/offload/nix` directory and `nix.mount` unit we can use instead of creating a mountpoint. + let requires_nix_bind_mount = detect_requires_bind_mount().await?; - let nix_directory_buf = format!( - "\ - [Unit]\n\ - Description=Create a `/nix` directory to be used for bind mounting\n\ - PropagatesStopTo=nix-daemon.service\n\ - PropagatesStopTo=nix.mount\n\ - DefaultDependencies=no\n\ - After=grub-recordfail.service\n\ - After=steamos-finish-oobe-migration.service\n\ - \n\ - [Service]\n\ - Type=oneshot\n\ - ExecStart=steamos-readonly disable\n\ - ExecStart=mkdir -vp /nix\n\ - ExecStart=chmod -v 0755 /nix\n\ - ExecStart=chown -v root /nix\n\ - ExecStart=chgrp -v root /nix\n\ - ExecStart=steamos-readonly enable\n\ - ExecStop=steamos-readonly disable\n\ - ExecStop=rmdir /nix\n\ - ExecStop=steamos-readonly enable\n\ - RemainAfterExit=true\n\ - " - ); - let nix_directory_unit = CreateFile::plan( - "/etc/systemd/system/nix-directory.service", - None, - None, - 0o0644, - nix_directory_buf, - false, - ) - .await - .map_err(PlannerError::Action)?; + let mut actions = vec![ + // Primarily for uninstall + SystemctlDaemonReload::plan() + .await + .map_err(PlannerError::Action)? + .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 = 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)?; + if let Ok(nix_mount_status) = systemctl_status("nix.mount").await { + let nix_mount_status_stderr = String::from_utf8(nix_mount_status.stderr)?; + if nix_mount_status_stderr.contains("Warning: The unit file, source configuration file or drop-ins of nix.mount changed on disk. Run 'systemctl daemon-reload' to reload units.") { + return Err(PlannerError::Custom(Box::new( + SteamDeckError::NixMountSystemctlDaemonReloadRequired, + ))) + } + } + + if requires_nix_bind_mount { + let persistence = &self.persistence; + if !persistence.is_absolute() { + return Err(PlannerError::Custom(Box::new( + SteamDeckError::AbsolutePathRequired(self.persistence.clone()), + ))); + }; + actions.push( + CreateDirectory::plan(&persistence, None, None, 0o0755, true) + .await + .map_err(PlannerError::Action)? + .boxed(), + ); + + let nix_directory_buf = format!( + "\ + [Unit]\n\ + Description=Create a `/nix` directory to be used for bind mounting\n\ + PropagatesStopTo=nix-daemon.service\n\ + PropagatesStopTo=nix.mount\n\ + DefaultDependencies=no\n\ + After=grub-recordfail.service\n\ + After=steamos-finish-oobe-migration.service\n\ + \n\ + [Service]\n\ + Type=oneshot\n\ + ExecStart=steamos-readonly disable\n\ + ExecStart=mkdir -vp /nix\n\ + ExecStart=chmod -v 0755 /nix\n\ + ExecStart=chown -v root /nix\n\ + ExecStart=chgrp -v root /nix\n\ + ExecStart=steamos-readonly enable\n\ + ExecStop=steamos-readonly disable\n\ + ExecStop=rmdir /nix\n\ + ExecStop=steamos-readonly enable\n\ + RemainAfterExit=true\n\ + " + ); + let nix_directory_unit = CreateFile::plan( + "/etc/systemd/system/nix-directory.service", + None, + None, + 0o0644, + nix_directory_buf, + false, + ) + .await + .map_err(PlannerError::Action)?; + actions.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 = 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)?; + actions.push(create_bind_mount_unit.boxed()); + } else { + let ensure_steamos_nix_directory = EnsureSteamosNixDirectory::plan() + .await + .map_err(PlannerError::Action)?; + actions.push(ensure_steamos_nix_directory.boxed()); + let start_nix_mount = StartSystemdUnit::plan("nix.mount".to_string(), true) + .await + .map_err(PlannerError::Action)?; + actions.push(start_nix_mount.boxed()); + } let ensure_symlinked_units_resolve_buf = format!( "\ [Unit]\n\ Description=Ensure Nix related units which are symlinked resolve\n\ After=nix.mount\n\ - Requires=nix-directory.service\n\ Requires=nix.mount\n\ DefaultDependencies=no\n\ \n\ @@ -208,6 +286,7 @@ impl Planner for SteamDeck { ) .await .map_err(PlannerError::Action)?; + actions.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(); @@ -223,18 +302,16 @@ impl Planner for SteamDeck { .remove(index); } - Ok(vec![ - CreateDirectory::plan(&persistence, None, None, 0o0755, true) - .await - .map_err(PlannerError::Action)? - .boxed(), - nix_directory_unit.boxed(), - create_bind_mount_unit.boxed(), - ensure_symlinked_units_resolve_unit.boxed(), - StartSystemdUnit::plan("nix.mount".to_string(), false) - .await - .map_err(PlannerError::Action)? - .boxed(), + if requires_nix_bind_mount { + actions.push( + StartSystemdUnit::plan("nix.mount".to_string(), false) + .await + .map_err(PlannerError::Action)? + .boxed(), + ) + } + + actions.append(&mut vec![ ProvisionNix::plan(&self.settings.clone()) .await .map_err(PlannerError::Action)? @@ -260,7 +337,12 @@ impl Planner for SteamDeck { .await .map_err(PlannerError::Action)? .boxed(), - ]) + SystemctlDaemonReload::plan() + .await + .map_err(PlannerError::Action)? + .boxed(), + ]); + Ok(actions) } fn settings(&self) -> Result, InstallSettingsError> { @@ -320,4 +402,36 @@ impl Into for SteamDeck { pub enum SteamDeckError { #[error("`{0}` is not a path that can be canonicalized into an absolute path, bind mounts require an absolute path")] AbsolutePathRequired(PathBuf), + #[error("A `/home/.steamos/offload/nix` exists, however `nix.mount` does not point at it. If Nix was previously installed, try uninstalling then rebooting first")] + OffloadExistsButUnitIncorrect, + #[error("Detected the SteamOS `nix.mount` unit exists, but `systemctl status nix.mount` did not return success. Try running `systemctl daemon-reload`.")] + SteamosNixMountUnitNotExists, + #[error("Detected the SteamOS `nix.mount` unit exists, but `systemctl status nix.mount` returned a warning that `systemctl daemon-reload` should be run. Run `systemctl daemon-reload` then `systemctl start nix.mount`, then try again.")] + NixMountSystemctlDaemonReloadRequired, +} + +pub(crate) async fn detect_requires_bind_mount() -> Result { + let steamos_nix_mount_unit_path = "/usr/lib/systemd/system/nix.mount"; + let nix_mount_unit = tokio::fs::read_to_string(steamos_nix_mount_unit_path) + .await + .map(|v| Some(v)) + .unwrap_or_else(|_| None); + + match nix_mount_unit { + Some(nix_mount_unit) if nix_mount_unit.contains("What=/home/.steamos/offload/nix") => { + Ok(false) + }, + None | Some(_) => Ok(true), + } +} + +async fn systemctl_status(unit: &str) -> Result { + let mut command = Command::new("systemctl"); + command.arg("status"); + command.arg(unit); + let output = command + .output() + .await + .map_err(|e| PlannerError::Command(format!("{:?}", command.as_std()), e))?; + Ok(output) }