diff --git a/Cargo.lock b/Cargo.lock index 698b536..94ce175 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -797,6 +797,7 @@ dependencies = [ "plist", "rand 0.8.5", "reqwest", + "semver", "serde", "serde_json", "serde_with", @@ -1633,6 +1634,15 @@ dependencies = [ "untrusted", ] +[[package]] +name = "semver" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e25dfac463d778e353db5be2449d1cce89bd6fd23c9f1ea21310ce6e5a1b29c4" +dependencies = [ + "serde", +] + [[package]] name = "serde" version = "1.0.147" diff --git a/Cargo.toml b/Cargo.toml index fd91666..cfafde4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,3 +49,4 @@ erased-serde = "0.3.23" typetag = "0.2.3" dyn-clone = "1.0.9" rand = "0.8.5" +semver = { version = "1.0.14", features = ["serde"] } diff --git a/src/action/common/configure_nix.rs b/src/action/common/configure_nix.rs index 61c3a27..a03062c 100644 --- a/src/action/common/configure_nix.rs +++ b/src/action/common/configure_nix.rs @@ -23,7 +23,7 @@ pub struct ConfigureNix { impl ConfigureNix { #[tracing::instrument(skip_all)] pub async fn plan( - settings: CommonSettings, + settings: &CommonSettings, ) -> Result> { let channels: Vec<(String, Url)> = settings .channels @@ -44,8 +44,8 @@ impl ConfigureNix { let place_channel_configuration = PlaceChannelConfiguration::plan(channels, settings.force).await?; let place_nix_configuration = PlaceNixConfiguration::plan( - settings.nix_build_group_name, - settings.extra_conf, + settings.nix_build_group_name.clone(), + settings.extra_conf.clone(), settings.force, ) .await?; diff --git a/src/action/common/provision_nix.rs b/src/action/common/provision_nix.rs index 0568163..6d18fc6 100644 --- a/src/action/common/provision_nix.rs +++ b/src/action/common/provision_nix.rs @@ -23,7 +23,7 @@ pub struct ProvisionNix { impl ProvisionNix { #[tracing::instrument(skip_all)] pub async fn plan( - settings: CommonSettings, + settings: &CommonSettings, ) -> Result> { let fetch_nix = FetchNix::plan( settings.nix_package_url.clone(), diff --git a/src/cli/subcommand/install.rs b/src/cli/subcommand/install.rs index 3e1fa33..2d58388 100644 --- a/src/cli/subcommand/install.rs +++ b/src/cli/subcommand/install.rs @@ -83,7 +83,7 @@ impl CommandExecute for Install { existing_receipt } , None => { - planner.plan().await.map_err(|e| eyre!(e))? + InstallPlan::plan(planner.boxed()).await.map_err(|e| eyre!(e))? }, } }, @@ -97,7 +97,7 @@ impl CommandExecute for Install { let builtin_planner = BuiltinPlanner::default() .await .map_err(|e| eyre::eyre!(e))?; - builtin_planner.plan().await.map_err(|e| eyre!(e))? + InstallPlan::plan(builtin_planner.boxed()).await.map_err(|e| eyre!(e))? }, (Some(_), Some(_)) => return Err(eyre!("`--plan` conflicts with passing a planner, a planner creates plans, so passing an existing plan doesn't make sense")), }; diff --git a/src/plan.rs b/src/plan.rs index 299fda1..26e4609 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -1,33 +1,51 @@ -use std::path::PathBuf; - -use crossterm::style::Stylize; -use tokio::sync::broadcast::Receiver; +use std::{path::PathBuf, str::FromStr}; use crate::{ action::{Action, ActionDescription, ActionImplementation}, planner::Planner, HarmonicError, }; +use crossterm::style::Stylize; +use semver::{Version, VersionReq}; +use serde::{de::Error, Deserialize, Deserializer}; +use tokio::sync::broadcast::Receiver; pub const RECEIPT_LOCATION: &str = "/nix/receipt.json"; #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] pub struct InstallPlan { + #[serde(deserialize_with = "ensure_version")] + pub(crate) version: Version, + pub(crate) actions: Vec>, pub(crate) planner: Box, } impl InstallPlan { + pub async fn plan( + planner: Box, + ) -> Result> { + let actions = planner.plan().await?; + Ok(Self { + planner, + actions, + version: current_version()?, + }) + } #[tracing::instrument(skip_all)] pub fn describe_execute( &self, explain: bool, ) -> Result> { - let Self { planner, actions } = self; + let Self { + planner, + actions, + version, + } = self; let buf = format!( "\ - Nix install plan\n\ + Nix install plan (v{version})\n\ \n\ Planner: {planner}\n\ \n\ @@ -82,6 +100,7 @@ impl InstallPlan { cancel_channel: impl Into>>, ) -> Result<(), HarmonicError> { let Self { + version: _, actions, planner: _, } = self; @@ -119,7 +138,11 @@ impl InstallPlan { &self, explain: bool, ) -> Result> { - let Self { planner, actions } = self; + let Self { + version: _, + planner, + actions, + } = self; let buf = format!( "\ Nix uninstall plan\n\ @@ -178,6 +201,7 @@ impl InstallPlan { cancel_channel: impl Into>>, ) -> Result<(), HarmonicError> { let Self { + version: _, actions, planner: _, } = self; @@ -222,3 +246,70 @@ async fn write_receipt(plan: InstallPlan) -> Result<(), HarmonicError> { .map_err(|e| HarmonicError::RecordingReceipt(install_receipt_path, e))?; Result::<(), HarmonicError>::Ok(()) } + +fn current_version() -> Result { + let harmonic_version_str = env!("CARGO_PKG_VERSION"); + Version::from_str(harmonic_version_str) +} + +fn ensure_version<'de, D: Deserializer<'de>>(d: D) -> Result { + let plan_version = Version::deserialize(d)?; + let req = VersionReq::parse(&plan_version.to_string()).map_err(|_e| { + D::Error::custom(&format!( + "Could not parse version `{plan_version}` as a version requirement, please report this", + )) + })?; + let harmonic_version = current_version().map_err(|_e| { + D::Error::custom(&format!( + "Could not parse Harmonic's version `{}` as a valid version according to Semantic Versioning, therefore the plan version ({plan_version}) compatibility cannot be checked", env!("CARGO_PKG_VERSION") + )) + })?; + if req.matches(&harmonic_version) { + Ok(plan_version) + } else { + Err(D::Error::custom(&format!( + "This version of Harmonic ({harmonic_version}) is not compatible with this plan's version ({plan_version}), please use a compatible version (according to Semantic Versioning)", + ))) + } +} + +#[cfg(test)] +mod test { + use semver::Version; + + use crate::{planner::BuiltinPlanner, InstallPlan}; + + #[tokio::test] + async fn ensure_version_allows_compatible() -> eyre::Result<()> { + let planner = BuiltinPlanner::default() + .await + .map_err(|e| eyre::eyre!(e))?; + let good_version = Version::parse(env!("CARGO_PKG_VERSION"))?; + let value = serde_json::json!({ + "planner": planner.boxed(), + "version": good_version, + "actions": [], + }); + let maybe_plan: Result = serde_json::from_value(value); + maybe_plan.unwrap(); + Ok(()) + } + + #[tokio::test] + async fn ensure_version_denies_incompatible() -> eyre::Result<()> { + let planner = BuiltinPlanner::default() + .await + .map_err(|e| eyre::eyre!(e))?; + let bad_version = Version::parse("9999999999999.9999999999.99999999")?; + let value = serde_json::json!({ + "planner": planner.boxed(), + "version": bad_version, + "actions": [], + }); + let maybe_plan: Result = serde_json::from_value(value); + assert!(maybe_plan.is_err()); + let err = maybe_plan.unwrap_err(); + assert!(err.is_data()); + Ok(()) + } +} diff --git a/src/planner/darwin/multi.rs b/src/planner/darwin/multi.rs index afc9ec6..8aa2dac 100644 --- a/src/planner/darwin/multi.rs +++ b/src/planner/darwin/multi.rs @@ -11,7 +11,7 @@ use crate::{ execute_command, os::darwin::DiskUtilOutput, planner::{BuiltinPlannerError, Planner}, - BuiltinPlanner, CommonSettings, InstallPlan, + Action, BuiltinPlanner, CommonSettings, }; #[derive(Debug, Clone, clap::Parser, serde::Serialize, serde::Deserialize)] @@ -67,11 +67,9 @@ impl Planner for DarwinMulti { }) } - async fn plan( - mut self, - ) -> Result> { - self.root_disk = match self.root_disk { - root_disk @ Some(_) => root_disk, + async fn plan(&self) -> Result>, Box> { + let root_disk = match &self.root_disk { + root_disk @ Some(_) => root_disk.clone(), None => { let buf = execute_command( Command::new("/usr/sbin/diskutil") @@ -99,29 +97,24 @@ impl Planner for DarwinMulti { false }; - Ok(InstallPlan { - planner: Box::new(self.clone()), - actions: vec![ - // Create Volume step: - // - // setup_Synthetic -> create_synthetic_objects - // Unmount -> create_volume -> Setup_fstab -> maybe encrypt_volume -> launchctl bootstrap -> launchctl kickstart -> await_volume -> maybe enableOwnership - Box::new( - CreateApfsVolume::plan( - self.root_disk.unwrap(), /* We just ensured it was populated */ - self.volume_label, - false, - encrypt, - ) - .await?, - ), - Box::new(ProvisionNix::plan(self.settings.clone()).await?), - Box::new(ConfigureNix::plan(self.settings).await?), - Box::new( - KickstartLaunchctlService::plan("system/org.nixos.nix-daemon".into()).await?, - ), - ], - }) + Ok(vec![ + // Create Volume step: + // + // setup_Synthetic -> create_synthetic_objects + // Unmount -> create_volume -> Setup_fstab -> maybe encrypt_volume -> launchctl bootstrap -> launchctl kickstart -> await_volume -> maybe enableOwnership + Box::new( + CreateApfsVolume::plan( + root_disk.unwrap(), /* We just ensured it was populated */ + self.volume_label.clone(), + false, + encrypt, + ) + .await?, + ), + Box::new(ProvisionNix::plan(&self.settings).await?), + Box::new(ConfigureNix::plan(&self.settings).await?), + Box::new(KickstartLaunchctlService::plan("system/org.nixos.nix-daemon".into()).await?), + ]) } fn settings( diff --git a/src/planner/linux/multi.rs b/src/planner/linux/multi.rs index 526cf63..8611df9 100644 --- a/src/planner/linux/multi.rs +++ b/src/planner/linux/multi.rs @@ -4,7 +4,7 @@ use crate::{ common::{ConfigureNix, ProvisionNix}, }, planner::Planner, - BuiltinPlanner, CommonSettings, InstallPlan, + Action, BuiltinPlanner, CommonSettings, }; use std::{collections::HashMap, path::Path}; @@ -23,7 +23,7 @@ impl Planner for LinuxMulti { }) } - async fn plan(self) -> Result> { + async fn plan(&self) -> Result>, Box> { // If on NixOS, running `harmonic` is pointless // NixOS always sets up this file as part of setting up /etc itself: https://github.com/NixOS/nixpkgs/blob/bdd39e5757d858bd6ea58ed65b4a2e52c8ed11ca/nixos/modules/system/etc/setup-etc.pl#L145 if Path::new("/etc/NIXOS").exists() { @@ -40,26 +40,23 @@ impl Planner for LinuxMulti { return Err(Error::NixExists.into()); } - Ok(InstallPlan { - planner: Box::new(self.clone()), - actions: vec![ - Box::new( - CreateDirectory::plan("/nix", None, None, 0o0755, true) - .await - .map_err(|v| Error::Action(v.into()))?, - ), - Box::new( - ProvisionNix::plan(self.settings.clone()) - .await - .map_err(|v| Error::Action(v.into()))?, - ), - Box::new( - ConfigureNix::plan(self.settings) - .await - .map_err(|v| Error::Action(v.into()))?, - ), - ], - }) + Ok(vec![ + Box::new( + CreateDirectory::plan("/nix", None, None, 0o0755, true) + .await + .map_err(|v| Error::Action(v.into()))?, + ), + Box::new( + ProvisionNix::plan(&self.settings.clone()) + .await + .map_err(|v| Error::Action(v.into()))?, + ), + Box::new( + ConfigureNix::plan(&self.settings) + .await + .map_err(|v| Error::Action(v.into()))?, + ), + ]) } fn settings( diff --git a/src/planner/mod.rs b/src/planner/mod.rs index f3a2bf0..90b009a 100644 --- a/src/planner/mod.rs +++ b/src/planner/mod.rs @@ -4,7 +4,7 @@ pub mod specific; use std::collections::HashMap; -use crate::{settings::InstallSettingsError, BoxableError, InstallPlan}; +use crate::{settings::InstallSettingsError, Action, BoxableError}; #[async_trait::async_trait] #[typetag::serde(tag = "planner")] @@ -12,7 +12,7 @@ pub trait Planner: std::fmt::Debug + Send + Sync + dyn_clone::DynClone { async fn default() -> Result> where Self: Sized; - async fn plan(self) -> Result>; + async fn plan(&self) -> Result>, Box>; fn settings( &self, ) -> Result, Box>; @@ -55,7 +55,9 @@ impl BuiltinPlanner { } } - pub async fn plan(self) -> Result> { + pub async fn plan( + self, + ) -> Result>, Box> { match self { BuiltinPlanner::LinuxMulti(planner) => planner.plan().await, BuiltinPlanner::DarwinMulti(planner) => planner.plan().await, diff --git a/src/planner/specific/steam_deck.rs b/src/planner/specific/steam_deck.rs index 628f6e0..a0f6ccb 100644 --- a/src/planner/specific/steam_deck.rs +++ b/src/planner/specific/steam_deck.rs @@ -7,7 +7,7 @@ use crate::{ linux::{CreateSystemdSysext, StartSystemdUnit}, }, planner::Planner, - BuiltinPlanner, CommonSettings, InstallPlan, + Action, BuiltinPlanner, CommonSettings, }; #[derive(Debug, Clone, clap::Parser, serde::Serialize, serde::Deserialize)] @@ -25,16 +25,13 @@ impl Planner for SteamDeck { }) } - async fn plan(self) -> Result> { - Ok(InstallPlan { - planner: Box::new(self.clone()), - actions: vec![ - Box::new(CreateSystemdSysext::plan("/var/lib/extensions/nix").await?), - Box::new(CreateDirectory::plan("/nix", None, None, 0o0755, true).await?), - Box::new(ProvisionNix::plan(self.settings.clone()).await?), - Box::new(StartSystemdUnit::plan("nix-daemon.socket".into()).await?), - ], - }) + async fn plan(&self) -> Result>, Box> { + Ok(vec![ + Box::new(CreateSystemdSysext::plan("/var/lib/extensions/nix").await?), + Box::new(CreateDirectory::plan("/nix", None, None, 0o0755, true).await?), + Box::new(ProvisionNix::plan(&self.settings.clone()).await?), + Box::new(StartSystemdUnit::plan("nix-daemon.socket".into()).await?), + ]) } fn settings(