]> git.proxmox.com Git - pve-installer.git/blame - proxmox-auto-installer/src/bin/proxmox-autoinst-helper.rs
auto-installer: add proxmox-autoinst-helper tool
[pve-installer.git] / proxmox-auto-installer / src / bin / proxmox-autoinst-helper.rs
CommitLineData
9143507d
AL
1use anyhow::{bail, Result};
2use clap::{Args, Parser, Subcommand, ValueEnum};
3use glob::Pattern;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::{collections::BTreeMap, fs, io::Read, path::PathBuf, process::Command};
7
8use proxmox_auto_installer::{
9 answer::Answer,
10 answer::FilterMatch,
11 utils::{get_matched_udev_indexes, get_single_udev_index},
12};
13
14/// This tool validates the format of an answer file. Additionally it can test match filters and
15/// print information on the properties to match against for the current hardware.
16#[derive(Parser, Debug)]
17#[command(author, version, about, long_about = None)]
18struct Cli {
19 #[command(subcommand)]
20 command: Commands,
21}
22
23#[derive(Subcommand, Debug)]
24enum Commands {
25 ValidateAnswer(CommandValidateAnswer),
26 Match(CommandMatch),
27 Info(CommandInfo),
28}
29
30/// Show device information that can be used for filters
31#[derive(Args, Debug)]
32struct CommandInfo {
33 /// For which device type information should be shown
34 #[arg(name="type", short, long, value_enum, default_value_t=AllDeviceTypes::All)]
35 device: AllDeviceTypes,
36}
37
38/// Test which devices the given filter matches against
39///
40/// Filters support the following syntax:
41/// ? Match a single character
42/// * Match any number of characters
43/// [a], [0-9] Specifc character or range of characters
44/// [!a] Negate a specific character of range
45///
46/// To avoid globbing characters being interpreted by the shell, use single quotes.
47/// Multiple filters can be defined.
48///
49/// Examples:
50/// Match disks against the serial number and device name, both must match:
51///
52/// proxmox-autoinst-helper match --filter-match all disk 'ID_SERIAL_SHORT=*2222*' 'DEVNAME=*nvme*'
53#[derive(Args, Debug)]
54#[command(verbatim_doc_comment)]
55struct CommandMatch {
56 /// Device type to match the filter against
57 r#type: Devicetype,
58
59 /// Filter in the format KEY=VALUE where the key is the UDEV key and VALUE the filter string.
60 /// Multiple filters are possible, separated by a space.
61 filter: Vec<String>,
62
63 /// Defines if any filter or all filters must match.
64 #[arg(long, value_enum, default_value_t=FilterMatch::Any)]
65 filter_match: FilterMatch,
66}
67
68/// Validate if an answer file is formatted correctly.
69#[derive(Args, Debug)]
70struct CommandValidateAnswer {
71 /// Path to the answer file
72 path: PathBuf,
73 #[arg(short, long, default_value_t = false)]
74 debug: bool,
75}
76
77#[derive(Args, Debug)]
78struct GlobalOpts {
79 /// Output format
80 #[arg(long, short, value_enum)]
81 format: OutputFormat,
82}
83
84#[derive(Clone, Debug, ValueEnum, PartialEq)]
85enum AllDeviceTypes {
86 All,
87 Network,
88 Disk,
89}
90
91#[derive(Clone, Debug, ValueEnum)]
92enum Devicetype {
93 Network,
94 Disk,
95}
96
97#[derive(Clone, Debug, ValueEnum)]
98enum OutputFormat {
99 Pretty,
100 Json,
101}
102
103#[derive(Serialize)]
104struct Devs {
105 disks: Option<BTreeMap<String, BTreeMap<String, String>>>,
106 nics: Option<BTreeMap<String, BTreeMap<String, String>>>,
107}
108
109fn main() {
110 let args = Cli::parse();
111 let res = match &args.command {
112 Commands::Info(args) => info(args),
113 Commands::Match(args) => match_filter(args),
114 Commands::ValidateAnswer(args) => validate_answer(args),
115 };
116 if let Err(err) = res {
117 eprintln!("{err}");
118 std::process::exit(1);
119 }
120}
121
122fn info(args: &CommandInfo) -> Result<()> {
123 let mut devs = Devs {
124 disks: None,
125 nics: None,
126 };
127
128 if args.device == AllDeviceTypes::Network || args.device == AllDeviceTypes::All {
129 match get_nics() {
130 Ok(res) => devs.nics = Some(res),
131 Err(err) => bail!("Error getting NIC data: {err}"),
132 }
133 }
134 if args.device == AllDeviceTypes::Disk || args.device == AllDeviceTypes::All {
135 match get_disks() {
136 Ok(res) => devs.disks = Some(res),
137 Err(err) => bail!("Error getting disk data: {err}"),
138 }
139 }
140 println!("{}", serde_json::to_string_pretty(&devs).unwrap());
141 Ok(())
142}
143
144fn match_filter(args: &CommandMatch) -> Result<()> {
145 let devs: BTreeMap<String, BTreeMap<String, String>> = match args.r#type {
146 Devicetype::Disk => get_disks().unwrap(),
147 Devicetype::Network => get_nics().unwrap(),
148 };
149 // parse filters
150
151 let mut filters: BTreeMap<String, String> = BTreeMap::new();
152
153 for f in &args.filter {
154 match f.split_once('=') {
155 Some((key, value)) => {
156 if key.is_empty() || value.is_empty() {
157 bail!("Filter key or value is empty in filter: '{f}'");
158 }
159 filters.insert(String::from(key), String::from(value));
160 }
161 None => {
162 bail!("Could not find separator '=' in filter: '{f}'");
163 }
164 }
165 }
166
167 // align return values
168 let result = match args.r#type {
169 Devicetype::Disk => {
170 get_matched_udev_indexes(filters, &devs, args.filter_match == FilterMatch::All)
171 }
172 Devicetype::Network => get_single_udev_index(filters, &devs).map(|r| vec![r]),
173 };
174
175 match result {
176 Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
177 Err(err) => bail!("Error matching filters: {err}"),
178 }
179 Ok(())
180}
181
182fn validate_answer(args: &CommandValidateAnswer) -> Result<()> {
183 let mut file = match fs::File::open(&args.path) {
184 Ok(file) => file,
185 Err(err) => bail!(
186 "Opening answer file '{}' failed: {err}",
187 args.path.display()
188 ),
189 };
190 let mut contents = String::new();
191 if let Err(err) = file.read_to_string(&mut contents) {
192 bail!("Reading from file '{}' failed: {err}", args.path.display());
193 }
194
195 let answer: Answer = match toml::from_str(&contents) {
196 Ok(answer) => {
197 println!("The file was parsed successfully, no syntax errors found!");
198 answer
199 }
200 Err(err) => bail!("Error parsing answer file: {err}"),
201 };
202 if args.debug {
203 println!("Parsed data from answer file:\n{:#?}", answer);
204 }
205 Ok(())
206}
207
208fn get_disks() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
209 let unwantend_block_devs = vec![
210 "ram[0-9]*",
211 "loop[0-9]*",
212 "md[0-9]*",
213 "dm-*",
214 "fd[0-9]*",
215 "sr[0-9]*",
216 ];
217
218 // compile Regex here once and not inside the loop
219 let re_disk = Regex::new(r"(?m)^E: DEVTYPE=disk")?;
220 let re_cdrom = Regex::new(r"(?m)^E: ID_CDROM")?;
221 let re_iso9660 = Regex::new(r"(?m)^E: ID_FS_TYPE=iso9660")?;
222
223 let re_name = Regex::new(r"(?m)^N: (.*)$")?;
224 let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
225
226 let mut disks: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
227
228 'outer: for entry in fs::read_dir("/sys/block")? {
229 let entry = entry.unwrap();
230 let filename = entry.file_name().into_string().unwrap();
231
232 for p in &unwantend_block_devs {
233 if Pattern::new(p)?.matches(&filename) {
234 continue 'outer;
235 }
236 }
237
238 let output = match get_udev_properties(&entry.path()) {
239 Ok(output) => output,
240 Err(err) => {
241 eprint!("{err}");
242 continue 'outer;
243 }
244 };
245
246 if !re_disk.is_match(&output) {
247 continue 'outer;
248 };
249 if re_cdrom.is_match(&output) {
250 continue 'outer;
251 };
252 if re_iso9660.is_match(&output) {
253 continue 'outer;
254 };
255
256 let mut name = filename;
257 if let Some(cap) = re_name.captures(&output) {
258 if let Some(res) = cap.get(1) {
259 name = String::from(res.as_str());
260 }
261 }
262
263 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
264
265 for line in output.lines() {
266 if let Some(caps) = re_props.captures(line) {
267 let key = String::from(caps.get(1).unwrap().as_str());
268 let value = String::from(caps.get(2).unwrap().as_str());
269 udev_props.insert(key, value);
270 }
271 }
272
273 disks.insert(name, udev_props);
274 }
275 Ok(disks)
276}
277
278#[derive(Deserialize, Debug)]
279struct IpLinksInfo {
280 ifname: String,
281}
282
283fn get_nics() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
284 let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
285 let mut nics: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
286
287 let ip_output = Command::new("/usr/sbin/ip")
288 .arg("-j")
289 .arg("link")
290 .output()?;
291 let parsed_links: Vec<IpLinksInfo> =
292 serde_json::from_str(String::from_utf8(ip_output.stdout)?.as_str())?;
293 let mut links: Vec<String> = Vec::new();
294
295 for link in parsed_links {
296 if link.ifname == *"lo" {
297 continue;
298 }
299 links.push(link.ifname);
300 }
301
302 for link in links {
303 let path = format!("/sys/class/net/{link}");
304
305 let output = match get_udev_properties(&PathBuf::from(path)) {
306 Ok(output) => output,
307 Err(err) => {
308 eprint!("{err}");
309 continue;
310 }
311 };
312
313 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
314
315 for line in output.lines() {
316 if let Some(caps) = re_props.captures(line) {
317 let key = String::from(caps.get(1).unwrap().as_str());
318 let value = String::from(caps.get(2).unwrap().as_str());
319 udev_props.insert(key, value);
320 }
321 }
322
323 nics.insert(link, udev_props);
324 }
325 Ok(nics)
326}
327
328fn get_udev_properties(path: &PathBuf) -> Result<String> {
329 let udev_output = Command::new("udevadm")
330 .arg("info")
331 .arg("--path")
332 .arg(path)
333 .arg("--query")
334 .arg("all")
335 .output()?;
336 if !udev_output.status.success() {
337 bail!("could not run udevadm successfully for {}", path.display());
338 }
339 Ok(String::from_utf8(udev_output.stdout)?)
340}