1use anyhow::{Context, Result};
18use clap::Parser;
19use reqwest::Url;
20use serde::{Deserialize, Serialize};
21use serde_with::{serde_as, skip_serializing_none, DisplayFromStr};
22use std::default::Default;
23use std::ffi::OsStr;
24use std::fs::OpenOptions;
25
26use crate::io::IgnitionHash;
27
28use super::console::Console;
29use super::serializer;
30use super::types::*;
31use super::Cmd;
32
33const ADVANCED: &str = "Advanced Options";
37
38#[serde_as]
57#[skip_serializing_none]
58#[derive(Debug, Default, Parser, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
60#[command(args_override_self = true)]
61pub struct InstallConfig {
62 #[serde(skip)]
73 #[arg(short, long, value_name = "path")]
74 pub config_file: Vec<String>,
75
76 #[arg(short, long, value_name = "name")]
82 #[arg(conflicts_with_all = ["image_file", "image_url"])]
83 pub stream: Option<String>,
84 #[serde_as(as = "Option<DisplayFromStr>")]
90 #[arg(short = 'u', long, value_name = "URL")]
91 #[arg(conflicts_with_all = ["stream", "image_file"])]
92 pub image_url: Option<Url>,
93 #[arg(short = 'f', long, value_name = "path")]
99 #[arg(conflicts_with_all = ["stream", "image_url"])]
100 pub image_file: Option<String>,
101
102 #[arg(short, long, alias = "ignition", value_name = "path")]
108 #[arg(conflicts_with = "ignition_url")]
109 pub ignition_file: Option<String>,
110 #[serde_as(as = "Option<DisplayFromStr>")]
115 #[arg(short = 'I', long, value_name = "URL")]
116 #[arg(conflicts_with = "ignition_file")]
117 pub ignition_url: Option<Url>,
118 #[arg(long, value_name = "digest")]
123 pub ignition_hash: Option<IgnitionHash>,
124 #[serde(skip_serializing_if = "is_default")]
129 #[arg(short, long, default_value_t, value_name = "name")]
130 pub architecture: DefaultedString<Architecture>,
131 #[arg(short, long, value_name = "name")]
136 pub platform: Option<String>,
137 #[serde(skip_serializing_if = "is_default")]
142 #[arg(long, value_name = "spec")]
143 pub console: Vec<Console>,
144 #[serde(skip)]
149 #[arg(long, hide = true, value_name = "args")]
150 pub firstboot_args: Option<String>,
151 #[serde(skip_serializing_if = "is_default")]
155 #[arg(long, value_name = "arg")]
156 pub append_karg: Vec<String>,
157 #[serde(skip_serializing_if = "is_default")]
161 #[arg(long, value_name = "arg")]
162 pub delete_karg: Vec<String>,
163 #[serde(skip_serializing_if = "is_default")]
168 #[arg(short = 'n', long)]
169 pub copy_network: bool,
170 #[serde(skip_serializing_if = "is_default")]
177 #[arg(long, value_name = "path", default_value_t)]
178 #[arg(hide_default_value = true)]
180 pub network_dir: DefaultedString<NetworkDir>,
181 #[serde(skip_serializing_if = "is_default")]
192 #[arg(long, value_name = "lx")]
193 #[arg(value_delimiter = ',')]
196 pub save_partlabel: Vec<String>,
197 #[serde(skip_serializing_if = "is_default")]
209 #[arg(long, value_name = "id")]
210 #[arg(value_delimiter = ',')]
213 #[arg(allow_hyphen_values = true)]
215 pub save_partindex: Vec<String>,
216
217 #[serde(skip_serializing_if = "is_default")]
220 #[arg(long, help_heading = ADVANCED)]
221 pub offline: bool,
222 #[serde(skip_serializing_if = "is_default")]
227 #[arg(long, help_heading = ADVANCED)]
228 pub insecure: bool,
229 #[serde(skip_serializing_if = "is_default")]
231 #[arg(long, help_heading = ADVANCED)]
232 pub insecure_ignition: bool,
233 #[serde_as(as = "Option<DisplayFromStr>")]
238 #[arg(long, value_name = "URL", help_heading = ADVANCED)]
239 pub stream_base_url: Option<Url>,
240 #[serde(skip_serializing_if = "is_default")]
246 #[arg(long, help_heading = ADVANCED)]
247 pub preserve_on_error: bool,
248 #[serde(skip_serializing_if = "is_default")]
253 #[arg(long, value_name = "N", default_value_t, help_heading = ADVANCED)]
254 pub fetch_retries: FetchRetries,
255 #[serde(skip_serializing_if = "is_default")]
257 #[arg(long, help_heading = ADVANCED)]
258 pub secure_ipl: bool,
259
260 #[arg(required_unless_present = "config_file")]
266 pub dest_device: Option<String>,
267}
268
269impl InstallConfig {
270 pub fn expand_config_files(self) -> Result<Self> {
271 if self.config_file.is_empty() {
272 return Ok(self);
273 }
274
275 let mut args = self
276 .config_file
277 .iter()
278 .map(|path| {
279 serde_yaml::from_reader::<_, InstallConfig>(
280 OpenOptions::new()
281 .read(true)
282 .open(path)
283 .with_context(|| format!("opening config file {path}"))?,
284 )
285 .with_context(|| format!("parsing config file {path}"))?
286 .to_args()
287 .with_context(|| format!("serializing config file {path}"))
288 })
289 .collect::<Result<Vec<Vec<_>>>>()?
290 .into_iter()
291 .flatten()
292 .chain(
293 self.to_args()
294 .context("serializing command-line arguments")?,
295 )
296 .collect::<Vec<_>>();
297
298 if let Some(firstboot_args) = &self.firstboot_args {
300 args.push("--firstboot-args".to_string());
301 args.push(firstboot_args.clone());
302 }
303
304 println!("Running with arguments: {}", args.join(" "));
305 Self::from_args(&args)
306 }
307
308 fn from_args<T: AsRef<OsStr>>(args: &[T]) -> Result<Self> {
309 match Cmd::try_parse_from(
310 vec![
311 std::env::args_os().next().expect("no program name"),
312 "install".into(),
313 ]
314 .into_iter()
315 .chain(args.iter().map(<_>::into)),
316 )
317 .context("reprocessing command-line arguments")?
318 {
319 Cmd::Install(c) => Ok(c),
320 _ => unreachable!(),
321 }
322 }
323
324 fn to_args(&self) -> Result<Vec<String>> {
325 serializer::to_args(self)
326 }
327}
328
329#[cfg(test)]
330mod test {
331 use super::*;
332 use std::io::Write;
333 use std::num::NonZeroU32;
334 use std::str::FromStr;
335 use tempfile::NamedTempFile;
336
337 #[test]
339 fn serialize_full_install_config() {
340 let config = InstallConfig {
341 config_file: vec!["a".into(), "b".into()],
343 stream: Some("c".into()),
344 image_url: Some(Url::parse("http://example.com/d").unwrap()),
345 image_file: Some("e".into()),
346 ignition_file: Some("f".into()),
347 ignition_url: Some(Url::parse("http://example.com/g").unwrap()),
348 ignition_hash: Some(
349 IgnitionHash::from_str(
350 "sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
351 )
352 .unwrap(),
353 ),
354 architecture: DefaultedString::<Architecture>::from_str("h").unwrap(),
355 platform: Some("i".into()),
356 console: vec![
357 Console::from_str("ttyS0").unwrap(),
358 Console::from_str("ttyS1,115200n8").unwrap(),
359 ],
360 firstboot_args: Some("j".into()),
362 append_karg: vec!["k".into(), "l".into()],
363 delete_karg: vec!["m".into(), "n".into()],
364 copy_network: true,
365 network_dir: DefaultedString::<NetworkDir>::from_str("o").unwrap(),
366 save_partlabel: vec!["p".into(), "q".into()],
367 save_partindex: vec!["r".into(), "s".into()],
368 offline: true,
369 insecure: true,
370 insecure_ignition: true,
371 stream_base_url: Some(Url::parse("http://example.com/t").unwrap()),
372 preserve_on_error: true,
373 fetch_retries: FetchRetries::from_str("3").unwrap(),
374 secure_ipl: true,
375 dest_device: Some("u".into()),
376 };
377 let expected = vec![
378 "--stream",
379 "c",
380 "--image-url",
381 "http://example.com/d",
382 "--image-file",
383 "e",
384 "--ignition-file",
385 "f",
386 "--ignition-url",
387 "http://example.com/g",
388 "--ignition-hash",
389 "sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
390 "--architecture",
391 "h",
392 "--platform",
393 "i",
394 "--console",
395 "ttyS0,9600n8",
397 "--console",
398 "ttyS1,115200n8",
399 "--append-karg",
400 "k",
401 "--append-karg",
402 "l",
403 "--delete-karg",
404 "m",
405 "--delete-karg",
406 "n",
407 "--copy-network",
408 "--network-dir",
409 "o",
410 "--save-partlabel",
411 "p",
412 "--save-partlabel",
413 "q",
414 "--save-partindex",
415 "r",
416 "--save-partindex",
417 "s",
418 "--offline",
419 "--insecure",
420 "--insecure-ignition",
421 "--stream-base-url",
422 "http://example.com/t",
423 "--preserve-on-error",
424 "--fetch-retries",
425 "3",
426 "--secure-ipl",
427 "u",
428 ];
429 assert_eq!(config.to_args().unwrap(), expected);
430 }
431
432 #[test]
434 fn parse_full_install_config_file() {
435 let mut f = NamedTempFile::new().unwrap();
436 f.as_file_mut()
437 .write_all(
438 r#"
439image-url: http://example.com/d
440ignition-url: http://example.com/g
441ignition-hash: sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
442architecture: h
443platform: i
444console: [ttyS0, "ttyS1,115200n8"]
445append-karg: [k, l]
446delete-karg: [m, n]
447copy-network: true
448network-dir: o
449save-partlabel: [p, q]
450save-partindex: [r, s]
451offline: true
452insecure: true
453insecure-ignition: true
454stream-base-url: http://example.com/t
455preserve-on-error: true
456fetch-retries: 3
457dest-device: u
458"#
459 .as_bytes(),
460 )
461 .unwrap();
462 let expected = InstallConfig {
463 config_file: Vec::new(),
465 stream: None,
467 image_url: Some(Url::parse("http://example.com/d").unwrap()),
468 image_file: None,
470 ignition_file: None,
472 ignition_url: Some(Url::parse("http://example.com/g").unwrap()),
473 ignition_hash: Some(
474 IgnitionHash::from_str(
475 "sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
476 )
477 .unwrap(),
478 ),
479 architecture: DefaultedString::<Architecture>::from_str("h").unwrap(),
480 platform: Some("i".into()),
481 console: vec![
482 Console::from_str("ttyS0").unwrap(),
483 Console::from_str("ttyS1,115200n8").unwrap(),
484 ],
485 firstboot_args: None,
487 append_karg: vec!["k".into(), "l".into()],
488 delete_karg: vec!["m".into(), "n".into()],
489 copy_network: true,
490 network_dir: DefaultedString::<NetworkDir>::from_str("o").unwrap(),
491 save_partlabel: vec!["p".into(), "q".into()],
492 save_partindex: vec!["r".into(), "s".into()],
493 offline: true,
494 insecure: true,
495 insecure_ignition: true,
496 stream_base_url: Some(Url::parse("http://example.com/t").unwrap()),
497 preserve_on_error: true,
498 fetch_retries: FetchRetries::from_str("3").unwrap(),
499 secure_ipl: false,
500 dest_device: Some("u".into()),
501 };
502 let config = InstallConfig::from_args(&["--config-file", f.path().to_str().unwrap()])
503 .unwrap()
504 .expand_config_files()
505 .unwrap();
506 assert_eq!(expected, config);
507 }
508
509 #[test]
511 fn serialize_default_install_config_args() {
512 let config = InstallConfig::default();
513 let expected: Vec<String> = Vec::new();
514 assert_eq!(config.to_args().unwrap(), expected);
515 }
516
517 #[test]
519 fn serialize_default_install_config_yaml() {
520 let config = InstallConfig::default();
521 assert_eq!(
522 serde_yaml::to_string(&config).unwrap().replace("---\n", ""),
524 "{}\n"
525 );
526 }
527
528 #[test]
530 fn serialize_empty_install_config_file() {
531 let config: InstallConfig = serde_yaml::from_str("dest-device: foo").unwrap();
532 assert_eq!(config.to_args().unwrap(), vec!["foo"]);
533 }
534
535 #[test]
537 fn serialize_empty_command_line() {
538 let expected = ["/dev/missing"];
539 let config = InstallConfig::from_args(&expected).unwrap();
540 assert_eq!(config.to_args().unwrap(), expected);
541 }
542
543 #[test]
545 fn install_config_file_overlapping_field() {
546 let mut f1 = NamedTempFile::new().unwrap();
547 f1.as_file_mut()
548 .write_all(b"append-karg: [a, b]\nfetch-retries: 1")
549 .unwrap();
550 let mut f2 = NamedTempFile::new().unwrap();
551 f2.as_file_mut()
552 .write_all(b"append-karg: [c, d]\nfetch-retries: 2\ndest-device: /dev/missing")
553 .unwrap();
554 let config = InstallConfig::from_args(&[
555 "--append-karg",
556 "e",
557 "--fetch-retries",
558 "0",
559 "--config-file",
560 f2.path().to_str().unwrap(),
561 "--config-file",
562 f1.path().to_str().unwrap(),
563 "--append-karg",
564 "f",
565 "--fetch-retries",
566 "3",
567 ])
568 .unwrap()
569 .expand_config_files()
570 .unwrap();
571 assert_eq!(config.append_karg, ["c", "d", "a", "b", "e", "f"]);
572 assert_eq!(
573 config.fetch_retries,
574 FetchRetries::Finite(NonZeroU32::new(3).unwrap())
575 );
576
577 InstallConfig::from_args(&[
579 "--config-file",
580 f2.path().to_str().unwrap(),
581 "/dev/also-missing",
582 ])
583 .unwrap()
584 .expand_config_files()
585 .unwrap_err();
586 }
587
588 #[test]
590 fn test_firstboot_args_manually_added() {
591 let mut f = NamedTempFile::new().unwrap();
592 f.as_file_mut().write_all(b"dest-device: /dev/sda").unwrap();
593
594 let config = InstallConfig::from_args(&[
595 "--config-file",
596 f.path().to_str().unwrap(),
597 "--firstboot-args",
598 "ip=dhcp",
599 ])
600 .unwrap();
601
602 assert!(config.firstboot_args.is_some());
604 assert_eq!(config.firstboot_args.as_ref().unwrap(), "ip=dhcp");
605
606 let expanded = config.expand_config_files().unwrap();
608
609 assert!(expanded.firstboot_args.is_some());
611 assert_eq!(expanded.firstboot_args.unwrap(), "ip=dhcp");
612 }
613}