]> git.proxmox.com Git - pve-installer.git/blame - proxmox-auto-install-assistant/src/main.rs
split out assistant CLI tool into own debian package
[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,
eedc6521 17 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
01470aae
AL
24static PROXMOX_ISO_FLAG: &str = "/autoinst-capable";
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),
42 Identifiers(CommandIdentifiers),
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///
67/// proxmox-autoinst-helper match --filter-match all disk 'ID_SERIAL_SHORT=*2222*' 'DEVNAME=*nvme*'
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
95/// partition / file-system called "PROXMOXINST" (or lowercase) and a file in the root named
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
100/// get a URL from a DHCP option (250, TXT) or as a DNS TXT record at 'proxmoxinst.{search
101/// domain}'.
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
105/// at 'proxmoxinst-fp.{search domain}'.
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}
115/// * needs to be present in a partition / file-system called 'PROXMOXINST' ('partition'){n}
116/// * only be requested via an HTTP Post request ('http').
117#[derive(Args, Debug)]
118struct CommandPrepareISO {
119 /// Path to the source ISO
120 source: PathBuf,
121
122 /// Path to store the final ISO to.
123 #[arg(short, long)]
124 target: Option<PathBuf>,
125
126 /// Where to fetch the answer file from.
127 #[arg(short, long, value_enum, default_value_t=AutoInstModes::Auto)]
128 install_mode: AutoInstModes,
129
130 /// Include the specified answer file in the ISO. Requires the '--install-mode', '-i' parameter
131 /// to be set to 'included'.
132 #[arg(short, long)]
133 answer_file: Option<PathBuf>,
134
135 /// Specify URL for fetching the answer file via HTTP
136 #[arg(short, long)]
137 url: Option<String>,
138
139 /// Pin the ISO to the specified SHA256 TLS certificate fingerprint.
140 #[arg(short, long)]
141 cert_fingerprint: Option<String>,
142
143 /// Tmp directory to use.
144 #[arg(long)]
145 tmp: Option<String>,
146}
147
9b9754a5
AL
148/// Show identifiers for the current machine. This information is part of the POST request to fetch
149/// an answer file.
150#[derive(Args, Debug)]
151struct CommandIdentifiers {}
152
9143507d
AL
153#[derive(Args, Debug)]
154struct GlobalOpts {
155 /// Output format
156 #[arg(long, short, value_enum)]
157 format: OutputFormat,
158}
159
160#[derive(Clone, Debug, ValueEnum, PartialEq)]
161enum AllDeviceTypes {
162 All,
163 Network,
164 Disk,
165}
166
167#[derive(Clone, Debug, ValueEnum)]
168enum Devicetype {
169 Network,
170 Disk,
171}
172
173#[derive(Clone, Debug, ValueEnum)]
174enum OutputFormat {
175 Pretty,
176 Json,
177}
178
179#[derive(Serialize)]
180struct Devs {
181 disks: Option<BTreeMap<String, BTreeMap<String, String>>>,
182 nics: Option<BTreeMap<String, BTreeMap<String, String>>>,
183}
184
185fn main() {
186 let args = Cli::parse();
187 let res = match &args.command {
01470aae 188 Commands::PrepareIso(args) => prepare_iso(args),
9143507d 189 Commands::ValidateAnswer(args) => validate_answer(args),
9b9754a5
AL
190 Commands::DeviceInfo(args) => info(args),
191 Commands::DeviceMatch(args) => match_filter(args),
192 Commands::Identifiers(args) => show_identifiers(args),
9143507d
AL
193 };
194 if let Err(err) = res {
195 eprintln!("{err}");
196 std::process::exit(1);
197 }
198}
199
9b9754a5 200fn info(args: &CommandDeviceInfo) -> Result<()> {
9143507d
AL
201 let mut devs = Devs {
202 disks: None,
203 nics: None,
204 };
205
206 if args.device == AllDeviceTypes::Network || args.device == AllDeviceTypes::All {
207 match get_nics() {
208 Ok(res) => devs.nics = Some(res),
209 Err(err) => bail!("Error getting NIC data: {err}"),
210 }
211 }
212 if args.device == AllDeviceTypes::Disk || args.device == AllDeviceTypes::All {
213 match get_disks() {
214 Ok(res) => devs.disks = Some(res),
215 Err(err) => bail!("Error getting disk data: {err}"),
216 }
217 }
218 println!("{}", serde_json::to_string_pretty(&devs).unwrap());
219 Ok(())
220}
221
9b9754a5 222fn match_filter(args: &CommandDeviceMatch) -> Result<()> {
9143507d
AL
223 let devs: BTreeMap<String, BTreeMap<String, String>> = match args.r#type {
224 Devicetype::Disk => get_disks().unwrap(),
225 Devicetype::Network => get_nics().unwrap(),
226 };
227 // parse filters
228
229 let mut filters: BTreeMap<String, String> = BTreeMap::new();
230
231 for f in &args.filter {
232 match f.split_once('=') {
233 Some((key, value)) => {
234 if key.is_empty() || value.is_empty() {
235 bail!("Filter key or value is empty in filter: '{f}'");
236 }
237 filters.insert(String::from(key), String::from(value));
238 }
239 None => {
240 bail!("Could not find separator '=' in filter: '{f}'");
241 }
242 }
243 }
244
245 // align return values
246 let result = match args.r#type {
247 Devicetype::Disk => {
248 get_matched_udev_indexes(filters, &devs, args.filter_match == FilterMatch::All)
249 }
250 Devicetype::Network => get_single_udev_index(filters, &devs).map(|r| vec![r]),
251 };
252
253 match result {
254 Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
255 Err(err) => bail!("Error matching filters: {err}"),
256 }
257 Ok(())
258}
259
260fn validate_answer(args: &CommandValidateAnswer) -> Result<()> {
01470aae 261 let answer = parse_answer(&args.path)?;
9143507d
AL
262 if args.debug {
263 println!("Parsed data from answer file:\n{:#?}", answer);
264 }
265 Ok(())
266}
267
9b9754a5
AL
268fn show_identifiers(_args: &CommandIdentifiers) -> Result<()> {
269 match sysinfo::get_sysinfo(true) {
270 Ok(res) => println!("{res}"),
271 Err(err) => eprintln!("Error fetching system identifiers: {err}"),
272 }
273 Ok(())
274}
275
01470aae
AL
276fn prepare_iso(args: &CommandPrepareISO) -> Result<()> {
277 check_prepare_requirements(args)?;
278
279 if args.install_mode == AutoInstModes::Included {
280 if args.answer_file.is_none() {
281 bail!("Missing path to answer file needed for 'direct' install mode.");
282 }
283 if args.cert_fingerprint.is_some() {
284 bail!("No certificate fingerprint needed for direct install mode. Drop the parameter!");
285 }
286 if args.url.is_some() {
287 bail!("No URL needed for direct install mode. Drop the parameter!");
288 }
289 } else if args.install_mode == AutoInstModes::Partition {
290 if args.cert_fingerprint.is_some() {
291 bail!(
292 "No certificate fingerprint needed for partition install mode. Drop the parameter!"
293 );
294 }
295 if args.url.is_some() {
296 bail!("No URL needed for partition install mode. Drop the parameter!");
297 }
298 }
299 if args.answer_file.is_some() && args.install_mode != AutoInstModes::Included {
300 bail!("Set '-i', '--install-mode' to 'included' to place the answer file directly in the ISO.");
301 }
302
303 if let Some(file) = &args.answer_file {
304 println!("Checking provided answer file...");
305 parse_answer(file)?;
306 }
307
308 let mut tmp_base = PathBuf::new();
309 if args.tmp.is_some() {
310 tmp_base.push(args.tmp.as_ref().unwrap());
311 } else {
312 tmp_base.push(args.source.parent().unwrap());
313 tmp_base.push(".proxmox-iso-prepare");
314 }
315 fs::create_dir_all(&tmp_base)?;
316
317 let mut tmp_iso = tmp_base.clone();
318 tmp_iso.push("proxmox.iso");
319 let mut tmp_answer = tmp_base.clone();
320 tmp_answer.push("answer.toml");
321
322 println!("Copying source ISO to temporary location...");
323 fs::copy(&args.source, &tmp_iso)?;
324 println!("Done copying source ISO");
325
326 println!("Preparing ISO...");
327 let install_mode = AutoInstSettings {
328 mode: args.install_mode.clone(),
329 http_url: args.url.clone(),
330 cert_fingerprint: args.cert_fingerprint.clone(),
331 };
332 let mut instmode_file_tmp = tmp_base.clone();
333 instmode_file_tmp.push("autoinst-mode.toml");
334 fs::write(&instmode_file_tmp, toml::to_string_pretty(&install_mode)?)?;
335
336 inject_file_to_iso(&tmp_iso, &instmode_file_tmp, "/autoinst-mode.toml")?;
337
338 if let Some(answer) = &args.answer_file {
339 fs::copy(answer, &tmp_answer)?;
340 inject_file_to_iso(&tmp_iso, &tmp_answer, "/answer.toml")?;
341 }
342
343 println!("Done preparing iso.");
344 println!("Move ISO to target location...");
345 let iso_target = final_iso_location(args);
346 fs::rename(&tmp_iso, &iso_target)?;
347 println!("Cleaning up...");
348 fs::remove_dir_all(&tmp_base)?;
349 println!("Final ISO is available at {}.", &iso_target.display());
350
351 Ok(())
352}
353
354fn final_iso_location(args: &CommandPrepareISO) -> PathBuf {
355 if let Some(specified) = args.target.clone() {
356 return specified;
357 }
358 let mut suffix: String = match args.install_mode {
359 AutoInstModes::Auto => "auto".into(),
360 AutoInstModes::Http => "auto-http".into(),
361 AutoInstModes::Included => "auto-answer-included".into(),
362 AutoInstModes::Partition => "auto-part".into(),
363 };
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!(
394 "Error injecting {} into {}: {}",
395 file.display(),
396 iso.display(),
397 String::from_utf8(result.stderr)?
398 );
399 }
400 Ok(())
401}
402
9143507d
AL
403fn get_disks() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
404 let unwantend_block_devs = vec![
405 "ram[0-9]*",
406 "loop[0-9]*",
407 "md[0-9]*",
408 "dm-*",
409 "fd[0-9]*",
410 "sr[0-9]*",
411 ];
412
413 // compile Regex here once and not inside the loop
414 let re_disk = Regex::new(r"(?m)^E: DEVTYPE=disk")?;
415 let re_cdrom = Regex::new(r"(?m)^E: ID_CDROM")?;
416 let re_iso9660 = Regex::new(r"(?m)^E: ID_FS_TYPE=iso9660")?;
417
418 let re_name = Regex::new(r"(?m)^N: (.*)$")?;
419 let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
420
421 let mut disks: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
422
423 'outer: for entry in fs::read_dir("/sys/block")? {
424 let entry = entry.unwrap();
425 let filename = entry.file_name().into_string().unwrap();
426
427 for p in &unwantend_block_devs {
428 if Pattern::new(p)?.matches(&filename) {
429 continue 'outer;
430 }
431 }
432
433 let output = match get_udev_properties(&entry.path()) {
434 Ok(output) => output,
435 Err(err) => {
436 eprint!("{err}");
437 continue 'outer;
438 }
439 };
440
441 if !re_disk.is_match(&output) {
442 continue 'outer;
443 };
444 if re_cdrom.is_match(&output) {
445 continue 'outer;
446 };
447 if re_iso9660.is_match(&output) {
448 continue 'outer;
449 };
450
451 let mut name = filename;
452 if let Some(cap) = re_name.captures(&output) {
453 if let Some(res) = cap.get(1) {
454 name = String::from(res.as_str());
455 }
456 }
457
458 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
459
460 for line in output.lines() {
461 if let Some(caps) = re_props.captures(line) {
462 let key = String::from(caps.get(1).unwrap().as_str());
463 let value = String::from(caps.get(2).unwrap().as_str());
464 udev_props.insert(key, value);
465 }
466 }
467
468 disks.insert(name, udev_props);
469 }
470 Ok(disks)
471}
472
9143507d
AL
473fn get_nics() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
474 let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
475 let mut nics: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
476
9b9754a5 477 let links = get_nic_list()?;
9143507d
AL
478 for link in links {
479 let path = format!("/sys/class/net/{link}");
480
481 let output = match get_udev_properties(&PathBuf::from(path)) {
482 Ok(output) => output,
483 Err(err) => {
484 eprint!("{err}");
485 continue;
486 }
487 };
488
489 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
490
491 for line in output.lines() {
492 if let Some(caps) = re_props.captures(line) {
493 let key = String::from(caps.get(1).unwrap().as_str());
494 let value = String::from(caps.get(2).unwrap().as_str());
495 udev_props.insert(key, value);
496 }
497 }
498
499 nics.insert(link, udev_props);
500 }
501 Ok(nics)
502}
503
504fn get_udev_properties(path: &PathBuf) -> Result<String> {
505 let udev_output = Command::new("udevadm")
506 .arg("info")
507 .arg("--path")
508 .arg(path)
509 .arg("--query")
510 .arg("all")
511 .output()?;
512 if !udev_output.status.success() {
513 bail!("could not run udevadm successfully for {}", path.display());
514 }
515 Ok(String::from_utf8(udev_output.stdout)?)
516}
01470aae
AL
517
518fn parse_answer(path: &PathBuf) -> Result<Answer> {
519 let mut file = match fs::File::open(path) {
520 Ok(file) => file,
521 Err(err) => bail!("Opening answer file '{}' failed: {err}", path.display()),
522 };
523 let mut contents = String::new();
524 if let Err(err) = file.read_to_string(&mut contents) {
525 bail!("Reading from file '{}' failed: {err}", path.display());
526 }
527 match toml::from_str(&contents) {
528 Ok(answer) => {
529 println!("The file was parsed successfully, no syntax errors found!");
530 Ok(answer)
531 }
532 Err(err) => bail!("Error parsing answer file: {err}"),
533 }
534}
535
536fn check_prepare_requirements(args: &CommandPrepareISO) -> Result<()> {
537 match Path::try_exists(&args.source) {
538 Ok(true) => (),
539 Ok(false) => bail!("Source file does not exist."),
540 Err(_) => bail!("Source file does not exist."),
541 }
542
543 match Command::new("xorriso")
544 .arg("-dev")
545 .arg(&args.source)
546 .arg("-find")
547 .arg(PROXMOX_ISO_FLAG)
548 .stderr(Stdio::null())
549 .stdout(Stdio::null())
550 .status()
551 {
552 Ok(v) => {
553 if !v.success() {
554 bail!("The source ISO file is not able to be installed automatically. Please try a more current one.");
555 }
556 }
557 Err(_) => bail!("Could not run 'xorriso'. Please install it."),
558 };
559
560 Ok(())
561}