]> git.proxmox.com Git - pve-installer.git/blame - proxmox-auto-install-assistant/src/main.rs
assistant: default to output directory for tmp iso file on prepare
[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 18 utils::{
d2c9b9fd 19 get_matched_udev_indexes, get_nic_list, get_single_udev_index, AutoInstMode,
01470aae
AL
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
9aa27fa4
TL
99/// it can be defined for the ISO with the '--url' argument. If not present, it will try to get a
100/// URL from a DHCP option (250, TXT) or by querying a DNS TXT record at
528d1268 101/// 'proxmox-auto-installer.{search domain}'.
01470aae 102///
9aa27fa4
TL
103/// The TLS certificate fingerprint can either be defined via the '--cert-fingerprint' argument or
104/// alternatively via the custom DHCP option (251, TXT) or in a DNS TXT record located at
105/// '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///
9aa27fa4 112/// The behavior of how to fetch an answer file can be overridden with the '--fetch-from',
01470aae 113/// parameter. The answer file can be{n}
9aa27fa4 114/// * integrated into the ISO itself ('iso'){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 {
9aa27fa4
TL
120 /// Path to the source ISO to prepare
121 input: PathBuf,
01470aae 122
9aa27fa4 123 /// Path to store the final ISO to, defaults to auto-generated depending on mode.
01470aae 124 #[arg(short, long)]
9aa27fa4 125 output: Option<PathBuf>,
01470aae 126
9aa27fa4
TL
127 /// Where the automatic installer should fetch the answer file from.
128 #[arg(long, value_enum)]
129 fetch_from: AutoInstMode,
01470aae 130
9aa27fa4
TL
131 /// Include the specified answer file in the ISO. Requires the '--fetch-from' parameter
132 /// to be set to 'iso'.
133 #[arg(long)]
01470aae
AL
134 answer_file: Option<PathBuf>,
135
136 /// Specify URL for fetching the answer file via HTTP
9aa27fa4 137 #[arg(long)]
01470aae
AL
138 url: Option<String>,
139
140 /// Pin the ISO to the specified SHA256 TLS certificate fingerprint.
9aa27fa4 141 #[arg(long)]
01470aae
AL
142 cert_fingerprint: Option<String>,
143
9aa27fa4
TL
144 /// Staging directory to use for preparing the new ISO file. Defaults to the directory of the
145 /// input ISO file.
01470aae
AL
146 #[arg(long)]
147 tmp: Option<String>,
148}
149
0a733567
TL
150/// Show the system information that can be used to identify a host.
151///
152/// The shown information is sent as POST HTTP request when fetching the answer file for the
153/// automatic installation through HTTP, You can, for example, use this to return a dynamically
154/// assembled answer file.
9b9754a5 155#[derive(Args, Debug)]
0a733567 156struct CommandSystemInfo {}
9b9754a5 157
9143507d
AL
158#[derive(Args, Debug)]
159struct GlobalOpts {
160 /// Output format
161 #[arg(long, short, value_enum)]
162 format: OutputFormat,
163}
164
165#[derive(Clone, Debug, ValueEnum, PartialEq)]
166enum AllDeviceTypes {
167 All,
168 Network,
169 Disk,
170}
171
172#[derive(Clone, Debug, ValueEnum)]
173enum Devicetype {
174 Network,
175 Disk,
176}
177
178#[derive(Clone, Debug, ValueEnum)]
179enum OutputFormat {
180 Pretty,
181 Json,
182}
183
184#[derive(Serialize)]
185struct Devs {
186 disks: Option<BTreeMap<String, BTreeMap<String, String>>>,
187 nics: Option<BTreeMap<String, BTreeMap<String, String>>>,
188}
189
190fn main() {
191 let args = Cli::parse();
192 let res = match &args.command {
01470aae 193 Commands::PrepareIso(args) => prepare_iso(args),
9143507d 194 Commands::ValidateAnswer(args) => validate_answer(args),
9b9754a5
AL
195 Commands::DeviceInfo(args) => info(args),
196 Commands::DeviceMatch(args) => match_filter(args),
0a733567 197 Commands::SystemInfo(args) => show_system_info(args),
9143507d
AL
198 };
199 if let Err(err) = res {
200 eprintln!("{err}");
201 std::process::exit(1);
202 }
203}
204
9b9754a5 205fn info(args: &CommandDeviceInfo) -> Result<()> {
9143507d
AL
206 let mut devs = Devs {
207 disks: None,
208 nics: None,
209 };
210
211 if args.device == AllDeviceTypes::Network || args.device == AllDeviceTypes::All {
212 match get_nics() {
213 Ok(res) => devs.nics = Some(res),
214 Err(err) => bail!("Error getting NIC data: {err}"),
215 }
216 }
217 if args.device == AllDeviceTypes::Disk || args.device == AllDeviceTypes::All {
218 match get_disks() {
219 Ok(res) => devs.disks = Some(res),
220 Err(err) => bail!("Error getting disk data: {err}"),
221 }
222 }
223 println!("{}", serde_json::to_string_pretty(&devs).unwrap());
224 Ok(())
225}
226
9b9754a5 227fn match_filter(args: &CommandDeviceMatch) -> Result<()> {
9143507d
AL
228 let devs: BTreeMap<String, BTreeMap<String, String>> = match args.r#type {
229 Devicetype::Disk => get_disks().unwrap(),
230 Devicetype::Network => get_nics().unwrap(),
231 };
232 // parse filters
233
234 let mut filters: BTreeMap<String, String> = BTreeMap::new();
235
236 for f in &args.filter {
237 match f.split_once('=') {
238 Some((key, value)) => {
239 if key.is_empty() || value.is_empty() {
240 bail!("Filter key or value is empty in filter: '{f}'");
241 }
242 filters.insert(String::from(key), String::from(value));
243 }
244 None => {
245 bail!("Could not find separator '=' in filter: '{f}'");
246 }
247 }
248 }
249
250 // align return values
251 let result = match args.r#type {
252 Devicetype::Disk => {
2a777841 253 get_matched_udev_indexes(&filters, &devs, args.filter_match == FilterMatch::All)
9143507d 254 }
2a777841 255 Devicetype::Network => get_single_udev_index(&filters, &devs).map(|r| vec![r]),
9143507d
AL
256 };
257
258 match result {
259 Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
260 Err(err) => bail!("Error matching filters: {err}"),
261 }
262 Ok(())
263}
264
265fn validate_answer(args: &CommandValidateAnswer) -> Result<()> {
01470aae 266 let answer = parse_answer(&args.path)?;
9143507d
AL
267 if args.debug {
268 println!("Parsed data from answer file:\n{:#?}", answer);
269 }
270 Ok(())
271}
272
0a733567 273fn show_system_info(_args: &CommandSystemInfo) -> Result<()> {
d4c43e9d 274 match SysInfo::as_json_pretty() {
9b9754a5 275 Ok(res) => println!("{res}"),
0a733567 276 Err(err) => eprintln!("Error fetching system info: {err}"),
9b9754a5
AL
277 }
278 Ok(())
279}
280
01470aae
AL
281fn prepare_iso(args: &CommandPrepareISO) -> Result<()> {
282 check_prepare_requirements(args)?;
283
9aa27fa4 284 if args.fetch_from == AutoInstMode::Included {
01470aae
AL
285 if args.answer_file.is_none() {
286 bail!("Missing path to answer file needed for 'direct' install mode.");
287 }
288 if args.cert_fingerprint.is_some() {
289 bail!("No certificate fingerprint needed for direct install mode. Drop the parameter!");
290 }
291 if args.url.is_some() {
292 bail!("No URL needed for direct install mode. Drop the parameter!");
293 }
9aa27fa4 294 } else if args.fetch_from == AutoInstMode::Partition {
01470aae
AL
295 if args.cert_fingerprint.is_some() {
296 bail!(
297 "No certificate fingerprint needed for partition install mode. Drop the parameter!"
298 );
299 }
300 if args.url.is_some() {
301 bail!("No URL needed for partition install mode. Drop the parameter!");
302 }
303 }
9aa27fa4 304 if args.answer_file.is_some() && args.fetch_from != AutoInstMode::Included {
01470aae
AL
305 bail!("Set '-i', '--install-mode' to 'included' to place the answer file directly in the ISO.");
306 }
307
308 if let Some(file) = &args.answer_file {
309 println!("Checking provided answer file...");
310 parse_answer(file)?;
311 }
312
11f2e83f
TL
313 let iso_target = final_iso_location(args);
314 let iso_target_file_name = match iso_target.file_name() {
315 None => bail!("no base filename in target ISO path found"),
316 Some(source_file_name) => source_file_name.to_string_lossy(),
317 };
318
01470aae 319 let mut tmp_base = PathBuf::new();
a14a9348
TL
320 match args.tmp.as_ref() {
321 Some(tmp_dir) => tmp_base.push(tmp_dir),
11f2e83f 322 None => tmp_base.push(iso_target.parent().unwrap()),
01470aae 323 }
a14a9348 324
01470aae 325 let mut tmp_iso = tmp_base.clone();
a14a9348
TL
326 tmp_iso.push(format!("{iso_target_file_name}.tmp",));
327
01470aae 328 println!("Copying source ISO to temporary location...");
9aa27fa4 329 fs::copy(&args.input, &tmp_iso)?;
01470aae
AL
330
331 println!("Preparing ISO...");
9aa27fa4
TL
332 let config = AutoInstSettings {
333 mode: args.fetch_from.clone(),
01470aae
AL
334 http_url: args.url.clone(),
335 cert_fingerprint: args.cert_fingerprint.clone(),
336 };
337 let mut instmode_file_tmp = tmp_base.clone();
95be2375 338 instmode_file_tmp.push("auto-installer-mode.toml");
9aa27fa4 339 fs::write(&instmode_file_tmp, toml::to_string_pretty(&config)?)?;
01470aae 340
95be2375 341 inject_file_to_iso(&tmp_iso, &instmode_file_tmp, "/auto-installer-mode.toml")?;
01470aae 342
938726d5 343 if let Some(answer_file) = &args.answer_file {
810c860d 344 inject_file_to_iso(&tmp_iso, answer_file, "/answer.toml")?;
01470aae
AL
345 }
346
a14a9348 347 println!("Moving prepared ISO to target location...");
01470aae 348 fs::rename(&tmp_iso, &iso_target)?;
a14a9348 349 println!("Final ISO is available at {iso_target:?}.");
01470aae
AL
350
351 Ok(())
352}
353
354fn final_iso_location(args: &CommandPrepareISO) -> PathBuf {
9aa27fa4 355 if let Some(specified) = args.output.clone() {
01470aae
AL
356 return specified;
357 }
9aa27fa4 358 let mut suffix: String = match args.fetch_from {
d2c9b9fd
TL
359 AutoInstMode::Http => "auto-http",
360 AutoInstMode::Included => "auto-answer-included",
361 AutoInstMode::Partition => "auto-part",
f59910eb
TL
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
9aa27fa4
TL
372 let base = args.input.parent().unwrap();
373 let iso = args.input.file_stem().unwrap();
01470aae
AL
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<()> {
9aa27fa4 535 match Path::try_exists(&args.input) {
01470aae
AL
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")
9aa27fa4 543 .arg(&args.input)
01470aae
AL
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}