]> git.proxmox.com Git - pve-installer.git/blame - proxmox-auto-install-assistant/src/main.rs
assistant: rename identifiers command to system-info
[pve-installer.git] / proxmox-auto-install-assistant / src / main.rs
CommitLineData
9143507d
AL
1use anyhow::{bail, Result};
2use clap::{Args, Parser, Subcommand, ValueEnum};
3use glob::Pattern;
4use regex::Regex;
9b9754a5 5use serde::Serialize;
01470aae
AL
6use std::{
7 collections::BTreeMap,
8 fs,
9 io::Read,
10 path::{Path, PathBuf},
11 process::{Command, Stdio},
12};
9143507d
AL
13
14use proxmox_auto_installer::{
15 answer::Answer,
16 answer::FilterMatch,
d4c43e9d 17 sysinfo::SysInfo,
01470aae
AL
18 utils::{
19 get_matched_udev_indexes, get_nic_list, get_single_udev_index, AutoInstModes,
20 AutoInstSettings,
21 },
9143507d
AL
22};
23
95be2375 24static PROXMOX_ISO_FLAG: &str = "/auto-installer-capable";
01470aae
AL
25
26/// This tool can be used to prepare a Proxmox installation ISO for automated installations.
27/// Additional uses are to validate the format of an answer file or to test match filters and
9143507d
AL
28/// print information on the properties to match against for the current hardware.
29#[derive(Parser, Debug)]
30#[command(author, version, about, long_about = None)]
31struct Cli {
32 #[command(subcommand)]
33 command: Commands,
34}
35
36#[derive(Subcommand, Debug)]
37enum Commands {
01470aae 38 PrepareIso(CommandPrepareISO),
9143507d 39 ValidateAnswer(CommandValidateAnswer),
9b9754a5
AL
40 DeviceMatch(CommandDeviceMatch),
41 DeviceInfo(CommandDeviceInfo),
0a733567 42 SystemInfo(CommandSystemInfo),
9143507d
AL
43}
44
45/// Show device information that can be used for filters
46#[derive(Args, Debug)]
9b9754a5 47struct CommandDeviceInfo {
9143507d
AL
48 /// For which device type information should be shown
49 #[arg(name="type", short, long, value_enum, default_value_t=AllDeviceTypes::All)]
50 device: AllDeviceTypes,
51}
52
53/// Test which devices the given filter matches against
54///
55/// Filters support the following syntax:
56/// ? Match a single character
57/// * Match any number of characters
58/// [a], [0-9] Specifc character or range of characters
59/// [!a] Negate a specific character of range
60///
61/// To avoid globbing characters being interpreted by the shell, use single quotes.
62/// Multiple filters can be defined.
63///
64/// Examples:
65/// Match disks against the serial number and device name, both must match:
66///
95be2375 67/// proxmox-auto-install-assistant match --filter-match all disk 'ID_SERIAL_SHORT=*2222*' 'DEVNAME=*nvme*'
9143507d
AL
68#[derive(Args, Debug)]
69#[command(verbatim_doc_comment)]
9b9754a5 70struct CommandDeviceMatch {
9143507d
AL
71 /// Device type to match the filter against
72 r#type: Devicetype,
73
74 /// Filter in the format KEY=VALUE where the key is the UDEV key and VALUE the filter string.
75 /// Multiple filters are possible, separated by a space.
76 filter: Vec<String>,
77
78 /// Defines if any filter or all filters must match.
79 #[arg(long, value_enum, default_value_t=FilterMatch::Any)]
80 filter_match: FilterMatch,
81}
82
83/// Validate if an answer file is formatted correctly.
84#[derive(Args, Debug)]
85struct CommandValidateAnswer {
86 /// Path to the answer file
87 path: PathBuf,
88 #[arg(short, long, default_value_t = false)]
89 debug: bool,
90}
91
01470aae
AL
92/// Prepare an ISO for automated installation.
93///
94/// The final ISO will try to fetch an answer file automatically. It will first search for a
528d1268 95/// partition / file-system called "PROXMOX-INST-SRC" (or lowercase) and a file in the root named
01470aae
AL
96/// "answer.toml".
97///
98/// If that is not found, it will try to fetch an answer file via an HTTP Post request. The URL for
99/// it can be defined for the ISO with the '--url', '-u' argument. If not present, it will try to
528d1268
TL
100/// get a URL from a DHCP option (250, TXT) or by querying a DNS TXT record at
101/// 'proxmox-auto-installer.{search domain}'.
01470aae
AL
102///
103/// The TLS certificate fingerprint can either be defined via the '--cert-fingerprint', '-c'
104/// argument or alternatively via the custom DHCP option (251, TXT) or in a DNS TXT record located
528d1268 105/// at 'proxmox-auto-installer-cert-fingerprint.{search domain}'.
01470aae
AL
106///
107/// The latter options to provide the TLS fingerprint will only be used if the same method was used
108/// to retrieve the URL. For example, the DNS TXT record for the fingerprint will only be used, if
109/// no one was configured with the '--cert-fingerprint' parameter and if the URL was retrieved via
110/// the DNS TXT record.
111///
112/// The behavior of how to fetch an answer file can be overridden with the '--install-mode', '-i'
113/// parameter. The answer file can be{n}
114/// * integrated into the ISO itself ('included'){n}
528d1268
TL
115/// * needs to be present in a partition / file-system with the label 'PROXMOX-INST-SRC'
116/// ('partition'){n}
117/// * get requested via an HTTP Post request ('http').
01470aae
AL
118#[derive(Args, Debug)]
119struct CommandPrepareISO {
120 /// Path to the source ISO
121 source: PathBuf,
122
123 /// Path to store the final ISO to.
124 #[arg(short, long)]
125 target: Option<PathBuf>,
126
127 /// Where to fetch the answer file from.
128 #[arg(short, long, value_enum, default_value_t=AutoInstModes::Auto)]
129 install_mode: AutoInstModes,
130
131 /// Include the specified answer file in the ISO. Requires the '--install-mode', '-i' parameter
132 /// to be set to 'included'.
133 #[arg(short, long)]
134 answer_file: Option<PathBuf>,
135
136 /// Specify URL for fetching the answer file via HTTP
137 #[arg(short, long)]
138 url: Option<String>,
139
140 /// Pin the ISO to the specified SHA256 TLS certificate fingerprint.
141 #[arg(short, long)]
142 cert_fingerprint: Option<String>,
143
144 /// Tmp directory to use.
145 #[arg(long)]
146 tmp: Option<String>,
147}
148
0a733567
TL
149/// Show the system information that can be used to identify a host.
150///
151/// The shown information is sent as POST HTTP request when fetching the answer file for the
152/// automatic installation through HTTP, You can, for example, use this to return a dynamically
153/// assembled answer file.
9b9754a5 154#[derive(Args, Debug)]
0a733567 155struct CommandSystemInfo {}
9b9754a5 156
9143507d
AL
157#[derive(Args, Debug)]
158struct GlobalOpts {
159 /// Output format
160 #[arg(long, short, value_enum)]
161 format: OutputFormat,
162}
163
164#[derive(Clone, Debug, ValueEnum, PartialEq)]
165enum AllDeviceTypes {
166 All,
167 Network,
168 Disk,
169}
170
171#[derive(Clone, Debug, ValueEnum)]
172enum Devicetype {
173 Network,
174 Disk,
175}
176
177#[derive(Clone, Debug, ValueEnum)]
178enum OutputFormat {
179 Pretty,
180 Json,
181}
182
183#[derive(Serialize)]
184struct Devs {
185 disks: Option<BTreeMap<String, BTreeMap<String, String>>>,
186 nics: Option<BTreeMap<String, BTreeMap<String, String>>>,
187}
188
189fn main() {
190 let args = Cli::parse();
191 let res = match &args.command {
01470aae 192 Commands::PrepareIso(args) => prepare_iso(args),
9143507d 193 Commands::ValidateAnswer(args) => validate_answer(args),
9b9754a5
AL
194 Commands::DeviceInfo(args) => info(args),
195 Commands::DeviceMatch(args) => match_filter(args),
0a733567 196 Commands::SystemInfo(args) => show_system_info(args),
9143507d
AL
197 };
198 if let Err(err) = res {
199 eprintln!("{err}");
200 std::process::exit(1);
201 }
202}
203
9b9754a5 204fn info(args: &CommandDeviceInfo) -> Result<()> {
9143507d
AL
205 let mut devs = Devs {
206 disks: None,
207 nics: None,
208 };
209
210 if args.device == AllDeviceTypes::Network || args.device == AllDeviceTypes::All {
211 match get_nics() {
212 Ok(res) => devs.nics = Some(res),
213 Err(err) => bail!("Error getting NIC data: {err}"),
214 }
215 }
216 if args.device == AllDeviceTypes::Disk || args.device == AllDeviceTypes::All {
217 match get_disks() {
218 Ok(res) => devs.disks = Some(res),
219 Err(err) => bail!("Error getting disk data: {err}"),
220 }
221 }
222 println!("{}", serde_json::to_string_pretty(&devs).unwrap());
223 Ok(())
224}
225
9b9754a5 226fn match_filter(args: &CommandDeviceMatch) -> Result<()> {
9143507d
AL
227 let devs: BTreeMap<String, BTreeMap<String, String>> = match args.r#type {
228 Devicetype::Disk => get_disks().unwrap(),
229 Devicetype::Network => get_nics().unwrap(),
230 };
231 // parse filters
232
233 let mut filters: BTreeMap<String, String> = BTreeMap::new();
234
235 for f in &args.filter {
236 match f.split_once('=') {
237 Some((key, value)) => {
238 if key.is_empty() || value.is_empty() {
239 bail!("Filter key or value is empty in filter: '{f}'");
240 }
241 filters.insert(String::from(key), String::from(value));
242 }
243 None => {
244 bail!("Could not find separator '=' in filter: '{f}'");
245 }
246 }
247 }
248
249 // align return values
250 let result = match args.r#type {
251 Devicetype::Disk => {
252 get_matched_udev_indexes(filters, &devs, args.filter_match == FilterMatch::All)
253 }
254 Devicetype::Network => get_single_udev_index(filters, &devs).map(|r| vec![r]),
255 };
256
257 match result {
258 Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
259 Err(err) => bail!("Error matching filters: {err}"),
260 }
261 Ok(())
262}
263
264fn validate_answer(args: &CommandValidateAnswer) -> Result<()> {
01470aae 265 let answer = parse_answer(&args.path)?;
9143507d
AL
266 if args.debug {
267 println!("Parsed data from answer file:\n{:#?}", answer);
268 }
269 Ok(())
270}
271
0a733567 272fn show_system_info(_args: &CommandSystemInfo) -> Result<()> {
d4c43e9d 273 match SysInfo::as_json_pretty() {
9b9754a5 274 Ok(res) => println!("{res}"),
0a733567 275 Err(err) => eprintln!("Error fetching system info: {err}"),
9b9754a5
AL
276 }
277 Ok(())
278}
279
01470aae
AL
280fn prepare_iso(args: &CommandPrepareISO) -> Result<()> {
281 check_prepare_requirements(args)?;
282
283 if args.install_mode == AutoInstModes::Included {
284 if args.answer_file.is_none() {
285 bail!("Missing path to answer file needed for 'direct' install mode.");
286 }
287 if args.cert_fingerprint.is_some() {
288 bail!("No certificate fingerprint needed for direct install mode. Drop the parameter!");
289 }
290 if args.url.is_some() {
291 bail!("No URL needed for direct install mode. Drop the parameter!");
292 }
293 } else if args.install_mode == AutoInstModes::Partition {
294 if args.cert_fingerprint.is_some() {
295 bail!(
296 "No certificate fingerprint needed for partition install mode. Drop the parameter!"
297 );
298 }
299 if args.url.is_some() {
300 bail!("No URL needed for partition install mode. Drop the parameter!");
301 }
302 }
303 if args.answer_file.is_some() && args.install_mode != AutoInstModes::Included {
304 bail!("Set '-i', '--install-mode' to 'included' to place the answer file directly in the ISO.");
305 }
306
307 if let Some(file) = &args.answer_file {
308 println!("Checking provided answer file...");
309 parse_answer(file)?;
310 }
311
312 let mut tmp_base = PathBuf::new();
a14a9348
TL
313 match args.tmp.as_ref() {
314 Some(tmp_dir) => tmp_base.push(tmp_dir),
315 None => tmp_base.push(args.source.parent().unwrap()),
01470aae 316 }
a14a9348
TL
317
318 let iso_target = final_iso_location(args);
01470aae
AL
319
320 let mut tmp_iso = tmp_base.clone();
a14a9348
TL
321 let iso_target_file_name = match iso_target.file_name() {
322 None => bail!("no base filename in target ISO path found"),
323 Some(source_file_name) => source_file_name.to_string_lossy(),
324 };
325 tmp_iso.push(format!("{iso_target_file_name}.tmp",));
326
01470aae
AL
327 let mut tmp_answer = tmp_base.clone();
328 tmp_answer.push("answer.toml");
329
330 println!("Copying source ISO to temporary location...");
331 fs::copy(&args.source, &tmp_iso)?;
01470aae
AL
332
333 println!("Preparing ISO...");
334 let install_mode = AutoInstSettings {
335 mode: args.install_mode.clone(),
336 http_url: args.url.clone(),
337 cert_fingerprint: args.cert_fingerprint.clone(),
338 };
339 let mut instmode_file_tmp = tmp_base.clone();
95be2375 340 instmode_file_tmp.push("auto-installer-mode.toml");
01470aae
AL
341 fs::write(&instmode_file_tmp, toml::to_string_pretty(&install_mode)?)?;
342
95be2375 343 inject_file_to_iso(&tmp_iso, &instmode_file_tmp, "/auto-installer-mode.toml")?;
01470aae
AL
344
345 if let Some(answer) = &args.answer_file {
346 fs::copy(answer, &tmp_answer)?;
347 inject_file_to_iso(&tmp_iso, &tmp_answer, "/answer.toml")?;
348 }
349
a14a9348 350 println!("Moving prepared ISO to target location...");
01470aae 351 fs::rename(&tmp_iso, &iso_target)?;
a14a9348 352 println!("Final ISO is available at {iso_target:?}.");
01470aae
AL
353
354 Ok(())
355}
356
357fn final_iso_location(args: &CommandPrepareISO) -> PathBuf {
358 if let Some(specified) = args.target.clone() {
359 return specified;
360 }
361 let mut suffix: String = match args.install_mode {
f59910eb
TL
362 AutoInstModes::Auto => "auto",
363 AutoInstModes::Http => "auto-http",
364 AutoInstModes::Included => "auto-answer-included",
365 AutoInstModes::Partition => "auto-part",
366 }
367 .into();
01470aae
AL
368
369 if args.url.is_some() {
370 suffix.push_str("-url");
371 }
372 if args.cert_fingerprint.is_some() {
373 suffix.push_str("-fp");
374 }
375
376 let base = args.source.parent().unwrap();
377 let iso = args.source.file_stem().unwrap();
378
379 let mut target = base.to_path_buf();
380 target.push(format!("{}-{}.iso", iso.to_str().unwrap(), suffix));
381
382 target.to_path_buf()
383}
384
385fn inject_file_to_iso(iso: &PathBuf, file: &PathBuf, location: &str) -> Result<()> {
386 let result = Command::new("xorriso")
387 .arg("--boot_image")
388 .arg("any")
389 .arg("keep")
390 .arg("-dev")
391 .arg(iso)
392 .arg("-map")
393 .arg(file)
394 .arg(location)
395 .output()?;
396 if !result.status.success() {
397 bail!(
f59910eb
TL
398 "Error injecting {file:?} into {iso:?}: {}",
399 String::from_utf8_lossy(&result.stderr)
01470aae
AL
400 );
401 }
402 Ok(())
403}
404
9143507d
AL
405fn get_disks() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
406 let unwantend_block_devs = vec![
407 "ram[0-9]*",
408 "loop[0-9]*",
409 "md[0-9]*",
410 "dm-*",
411 "fd[0-9]*",
412 "sr[0-9]*",
413 ];
414
415 // compile Regex here once and not inside the loop
416 let re_disk = Regex::new(r"(?m)^E: DEVTYPE=disk")?;
417 let re_cdrom = Regex::new(r"(?m)^E: ID_CDROM")?;
418 let re_iso9660 = Regex::new(r"(?m)^E: ID_FS_TYPE=iso9660")?;
419
420 let re_name = Regex::new(r"(?m)^N: (.*)$")?;
421 let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
422
423 let mut disks: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
424
425 'outer: for entry in fs::read_dir("/sys/block")? {
426 let entry = entry.unwrap();
427 let filename = entry.file_name().into_string().unwrap();
428
429 for p in &unwantend_block_devs {
430 if Pattern::new(p)?.matches(&filename) {
431 continue 'outer;
432 }
433 }
434
435 let output = match get_udev_properties(&entry.path()) {
436 Ok(output) => output,
437 Err(err) => {
438 eprint!("{err}");
439 continue 'outer;
440 }
441 };
442
443 if !re_disk.is_match(&output) {
444 continue 'outer;
445 };
446 if re_cdrom.is_match(&output) {
447 continue 'outer;
448 };
449 if re_iso9660.is_match(&output) {
450 continue 'outer;
451 };
452
453 let mut name = filename;
454 if let Some(cap) = re_name.captures(&output) {
455 if let Some(res) = cap.get(1) {
456 name = String::from(res.as_str());
457 }
458 }
459
460 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
461
462 for line in output.lines() {
463 if let Some(caps) = re_props.captures(line) {
464 let key = String::from(caps.get(1).unwrap().as_str());
465 let value = String::from(caps.get(2).unwrap().as_str());
466 udev_props.insert(key, value);
467 }
468 }
469
470 disks.insert(name, udev_props);
471 }
472 Ok(disks)
473}
474
9143507d
AL
475fn get_nics() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
476 let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
477 let mut nics: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
478
9b9754a5 479 let links = get_nic_list()?;
9143507d
AL
480 for link in links {
481 let path = format!("/sys/class/net/{link}");
482
483 let output = match get_udev_properties(&PathBuf::from(path)) {
484 Ok(output) => output,
485 Err(err) => {
486 eprint!("{err}");
487 continue;
488 }
489 };
490
491 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
492
493 for line in output.lines() {
494 if let Some(caps) = re_props.captures(line) {
495 let key = String::from(caps.get(1).unwrap().as_str());
496 let value = String::from(caps.get(2).unwrap().as_str());
497 udev_props.insert(key, value);
498 }
499 }
500
501 nics.insert(link, udev_props);
502 }
503 Ok(nics)
504}
505
506fn get_udev_properties(path: &PathBuf) -> Result<String> {
507 let udev_output = Command::new("udevadm")
508 .arg("info")
509 .arg("--path")
510 .arg(path)
511 .arg("--query")
512 .arg("all")
513 .output()?;
514 if !udev_output.status.success() {
f59910eb 515 bail!("could not run udevadm successfully for {path:?}");
9143507d
AL
516 }
517 Ok(String::from_utf8(udev_output.stdout)?)
518}
01470aae
AL
519
520fn parse_answer(path: &PathBuf) -> Result<Answer> {
521 let mut file = match fs::File::open(path) {
522 Ok(file) => file,
f59910eb 523 Err(err) => bail!("Opening answer file {path:?} failed: {err}"),
01470aae
AL
524 };
525 let mut contents = String::new();
526 if let Err(err) = file.read_to_string(&mut contents) {
f59910eb 527 bail!("Reading from file {path:?} failed: {err}");
01470aae
AL
528 }
529 match toml::from_str(&contents) {
530 Ok(answer) => {
531 println!("The file was parsed successfully, no syntax errors found!");
532 Ok(answer)
533 }
534 Err(err) => bail!("Error parsing answer file: {err}"),
535 }
536}
537
538fn check_prepare_requirements(args: &CommandPrepareISO) -> Result<()> {
539 match Path::try_exists(&args.source) {
540 Ok(true) => (),
541 Ok(false) => bail!("Source file does not exist."),
542 Err(_) => bail!("Source file does not exist."),
543 }
544
545 match Command::new("xorriso")
546 .arg("-dev")
547 .arg(&args.source)
548 .arg("-find")
549 .arg(PROXMOX_ISO_FLAG)
550 .stderr(Stdio::null())
551 .stdout(Stdio::null())
552 .status()
553 {
554 Ok(v) => {
555 if !v.success() {
556 bail!("The source ISO file is not able to be installed automatically. Please try a more current one.");
557 }
558 }
559 Err(_) => bail!("Could not run 'xorriso'. Please install it."),
560 };
561
562 Ok(())
563}