From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 9802993E9D for ; Wed, 21 Feb 2024 12:08:49 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id C45AC157FE for ; Wed, 21 Feb 2024 12:08:17 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Wed, 21 Feb 2024 12:08:11 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id AE76944445 for ; Wed, 21 Feb 2024 12:08:11 +0100 (CET) From: Aaron Lauterer To: pve-devel@lists.proxmox.com Date: Wed, 21 Feb 2024 12:07:52 +0100 Message-Id: <20240221110805.931925-10-a.lauterer@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20240221110805.931925-1-a.lauterer@proxmox.com> References: <20240221110805.931925-1-a.lauterer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.062 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record T_SCC_BODY_TEXT_LINE -0.01 - Subject: [pve-devel] [PATCH v2 09/22] auto-installer: add utils X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Wed, 21 Feb 2024 11:08:49 -0000 contains several utility structs and functions. For example: a simple pattern matcher that matches wildcards at the beginning or end of the filter. It currently uses a dedicated function (parse_answer) to generate the InstallConfig struct instead of a From implementation. This is because for now the source data is spread over several other structs in comparison to one in the TUI installer. Signed-off-by: Aaron Lauterer --- proxmox-auto-installer/src/lib.rs | 1 + proxmox-auto-installer/src/utils.rs | 471 ++++++++++++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 proxmox-auto-installer/src/utils.rs diff --git a/proxmox-auto-installer/src/lib.rs b/proxmox-auto-installer/src/lib.rs index 8cda416..72884c1 100644 --- a/proxmox-auto-installer/src/lib.rs +++ b/proxmox-auto-installer/src/lib.rs @@ -1,2 +1,3 @@ pub mod answer; pub mod udevinfo; +pub mod utils; diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs new file mode 100644 index 0000000..23bedd9 --- /dev/null +++ b/proxmox-auto-installer/src/utils.rs @@ -0,0 +1,471 @@ +use anyhow::{anyhow, bail, Context, Result}; +use log::info; +use std::{ + collections::BTreeMap, + net::IpAddr, + process::{Command, Stdio}, + str::FromStr, +}; + +use crate::{ + answer, + answer::{Answer, FilterMatch}, + udevinfo::UdevInfo, +}; +use proxmox_installer_common::{ + options::{ + BtrfsRaidLevel, FsType, NetworkOptions, ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel, + }, + setup::{InstallConfig, InstallZfsOption, LocaleInfo, RuntimeInfo, SetupInfo}, + utils::{CidrAddress, Fqdn}, +}; +use serde::Deserialize; + +/// Supports the globbing character '*' at the beginning, end or both of the pattern. +/// Globbing within the pattern is not supported +fn find_with_glob(pattern: &str, value: &str) -> bool { + let globbing_symbol = '*'; + let mut start_glob = false; + let mut end_glob = false; + let mut pattern = pattern; + + if pattern.starts_with(globbing_symbol) { + start_glob = true; + pattern = &pattern[1..]; + } + + if pattern.ends_with(globbing_symbol) { + end_glob = true; + pattern = &pattern[..pattern.len() - 1] + } + + match (start_glob, end_glob) { + (true, true) => value.contains(pattern), + (true, false) => value.ends_with(pattern), + (false, true) => value.starts_with(pattern), + _ => value == pattern, + } +} + +pub fn get_network_settings( + answer: &Answer, + udev_info: &UdevInfo, + runtime_info: &RuntimeInfo, + setup_info: &SetupInfo, +) -> Result { + let mut network_options = NetworkOptions::defaults_from(setup_info, &runtime_info.network); + + info!("Setting network configuration"); + + // Always use the FQDN from the answer file + network_options.fqdn = Fqdn::from(answer.global.fqdn.as_str()) + .map_err(|err| anyhow!("Error parsing FQDN: {err}"))?; + + if answer.network.use_dhcp.is_none() || !answer.network.use_dhcp.unwrap() { + network_options.address = CidrAddress::from_str( + answer + .network + .cidr + .clone() + .context("No CIDR defined")? + .as_str(), + ) + .map_err(|_| anyhow!("Error parsing CIDR"))?; + network_options.dns_server = IpAddr::from_str( + answer + .network + .dns + .clone() + .context("No DNS server defined")? + .as_str(), + ) + .map_err(|_| anyhow!("Error parsing DNS server"))?; + network_options.gateway = IpAddr::from_str( + answer + .network + .gateway + .clone() + .expect("No gateway defined") + .as_str(), + ) + .map_err(|_| anyhow!("Error parsing gateway"))?; + network_options.ifname = + get_single_udev_index(answer.network.filter.clone().unwrap(), &udev_info.nics)? + } + + Ok(network_options) +} + +fn get_single_udev_index( + filter: BTreeMap, + udev_list: &BTreeMap>, +) -> Result { + if filter.is_empty() { + bail!("no filter defined"); + } + let mut dev_index: Option = None; + 'outer: for (dev, dev_values) in udev_list { + for (filter_key, filter_value) in &filter { + for (udev_key, udev_value) in dev_values { + if udev_key == filter_key && find_with_glob(filter_value, udev_value) { + dev_index = Some(dev.clone()); + break 'outer; // take first match + } + } + } + } + if dev_index.is_none() { + bail!("filter did not match any device"); + } + + Ok(dev_index.unwrap()) +} + +fn get_matched_udev_indexes( + filter: BTreeMap, + udev_list: &BTreeMap>, + match_all: bool, +) -> Result> { + let mut matches = vec![]; + for (dev, dev_values) in udev_list { + let mut did_match_once = false; + let mut did_match_all = true; + for (filter_key, filter_value) in &filter { + for (udev_key, udev_value) in dev_values { + if udev_key == filter_key && find_with_glob(filter_value, udev_value) { + did_match_once = true; + } else if udev_key == filter_key { + did_match_all = false; + } + } + } + if (match_all && did_match_all) || (!match_all && did_match_once) { + matches.push(dev.clone()); + } + } + if matches.is_empty() { + bail!("filter did not match any devices"); + } + matches.sort(); + Ok(matches) +} + +pub fn set_disks( + answer: &Answer, + udev_info: &UdevInfo, + runtime_info: &RuntimeInfo, + config: &mut InstallConfig, +) -> Result<()> { + match config.filesys { + FsType::Ext4 | FsType::Xfs => set_single_disk(answer, udev_info, runtime_info, config), + FsType::Zfs(_) | FsType::Btrfs(_) => { + set_selected_disks(answer, udev_info, runtime_info, config) + } + } +} + +fn set_single_disk( + answer: &Answer, + udev_info: &UdevInfo, + runtime_info: &RuntimeInfo, + config: &mut InstallConfig, +) -> Result<()> { + match &answer.disks.disk_selection { + Some(selection) => { + let disk_name = selection[0].clone(); + let disk = runtime_info + .disks + .iter() + .find(|item| item.path.ends_with(disk_name.as_str())); + match disk { + Some(disk) => config.target_hd = Some(disk.clone()), + None => bail!("disk in 'disk_selection' not found"), + } + } + None => { + let disk_index = + get_single_udev_index(answer.disks.filter.clone().unwrap(), &udev_info.disks)?; + let disk = runtime_info + .disks + .iter() + .find(|item| item.index == disk_index); + config.target_hd = disk.cloned(); + } + } + Ok(()) +} + +fn set_selected_disks( + answer: &Answer, + udev_info: &UdevInfo, + runtime_info: &RuntimeInfo, + config: &mut InstallConfig, +) -> Result<()> { + match &answer.disks.disk_selection { + Some(selection) => { + info!("Disk selection found"); + for disk_name in selection { + let disk = runtime_info + .disks + .iter() + .find(|item| item.path.ends_with(disk_name.as_str())); + if let Some(disk) = disk { + config + .disk_selection + .insert(disk.index.clone(), disk.index.clone()); + } + } + } + None => { + info!("No disk selection found, looking for disk filters"); + let filter_match = answer + .disks + .filter_match + .clone() + .unwrap_or(FilterMatch::Any); + let disk_filters = answer + .disks + .filter + .clone() + .context("no disk filters defined")?; + let selected_disk_indexes = get_matched_udev_indexes( + disk_filters, + &udev_info.disks, + filter_match == FilterMatch::All, + )?; + + for i in selected_disk_indexes.into_iter() { + let disk = runtime_info + .disks + .iter() + .find(|item| item.index == i) + .unwrap(); + config + .disk_selection + .insert(disk.index.clone(), disk.index.clone()); + } + } + } + if config.disk_selection.is_empty() { + bail!("No disks found matching selection."); + } + Ok(()) +} + +pub fn get_first_selected_disk(config: &InstallConfig) -> usize { + config + .disk_selection + .iter() + .next() + .expect("no disks found") + .0 + .parse::() + .expect("could not parse key to usize") +} + +pub fn verify_locale_settings(answer: &Answer, locales: &LocaleInfo) -> Result<()> { + info!("Verifying locale settings"); + if !locales + .countries + .keys() + .any(|i| i == &answer.global.country) + { + bail!("country code '{}' is not valid", &answer.global.country); + } + if !locales.kmap.keys().any(|i| i == &answer.global.keyboard) { + bail!("keyboard layout '{}' is not valid", &answer.global.keyboard); + } + if !locales + .cczones + .iter() + .any(|(_, zones)| zones.contains(&answer.global.timezone)) + { + bail!("timezone '{}' is not valid", &answer.global.timezone); + } + Ok(()) +} + +pub fn run_cmds(step: &str, cmd_vec: &Option>) -> Result<()> { + if let Some(cmds) = cmd_vec { + if !cmds.is_empty() { + info!("Running {step}-Commands:"); + run_cmd(cmds)?; + info!("{step}-Commands finished"); + } + } + Ok(()) +} + +fn run_cmd(cmds: &Vec) -> Result<()> { + for cmd in cmds { + info!("Command '{cmd}':"); + let mut child = match Command::new("/bin/bash") + .arg("-c") + .arg(cmd.clone()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + { + Ok(child) => child, + Err(err) => bail!("error running command {cmd}: {err}"), + }; + if let Err(err) = child.wait() { + bail!("{err}"); + } + } + + Ok(()) +} + +pub fn parse_answer( + answer: &Answer, + udev_info: &UdevInfo, + runtime_info: &RuntimeInfo, + locales: &LocaleInfo, + setup_info: &SetupInfo, +) -> Result { + info!("Parsing answer file"); + info!("Setting File system"); + let filesystem = match &answer.disks.filesystem { + Some(answer::Filesystem::Ext4) => FsType::Ext4, + Some(answer::Filesystem::Xfs) => FsType::Xfs, + Some(answer::Filesystem::ZfsRaid0) => FsType::Zfs(ZfsRaidLevel::Raid0), + Some(answer::Filesystem::ZfsRaid1) => FsType::Zfs(ZfsRaidLevel::Raid1), + Some(answer::Filesystem::ZfsRaid10) => FsType::Zfs(ZfsRaidLevel::Raid10), + Some(answer::Filesystem::ZfsRaidZ1) => FsType::Zfs(ZfsRaidLevel::RaidZ), + Some(answer::Filesystem::ZfsRaidZ2) => FsType::Zfs(ZfsRaidLevel::RaidZ2), + Some(answer::Filesystem::ZfsRaidZ3) => FsType::Zfs(ZfsRaidLevel::RaidZ3), + Some(answer::Filesystem::BtrfsRaid0) => FsType::Btrfs(BtrfsRaidLevel::Raid0), + Some(answer::Filesystem::BtrfsRaid1) => FsType::Btrfs(BtrfsRaidLevel::Raid1), + Some(answer::Filesystem::BtrfsRaid10) => FsType::Btrfs(BtrfsRaidLevel::Raid10), + None => FsType::Ext4, + }; + info!("File system selected: {}", filesystem); + + let network_settings = get_network_settings(answer, udev_info, runtime_info, setup_info)?; + + verify_locale_settings(answer, locales)?; + + let mut config = InstallConfig { + autoreboot: 1_usize, + filesys: filesystem, + hdsize: 0., + swapsize: None, + maxroot: None, + minfree: None, + maxvz: None, + zfs_opts: None, + target_hd: None, + disk_selection: BTreeMap::new(), + + country: answer.global.country.clone(), + timezone: answer.global.timezone.clone(), + keymap: answer.global.keyboard.clone(), + + password: answer.global.password.clone(), + mailto: answer.global.mailto.clone(), + + mngmt_nic: network_settings.ifname, + + hostname: network_settings.fqdn.host().unwrap().to_string(), + domain: network_settings.fqdn.domain(), + cidr: network_settings.address, + gateway: network_settings.gateway, + dns: network_settings.dns_server, + }; + + set_disks(answer, udev_info, runtime_info, &mut config)?; + match &config.filesys { + FsType::Xfs | FsType::Ext4 => { + let lvm = match &answer.disks.lvm { + Some(lvm) => lvm.clone(), + None => answer::LvmOptions::new(), + }; + config.hdsize = lvm.hdsize.unwrap_or(config.target_hd.clone().unwrap().size); + config.swapsize = lvm.swapsize; + config.maxroot = lvm.maxroot; + config.maxvz = lvm.maxvz; + config.minfree = lvm.minfree; + } + FsType::Zfs(_) => { + let zfs = match &answer.disks.zfs { + Some(zfs) => zfs.clone(), + None => answer::ZfsOptions::new(), + }; + let first_selected_disk = get_first_selected_disk(&config); + + config.hdsize = zfs + .hdsize + .unwrap_or(runtime_info.disks[first_selected_disk].size); + config.zfs_opts = Some(InstallZfsOption { + ashift: zfs.ashift.unwrap_or(12), + arc_max: zfs.arc_max.unwrap_or(2048), + compress: match zfs.compress { + Some(answer::ZfsCompressOption::On) => ZfsCompressOption::On, + Some(answer::ZfsCompressOption::Off) => ZfsCompressOption::Off, + Some(answer::ZfsCompressOption::Lzjb) => ZfsCompressOption::Lzjb, + Some(answer::ZfsCompressOption::Lz4) => ZfsCompressOption::Lz4, + Some(answer::ZfsCompressOption::Zle) => ZfsCompressOption::Zle, + Some(answer::ZfsCompressOption::Gzip) => ZfsCompressOption::Gzip, + Some(answer::ZfsCompressOption::Zstd) => ZfsCompressOption::Zstd, + None => ZfsCompressOption::On, + }, + checksum: match zfs.checksum { + Some(answer::ZfsChecksumOption::On) => ZfsChecksumOption::On, + Some(answer::ZfsChecksumOption::Fletcher4) => ZfsChecksumOption::Fletcher4, + Some(answer::ZfsChecksumOption::Sha256) => ZfsChecksumOption::Sha256, + None => ZfsChecksumOption::On, + }, + copies: zfs.copies.unwrap_or(1), + }); + } + FsType::Btrfs(_) => { + let btrfs = match &answer.disks.btrfs { + Some(btrfs) => btrfs.clone(), + None => answer::BtrfsOptions::new(), + }; + let first_selected_disk = get_first_selected_disk(&config); + + config.hdsize = btrfs + .hdsize + .unwrap_or(runtime_info.disks[first_selected_disk].size); + } + } + Ok(config) +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum LowLevelMessage { + #[serde(rename = "message")] + Info { + message: String, + }, + Error { + message: String, + }, + Prompt { + query: String, + }, + Finished { + state: String, + message: String, + }, + Progress { + ratio: f32, + text: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_glob_patterns() { + let test_value = "foobar"; + assert_eq!(find_with_glob("*bar", test_value), true); + assert_eq!(find_with_glob("foo*", test_value), true); + assert_eq!(find_with_glob("foobar", test_value), true); + assert_eq!(find_with_glob("oobar", test_value), false); + } +} -- 2.39.2