Support for SteamOS Nix Offload in SteamOS 20230522.1000 (#495)

* Document how to handle different branches and buildIDs of the steam deck OS

* Changes required for SteamOS 20230522.1000

* Speeling

* Handle steamos upgrades better

* Speeling

* Tidy
This commit is contained in:
Ana Hobden 2023-06-01 09:48:50 -07:00 committed by GitHub
parent 539c21ec28
commit 5a07b2331b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 401 additions and 94 deletions

View file

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

View file

@ -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<StatefulAction<Self>, 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<ActionDescription> {
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<ActionDescription> {
vec![]
}
#[tracing::instrument(level = "debug", skip_all)]
async fn revert(&mut self) -> Result<(), ActionError> {
// noop
Ok(())
}
}

View file

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

View file

@ -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<ActionDescription> {
vec![ActionDescription::new(
format!("Disable (and stop) the systemd unit {}", self.unit),
format!("Disable (and stop) the systemd unit `{}`", self.unit),
vec![],
)]
}

View file

@ -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<StatefulAction<Self>, 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<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("systemctl")
.process_group(0)
.arg("daemon-reload")
.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("systemctl")
.process_group(0)
.arg("daemon-reload")
.stdin(std::process::Stdio::null()),
)
.await
.map_err(Self::error)?;
Ok(())
}
}

View file

@ -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<Self>
@ -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`"
)]

View file

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

View file

@ -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<Vec<StatefulAction<Box<dyn Action>>>, 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<HashMap<String, serde_json::Value>, InstallSettingsError> {
@ -320,4 +402,36 @@ impl Into<BuiltinPlanner> 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<bool, PlannerError> {
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<Output, PlannerError> {
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)
}