use crate::*; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::io::Write; use std::path::PathBuf; use std::process::{Command, ExitStatus, Stdio}; use std::{fmt, fs, io}; #[derive(Debug, Serialize, Deserialize, PartialEq)] pub enum UpdateSteps { PreInstall, Install, PostInstall, } impl Display for UpdateSteps { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { UpdateSteps::PreInstall => write!(f, "PreInstall"), UpdateSteps::Install => write!(f, "Install"), UpdateSteps::PostInstall => write!(f, "PostInstall"), } } } // TODO: change the struct’s names for the `topgrade`’s one. They are much better. /// Root of the machine’s dependency graph #[derive(Debug, Serialize, Deserialize)] pub struct Updater { pub packagers: BTreeMap, } /// A list of equivalent executors that will update a given component /// /// Example: the `system` one will try to do update the system for as if it is Debian with `apt update`, if it fails it will try for openSUSE with `zypper refresh`, … /// The step will be considered a succes if **any** executor succeed and will skip all the other ones. #[derive(Debug, Serialize, Deserialize)] pub struct Packager { executors: Vec, // TODO: => make a system dependend on another? This will allow to give a "Rust" config which update "rustup", and a custom "git helix" could then be executed after (with the updated toolchain, and NOT concurrently) } /// All the infos for an executor to proceed until completion #[derive(Debug, Serialize, Deserialize)] pub struct Executor { pub name: String, pre_install: Option>, install: Cmd, post_install: Option>, // deps or rDeps : Tree } /// A command to execute on the system as part of an executor #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Cmd { exe: String, params: Option>, current_dir: Option, env: Option>, } /// The actual (cleaned) command that will be executed on the system as part of an executor #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActualCmd { exe: String, params: Vec, current_dir: Option, env: BTreeMap, } impl From<&String> for UpdateSteps { fn from(value: &String) -> Self { match value.to_lowercase().as_str() { "pre_install" => UpdateSteps::PreInstall, "install" => UpdateSteps::Install, "post_install" => UpdateSteps::PostInstall, _ => panic!("Step {} not recognized", value), } } } pub fn get_packages_folder(opt: &Opt) -> io::Result { if let Some(p) = opt.config_folder.clone() { return Ok(p); } let config_folder = directories::ProjectDirs::from("net", "ZykiCorp", "System Updater") .ok_or(io::Error::new( io::ErrorKind::NotFound, "System’s configuration folder: for its standard location see https://docs.rs/directories/latest/directories/struct.ProjectDirs.html#method.config_dir", ))? .config_dir() .join("packagers"); Ok(config_folder) } impl Updater { fn new() -> Updater { Updater { packagers: BTreeMap::from([(String::new(), Packager { executors: vec![] })]), } } /// To create a sample config from code #[doc(hidden)] fn write_config(&self, opt: &Opt) { use std::fs::OpenOptions; let config_folder = get_packages_folder(&opt).unwrap(); let mut f = OpenOptions::new() .write(true) .create(true) .truncate(true) .open(config_folder.join("default.yaml")) .unwrap(); fs::create_dir_all(&config_folder).unwrap(); f.write_all(serde_yaml::to_string(&self).unwrap().as_bytes()) .unwrap(); } // TODO: add option to use &opt.config_file (or folder?) instead pub fn from_config(opt: &Opt) -> io::Result { let mut updater = Updater::new(); // Example to generate a config file if false { updater .packagers .insert("Test".to_owned(), Packager { executors: vec![] }); let sys = updater .packagers .get_mut("Test") .expect("We just created the key"); sys.executors.push(Executor { name: "Rustup".to_owned(), pre_install: None, install: Cmd { exe: "rustup".to_owned(), params: Some(vec!["self".to_owned(), "update".to_owned()]), current_dir: None, env: None, }, post_install: None, }); sys.executors.push(Executor { name: "Cargo".to_owned(), pre_install: None, install: Cmd { exe: "cargo".to_owned(), params: Some(vec!["install-update".to_owned(), "-a".to_owned()]), current_dir: None, env: None, }, post_install: None, }); updater.write_config(opt); panic!("Wrote a config sample."); } let packages_folder = get_packages_folder(&opt)?; // TODO: Useless match? Still a risck for "time-of-check to time-of-use" bug match packages_folder.try_exists() { Ok(true) => {} // Ok: Exist and should be readable Ok(false) => { return Err(io::Error::new( io::ErrorKind::Other, format!( "Configuration folder not accessible at: {}. (broken symlink?)", packages_folder.display() ), )) } Err(e) => return Err(e), } for file in packages_folder.read_dir()?.filter(|name| match name { Ok(n) => { if n.file_name().into_string().unwrap().ends_with(".yaml") { true } else { false } } Err(..) => false, }) { let file = file?.path(); let sys = std::fs::read_to_string(&file).unwrap(); let sys = serde_yaml::from_str(&sys).map_err(|err| { io::Error::new( io::ErrorKind::Other, format!( "Encontered an error while parsing config file {}: {}", file.display(), err ), ) })?; updater .packagers .insert(file.file_stem().unwrap().to_str().unwrap().to_owned(), sys); } // eprintln!("{:#?}", updater); Ok(updater) } pub fn update_all(&self, opt: &Opt) -> Summary { let mut status: Vec<_> = vec![]; // XXX: We may parallelise (iter_par from rayon?) this loop. But the UI will be problematic to handle for (_packager_name, packager) in &self.packagers { for pkg in &packager.executors { status.push((pkg.name.clone(), self.update(&pkg, opt).into())); } } Summary { status } } fn update(&self, sys: &Executor, opt: &Opt) -> Result<()> { // TODO: compute once before calling this function, maybe? let steps = if opt.steps.is_empty() { vec![ UpdateSteps::PreInstall, UpdateSteps::Install, UpdateSteps::PostInstall, ] } else { opt.steps.iter().map(|u| u.into()).collect() }; if steps.contains(&UpdateSteps::PreInstall) { sys.pre_install(opt)?; } if steps.contains(&UpdateSteps::Install) { sys.install(opt)?; } if steps.contains(&UpdateSteps::PostInstall) { sys.post_install(opt)?; } Ok(()) } } impl Executor { pub fn pre_install(&self, opt: &Opt) -> Result<()> { if let Some(pre_install) = &self.pre_install { for cmd in pre_install { let cmd = cmd.clone().prepare(opt); let exit_status = cmd.execute(opt).map_err(|err| Error::Execution { source: err, step: UpdateSteps::PreInstall, cmd: cmd.clone(), })?; if !exit_status.success() { return Err(Error::Execution { source: io::Error::new(io::ErrorKind::Other, format!("{}", exit_status)), step: UpdateSteps::PreInstall, cmd, }); } } } Ok(()) } pub fn install(&self, opt: &Opt) -> Result<()> { let cmd = self.install.clone().prepare(opt); let exit_status = cmd.execute(opt).map_err(|err| Error::Execution { source: err, step: UpdateSteps::Install, cmd: cmd.clone(), })?; if !exit_status.success() { return Err(Error::Execution { source: io::Error::new(io::ErrorKind::Other, format!("{}", exit_status)), step: UpdateSteps::Install, cmd, }); } Ok(()) } pub fn post_install(&self, opt: &Opt) -> Result<()> { if let Some(post_install) = &self.post_install { for cmd in post_install { let cmd = cmd.clone().prepare(opt); let exit_status = cmd.execute(opt).map_err(|err| Error::Execution { source: err, step: UpdateSteps::PostInstall, cmd: cmd.clone(), })?; if !exit_status.success() { return Err(Error::Execution { source: io::Error::new(io::ErrorKind::Other, format!("{}", exit_status)), step: UpdateSteps::PostInstall, cmd, }); } } } Ok(()) } } impl Cmd { fn new() -> Cmd { Cmd { exe: "".into(), params: None, current_dir: None, env: None, } } fn prepare(self, opt: &Opt) -> ActualCmd { // TODO: I’m not convinced by helping the user and only escaping the PATH. Either all or none // This means I need to know how to know which values to pass for (at least) rustup & cargo let env = match self.env { Some(env) => env, None => BTreeMap::default(), }; ActualCmd { exe: self.exe, params: self.params.unwrap_or_default(), current_dir: self.current_dir, env, } } } impl ActualCmd { fn execute(&self, opt: &Opt) -> io::Result { let mut cmd = Command::new(&self.exe); cmd.args(&self.params).envs(&self.env); if let Some(cdir) = &self.current_dir { cmd.current_dir(std::fs::canonicalize(cdir)?); } println!(); println!("*** Executing: {} ***", self); // eprintln!("{:?}", self.params); if opt.quiet { // FIXME: stdin does not work with sudo? cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); } Ok(cmd.status()?) } } impl fmt::Display for ActualCmd { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let command = if !self.params.is_empty() { format!("{} {}", &self.exe, &self.params.join(" ")) } else { self.exe.clone() }; write!(f, "{}", command)?; if let Some(cdir) = &self.current_dir { write!(f, " in {:?}", cdir)?; } Ok(()) // // TODO: remove me (too verbose) // if !self.env.is_empty() { // writeln!(f, " with the following environment variable:")?; // writeln!(f, "{:#?}", self.env) // } else { // write!(f, " without any environment variable. ") // } } }