system-updater/src/command.rs
2023-07-06 00:46:32 +02:00

397 lines
12 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 structs names for the `topgrade`s one. They are much better.
/// Root of the machines 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,
"Systems 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: Im 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. ")
// }
}
}