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 A17F193F06 for ; Wed, 21 Feb 2024 12:13:29 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8164015D80 for ; Wed, 21 Feb 2024 12:13:29 +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:13:28 +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 C813A44454 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:08:00 +0100 Message-Id: <20240221110805.931925-18-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.060 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 - URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [proxmox.com, answer.rs, proxmox-autoinst-helper.rs] Subject: [pve-devel] [PATCH v2 17/22] auto-installer: add proxmox-autoinst-helper tool 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:13:29 -0000 It can parse an answer file to check against syntax errors, test match filters against the current hardware and list properties of the current hardware to match against. Since this tool should be able to run outside of the installer environment, it does not rely on the device information provided by the low-level installer. It instead fetches the list of disks and NICs by itself. The rules when a device is ignored, should match how the low-level installer handles it. Signed-off-by: Aaron Lauterer --- Makefile | 1 + proxmox-auto-installer/Cargo.toml | 2 + proxmox-auto-installer/src/answer.rs | 3 +- .../src/bin/proxmox-autoinst-helper.rs | 335 ++++++++++++++++++ 4 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 proxmox-auto-installer/src/bin/proxmox-autoinst-helper.rs diff --git a/Makefile b/Makefile index b724789..dec5b7b 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ PREFIX = /usr BINDIR = $(PREFIX)/bin USR_BIN := \ proxmox-tui-installer\ + proxmox-autoinst-helper\ proxmox-fetch-answer\ proxmox-auto-installer diff --git a/proxmox-auto-installer/Cargo.toml b/proxmox-auto-installer/Cargo.toml index c00d25a..52bf004 100644 --- a/proxmox-auto-installer/Cargo.toml +++ b/proxmox-auto-installer/Cargo.toml @@ -9,9 +9,11 @@ homepage = "https://www.proxmox.com" [dependencies] anyhow = "1.0" +clap = { version = "4.0", features = ["derive"] } glob = "0.3" proxmox-installer-common = { path = "../proxmox-installer-common" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.7" log = "0.4.20" +regex = "1.7" diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs index 8ba677a..2485726 100644 --- a/proxmox-auto-installer/src/answer.rs +++ b/proxmox-auto-installer/src/answer.rs @@ -1,3 +1,4 @@ +use clap::ValueEnum; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -44,7 +45,7 @@ pub struct Disks { pub btrfs: Option, } -#[derive(Clone, Deserialize, Debug, PartialEq)] +#[derive(Clone, Deserialize, Debug, PartialEq, ValueEnum)] #[serde(rename_all = "lowercase")] pub enum FilterMatch { Any, diff --git a/proxmox-auto-installer/src/bin/proxmox-autoinst-helper.rs b/proxmox-auto-installer/src/bin/proxmox-autoinst-helper.rs new file mode 100644 index 0000000..9be7876 --- /dev/null +++ b/proxmox-auto-installer/src/bin/proxmox-autoinst-helper.rs @@ -0,0 +1,335 @@ +use anyhow::{bail, Result}; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use glob::Pattern; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, fs, io::Read, path::PathBuf, process::Command}; + +use proxmox_auto_installer::{ + answer::Answer, + answer::FilterMatch, + utils::{get_matched_udev_indexes, get_single_udev_index}, +}; + +/// This tool validates the format of an answer file. Additionally it can 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)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + Answer(CommandAnswer), + Match(CommandMatch), + Info(CommandInfo), +} + +/// Show device information that can be used for filters +#[derive(Args, Debug)] +struct CommandInfo { + /// For which device type information should be shown + #[arg(name="type", short, long, value_enum, default_value_t=AllDeviceTypes::All)] + device: AllDeviceTypes, +} + +/// Test which devices the given filter matches against +/// +/// Filters support the following syntax: +/// ? Match a single character +/// * Match any number of characters +/// [a], [0-9] Specifc character or range of characters +/// [!a] Negate a specific character of range +/// +/// To avoid globbing characters being interpreted by the shell, use single quotes. +/// Multiple filters can be defined. +/// +/// Examples: +/// Match disks against the serial number and device name, both must match: +/// +/// proxmox-installer-filter match --filter-match all disk 'ID_SERIAL_SHORT=*2222*' 'DEVNAME=*nvme*' +#[derive(Args, Debug)] +#[command(verbatim_doc_comment)] +struct CommandMatch { + /// Device type to match the filter against + r#type: Devicetype, + + /// Filter in the format KEY=VALUE where the key is the UDEV key and VALUE the filter string. + /// Multiple filters are possible, separated by a space. + filter: Vec, + + /// Defines if any filter or all filters must match. + #[arg(long, value_enum, default_value_t=FilterMatch::Any)] + filter_match: FilterMatch, +} + +/// Validate if an answer file is formatted correctly. +#[derive(Args, Debug)] +struct CommandAnswer { + /// Path to the answer file + path: PathBuf, +} + +#[derive(Args, Debug)] +struct GlobalOpts { + /// Output format + #[arg(long, short, value_enum)] + format: OutputFormat, +} + +#[derive(Clone, Debug, ValueEnum, PartialEq)] +enum AllDeviceTypes { + All, + Network, + Disk, +} + +#[derive(Clone, Debug, ValueEnum)] +enum Devicetype { + Network, + Disk, +} + +#[derive(Clone, Debug, ValueEnum)] +enum OutputFormat { + Pretty, + Json, +} + +#[derive(Serialize)] +struct Devs { + disks: Option>>, + nics: Option>>, +} + +fn main() { + let args = Cli::parse(); + let res = match &args.command { + Commands::Info(args) => info(args), + Commands::Match(args) => match_filter(args), + Commands::Answer(args) => validate_answer(args), + }; + if let Err(err) = res { + eprintln!("{err}"); + std::process::exit(1); + } +} + +fn info(args: &CommandInfo) -> Result<()> { + let mut devs = Devs { + disks: None, + nics: None, + }; + + if args.device == AllDeviceTypes::Network || args.device == AllDeviceTypes::All { + match get_nics() { + Ok(res) => devs.nics = Some(res), + Err(err) => bail!("Error getting NIC data: {err}"), + } + } + if args.device == AllDeviceTypes::Disk || args.device == AllDeviceTypes::All { + match get_disks() { + Ok(res) => devs.disks = Some(res), + Err(err) => bail!("Error getting disk data: {err}"), + } + } + println!("{}", serde_json::to_string_pretty(&devs).unwrap()); + Ok(()) +} + +fn match_filter(args: &CommandMatch) -> Result<()> { + let devs: BTreeMap> = match args.r#type { + Devicetype::Disk => get_disks().unwrap(), + Devicetype::Network => get_nics().unwrap(), + }; + // parse filters + + let mut filters: BTreeMap = BTreeMap::new(); + + for f in &args.filter { + match f.split_once('=') { + Some((key, value)) => { + if key.is_empty() || value.is_empty() { + bail!("Filter key or value is empty in filter: '{f}'"); + } + filters.insert(String::from(key), String::from(value)); + } + None => { + bail!("Could not find separator '=' in filter: '{f}'"); + } + } + } + + // align return values + let result = match args.r#type { + Devicetype::Disk => { + get_matched_udev_indexes(filters, &devs, args.filter_match == FilterMatch::All) + } + Devicetype::Network => get_single_udev_index(filters, &devs).map(|r| vec![r]), + }; + + match result { + Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()), + Err(err) => bail!("Error matching filters: {err}"), + } + Ok(()) +} + +fn validate_answer(args: &CommandAnswer) -> 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}"), + }; + Ok(()) +} + +fn get_disks() -> Result>> { + let unwantend_block_devs = vec![ + "ram[0-9]*", + "loop[0-9]*", + "md[0-9]*", + "dm-*", + "fd[0-9]*", + "sr[0-9]*", + ]; + + // compile Regex here once and not inside the loop + let re_disk = Regex::new(r"(?m)^E: DEVTYPE=disk")?; + let re_cdrom = Regex::new(r"(?m)^E: ID_CDROM")?; + let re_iso9660 = Regex::new(r"(?m)^E: ID_FS_TYPE=iso9660")?; + + let re_name = Regex::new(r"(?m)^N: (.*)$")?; + let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?; + + let mut disks: BTreeMap> = BTreeMap::new(); + + 'outer: for entry in fs::read_dir("/sys/block")? { + let entry = entry.unwrap(); + let filename = entry.file_name().into_string().unwrap(); + + for p in &unwantend_block_devs { + if Pattern::new(p)?.matches(&filename) { + continue 'outer; + } + } + + let output = match get_udev_properties(&entry.path()) { + Ok(output) => output, + Err(err) => { + eprint!("{err}"); + continue 'outer; + } + }; + + if !re_disk.is_match(&output) { + continue 'outer; + }; + if re_cdrom.is_match(&output) { + continue 'outer; + }; + if re_iso9660.is_match(&output) { + continue 'outer; + }; + + let mut name = filename; + if let Some(cap) = re_name.captures(&output) { + if let Some(res) = cap.get(1) { + name = String::from(res.as_str()); + } + } + + let mut udev_props: BTreeMap = BTreeMap::new(); + + for line in output.lines() { + if let Some(caps) = re_props.captures(line) { + let key = String::from(caps.get(1).unwrap().as_str()); + let value = String::from(caps.get(2).unwrap().as_str()); + udev_props.insert(key, value); + } + } + + disks.insert(name, udev_props); + } + Ok(disks) +} + +#[derive(Deserialize, Debug)] +struct IpLinksInfo { + ifname: String, +} + +fn get_nics() -> Result>> { + let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?; + let mut nics: BTreeMap> = BTreeMap::new(); + + let ip_output = Command::new("/usr/sbin/ip") + .arg("-j") + .arg("link") + .output()?; + let parsed_links: Vec = + serde_json::from_str(String::from_utf8(ip_output.stdout)?.as_str())?; + let mut links: Vec = Vec::new(); + + for link in parsed_links { + if link.ifname == *"lo" { + continue; + } + links.push(link.ifname); + } + + for link in links { + let path = format!("/sys/class/net/{link}"); + + let output = match get_udev_properties(&PathBuf::from(path)) { + Ok(output) => output, + Err(err) => { + eprint!("{err}"); + continue; + } + }; + + let mut udev_props: BTreeMap = BTreeMap::new(); + + for line in output.lines() { + if let Some(caps) = re_props.captures(line) { + let key = String::from(caps.get(1).unwrap().as_str()); + let value = String::from(caps.get(2).unwrap().as_str()); + udev_props.insert(key, value); + } + } + + nics.insert(link, udev_props); + } + Ok(nics) +} + +fn get_udev_properties(path: &PathBuf) -> Result { + let udev_output = Command::new("udevadm") + .arg("info") + .arg("--path") + .arg(path) + .arg("--query") + .arg("all") + .output()?; + if !udev_output.status.success() { + bail!("could not run udevadm successfully for {}", path.display()); + } + Ok(String::from_utf8(udev_output.stdout)?) +} -- 2.39.2