]> git.proxmox.com Git - pve-installer.git/commitdiff
auto-installer: add proxmox-autoinst-helper tool
authorAaron Lauterer <a.lauterer@proxmox.com>
Wed, 17 Apr 2024 12:30:51 +0000 (14:30 +0200)
committerThomas Lamprecht <t.lamprecht@proxmox.com>
Mon, 22 Apr 2024 12:31:37 +0000 (14:31 +0200)
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.

Tested-by: Christoph Heiss <c.heiss@proxmox.com>
Reviewed-by: Christoph Heiss <c.heiss@proxmox.com>
Signed-off-by: Aaron Lauterer <a.lauterer@proxmox.com>
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
Makefile
proxmox-auto-installer/Cargo.toml
proxmox-auto-installer/src/answer.rs
proxmox-auto-installer/src/bin/proxmox-autoinst-helper.rs [new file with mode: 0644]

index e44450ecfaf3ffe25e5a5c78de76394ddd26f656..197a35120222d5e116abe92dc156006d978c1d48 100644 (file)
--- 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 $@ $<
index dcb76b3c6e0de7f130ccd3d572026611a93ead0c..48be375760be9aee0590f334794cb3f25e8a0a7d 100644 (file)
@@ -12,9 +12,11 @@ homepage = "https://www.proxmox.com"
 
 [dependencies]
 anyhow = "1.0"
+clap = { version = "4.0", features = ["derive"] }
 glob = "0.3"
 log = "0.4.20"
 proxmox-installer-common = { path = "../proxmox-installer-common" }
+regex = "1.7"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 toml = "0.7"
index 0add95eda27a00b9712db851e84eb7aadfa2713c..94cebb39d9c77b61a2a8974bfb76ce9869f0a529 100644 (file)
@@ -1,3 +1,4 @@
+use clap::ValueEnum;
 use proxmox_installer_common::{
     options::{BtrfsRaidLevel, FsType, ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel},
     utils::{CidrAddress, Fqdn},
@@ -205,7 +206,7 @@ pub enum DiskSelection {
     Selection(Vec<String>),
     Filter(BTreeMap<String, String>),
 }
-#[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 (file)
index 0000000..058d5ff
--- /dev/null
@@ -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 {
+    ValidateAnswer(CommandValidateAnswer),
+    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-autoinst-helper 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<String>,
+
+    /// 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 CommandValidateAnswer {
+    /// 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<BTreeMap<String, BTreeMap<String, String>>>,
+    nics: Option<BTreeMap<String, BTreeMap<String, String>>>,
+}
+
+fn main() {
+    let args = Cli::parse();
+    let res = match &args.command {
+        Commands::Info(args) => info(args),
+        Commands::Match(args) => match_filter(args),
+        Commands::ValidateAnswer(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<String, BTreeMap<String, String>> = match args.r#type {
+        Devicetype::Disk => get_disks().unwrap(),
+        Devicetype::Network => get_nics().unwrap(),
+    };
+    // parse filters
+
+    let mut filters: BTreeMap<String, String> = 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: &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}"),
+    };
+    if args.debug {
+        println!("Parsed data from answer file:\n{:#?}", answer);
+    }
+    Ok(())
+}
+
+fn get_disks() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
+    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<String, BTreeMap<String, String>> = 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<String, String> = 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<BTreeMap<String, BTreeMap<String, String>>> {
+    let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
+    let mut nics: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
+
+    let ip_output = Command::new("/usr/sbin/ip")
+        .arg("-j")
+        .arg("link")
+        .output()?;
+    let parsed_links: Vec<IpLinksInfo> =
+        serde_json::from_str(String::from_utf8(ip_output.stdout)?.as_str())?;
+    let mut links: Vec<String> = 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<String, String> = 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<String> {
+    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)?)
+}