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 28BD5BC4BB for ; Thu, 28 Mar 2024 14:50:43 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 653EFCC13 for ; Thu, 28 Mar 2024 14:50:40 +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 ; Thu, 28 Mar 2024 14:50:36 +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 D9E614293D for ; Thu, 28 Mar 2024 14:50:35 +0100 (CET) From: Aaron Lauterer To: pve-devel@lists.proxmox.com Date: Thu, 28 Mar 2024 14:50:17 +0100 Message-Id: <20240328135028.504520-20-a.lauterer@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20240328135028.504520-1-a.lauterer@proxmox.com> References: <20240328135028.504520-1-a.lauterer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.061 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 Subject: [pve-devel] [PATCH v3 19/30] 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: Thu, 28 Mar 2024 13:50:43 -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 | 3 +- proxmox-auto-installer/Cargo.toml | 2 + proxmox-auto-installer/src/answer.rs | 3 +- .../src/bin/proxmox-autoinst-helper.rs | 340 ++++++++++++++++++ 4 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 proxmox-auto-installer/src/bin/proxmox-autoinst-helper.rs diff --git a/Makefile b/Makefile index e44450e..197a351 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 @@ -123,7 +124,7 @@ $(COMPILED_BINS): cargo-build cargo-build: $(CARGO) build --package proxmox-tui-installer --bin proxmox-tui-installer \ --package proxmox-auto-installer --bin proxmox-auto-installer \ - --bin proxmox-fetch-answer $(CARGO_BUILD_ARGS) + --bin proxmox-fetch-answer --bin proxmox-autoinst-helper $(CARGO_BUILD_ARGS) %-banner.png: %-banner.svg rsvg-convert -o $@ $< diff --git a/proxmox-auto-installer/Cargo.toml b/proxmox-auto-installer/Cargo.toml index 741794a..bb0b49c 100644 --- a/proxmox-auto-installer/Cargo.toml +++ b/proxmox-auto-installer/Cargo.toml @@ -9,6 +9,7 @@ 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"] } @@ -16,3 +17,4 @@ serde_json = "1.0" toml = "0.7" enum-iterator = "0.6.0" log = "0.4.20" +regex = "1.7" diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs index 96e5608..15607af 100644 --- a/proxmox-auto-installer/src/answer.rs +++ b/proxmox-auto-installer/src/answer.rs @@ -1,3 +1,4 @@ +use clap::ValueEnum; use enum_iterator::IntoEnumIterator; use proxmox_installer_common::{ options::{BtrfsRaidLevel, FsType, ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel}, @@ -195,7 +196,7 @@ pub enum DiskSelection { Selection(Vec), Filter(BTreeMap), } -#[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..0c9322e --- /dev/null +++ b/proxmox-auto-installer/src/bin/proxmox-autoinst-helper.rs @@ -0,0 +1,340 @@ +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, + #[arg(short, long, default_value_t=false)] + debug: bool, +} + +#[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}"), + }; + if args.debug { + println!("Parsed data from answer file:\n{:#?}", answer); + } + 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