]>
Commit | Line | Data |
---|---|---|
9143507d AL |
1 | use anyhow::{bail, Result}; |
2 | use clap::{Args, Parser, Subcommand, ValueEnum}; | |
3 | use glob::Pattern; | |
4 | use regex::Regex; | |
5 | use serde::{Deserialize, Serialize}; | |
6 | use std::{collections::BTreeMap, fs, io::Read, path::PathBuf, process::Command}; | |
7 | ||
8 | use 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)] | |
18 | struct Cli { | |
19 | #[command(subcommand)] | |
20 | command: Commands, | |
21 | } | |
22 | ||
23 | #[derive(Subcommand, Debug)] | |
24 | enum 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)] | |
32 | struct 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)] | |
55 | struct 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)] | |
70 | struct 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)] | |
78 | struct GlobalOpts { | |
79 | /// Output format | |
80 | #[arg(long, short, value_enum)] | |
81 | format: OutputFormat, | |
82 | } | |
83 | ||
84 | #[derive(Clone, Debug, ValueEnum, PartialEq)] | |
85 | enum AllDeviceTypes { | |
86 | All, | |
87 | Network, | |
88 | Disk, | |
89 | } | |
90 | ||
91 | #[derive(Clone, Debug, ValueEnum)] | |
92 | enum Devicetype { | |
93 | Network, | |
94 | Disk, | |
95 | } | |
96 | ||
97 | #[derive(Clone, Debug, ValueEnum)] | |
98 | enum OutputFormat { | |
99 | Pretty, | |
100 | Json, | |
101 | } | |
102 | ||
103 | #[derive(Serialize)] | |
104 | struct Devs { | |
105 | disks: Option<BTreeMap<String, BTreeMap<String, String>>>, | |
106 | nics: Option<BTreeMap<String, BTreeMap<String, String>>>, | |
107 | } | |
108 | ||
109 | fn 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 | ||
122 | fn 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 | ||
144 | fn 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 | ||
182 | fn 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 | ||
208 | fn 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)] | |
279 | struct IpLinksInfo { | |
280 | ifname: String, | |
281 | } | |
282 | ||
283 | fn 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 | ||
328 | fn 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 | } |