use anyhow::{Context, Result};
use clap::Parser;
use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, skip_serializing_none, DisplayFromStr};
use std::default::Default;
use std::ffi::OsStr;
use std::fs::OpenOptions;
use crate::io::IgnitionHash;
use super::console::Console;
use super::serializer;
use super::types::*;
use super::Cmd;
const ADVANCED: &str = "ADVANCED OPTIONS";
#[serde_as]
#[skip_serializing_none]
#[derive(Debug, Default, Parser, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
#[clap(args_override_self = true)]
pub struct InstallConfig {
#[serde(skip)]
#[clap(short, long, value_name = "path")]
pub config_file: Vec<String>,
#[clap(short, long, value_name = "name")]
#[clap(conflicts_with = "image-file", conflicts_with = "image-url")]
pub stream: Option<String>,
#[serde_as(as = "Option<DisplayFromStr>")]
#[clap(short = 'u', long, value_name = "URL")]
#[clap(conflicts_with = "stream", conflicts_with = "image-file")]
pub image_url: Option<Url>,
#[clap(short = 'f', long, value_name = "path")]
#[clap(conflicts_with = "stream", conflicts_with = "image-url")]
pub image_file: Option<String>,
#[clap(short, long, alias = "ignition", value_name = "path")]
#[clap(conflicts_with = "ignition-url")]
pub ignition_file: Option<String>,
#[serde_as(as = "Option<DisplayFromStr>")]
#[clap(short = 'I', long, value_name = "URL")]
#[clap(conflicts_with = "ignition-file")]
pub ignition_url: Option<Url>,
#[clap(long, value_name = "digest")]
pub ignition_hash: Option<IgnitionHash>,
#[serde(skip_serializing_if = "is_default")]
#[clap(short, long, default_value_t, value_name = "name")]
pub architecture: DefaultedString<Architecture>,
#[clap(short, long, value_name = "name")]
pub platform: Option<String>,
#[serde(skip_serializing_if = "is_default")]
#[clap(long, value_name = "spec")]
pub console: Vec<Console>,
#[serde(skip)]
#[clap(long, hide = true, value_name = "args")]
pub firstboot_args: Option<String>,
#[serde(skip_serializing_if = "is_default")]
#[clap(long, value_name = "arg")]
pub append_karg: Vec<String>,
#[serde(skip_serializing_if = "is_default")]
#[clap(long, value_name = "arg")]
pub delete_karg: Vec<String>,
#[serde(skip_serializing_if = "is_default")]
#[clap(short = 'n', long)]
pub copy_network: bool,
#[serde(skip_serializing_if = "is_default")]
#[clap(long, value_name = "path", default_value_t)]
#[clap(hide_default_value = true)]
pub network_dir: DefaultedString<NetworkDir>,
#[serde(skip_serializing_if = "is_default")]
#[clap(long, value_name = "lx")]
#[clap(number_of_values = 1, require_value_delimiter = true)]
#[clap(value_delimiter = ',')]
pub save_partlabel: Vec<String>,
#[serde(skip_serializing_if = "is_default")]
#[clap(long, value_name = "id")]
#[clap(number_of_values = 1, require_value_delimiter = true)]
#[clap(value_delimiter = ',')]
#[clap(allow_hyphen_values = true)]
pub save_partindex: Vec<String>,
#[serde(skip_serializing_if = "is_default")]
#[clap(long, help_heading = ADVANCED)]
pub offline: bool,
#[serde(skip_serializing_if = "is_default")]
#[clap(long, help_heading = ADVANCED)]
pub insecure: bool,
#[serde(skip_serializing_if = "is_default")]
#[clap(long, help_heading = ADVANCED)]
pub insecure_ignition: bool,
#[serde_as(as = "Option<DisplayFromStr>")]
#[clap(long, value_name = "URL", help_heading = ADVANCED)]
pub stream_base_url: Option<Url>,
#[serde(skip_serializing_if = "is_default")]
#[clap(long, help_heading = ADVANCED)]
pub preserve_on_error: bool,
#[serde(skip_serializing_if = "is_default")]
#[clap(long, value_name = "N", default_value_t, help_heading = ADVANCED)]
pub fetch_retries: FetchRetries,
#[clap(required_unless_present = "config-file")]
pub dest_device: Option<String>,
}
impl InstallConfig {
pub fn expand_config_files(self) -> Result<Self> {
if self.config_file.is_empty() {
return Ok(self);
}
let args = self
.config_file
.iter()
.map(|path| {
serde_yaml::from_reader::<_, InstallConfig>(
OpenOptions::new()
.read(true)
.open(path)
.with_context(|| format!("opening config file {}", path))?,
)
.with_context(|| format!("parsing config file {}", path))?
.to_args()
.with_context(|| format!("serializing config file {}", path))
})
.collect::<Result<Vec<Vec<_>>>>()?
.into_iter()
.flatten()
.chain(
self.to_args()
.context("serializing command-line arguments")?,
)
.collect::<Vec<_>>();
println!("Running with arguments: {}", args.join(" "));
Self::from_args(&args)
}
fn from_args<T: AsRef<OsStr>>(args: &[T]) -> Result<Self> {
match Cmd::try_parse_from(
vec![
std::env::args_os().next().expect("no program name"),
"install".into(),
]
.into_iter()
.chain(args.iter().map(<_>::into)),
)
.context("reprocessing command-line arguments")?
{
Cmd::Install(c) => Ok(c),
_ => unreachable!(),
}
}
fn to_args(&self) -> Result<Vec<String>> {
serializer::to_args(self)
}
}
#[cfg(test)]
mod test {
use super::*;
use std::io::Write;
use std::num::NonZeroU32;
use std::str::FromStr;
use tempfile::NamedTempFile;
#[test]
fn serialize_full_install_config() {
let config = InstallConfig {
config_file: vec!["a".into(), "b".into()],
stream: Some("c".into()),
image_url: Some(Url::parse("http://example.com/d").unwrap()),
image_file: Some("e".into()),
ignition_file: Some("f".into()),
ignition_url: Some(Url::parse("http://example.com/g").unwrap()),
ignition_hash: Some(
IgnitionHash::from_str(
"sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
)
.unwrap(),
),
architecture: DefaultedString::<Architecture>::from_str("h").unwrap(),
platform: Some("i".into()),
console: vec![
Console::from_str("ttyS0").unwrap(),
Console::from_str("ttyS1,115200n8").unwrap(),
],
firstboot_args: Some("j".into()),
append_karg: vec!["k".into(), "l".into()],
delete_karg: vec!["m".into(), "n".into()],
copy_network: true,
network_dir: DefaultedString::<NetworkDir>::from_str("o").unwrap(),
save_partlabel: vec!["p".into(), "q".into()],
save_partindex: vec!["r".into(), "s".into()],
offline: true,
insecure: true,
insecure_ignition: true,
stream_base_url: Some(Url::parse("http://example.com/t").unwrap()),
preserve_on_error: true,
fetch_retries: FetchRetries::from_str("3").unwrap(),
dest_device: Some("u".into()),
};
let expected = vec![
"--stream",
"c",
"--image-url",
"http://example.com/d",
"--image-file",
"e",
"--ignition-file",
"f",
"--ignition-url",
"http://example.com/g",
"--ignition-hash",
"sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"--architecture",
"h",
"--platform",
"i",
"--console",
"ttyS0,9600n8",
"--console",
"ttyS1,115200n8",
"--append-karg",
"k",
"--append-karg",
"l",
"--delete-karg",
"m",
"--delete-karg",
"n",
"--copy-network",
"--network-dir",
"o",
"--save-partlabel",
"p",
"--save-partlabel",
"q",
"--save-partindex",
"r",
"--save-partindex",
"s",
"--offline",
"--insecure",
"--insecure-ignition",
"--stream-base-url",
"http://example.com/t",
"--preserve-on-error",
"--fetch-retries",
"3",
"u",
];
assert_eq!(config.to_args().unwrap(), expected);
}
#[test]
fn parse_full_install_config_file() {
let mut f = NamedTempFile::new().unwrap();
f.as_file_mut()
.write_all(
r#"
image-url: http://example.com/d
ignition-url: http://example.com/g
ignition-hash: sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
architecture: h
platform: i
console: [ttyS0, "ttyS1,115200n8"]
append-karg: [k, l]
delete-karg: [m, n]
copy-network: true
network-dir: o
save-partlabel: [p, q]
save-partindex: [r, s]
offline: true
insecure: true
insecure-ignition: true
stream-base-url: http://example.com/t
preserve-on-error: true
fetch-retries: 3
dest-device: u
"#
.as_bytes(),
)
.unwrap();
let expected = InstallConfig {
config_file: Vec::new(),
stream: None,
image_url: Some(Url::parse("http://example.com/d").unwrap()),
image_file: None,
ignition_file: None,
ignition_url: Some(Url::parse("http://example.com/g").unwrap()),
ignition_hash: Some(
IgnitionHash::from_str(
"sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
)
.unwrap(),
),
architecture: DefaultedString::<Architecture>::from_str("h").unwrap(),
platform: Some("i".into()),
console: vec![
Console::from_str("ttyS0").unwrap(),
Console::from_str("ttyS1,115200n8").unwrap(),
],
firstboot_args: None,
append_karg: vec!["k".into(), "l".into()],
delete_karg: vec!["m".into(), "n".into()],
copy_network: true,
network_dir: DefaultedString::<NetworkDir>::from_str("o").unwrap(),
save_partlabel: vec!["p".into(), "q".into()],
save_partindex: vec!["r".into(), "s".into()],
offline: true,
insecure: true,
insecure_ignition: true,
stream_base_url: Some(Url::parse("http://example.com/t").unwrap()),
preserve_on_error: true,
fetch_retries: FetchRetries::from_str("3").unwrap(),
dest_device: Some("u".into()),
};
let config = InstallConfig::from_args(&["--config-file", f.path().to_str().unwrap()])
.unwrap()
.expand_config_files()
.unwrap();
assert_eq!(expected, config);
}
#[test]
fn serialize_default_install_config_args() {
let config = InstallConfig::default();
let expected: Vec<String> = Vec::new();
assert_eq!(config.to_args().unwrap(), expected);
}
#[test]
fn serialize_default_install_config_yaml() {
let config = InstallConfig::default();
assert_eq!(
serde_yaml::to_string(&config).unwrap().replace("---\n", ""),
"{}\n"
);
}
#[test]
fn serialize_empty_install_config_file() {
let config: InstallConfig = serde_yaml::from_str("dest-device: foo").unwrap();
assert_eq!(config.to_args().unwrap(), vec!["foo"]);
}
#[test]
fn serialize_empty_command_line() {
let expected = ["/dev/missing"];
let config = InstallConfig::from_args(&expected).unwrap();
assert_eq!(config.to_args().unwrap(), expected);
}
#[test]
fn install_config_file_overlapping_field() {
let mut f1 = NamedTempFile::new().unwrap();
f1.as_file_mut()
.write_all(b"append-karg: [a, b]\nfetch-retries: 1")
.unwrap();
let mut f2 = NamedTempFile::new().unwrap();
f2.as_file_mut()
.write_all(b"append-karg: [c, d]\nfetch-retries: 2\ndest-device: /dev/missing")
.unwrap();
let config = InstallConfig::from_args(&[
"--append-karg",
"e",
"--fetch-retries",
"0",
"--config-file",
f2.path().to_str().unwrap(),
"--config-file",
f1.path().to_str().unwrap(),
"--append-karg",
"f",
"--fetch-retries",
"3",
])
.unwrap()
.expand_config_files()
.unwrap();
assert_eq!(config.append_karg, ["c", "d", "a", "b", "e", "f"]);
assert_eq!(
config.fetch_retries,
FetchRetries::Finite(NonZeroU32::new(3).unwrap())
);
InstallConfig::from_args(&[
"--config-file",
f2.path().to_str().unwrap(),
"/dev/also-missing",
])
.unwrap()
.expand_config_files()
.unwrap_err();
}
}