397 lines
12 KiB
Rust
397 lines
12 KiB
Rust
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<String, Packager>,
|
||
}
|
||
|
||
/// 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<Executor>,
|
||
// 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<Vec<Cmd>>,
|
||
install: Cmd,
|
||
post_install: Option<Vec<Cmd>>,
|
||
// 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<Vec<String>>,
|
||
current_dir: Option<PathBuf>,
|
||
env: Option<BTreeMap<String, String>>,
|
||
}
|
||
|
||
/// 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<String>,
|
||
current_dir: Option<PathBuf>,
|
||
env: BTreeMap<String, String>,
|
||
}
|
||
|
||
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<PathBuf> {
|
||
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<Updater> {
|
||
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<ExitStatus> {
|
||
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. ")
|
||
// }
|
||
}
|
||
}
|