]> git.proxmox.com Git - pve-installer.git/blame - proxmox-auto-install-assistant/src/main.rs
assistant: fix mentioning outdated fetch-from mode in error
[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::{
57ca9622
TL
19 get_matched_udev_indexes, get_nic_list, get_single_udev_index, AutoInstSettings,
20 FetchAnswerFrom, HttpOptions,
01470aae 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///
34dd7bcf
TL
94/// The behavior of how to fetch an answer file must be set with the '--fetch-from', parameter. The
95/// answer file can be{n}:
96/// * integrated into the ISO itself ('iso'){n}
97/// * needs to be present in a partition / file-system with the label 'PROXMOX-INST-SRC'
98/// ('partition'){n}
99/// * get requested via an HTTP Post request ('http').
01470aae 100///
34dd7bcf
TL
101/// The URL for the HTTP mode can be defined for the ISO with the '--url' argument. If not present,
102/// it will try to get a URL from a DHCP option (250, TXT) or by querying a DNS TXT record at
528d1268 103/// 'proxmox-auto-installer.{search domain}'.
01470aae 104///
9aa27fa4
TL
105/// The TLS certificate fingerprint can either be defined via the '--cert-fingerprint' argument or
106/// alternatively via the custom DHCP option (251, TXT) or in a DNS TXT record located at
107/// 'proxmox-auto-installer-cert-fingerprint.{search domain}'.
01470aae
AL
108///
109/// The latter options to provide the TLS fingerprint will only be used if the same method was used
110/// to retrieve the URL. For example, the DNS TXT record for the fingerprint will only be used, if
111/// no one was configured with the '--cert-fingerprint' parameter and if the URL was retrieved via
112/// the DNS TXT record.
01470aae
AL
113#[derive(Args, Debug)]
114struct CommandPrepareISO {
9aa27fa4
TL
115 /// Path to the source ISO to prepare
116 input: PathBuf,
01470aae 117
34dd7bcf
TL
118 /// Path to store the final ISO to, defaults to an auto-generated file name depending on mode
119 /// and the same directory as the source file is located in.
120 #[arg(long)]
9aa27fa4 121 output: Option<PathBuf>,
01470aae 122
9aa27fa4
TL
123 /// Where the automatic installer should fetch the answer file from.
124 #[arg(long, value_enum)]
c8160a3f 125 fetch_from: FetchAnswerFrom,
01470aae 126
9aa27fa4
TL
127 /// Include the specified answer file in the ISO. Requires the '--fetch-from' parameter
128 /// to be set to 'iso'.
129 #[arg(long)]
01470aae
AL
130 answer_file: Option<PathBuf>,
131
132 /// Specify URL for fetching the answer file via HTTP
9aa27fa4 133 #[arg(long)]
01470aae
AL
134 url: Option<String>,
135
136 /// Pin the ISO to the specified SHA256 TLS certificate fingerprint.
9aa27fa4 137 #[arg(long)]
01470aae
AL
138 cert_fingerprint: Option<String>,
139
9aa27fa4
TL
140 /// Staging directory to use for preparing the new ISO file. Defaults to the directory of the
141 /// input ISO file.
01470aae
AL
142 #[arg(long)]
143 tmp: Option<String>,
144}
145
0a733567
TL
146/// Show the system information that can be used to identify a host.
147///
148/// The shown information is sent as POST HTTP request when fetching the answer file for the
149/// automatic installation through HTTP, You can, for example, use this to return a dynamically
150/// assembled answer file.
9b9754a5 151#[derive(Args, Debug)]
0a733567 152struct CommandSystemInfo {}
9b9754a5 153
9143507d
AL
154#[derive(Args, Debug)]
155struct GlobalOpts {
156 /// Output format
157 #[arg(long, short, value_enum)]
158 format: OutputFormat,
159}
160
161#[derive(Clone, Debug, ValueEnum, PartialEq)]
162enum AllDeviceTypes {
163 All,
164 Network,
165 Disk,
166}
167
168#[derive(Clone, Debug, ValueEnum)]
169enum Devicetype {
170 Network,
171 Disk,
172}
173
174#[derive(Clone, Debug, ValueEnum)]
175enum OutputFormat {
176 Pretty,
177 Json,
178}
179
180#[derive(Serialize)]
181struct Devs {
182 disks: Option<BTreeMap<String, BTreeMap<String, String>>>,
183 nics: Option<BTreeMap<String, BTreeMap<String, String>>>,
184}
185
186fn main() {
187 let args = Cli::parse();
188 let res = match &args.command {
01470aae 189 Commands::PrepareIso(args) => prepare_iso(args),
9143507d 190 Commands::ValidateAnswer(args) => validate_answer(args),
9b9754a5
AL
191 Commands::DeviceInfo(args) => info(args),
192 Commands::DeviceMatch(args) => match_filter(args),
0a733567 193 Commands::SystemInfo(args) => show_system_info(args),
9143507d
AL
194 };
195 if let Err(err) = res {
196 eprintln!("{err}");
197 std::process::exit(1);
198 }
199}
200
9b9754a5 201fn info(args: &CommandDeviceInfo) -> Result<()> {
9143507d
AL
202 let mut devs = Devs {
203 disks: None,
204 nics: None,
205 };
206
207 if args.device == AllDeviceTypes::Network || args.device == AllDeviceTypes::All {
208 match get_nics() {
209 Ok(res) => devs.nics = Some(res),
210 Err(err) => bail!("Error getting NIC data: {err}"),
211 }
212 }
213 if args.device == AllDeviceTypes::Disk || args.device == AllDeviceTypes::All {
214 match get_disks() {
215 Ok(res) => devs.disks = Some(res),
216 Err(err) => bail!("Error getting disk data: {err}"),
217 }
218 }
219 println!("{}", serde_json::to_string_pretty(&devs).unwrap());
220 Ok(())
221}
222
9b9754a5 223fn match_filter(args: &CommandDeviceMatch) -> Result<()> {
9143507d
AL
224 let devs: BTreeMap<String, BTreeMap<String, String>> = match args.r#type {
225 Devicetype::Disk => get_disks().unwrap(),
226 Devicetype::Network => get_nics().unwrap(),
227 };
228 // parse filters
229
230 let mut filters: BTreeMap<String, String> = BTreeMap::new();
231
232 for f in &args.filter {
233 match f.split_once('=') {
234 Some((key, value)) => {
235 if key.is_empty() || value.is_empty() {
236 bail!("Filter key or value is empty in filter: '{f}'");
237 }
238 filters.insert(String::from(key), String::from(value));
239 }
240 None => {
241 bail!("Could not find separator '=' in filter: '{f}'");
242 }
243 }
244 }
245
246 // align return values
247 let result = match args.r#type {
248 Devicetype::Disk => {
2a777841 249 get_matched_udev_indexes(&filters, &devs, args.filter_match == FilterMatch::All)
9143507d 250 }
2a777841 251 Devicetype::Network => get_single_udev_index(&filters, &devs).map(|r| vec![r]),
9143507d
AL
252 };
253
254 match result {
255 Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
256 Err(err) => bail!("Error matching filters: {err}"),
257 }
258 Ok(())
259}
260
261fn validate_answer(args: &CommandValidateAnswer) -> Result<()> {
01470aae 262 let answer = parse_answer(&args.path)?;
9143507d
AL
263 if args.debug {
264 println!("Parsed data from answer file:\n{:#?}", answer);
265 }
266 Ok(())
267}
268
0a733567 269fn show_system_info(_args: &CommandSystemInfo) -> Result<()> {
d4c43e9d 270 match SysInfo::as_json_pretty() {
9b9754a5 271 Ok(res) => println!("{res}"),
0a733567 272 Err(err) => eprintln!("Error fetching system info: {err}"),
9b9754a5
AL
273 }
274 Ok(())
275}
276
01470aae
AL
277fn prepare_iso(args: &CommandPrepareISO) -> Result<()> {
278 check_prepare_requirements(args)?;
279
c8160a3f 280 if args.fetch_from == FetchAnswerFrom::Iso {
01470aae 281 if args.answer_file.is_none() {
3715f3cc 282 bail!("Missing path to the answer file required for the fetch-from 'iso' mode.");
01470aae
AL
283 }
284 }
3715f3cc
TL
285 if args.url.is_some() && args.fetch_from != FetchAnswerFrom::Http {
286 bail!(
287 "Setting a URL is incompatible with the fetch-from '{:?}' mode, only works with the 'http' mode",
288 args.fetch_from,
289 );
290 }
291 if args.cert_fingerprint.is_some() && args.fetch_from != FetchAnswerFrom::Http {
292 bail!(
293 "Setting a certificate fingerprint incompatible is fetch-from '{:?}' mode, only works for 'http' mode.",
294 args.fetch_from,
295 );
296 }
c8160a3f 297 if args.answer_file.is_some() && args.fetch_from != FetchAnswerFrom::Iso {
01470aae
AL
298 bail!("Set '-i', '--install-mode' to 'included' to place the answer file directly in the ISO.");
299 }
300
301 if let Some(file) = &args.answer_file {
302 println!("Checking provided answer file...");
303 parse_answer(file)?;
304 }
305
11f2e83f
TL
306 let iso_target = final_iso_location(args);
307 let iso_target_file_name = match iso_target.file_name() {
308 None => bail!("no base filename in target ISO path found"),
309 Some(source_file_name) => source_file_name.to_string_lossy(),
310 };
311
01470aae 312 let mut tmp_base = PathBuf::new();
a14a9348
TL
313 match args.tmp.as_ref() {
314 Some(tmp_dir) => tmp_base.push(tmp_dir),
11f2e83f 315 None => tmp_base.push(iso_target.parent().unwrap()),
01470aae 316 }
a14a9348 317
01470aae 318 let mut tmp_iso = tmp_base.clone();
a14a9348
TL
319 tmp_iso.push(format!("{iso_target_file_name}.tmp",));
320
01470aae 321 println!("Copying source ISO to temporary location...");
9aa27fa4 322 fs::copy(&args.input, &tmp_iso)?;
01470aae
AL
323
324 println!("Preparing ISO...");
9aa27fa4
TL
325 let config = AutoInstSettings {
326 mode: args.fetch_from.clone(),
4cf15bd1
TL
327 http: HttpOptions {
328 url: args.url.clone(),
329 cert_fingerprint: args.cert_fingerprint.clone(),
330 },
01470aae
AL
331 };
332 let mut instmode_file_tmp = tmp_base.clone();
95be2375 333 instmode_file_tmp.push("auto-installer-mode.toml");
9aa27fa4 334 fs::write(&instmode_file_tmp, toml::to_string_pretty(&config)?)?;
01470aae 335
95be2375 336 inject_file_to_iso(&tmp_iso, &instmode_file_tmp, "/auto-installer-mode.toml")?;
01470aae 337
938726d5 338 if let Some(answer_file) = &args.answer_file {
810c860d 339 inject_file_to_iso(&tmp_iso, answer_file, "/answer.toml")?;
01470aae
AL
340 }
341
a14a9348 342 println!("Moving prepared ISO to target location...");
01470aae 343 fs::rename(&tmp_iso, &iso_target)?;
a14a9348 344 println!("Final ISO is available at {iso_target:?}.");
01470aae
AL
345
346 Ok(())
347}
348
349fn final_iso_location(args: &CommandPrepareISO) -> PathBuf {
9aa27fa4 350 if let Some(specified) = args.output.clone() {
01470aae
AL
351 return specified;
352 }
9aa27fa4 353 let mut suffix: String = match args.fetch_from {
c8160a3f
TL
354 FetchAnswerFrom::Http => "auto-from-http",
355 FetchAnswerFrom::Iso => "auto-from-iso",
356 FetchAnswerFrom::Partition => "auto-from-partition",
f59910eb
TL
357 }
358 .into();
01470aae
AL
359
360 if args.url.is_some() {
361 suffix.push_str("-url");
362 }
363 if args.cert_fingerprint.is_some() {
364 suffix.push_str("-fp");
365 }
366
9aa27fa4
TL
367 let base = args.input.parent().unwrap();
368 let iso = args.input.file_stem().unwrap();
01470aae
AL
369
370 let mut target = base.to_path_buf();
371 target.push(format!("{}-{}.iso", iso.to_str().unwrap(), suffix));
372
373 target.to_path_buf()
374}
375
376fn inject_file_to_iso(iso: &PathBuf, file: &PathBuf, location: &str) -> Result<()> {
377 let result = Command::new("xorriso")
378 .arg("--boot_image")
379 .arg("any")
380 .arg("keep")
381 .arg("-dev")
382 .arg(iso)
383 .arg("-map")
384 .arg(file)
385 .arg(location)
386 .output()?;
387 if !result.status.success() {
388 bail!(
f59910eb
TL
389 "Error injecting {file:?} into {iso:?}: {}",
390 String::from_utf8_lossy(&result.stderr)
01470aae
AL
391 );
392 }
393 Ok(())
394}
395
9143507d
AL
396fn get_disks() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
397 let unwantend_block_devs = vec![
398 "ram[0-9]*",
399 "loop[0-9]*",
400 "md[0-9]*",
401 "dm-*",
402 "fd[0-9]*",
403 "sr[0-9]*",
404 ];
405
406 // compile Regex here once and not inside the loop
407 let re_disk = Regex::new(r"(?m)^E: DEVTYPE=disk")?;
408 let re_cdrom = Regex::new(r"(?m)^E: ID_CDROM")?;
409 let re_iso9660 = Regex::new(r"(?m)^E: ID_FS_TYPE=iso9660")?;
410
411 let re_name = Regex::new(r"(?m)^N: (.*)$")?;
a7edd237 412 let re_props = Regex::new(r"(?m)^E: ([^=]+)=(.*)$")?;
9143507d
AL
413
414 let mut disks: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
415
416 'outer: for entry in fs::read_dir("/sys/block")? {
417 let entry = entry.unwrap();
418 let filename = entry.file_name().into_string().unwrap();
419
420 for p in &unwantend_block_devs {
421 if Pattern::new(p)?.matches(&filename) {
422 continue 'outer;
423 }
424 }
425
426 let output = match get_udev_properties(&entry.path()) {
427 Ok(output) => output,
428 Err(err) => {
429 eprint!("{err}");
430 continue 'outer;
431 }
432 };
433
434 if !re_disk.is_match(&output) {
435 continue 'outer;
436 };
437 if re_cdrom.is_match(&output) {
438 continue 'outer;
439 };
440 if re_iso9660.is_match(&output) {
441 continue 'outer;
442 };
443
444 let mut name = filename;
445 if let Some(cap) = re_name.captures(&output) {
446 if let Some(res) = cap.get(1) {
447 name = String::from(res.as_str());
448 }
449 }
450
451 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
452
453 for line in output.lines() {
454 if let Some(caps) = re_props.captures(line) {
455 let key = String::from(caps.get(1).unwrap().as_str());
456 let value = String::from(caps.get(2).unwrap().as_str());
457 udev_props.insert(key, value);
458 }
459 }
460
461 disks.insert(name, udev_props);
462 }
463 Ok(disks)
464}
465
9143507d
AL
466fn get_nics() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
467 let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
468 let mut nics: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
469
9b9754a5 470 let links = get_nic_list()?;
9143507d
AL
471 for link in links {
472 let path = format!("/sys/class/net/{link}");
473
474 let output = match get_udev_properties(&PathBuf::from(path)) {
475 Ok(output) => output,
476 Err(err) => {
477 eprint!("{err}");
478 continue;
479 }
480 };
481
482 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
483
484 for line in output.lines() {
485 if let Some(caps) = re_props.captures(line) {
486 let key = String::from(caps.get(1).unwrap().as_str());
487 let value = String::from(caps.get(2).unwrap().as_str());
488 udev_props.insert(key, value);
489 }
490 }
491
492 nics.insert(link, udev_props);
493 }
494 Ok(nics)
495}
496
497fn get_udev_properties(path: &PathBuf) -> Result<String> {
498 let udev_output = Command::new("udevadm")
499 .arg("info")
500 .arg("--path")
501 .arg(path)
502 .arg("--query")
503 .arg("all")
504 .output()?;
505 if !udev_output.status.success() {
f59910eb 506 bail!("could not run udevadm successfully for {path:?}");
9143507d
AL
507 }
508 Ok(String::from_utf8(udev_output.stdout)?)
509}
01470aae
AL
510
511fn parse_answer(path: &PathBuf) -> Result<Answer> {
512 let mut file = match fs::File::open(path) {
513 Ok(file) => file,
f59910eb 514 Err(err) => bail!("Opening answer file {path:?} failed: {err}"),
01470aae
AL
515 };
516 let mut contents = String::new();
517 if let Err(err) = file.read_to_string(&mut contents) {
f59910eb 518 bail!("Reading from file {path:?} failed: {err}");
01470aae
AL
519 }
520 match toml::from_str(&contents) {
521 Ok(answer) => {
522 println!("The file was parsed successfully, no syntax errors found!");
523 Ok(answer)
524 }
525 Err(err) => bail!("Error parsing answer file: {err}"),
526 }
527}
528
529fn check_prepare_requirements(args: &CommandPrepareISO) -> Result<()> {
9aa27fa4 530 match Path::try_exists(&args.input) {
01470aae
AL
531 Ok(true) => (),
532 Ok(false) => bail!("Source file does not exist."),
533 Err(_) => bail!("Source file does not exist."),
534 }
535
536 match Command::new("xorriso")
537 .arg("-dev")
9aa27fa4 538 .arg(&args.input)
01470aae
AL
539 .arg("-find")
540 .arg(PROXMOX_ISO_FLAG)
541 .stderr(Stdio::null())
542 .stdout(Stdio::null())
543 .status()
544 {
545 Ok(v) => {
546 if !v.success() {
547 bail!("The source ISO file is not able to be installed automatically. Please try a more current one.");
548 }
549 }
ca8b8ace
TL
550 Err(err) if err.kind() == io::ErrorKind::NotFound => {
551 bail!("Could not find the 'xorriso' binary. Please install it.")
552 }
553 Err(err) => bail!("unexpected error when trying to execute 'xorriso' - {err}"),
01470aae
AL
554 };
555
556 Ok(())
557}