This new subcommand makes it possible to prepare an ISO to use it for an automated installation.
It is possible to control the behavior of the resulting automated ISO with optional parameters. If no target file is specified, the new ISO will be named with suffixes to indicate it as automated and additional information. This should help to distinct between the different options that were chosen to create it. The code for parsing an answer file is moved to its own function. Signed-off-by: Aaron Lauterer <a.laute...@proxmox.com> --- proxmox-autoinst-helper/Cargo.toml | 1 + proxmox-autoinst-helper/src/main.rs | 268 +++++++++++++++++++++++++--- 2 files changed, 247 insertions(+), 22 deletions(-) diff --git a/proxmox-autoinst-helper/Cargo.toml b/proxmox-autoinst-helper/Cargo.toml index 2a88c0f..75399e0 100644 --- a/proxmox-autoinst-helper/Cargo.toml +++ b/proxmox-autoinst-helper/Cargo.toml @@ -19,3 +19,4 @@ serde_json = "1.0" toml = "0.7" log = "0.4.20" regex = "1.7" +which = "4.2.5" diff --git a/proxmox-autoinst-helper/src/main.rs b/proxmox-autoinst-helper/src/main.rs index fe1cbec..33833ae 100644 --- a/proxmox-autoinst-helper/src/main.rs +++ b/proxmox-autoinst-helper/src/main.rs @@ -3,16 +3,29 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use glob::Pattern; use regex::Regex; use serde::Serialize; -use std::{collections::BTreeMap, fs, io::Read, path::PathBuf, process::Command}; +use std::{ + collections::BTreeMap, + fs, + io::Read, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; +use which::which; use proxmox_auto_installer::{ answer::Answer, answer::FilterMatch, sysinfo, - utils::{get_matched_udev_indexes, get_nic_list, get_single_udev_index}, + utils::{ + get_matched_udev_indexes, get_nic_list, get_single_udev_index, AutoInstModes, + AutoInstSettings, + }, }; -/// This tool validates the format of an answer file. Additionally it can test match filters and +static PROXMOX_ISO_FLAG: &str = "/autoinst-capable"; + +/// This tool can be used to prepare a Proxmox installation ISO for automated installations. +/// Additional uses are to validate the format of an answer file or to test match filters and /// print information on the properties to match against for the current hardware. #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -23,6 +36,7 @@ struct Cli { #[derive(Subcommand, Debug)] enum Commands { + PrepareIso(CommandPrepareISO), ValidateAnswer(CommandValidateAnswer), DeviceMatch(CommandDeviceMatch), DeviceInfo(CommandDeviceInfo), @@ -76,6 +90,61 @@ struct CommandValidateAnswer { debug: bool, } +/// Prepare an ISO for automated installation. +/// +/// In the simplest way, the final ISO will try to fetch an answer file automatically. +/// It will first search for a partition / file-system called "PROXMOXINST" and a file in the root +/// named "answer.toml". +/// If that is not found, it will try to fetch an answer file via an HTTP Post request. The URL for +/// it can be defined for the ISO with the '--url' argument. If not present, it will try to get a +/// URL from a DHCP option (250, TXT) or as a DNS TXT record located at 'proxmoxinst.{search +/// domain}'. +/// +/// The SSL certificate fingerprint can either be defined via the '--ssl_fingerprint' argument or +/// alternatively via the custom DHCP option (251, TXT) or in a DNS TXT record located at +/// 'proxmoxinst-fp.{search domain}'. +/// The latter options to provide the SSL fingerprint will only be used if the same method was used +/// to retrieve the URL. For example, the DNS TXT record for the fingerprint will only be used, if +/// no one was configured with the '--ssl_fingerprint' parameter and if the URL was retrieved via +/// the DNS TXT record. +/// +/// The behavior of how to fetch an answer file can be overridden with the 'install_mode' parameter. +/// The answer file can be +/// * integrated into the ISO itself ('direct') +/// * needs to be present in a partition / file-system called 'PROXMOXINST' ('partition') +/// * only be requested via an HTTP Post request ('http'). +#[derive(Args, Debug)] +#[command(verbatim_doc_comment)] +struct CommandPrepareISO { + /// Path to the source ISO + source: PathBuf, + + /// Path to store the final ISO to. + #[arg(short, long)] + target: Option<PathBuf>, + + /// Where to fetch the answer file from. + #[arg(short, long, value_enum, default_value_t=AutoInstModes::Auto)] + install_mode: AutoInstModes, + + /// Include the specified answer file in the ISO. Requires the '--install-mode', '-i' parameter + /// to be set to 'included'. + #[arg(short, long)] + answer_file: Option<PathBuf>, + + /// Specify URL for fetching the answer file via HTTP + #[arg(short, long)] + url: Option<String>, + + /// Pin the ISO to the specified SHA256 SSL fingerprint. + #[arg(short, long)] + ssl_fingerprint: Option<String>, + + /// Tmp directory to use. + #[arg(long)] + tmp: Option<String>, +} + /// Show identifiers for the current machine. This information is part of the POST request to fetch /// an answer file. #[derive(Args, Debug)] @@ -116,6 +185,7 @@ struct Devs { fn main() { let args = Cli::parse(); let res = match &args.command { + Commands::PrepareIso(args) => prepare_iso(args), Commands::ValidateAnswer(args) => validate_answer(args), Commands::DeviceInfo(args) => info(args), Commands::DeviceMatch(args) => match_filter(args), @@ -188,25 +258,7 @@ fn match_filter(args: &CommandDeviceMatch) -> Result<()> { } fn validate_answer(args: &CommandValidateAnswer) -> Result<()> { - let mut file = match fs::File::open(&args.path) { - Ok(file) => file, - Err(err) => bail!( - "Opening answer file '{}' failed: {err}", - args.path.display() - ), - }; - let mut contents = String::new(); - if let Err(err) = file.read_to_string(&mut contents) { - bail!("Reading from file '{}' failed: {err}", args.path.display()); - } - - let answer: Answer = match toml::from_str(&contents) { - Ok(answer) => { - println!("The file was parsed successfully, no syntax errors found!"); - answer - } - Err(err) => bail!("Error parsing answer file: {err}"), - }; + let answer = parse_answer(&args.path)?; if args.debug { println!("Parsed data from answer file:\n{:#?}", answer); } @@ -221,6 +273,128 @@ fn show_identifiers(_args: &CommandIdentifiers) -> Result<()> { Ok(()) } +fn prepare_iso(args: &CommandPrepareISO) -> Result<()> { + check_prepare_requirements(args)?; + + if args.install_mode == AutoInstModes::Included && args.answer_file.is_none() { + bail!("Missing path to answer file needed for 'direct' install mode."); + } + if args.install_mode == AutoInstModes::Included && args.ssl_fingerprint.is_some() { + bail!("No SSL fingerprint needed for direct install mode. Drop the parameter!"); + } + if args.install_mode == AutoInstModes::Included && args.url.is_some() { + bail!("No URL needed for direct install mode. Drop the parameter!"); + } + if args.answer_file.is_some() && args.install_mode != AutoInstModes::Included { + bail!("Set '-i', '--install-mode' to 'included' to place the answer file directly in the ISO."); + } + if args.install_mode == AutoInstModes::Partition && args.ssl_fingerprint.is_some() { + bail!("No SSL fingerprint needed for partition install mode. Drop the parameter!"); + } + if args.install_mode == AutoInstModes::Partition && args.url.is_some() { + bail!("No URL needed for partition install mode. Drop the parameter!"); + } + + if let Some(file) = &args.answer_file { + println!("Checking provided answer file..."); + parse_answer(file)?; + } + + let mut tmp_base = PathBuf::new(); + if args.tmp.is_some() { + tmp_base.push(args.tmp.as_ref().unwrap()); + } else { + tmp_base.push(args.source.parent().unwrap()); + tmp_base.push(".proxmox-iso-prepare"); + } + fs::create_dir_all(&tmp_base)?; + + let mut tmp_iso = tmp_base.clone(); + tmp_iso.push("proxmox.iso"); + let mut tmp_answer = tmp_base.clone(); + tmp_answer.push("answer.toml"); + + println!("Copying source ISO to temporary location..."); + fs::copy(&args.source, &tmp_iso)?; + println!("Done copying source ISO"); + + println!("Preparing ISO..."); + let install_mode = AutoInstSettings { + mode: args.install_mode.clone(), + http_url: args.url.clone(), + ssl_fingerprint: args.ssl_fingerprint.clone(), + }; + let mut instmode_file_tmp = tmp_base.clone(); + instmode_file_tmp.push("autoinst-mode.toml"); + fs::write(&instmode_file_tmp, toml::to_string_pretty(&install_mode)?)?; + + inject_file_to_iso(&tmp_iso, &instmode_file_tmp, "/autoinst-mode.toml")?; + + if let Some(answer) = &args.answer_file { + fs::copy(answer, &tmp_answer)?; + inject_file_to_iso(&tmp_iso, &tmp_answer, "/answer.toml")?; + } + + println!("Done preparing iso."); + println!("Move ISO to target location..."); + let iso_target = final_iso_location(args); + fs::rename(&tmp_iso, &iso_target)?; + println!("Cleaning up..."); + fs::remove_dir_all(&tmp_base)?; + println!("Final ISO is available at {}.", &iso_target.display()); + + Ok(()) +} + +fn final_iso_location(args: &CommandPrepareISO) -> PathBuf { + if let Some(specified) = args.target.clone() { + return specified; + } + let mut suffix: String = match args.install_mode { + AutoInstModes::Auto => "automated".into(), + AutoInstModes::Http => "automated-http".into(), + AutoInstModes::Included => "automated-answer-included".into(), + AutoInstModes::Partition => "automated-part".into(), + }; + + if args.url.is_some() { + suffix.push_str("-url"); + } + if args.ssl_fingerprint.is_some() { + suffix.push_str("-fp"); + } + + let base = args.source.parent().unwrap(); + let iso = args.source.file_stem().unwrap(); + + let mut target = base.to_path_buf(); + target.push(format!("{}-{}.iso", iso.to_str().unwrap(), suffix)); + + target.to_path_buf() +} + +fn inject_file_to_iso(iso: &PathBuf, file: &PathBuf, location: &str) -> Result<()> { + let result = Command::new("xorriso") + .arg("--boot_image") + .arg("any") + .arg("keep") + .arg("-dev") + .arg(iso) + .arg("-map") + .arg(file) + .arg(location) + .output()?; + if !result.status.success() { + bail!( + "Error injecting {} into {}: {}", + file.display(), + iso.display(), + String::from_utf8(result.stderr)? + ); + } + Ok(()) +} + fn get_disks() -> Result<BTreeMap<String, BTreeMap<String, String>>> { let unwantend_block_devs = vec![ "ram[0-9]*", @@ -335,3 +509,53 @@ fn get_udev_properties(path: &PathBuf) -> Result<String> { } Ok(String::from_utf8(udev_output.stdout)?) } + +fn parse_answer(path: &PathBuf) -> Result<Answer> { + let mut file = match fs::File::open(path) { + Ok(file) => file, + Err(err) => bail!("Opening answer file '{}' failed: {err}", path.display()), + }; + let mut contents = String::new(); + if let Err(err) = file.read_to_string(&mut contents) { + bail!("Reading from file '{}' failed: {err}", path.display()); + } + match toml::from_str(&contents) { + Ok(answer) => { + println!("The file was parsed successfully, no syntax errors found!"); + Ok(answer) + } + Err(err) => bail!("Error parsing answer file: {err}"), + } +} + +fn check_prepare_requirements(args: &CommandPrepareISO) -> Result<()> { + match which("xorriso") { + Ok(_) => (), + Err(_) => bail!("Could not find 'xorriso'. Please install it and try again"), + } + + match Path::try_exists(&args.source) { + Ok(true) => (), + Ok(false) => bail!("Source file does not exist."), + Err(_) => bail!("Source file does not exist."), + } + + match Command::new("xorriso") + .arg("-dev") + .arg(&args.source) + .arg("-find") + .arg(PROXMOX_ISO_FLAG) + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .status() + { + Ok(v) => { + if !v.success() { + bail!("The source ISO file is not able to be installed automatically. Please try a more current one."); + } + } + Err(_) => bail!("Could not run 'xorriso'. Please install it."), + }; + + Ok(()) +} -- 2.39.2 _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel