]> git.proxmox.com Git - pve-installer.git/blame - proxmox-auto-install-assistant/src/main.rs
cleanup unnecessary clones
[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,
ca8b8ace 9 io::{self, Read},
01470aae
AL
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 => {
2a777841 252 get_matched_udev_indexes(&filters, &devs, args.filter_match == FilterMatch::All)
9143507d 253 }
2a777841 254 Devicetype::Network => get_single_udev_index(&filters, &devs).map(|r| vec![r]),
9143507d
AL
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 println!("Copying source ISO to temporary location...");
328 fs::copy(&args.source, &tmp_iso)?;
01470aae
AL
329
330 println!("Preparing ISO...");
331 let install_mode = AutoInstSettings {
332 mode: args.install_mode.clone(),
333 http_url: args.url.clone(),
334 cert_fingerprint: args.cert_fingerprint.clone(),
335 };
336 let mut instmode_file_tmp = tmp_base.clone();
95be2375 337 instmode_file_tmp.push("auto-installer-mode.toml");
01470aae
AL
338 fs::write(&instmode_file_tmp, toml::to_string_pretty(&install_mode)?)?;
339
95be2375 340 inject_file_to_iso(&tmp_iso, &instmode_file_tmp, "/auto-installer-mode.toml")?;
01470aae 341
938726d5
TL
342 if let Some(answer_file) = &args.answer_file {
343 inject_file_to_iso(&tmp_iso, &answer_file, "/answer.toml")?;
01470aae
AL
344 }
345
a14a9348 346 println!("Moving prepared ISO to target location...");
01470aae 347 fs::rename(&tmp_iso, &iso_target)?;
a14a9348 348 println!("Final ISO is available at {iso_target:?}.");
01470aae
AL
349
350 Ok(())
351}
352
353fn final_iso_location(args: &CommandPrepareISO) -> PathBuf {
354 if let Some(specified) = args.target.clone() {
355 return specified;
356 }
357 let mut suffix: String = match args.install_mode {
f59910eb
TL
358 AutoInstModes::Auto => "auto",
359 AutoInstModes::Http => "auto-http",
360 AutoInstModes::Included => "auto-answer-included",
361 AutoInstModes::Partition => "auto-part",
362 }
363 .into();
01470aae
AL
364
365 if args.url.is_some() {
366 suffix.push_str("-url");
367 }
368 if args.cert_fingerprint.is_some() {
369 suffix.push_str("-fp");
370 }
371
372 let base = args.source.parent().unwrap();
373 let iso = args.source.file_stem().unwrap();
374
375 let mut target = base.to_path_buf();
376 target.push(format!("{}-{}.iso", iso.to_str().unwrap(), suffix));
377
378 target.to_path_buf()
379}
380
381fn inject_file_to_iso(iso: &PathBuf, file: &PathBuf, location: &str) -> Result<()> {
382 let result = Command::new("xorriso")
383 .arg("--boot_image")
384 .arg("any")
385 .arg("keep")
386 .arg("-dev")
387 .arg(iso)
388 .arg("-map")
389 .arg(file)
390 .arg(location)
391 .output()?;
392 if !result.status.success() {
393 bail!(
f59910eb
TL
394 "Error injecting {file:?} into {iso:?}: {}",
395 String::from_utf8_lossy(&result.stderr)
01470aae
AL
396 );
397 }
398 Ok(())
399}
400
9143507d
AL
401fn get_disks() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
402 let unwantend_block_devs = vec![
403 "ram[0-9]*",
404 "loop[0-9]*",
405 "md[0-9]*",
406 "dm-*",
407 "fd[0-9]*",
408 "sr[0-9]*",
409 ];
410
411 // compile Regex here once and not inside the loop
412 let re_disk = Regex::new(r"(?m)^E: DEVTYPE=disk")?;
413 let re_cdrom = Regex::new(r"(?m)^E: ID_CDROM")?;
414 let re_iso9660 = Regex::new(r"(?m)^E: ID_FS_TYPE=iso9660")?;
415
416 let re_name = Regex::new(r"(?m)^N: (.*)$")?;
a7edd237 417 let re_props = Regex::new(r"(?m)^E: ([^=]+)=(.*)$")?;
9143507d
AL
418
419 let mut disks: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
420
421 'outer: for entry in fs::read_dir("/sys/block")? {
422 let entry = entry.unwrap();
423 let filename = entry.file_name().into_string().unwrap();
424
425 for p in &unwantend_block_devs {
426 if Pattern::new(p)?.matches(&filename) {
427 continue 'outer;
428 }
429 }
430
431 let output = match get_udev_properties(&entry.path()) {
432 Ok(output) => output,
433 Err(err) => {
434 eprint!("{err}");
435 continue 'outer;
436 }
437 };
438
439 if !re_disk.is_match(&output) {
440 continue 'outer;
441 };
442 if re_cdrom.is_match(&output) {
443 continue 'outer;
444 };
445 if re_iso9660.is_match(&output) {
446 continue 'outer;
447 };
448
449 let mut name = filename;
450 if let Some(cap) = re_name.captures(&output) {
451 if let Some(res) = cap.get(1) {
452 name = String::from(res.as_str());
453 }
454 }
455
456 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
457
458 for line in output.lines() {
459 if let Some(caps) = re_props.captures(line) {
460 let key = String::from(caps.get(1).unwrap().as_str());
461 let value = String::from(caps.get(2).unwrap().as_str());
462 udev_props.insert(key, value);
463 }
464 }
465
466 disks.insert(name, udev_props);
467 }
468 Ok(disks)
469}
470
9143507d
AL
471fn get_nics() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
472 let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
473 let mut nics: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
474
9b9754a5 475 let links = get_nic_list()?;
9143507d
AL
476 for link in links {
477 let path = format!("/sys/class/net/{link}");
478
479 let output = match get_udev_properties(&PathBuf::from(path)) {
480 Ok(output) => output,
481 Err(err) => {
482 eprint!("{err}");
483 continue;
484 }
485 };
486
487 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
488
489 for line in output.lines() {
490 if let Some(caps) = re_props.captures(line) {
491 let key = String::from(caps.get(1).unwrap().as_str());
492 let value = String::from(caps.get(2).unwrap().as_str());
493 udev_props.insert(key, value);
494 }
495 }
496
497 nics.insert(link, udev_props);
498 }
499 Ok(nics)
500}
501
502fn get_udev_properties(path: &PathBuf) -> Result<String> {
503 let udev_output = Command::new("udevadm")
504 .arg("info")
505 .arg("--path")
506 .arg(path)
507 .arg("--query")
508 .arg("all")
509 .output()?;
510 if !udev_output.status.success() {
f59910eb 511 bail!("could not run udevadm successfully for {path:?}");
9143507d
AL
512 }
513 Ok(String::from_utf8(udev_output.stdout)?)
514}
01470aae
AL
515
516fn parse_answer(path: &PathBuf) -> Result<Answer> {
517 let mut file = match fs::File::open(path) {
518 Ok(file) => file,
f59910eb 519 Err(err) => bail!("Opening answer file {path:?} failed: {err}"),
01470aae
AL
520 };
521 let mut contents = String::new();
522 if let Err(err) = file.read_to_string(&mut contents) {
f59910eb 523 bail!("Reading from file {path:?} failed: {err}");
01470aae
AL
524 }
525 match toml::from_str(&contents) {
526 Ok(answer) => {
527 println!("The file was parsed successfully, no syntax errors found!");
528 Ok(answer)
529 }
530 Err(err) => bail!("Error parsing answer file: {err}"),
531 }
532}
533
534fn check_prepare_requirements(args: &CommandPrepareISO) -> Result<()> {
535 match Path::try_exists(&args.source) {
536 Ok(true) => (),
537 Ok(false) => bail!("Source file does not exist."),
538 Err(_) => bail!("Source file does not exist."),
539 }
540
541 match Command::new("xorriso")
542 .arg("-dev")
543 .arg(&args.source)
544 .arg("-find")
545 .arg(PROXMOX_ISO_FLAG)
546 .stderr(Stdio::null())
547 .stdout(Stdio::null())
548 .status()
549 {
550 Ok(v) => {
551 if !v.success() {
552 bail!("The source ISO file is not able to be installed automatically. Please try a more current one.");
553 }
554 }
ca8b8ace
TL
555 Err(err) if err.kind() == io::ErrorKind::NotFound => {
556 bail!("Could not find the 'xorriso' binary. Please install it.")
557 }
558 Err(err) => bail!("unexpected error when trying to execute 'xorriso' - {err}"),
01470aae
AL
559 };
560
561 Ok(())
562}