From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 34F2B9ACE for ; Tue, 5 Sep 2023 15:29:12 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 15EDB19F38 for ; Tue, 5 Sep 2023 15:28:42 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Tue, 5 Sep 2023 15:28:38 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 3C3914226F for ; Tue, 5 Sep 2023 15:28:38 +0200 (CEST) From: Aaron Lauterer To: pve-devel@lists.proxmox.com Date: Tue, 5 Sep 2023 15:28:28 +0200 Message-Id: <20230905132832.3179097-3-a.lauterer@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20230905132832.3179097-1-a.lauterer@proxmox.com> References: <20230905132832.3179097-1-a.lauterer@proxmox.com> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: quoted-printable X-SPAM-LEVEL: Spam detection results: 0 AWL -0.079 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record WEIRD_QUOTING 0.001 Weird repeated double-quotation marks X-Mailman-Approved-At: Wed, 06 Sep 2023 09:29:54 +0200 Subject: [pve-devel] [RFC installer 2/6] add proxmox-auto-installer X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Tue, 05 Sep 2023 13:29:12 -0000 The auto installer, in this first iteration expects an answer file on stdin. It needs to be in TOML format. It then translates the information from the answer file into the JSON needed for the low level installer. Basically all options that can be chosen during a normal installation (GUI/TUI) can be configured in the answer file. Additionally it is possible to select the NIC and disks by matching them against UDEV device properties. For example, if one wants to use a NIC with a specific MAC address. Or the disks for the OS can be matched against a vendor or model number. It supports basic globbing/wildcard is supported at the beginning and end of the search string. The matching is implemented by us as it isn't that difficult and we can avoid additional crates. The answer file has options to configure commands to be run pre- and post-installation. The idea is that one could for example clean up the disks or send a status update to some dashboard, or modify the installation further before rebooting. Technically it is reusing a lot of the TUI installer. All the source files needed are in the 'tui' subdirectory. The idea is, that we can factor out common code into a dedicated library crate. To make it easier, unused parts are removed. Some changes were made as well, for example changing HashMaps to BTreeMaps to avoid random ordering. Some structs got their properties made public, but with a refactor, we can probably rework that and implement additional From methods. For the tests, I used the information from one of our benchmark servers to have a realistic starting point. Signed-off-by: Aaron Lauterer --- I am not too happy that the json files created by the low level installer are currently all stored as is in the test directory. E.g. locales, iso-info and so forth. Those could probably be created on demand. While the others, run-env-{info,udev}, need to be stable for the tests to work as expected. Cargo.toml | 1 + proxmox-auto-installer/Cargo.toml | 13 + proxmox-auto-installer/answer.toml | 36 ++ .../resources/test/iso-info.json | 1 + .../resources/test/locales.json | 1 + .../test/parse_answer/disk_match.json | 28 ++ .../test/parse_answer/disk_match.toml | 14 + .../test/parse_answer/disk_match_all.json | 25 + .../test/parse_answer/disk_match_all.toml | 16 + .../test/parse_answer/disk_match_any.json | 32 ++ .../test/parse_answer/disk_match_any.toml | 16 + .../resources/test/parse_answer/minimal.json | 17 + .../resources/test/parse_answer/minimal.toml | 14 + .../test/parse_answer/nic_matching.json | 17 + .../test/parse_answer/nic_matching.toml | 19 + .../resources/test/parse_answer/readme | 4 + .../test/parse_answer/specific_nic.json | 17 + .../test/parse_answer/specific_nic.toml | 19 + .../resources/test/parse_answer/zfs.json | 26 + .../resources/test/parse_answer/zfs.toml | 19 + .../resources/test/run-env-info.json | 1 + .../resources/test/run-env-udev.json | 1 + proxmox-auto-installer/src/answer.rs | 144 ++++++ proxmox-auto-installer/src/main.rs | 412 ++++++++++++++++ proxmox-auto-installer/src/tui/mod.rs | 3 + proxmox-auto-installer/src/tui/options.rs | 302 ++++++++++++ proxmox-auto-installer/src/tui/setup.rs | 447 ++++++++++++++++++ proxmox-auto-installer/src/tui/utils.rs | 268 +++++++++++ proxmox-auto-installer/src/udevinfo.rs | 9 + proxmox-auto-installer/src/utils.rs | 325 +++++++++++++ 30 files changed, 2247 insertions(+) create mode 100644 proxmox-auto-installer/Cargo.toml create mode 100644 proxmox-auto-installer/answer.toml create mode 100644 proxmox-auto-installer/resources/test/iso-info.json create mode 100644 proxmox-auto-installer/resources/test/locales.json create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk= _match.json create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk= _match.toml create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk= _match_all.json create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk= _match_all.toml create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk= _match_any.json create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk= _match_any.toml create mode 100644 proxmox-auto-installer/resources/test/parse_answer/mini= mal.json create mode 100644 proxmox-auto-installer/resources/test/parse_answer/mini= mal.toml create mode 100644 proxmox-auto-installer/resources/test/parse_answer/nic_= matching.json create mode 100644 proxmox-auto-installer/resources/test/parse_answer/nic_= matching.toml create mode 100644 proxmox-auto-installer/resources/test/parse_answer/read= me create mode 100644 proxmox-auto-installer/resources/test/parse_answer/spec= ific_nic.json create mode 100644 proxmox-auto-installer/resources/test/parse_answer/spec= ific_nic.toml create mode 100644 proxmox-auto-installer/resources/test/parse_answer/zfs.= json create mode 100644 proxmox-auto-installer/resources/test/parse_answer/zfs.= toml create mode 100644 proxmox-auto-installer/resources/test/run-env-info.json create mode 100644 proxmox-auto-installer/resources/test/run-env-udev.json create mode 100644 proxmox-auto-installer/src/answer.rs create mode 100644 proxmox-auto-installer/src/main.rs create mode 100644 proxmox-auto-installer/src/tui/mod.rs create mode 100644 proxmox-auto-installer/src/tui/options.rs create mode 100644 proxmox-auto-installer/src/tui/setup.rs create mode 100644 proxmox-auto-installer/src/tui/utils.rs create mode 100644 proxmox-auto-installer/src/udevinfo.rs create mode 100644 proxmox-auto-installer/src/utils.rs diff --git a/Cargo.toml b/Cargo.toml index fd151ba..a942636 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members =3D [ + "proxmox-auto-installer", "proxmox-tui-installer", ] =20 diff --git a/proxmox-auto-installer/Cargo.toml b/proxmox-auto-installer/Car= go.toml new file mode 100644 index 0000000..fd38d28 --- /dev/null +++ b/proxmox-auto-installer/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name =3D "proxmox-auto-installer" +version =3D "0.1.0" +edition =3D "2021" +authors =3D [ "Aaron Lauerer >, + pub post_command: Option>, +} + +#[derive(Clone, Deserialize, Debug)] +pub struct Network { + pub use_dhcp: Option, + pub cidr: Option, + pub dns: Option, + pub gateway: Option, + // use BTreeMap to have keys sorted + pub filter: Option>, +} + +#[derive(Clone, Deserialize, Debug)] +pub struct Disks { + pub filesystem: Option, + pub disk_selection: Option>, + pub filter_match: Option, + // use BTreeMap to have keys sorted + pub filter: Option>, + pub zfs: Option, + pub lvm: Option, + pub btrfs: Option, +} + +#[derive(Clone, Deserialize, Debug, PartialEq)] +#[serde(rename_all =3D "lowercase")] +pub enum FilterMatch { + Any, + All, +} + +#[derive(Clone, Deserialize, Serialize, Debug)] +#[serde(rename_all =3D "kebab-case")] +pub enum Filesystem { + Ext4, + Xfs, + ZfsRaid0, + ZfsRaid1, + ZfsRaid10, + ZfsRaidZ1, + ZfsRaidZ2, + ZfsRaidZ3, + BtrfsRaid0, + BtrfsRaid1, + BtrfsRaid10, +} + +#[derive(Clone, Deserialize, Debug)] +pub struct ZfsOptions { + pub ashift: Option, + pub checksum: Option, + pub compress: Option, + pub copies: Option, + pub hdsize: Option, +} + +impl ZfsOptions { + pub fn new() -> ZfsOptions { + ZfsOptions { + ashift: None, + checksum: None, + compress: None, + copies: None, + hdsize: None, + } + } +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)] +#[serde(rename_all(deserialize =3D "lowercase"))] +pub enum ZfsCompressOption { + #[default] + On, + Off, + Lzjb, + Lz4, + Zle, + Gzip, + Zstd, +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)] +#[serde(rename_all =3D "kebab-case")] +pub enum ZfsChecksumOption { + #[default] + On, + Off, + Fletcher2, + Fletcher4, + Sha256, +} + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct LvmOptions { + pub hdsize: Option, + pub swapsize: Option, + pub maxroot: Option, + pub maxvz: Option, + pub minfree: Option, +} + +impl LvmOptions { + pub fn new() -> LvmOptions { + LvmOptions { + hdsize: None, + swapsize: None, + maxroot: None, + maxvz: None, + minfree: None, + } + } +} + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct BtrfsOptions { + pub hdsize: Option, +} + +impl BtrfsOptions { + pub fn new() -> BtrfsOptions { + BtrfsOptions { hdsize: None } + } +} diff --git a/proxmox-auto-installer/src/main.rs b/proxmox-auto-installer/sr= c/main.rs new file mode 100644 index 0000000..d647567 --- /dev/null +++ b/proxmox-auto-installer/src/main.rs @@ -0,0 +1,412 @@ +use std::{ + collections::BTreeMap, + env, + io::{BufRead, BufReader, Write}, + path::PathBuf, + process::ExitCode, +}; +mod tui; +use tui::{ + options::{BtrfsRaidLevel, FsType, ZfsChecksumOption, ZfsCompressOption= , ZfsRaidLevel}, + setup::{InstallConfig, InstallZfsOption, LocaleInfo, RuntimeInfo, Setu= pInfo}, +}; + +mod answer; +mod udevinfo; +mod utils; +use answer::Answer; +use udevinfo::UdevInfo; + +/// ISO information is available globally. +static mut SETUP_INFO: Option =3D None; + +pub fn setup_info() -> &'static SetupInfo { + unsafe { SETUP_INFO.as_ref().unwrap() } +} + +fn init_setup_info(info: SetupInfo) { + unsafe { + SETUP_INFO =3D Some(info); + } +} + +#[inline] +pub fn current_product() -> tui::setup::ProxmoxProduct { + setup_info().config.product +} + +fn installer_setup( + in_test_mode: bool, +) -> Result<(Answer, LocaleInfo, RuntimeInfo, UdevInfo), String> { + let base_path =3D if in_test_mode { "./testdir" } else { "/" }; + let mut path =3D PathBuf::from(base_path); + + path.push("run"); + path.push("proxmox-installer"); + + let installer_info =3D { + let mut path =3D path.clone(); + path.push("iso-info.json"); + + tui::setup::read_json(&path) + .map_err(|err| format!("Failed to retrieve setup info: {err}")= )? + }; + init_setup_info(installer_info); + + let locale_info =3D { + let mut path =3D path.clone(); + path.push("locales.json"); + + tui::setup::read_json(&path) + .map_err(|err| format!("Failed to retrieve locale info: {err}"= ))? + }; + + let mut runtime_info: RuntimeInfo =3D { + let mut path =3D path.clone(); + path.push("run-env-info.json"); + + tui::setup::read_json(&path) + .map_err(|err| format!("Failed to retrieve runtime environment= info: {err}"))? + }; + + let udev_info: UdevInfo =3D { + let mut path =3D path.clone(); + path.push("run-env-udev.json"); + + tui::setup::read_json(&path) + .map_err(|err| format!("Failed to retrieve udev info details: = {err}"))? + }; + + let mut buffer =3D String::new(); + let lines =3D std::io::stdin().lock().lines(); + for line in lines { + buffer.push_str(&line.unwrap()); + buffer.push('\n'); + } + + let answer: answer::Answer =3D + toml::from_str(&buffer).map_err(|err| format!("Failed parsing answ= er file: {err}"))?; + + runtime_info.disks.sort(); + if runtime_info.disks.is_empty() { + Err("The installer could not find any supported hard disks.".to_ow= ned()) + } else { + Ok((answer, locale_info, runtime_info, udev_info)) + } +} + +fn main() -> ExitCode { + let in_test_mode =3D match env::args().nth(1).as_deref() { + Some("-t") =3D> true, + // Always force the test directory in debug builds + _ =3D> cfg!(debug_assertions), + }; + println!("Starting auto installer"); + + let (answer, locales, runtime_info, udevadm_info) =3D match installer_= setup(in_test_mode) { + Ok(result) =3D> result, + Err(err) =3D> { + eprintln!("Installer setup error: {err}"); + return ExitCode::FAILURE; + } + }; + + match utils::run_cmds("Pre", &answer.global.pre_command) { + Ok(_) =3D> (), + Err(err) =3D> { + eprintln!("Error when running Pre-Commands: {}", err); + return ExitCode::FAILURE; + } + }; + match run_installation( + &answer, + &locales, + &runtime_info, + &udevadm_info, + ) { + Ok(_) =3D> println!("Installation done."), + Err(err) =3D> { + eprintln!("Installation failed: {err}"); + return ExitCode::FAILURE; + } + } + match utils::run_cmds("Post", &answer.global.post_command) { + Ok(_) =3D> (), + Err(err) =3D> { + eprintln!("Error when running Post-Commands: {}", err); + return ExitCode::FAILURE; + } + }; + ExitCode::SUCCESS +} + +fn run_installation( + answer: &Answer, + locales: &LocaleInfo, + runtime_info: &RuntimeInfo, + udevadm_info: &UdevInfo, +) -> Result<(), String> { + let config =3D parse_answer(answer, udevadm_info, runtime_info, locale= s)?; + #[cfg(debug_assertions)] + println!( + "FINAL JSON:\n{}", + serde_json::to_string_pretty(&config).expect("serialization failed= ") + ); + + let child =3D { + use std::process::{Command, Stdio}; + + #[cfg(not(debug_assertions))] + let (path, args, envs): (&str, [&str; 1], [(&str, &str); 0]) =3D + ("proxmox-low-level-installer", ["start-session"], []); + + #[cfg(debug_assertions)] + let (path, args, envs) =3D ( + PathBuf::from("./proxmox-low-level-installer"), + ["-t", "start-session-test"], + [("PERL5LIB", ".")], + ); + + Command::new(path) + .args(args) + .envs(envs) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + }; + + let mut child =3D match child { + Ok(child) =3D> child, + Err(err) =3D> { + return Err(format!("Low level installer could not be started: = {err}")); + } + }; + + let mut inner =3D || { + let reader =3D child.stdout.take().map(BufReader::new)?; + let mut writer =3D child.stdin.take()?; + + serde_json::to_writer(&mut writer, &config).unwrap(); + writeln!(writer).unwrap(); + + for line in reader.lines() { + match line { + Ok(line) =3D> print!("{line}"), + Err(_) =3D> break, + }; + } + Some(()) + }; + match inner() { + Some(_) =3D> Ok(()), + None =3D> Err("low level installer returned early".to_string()), + } +} + +fn parse_answer( + answer: &Answer, + udev_info: &UdevInfo, + runtime_info: &RuntimeInfo, + locales: &LocaleInfo, +) -> Result { + let filesystem =3D match &answer.disks.filesystem { + Some(answer::Filesystem::Ext4) =3D> FsType::Ext4, + Some(answer::Filesystem::Xfs) =3D> FsType::Xfs, + Some(answer::Filesystem::ZfsRaid0) =3D> FsType::Zfs(ZfsRaidLevel::= Raid0), + Some(answer::Filesystem::ZfsRaid1) =3D> FsType::Zfs(ZfsRaidLevel::= Raid1), + Some(answer::Filesystem::ZfsRaid10) =3D> FsType::Zfs(ZfsRaidLevel:= :Raid10), + Some(answer::Filesystem::ZfsRaidZ1) =3D> FsType::Zfs(ZfsRaidLevel:= :RaidZ), + Some(answer::Filesystem::ZfsRaidZ2) =3D> FsType::Zfs(ZfsRaidLevel:= :RaidZ2), + Some(answer::Filesystem::ZfsRaidZ3) =3D> FsType::Zfs(ZfsRaidLevel:= :RaidZ3), + Some(answer::Filesystem::BtrfsRaid0) =3D> FsType::Btrfs(BtrfsRaidL= evel::Raid0), + Some(answer::Filesystem::BtrfsRaid1) =3D> FsType::Btrfs(BtrfsRaidL= evel::Raid1), + Some(answer::Filesystem::BtrfsRaid10) =3D> FsType::Btrfs(BtrfsRaid= Level::Raid10), + None =3D> FsType::Ext4, + }; + + let network_settings =3D utils::get_network_settings(answer, udev_info= , runtime_info)?; + + utils::verify_locale_settings(answer, locales)?; + + let mut config =3D InstallConfig { + autoreboot: 1_usize, + filesys: filesystem, + hdsize: 0., + swapsize: None, + maxroot: None, + minfree: None, + maxvz: None, + zfs_opts: None, + target_hd: None, + disk_selection: BTreeMap::new(), + + country: answer.global.country.clone(), + timezone: answer.global.timezone.clone(), + keymap: answer.global.keyboard.clone(), + + password: answer.global.password.clone(), + mailto: answer.global.mailto.clone(), + + mngmt_nic: network_settings.ifname, + + hostname: network_settings.fqdn.host().unwrap().to_string(), + domain: network_settings.fqdn.domain(), + cidr: network_settings.address, + gateway: network_settings.gateway, + dns: network_settings.dns_server, + }; + + utils::set_disks(answer, udev_info, runtime_info, &mut config)?; + match &config.filesys { + FsType::Xfs | FsType::Ext4 =3D> { + let lvm =3D match &answer.disks.lvm { + Some(lvm) =3D> lvm.clone(), + None =3D> answer::LvmOptions::new(), + }; + config.hdsize =3D lvm.hdsize.unwrap_or(config.target_hd.clone(= ).unwrap().size); + config.swapsize =3D lvm.swapsize; + config.maxroot =3D lvm.maxroot; + config.maxvz =3D lvm.maxvz; + config.minfree =3D lvm.minfree; + } + FsType::Zfs(_) =3D> { + let zfs =3D match &answer.disks.zfs { + Some(zfs) =3D> zfs.clone(), + None =3D> answer::ZfsOptions::new(), + }; + let first_selected_disk =3D utils::get_first_selected_disk(&co= nfig); + + config.hdsize =3D zfs + .hdsize + .unwrap_or(runtime_info.disks[first_selected_disk].size); + config.zfs_opts =3D Some(InstallZfsOption { + ashift: zfs.ashift.unwrap_or(12), + compress: match zfs.compress { + Some(answer::ZfsCompressOption::On) =3D> ZfsCompressOp= tion::On, + Some(answer::ZfsCompressOption::Off) =3D> ZfsCompressO= ption::Off, + Some(answer::ZfsCompressOption::Lzjb) =3D> ZfsCompress= Option::Lzjb, + Some(answer::ZfsCompressOption::Lz4) =3D> ZfsCompressO= ption::Lz4, + Some(answer::ZfsCompressOption::Zle) =3D> ZfsCompressO= ption::Zle, + Some(answer::ZfsCompressOption::Gzip) =3D> ZfsCompress= Option::Gzip, + Some(answer::ZfsCompressOption::Zstd) =3D> ZfsCompress= Option::Zstd, + None =3D> ZfsCompressOption::On, + }, + checksum: match zfs.checksum { + Some(answer::ZfsChecksumOption::On) =3D> ZfsChecksumOp= tion::On, + Some(answer::ZfsChecksumOption::Off) =3D> ZfsChecksumO= ption::Off, + Some(answer::ZfsChecksumOption::Fletcher2) =3D> ZfsChe= cksumOption::Fletcher2, + Some(answer::ZfsChecksumOption::Fletcher4) =3D> ZfsChe= cksumOption::Fletcher4, + Some(answer::ZfsChecksumOption::Sha256) =3D> ZfsChecks= umOption::Sha256, + None =3D> ZfsChecksumOption::On, + }, + copies: zfs.copies.unwrap_or(1), + }); + } + FsType::Btrfs(_) =3D> { + let btrfs =3D match &answer.disks.btrfs { + Some(btrfs) =3D> btrfs.clone(), + None =3D> answer::BtrfsOptions::new(), + }; + let first_selected_disk =3D utils::get_first_selected_disk(&co= nfig); + + config.hdsize =3D btrfs + .hdsize + .unwrap_or(runtime_info.disks[first_selected_disk].size); + } + } + Ok(config) +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + use std::fs; + + use super::*; + fn get_test_resource_path() -> Result { + Ok(std::env::current_dir() + .expect("current dir failed") + .join("resources/test")) + } + fn get_answer(path: PathBuf) -> Result { + let answer_raw =3D std::fs::read_to_string(&path).unwrap(); + let answer: answer::Answer =3D toml::from_str(&answer_raw) + .map_err(|err| format!("error parsing answer.toml: {err}")) + .unwrap(); + + Ok(answer) + } + + fn setup_test_basic(path: &PathBuf) -> (RuntimeInfo, UdevInfo, LocaleI= nfo) { + let installer_info: SetupInfo =3D { + let mut path =3D path.clone(); + path.push("iso-info.json"); + + tui::setup::read_json(&path) + .map_err(|err| format!("Failed to retrieve setup info: {er= r}")) + .unwrap() + }; + init_setup_info(installer_info.clone()); + let udev_info: UdevInfo =3D { + let mut path =3D path.clone(); + path.push("run-env-udev.json"); + + tui::setup::read_json(&path) + .map_err(|err| format!("Failed to retrieve udev info detai= ls: {err}")) + .unwrap() + }; + let runtime_info: RuntimeInfo =3D { + let mut path =3D path.clone(); + path.push("run-env-info.json"); + + tui::setup::read_json(&path) + .map_err(|err| format!("Failed to retrieve udev info detai= ls: {err}")) + .unwrap() + }; + let locales: LocaleInfo =3D { + let mut path =3D path.clone(); + path.push("locales.json"); + + tui::setup::read_json(&path) + .map_err(|err| format!("Failed to retrieve locales: {err}"= )) + .unwrap() + }; + (runtime_info, udev_info, locales) + } + + #[test] + fn test_parse_answers() { + let path =3D get_test_resource_path().unwrap(); + let (runtime_info, udev_info, locales) =3D setup_test_basic(&path); + let mut tests_path =3D path.clone(); + tests_path.push("parse_answer"); + let test_dir =3D fs::read_dir(tests_path.clone()).unwrap(); + for file_entry in test_dir { + let file =3D file_entry.unwrap(); + if !file.file_type().unwrap().is_file() || file.file_name() = =3D=3D "readme" { + continue; + } + let p =3D file.path(); + let name =3D p.file_stem().unwrap().to_str().unwrap(); + let extension =3D p.extension().unwrap().to_str().unwrap(); + if extension =3D=3D "toml" { + println!("Test: {name}"); + let answer =3D get_answer(p.clone()).unwrap(); + let config =3D &parse_answer(&answer, &udev_info, &runtime= _info, &locales).unwrap(); + println!("Selected disks: {:#?}", &config.disk_selection); + let config_json =3D serde_json::to_string(config); + let config: Value =3D serde_json::from_str(config_json.unw= rap().as_str()).unwrap(); + let mut path =3D tests_path.clone(); + path.push(format!("{name}.json")); + let compare_raw =3D std::fs::read_to_string(&path).unwrap(= ); + let compare: Value =3D serde_json::from_str(&compare_raw).= unwrap(); + if config !=3D compare { + panic!( + "Test {} failed:\nleft: {:?}\nright: {:?}", + name, config, compare + ); + } + } + } + } +} diff --git a/proxmox-auto-installer/src/tui/mod.rs b/proxmox-auto-installer= /src/tui/mod.rs new file mode 100644 index 0000000..0b7fc39 --- /dev/null +++ b/proxmox-auto-installer/src/tui/mod.rs @@ -0,0 +1,3 @@ +pub mod options; +pub mod setup; +pub mod utils; diff --git a/proxmox-auto-installer/src/tui/options.rs b/proxmox-auto-insta= ller/src/tui/options.rs new file mode 100644 index 0000000..f87584c --- /dev/null +++ b/proxmox-auto-installer/src/tui/options.rs @@ -0,0 +1,302 @@ +use std::net::{IpAddr, Ipv4Addr}; +use std::{cmp, fmt}; + +use serde::Deserialize; + +use crate::tui::setup::NetworkInfo; +use crate::tui::utils::{CidrAddress, Fqdn}; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum BtrfsRaidLevel { + Raid0, + Raid1, + Raid10, +} + +impl fmt::Display for BtrfsRaidLevel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use BtrfsRaidLevel::*; + match self { + Raid0 =3D> write!(f, "RAID0"), + Raid1 =3D> write!(f, "RAID1"), + Raid10 =3D> write!(f, "RAID10"), + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ZfsRaidLevel { + Raid0, + Raid1, + Raid10, + RaidZ, + RaidZ2, + RaidZ3, +} + +impl fmt::Display for ZfsRaidLevel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use ZfsRaidLevel::*; + match self { + Raid0 =3D> write!(f, "RAID0"), + Raid1 =3D> write!(f, "RAID1"), + Raid10 =3D> write!(f, "RAID10"), + RaidZ =3D> write!(f, "RAIDZ-1"), + RaidZ2 =3D> write!(f, "RAIDZ-2"), + RaidZ3 =3D> write!(f, "RAIDZ-3"), + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum FsType { + Ext4, + Xfs, + Zfs(ZfsRaidLevel), + Btrfs(BtrfsRaidLevel), +} + +impl fmt::Display for FsType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use FsType::*; + match self { + Ext4 =3D> write!(f, "ext4"), + Xfs =3D> write!(f, "XFS"), + Zfs(level) =3D> write!(f, "ZFS ({level})"), + Btrfs(level) =3D> write!(f, "Btrfs ({level})"), + } + } +} + +#[derive(Clone, Debug)] +pub struct LvmBootdiskOptions { + pub total_size: f64, + pub swap_size: Option, + pub max_root_size: Option, + pub max_data_size: Option, + pub min_lvm_free: Option, +} + +#[derive(Clone, Debug)] +pub struct BtrfsBootdiskOptions { + pub disk_size: f64, + pub selected_disks: Vec, +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)] +pub enum ZfsCompressOption { + #[default] + On, + Off, + Lzjb, + Lz4, + Zle, + Gzip, + Zstd, +} + +impl fmt::Display for ZfsCompressOption { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", format!("{self:?}").to_lowercase()) + } +} + +impl From<&ZfsCompressOption> for String { + fn from(value: &ZfsCompressOption) -> Self { + value.to_string() + } +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)] +pub enum ZfsChecksumOption { + #[default] + On, + Off, + Fletcher2, + Fletcher4, + Sha256, +} + +impl fmt::Display for ZfsChecksumOption { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", format!("{self:?}").to_lowercase()) + } +} + +impl From<&ZfsChecksumOption> for String { + fn from(value: &ZfsChecksumOption) -> Self { + value.to_string() + } +} + +#[derive(Clone, Debug)] +pub struct ZfsBootdiskOptions { + pub ashift: usize, + pub compress: ZfsCompressOption, + pub checksum: ZfsChecksumOption, + pub copies: usize, + pub disk_size: f64, + pub selected_disks: Vec, +} + +#[derive(Clone, Debug)] +pub enum AdvancedBootdiskOptions { + Lvm(LvmBootdiskOptions), + Zfs(ZfsBootdiskOptions), + Btrfs(BtrfsBootdiskOptions), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Disk { + pub index: String, + pub path: String, + pub model: Option, + pub size: f64, + pub block_size: usize, +} + +impl fmt::Display for Disk { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // TODO: Format sizes properly with `proxmox-human-byte` once merg= ed + // https://lists.proxmox.com/pipermail/pbs-devel/2023-May/006125.h= tml + f.write_str(&self.path)?; + if let Some(model) =3D &self.model { + // FIXME: ellipsize too-long names? + write!(f, " ({model})")?; + } + write!(f, " ({:.2} GiB)", self.size) + } +} + +impl From<&Disk> for String { + fn from(value: &Disk) -> Self { + value.to_string() + } +} + +impl cmp::Eq for Disk {} + +impl cmp::PartialOrd for Disk { + fn partial_cmp(&self, other: &Self) -> Option { + self.index.partial_cmp(&other.index) + } +} + +impl cmp::Ord for Disk { + fn cmp(&self, other: &Self) -> cmp::Ordering { + self.index.cmp(&other.index) + } +} + +#[derive(Clone, Debug)] +pub struct BootdiskOptions { + pub disks: Vec, + pub fstype: FsType, + pub advanced: AdvancedBootdiskOptions, +} + +#[derive(Clone, Debug)] +pub struct TimezoneOptions { + pub country: String, + pub timezone: String, + pub kb_layout: String, +} + +#[derive(Clone, Debug)] +pub struct PasswordOptions { + pub email: String, + pub root_password: String, +} + +impl Default for PasswordOptions { + fn default() -> Self { + Self { + email: "mail@example.invalid".to_string(), + root_password: String::new(), + } + } +} + +#[derive(Clone, Debug)] +pub struct NetworkOptions { + pub ifname: String, + pub fqdn: Fqdn, + pub address: CidrAddress, + pub gateway: IpAddr, + pub dns_server: IpAddr, +} + +impl Default for NetworkOptions { + fn default() -> Self { + let fqdn =3D format!( + "{}.example.invalid", + crate::current_product().default_hostname() + ); + // TODO: Retrieve automatically + Self { + ifname: String::new(), + fqdn: fqdn.parse().unwrap(), + // Safety: The provided mask will always be valid. + address: CidrAddress::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(), + gateway: Ipv4Addr::UNSPECIFIED.into(), + dns_server: Ipv4Addr::UNSPECIFIED.into(), + } + } +} + +impl From<&NetworkInfo> for NetworkOptions { + fn from(info: &NetworkInfo) -> Self { + let mut this =3D Self::default(); + + if let Some(ip) =3D info.dns.dns.first() { + this.dns_server =3D *ip; + } + + if let Some(domain) =3D &info.dns.domain { + let hostname =3D crate::current_product().default_hostname(); + if let Ok(fqdn) =3D Fqdn::from(&format!("{hostname}.{domain}")= ) { + this.fqdn =3D fqdn; + } + } + + if let Some(routes) =3D &info.routes { + let mut filled =3D false; + if let Some(gw) =3D &routes.gateway4 { + if let Some(iface) =3D info.interfaces.get(&gw.dev) { + this.ifname =3D iface.name.clone(); + if let Some(addresses) =3D &iface.addresses { + if let Some(addr) =3D addresses.iter().find(|addr|= addr.is_ipv4()) { + this.gateway =3D gw.gateway; + this.address =3D addr.clone(); + filled =3D true; + } + } + } + } + if !filled { + if let Some(gw) =3D &routes.gateway6 { + if let Some(iface) =3D info.interfaces.get(&gw.dev) { + if let Some(addresses) =3D &iface.addresses { + if let Some(addr) =3D addresses.iter().find(|a= ddr| addr.is_ipv6()) { + this.ifname =3D iface.name.clone(); + this.gateway =3D gw.gateway; + this.address =3D addr.clone(); + } + } + } + } + } + } + + this + } +} + +#[derive(Clone, Debug)] +pub struct InstallerOptions { + pub bootdisk: BootdiskOptions, + pub timezone: TimezoneOptions, + pub password: PasswordOptions, + pub network: NetworkOptions, + pub autoreboot: bool, +} diff --git a/proxmox-auto-installer/src/tui/setup.rs b/proxmox-auto-install= er/src/tui/setup.rs new file mode 100644 index 0000000..c4523f2 --- /dev/null +++ b/proxmox-auto-installer/src/tui/setup.rs @@ -0,0 +1,447 @@ +use std::{ + cmp, + collections::{BTreeMap, HashMap}, + fmt, + fs::File, + io::BufReader, + net::IpAddr, + path::{Path, PathBuf}, +}; + +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; + +use crate::tui::{ + options::{ + AdvancedBootdiskOptions, BtrfsRaidLevel, Disk, FsType, InstallerOp= tions, + ZfsBootdiskOptions, ZfsChecksumOption, ZfsCompressOption, ZfsRaidL= evel, + }, + utils::CidrAddress, +}; + +#[allow(clippy::upper_case_acronyms)] +#[derive(Clone, Copy, Deserialize, PartialEq, Debug)] +#[serde(rename_all =3D "lowercase")] +pub enum ProxmoxProduct { + PVE, + PBS, + PMG, +} + +impl ProxmoxProduct { + pub fn default_hostname(self) -> &'static str { + match self { + Self::PVE =3D> "pve", + Self::PMG =3D> "pmg", + Self::PBS =3D> "pbs", + } + } +} + +#[derive(Clone, Deserialize, Debug)] +pub struct ProductConfig { + pub fullname: String, + pub product: ProxmoxProduct, + #[serde(deserialize_with =3D "deserialize_bool_from_int")] + pub enable_btrfs: bool, +} + +#[derive(Clone, Deserialize, Debug)] +pub struct IsoInfo { + pub release: String, + pub isorelease: String, +} + +/// Paths in the ISO environment containing installer data. +#[derive(Clone, Deserialize, Debug)] +pub struct IsoLocations { + pub iso: PathBuf, +} + +#[derive(Clone, Deserialize, Debug)] +pub struct SetupInfo { + #[serde(rename =3D "product-cfg")] + pub config: ProductConfig, + #[serde(rename =3D "iso-info")] + pub iso_info: IsoInfo, + pub locations: IsoLocations, +} + +#[derive(Clone, Deserialize, Debug)] +pub struct CountryInfo { + pub name: String, + #[serde(default)] + pub zone: String, + pub kmap: String, +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Debug)] +pub struct KeyboardMapping { + pub name: String, + #[serde(rename =3D "kvm")] + pub id: String, + #[serde(rename =3D "x11")] + pub xkb_layout: String, + #[serde(rename =3D "x11var")] + pub xkb_variant: String, +} + +impl cmp::PartialOrd for KeyboardMapping { + fn partial_cmp(&self, other: &Self) -> Option { + self.name.partial_cmp(&other.name) + } +} + +impl cmp::Ord for KeyboardMapping { + fn cmp(&self, other: &Self) -> cmp::Ordering { + self.name.cmp(&other.name) + } +} + +#[derive(Clone, Deserialize, Debug)] +pub struct LocaleInfo { + #[serde(deserialize_with =3D "deserialize_cczones_map")] + pub cczones: HashMap>, + #[serde(rename =3D "country")] + pub countries: HashMap, + pub kmap: HashMap, +} + +#[derive(Serialize)] +pub struct InstallZfsOption { + pub ashift: usize, + #[serde(serialize_with =3D "serialize_as_display")] + pub compress: ZfsCompressOption, + #[serde(serialize_with =3D "serialize_as_display")] + pub checksum: ZfsChecksumOption, + pub copies: usize, +} + +impl From for InstallZfsOption { + fn from(opts: ZfsBootdiskOptions) -> Self { + InstallZfsOption { + ashift: opts.ashift, + compress: opts.compress, + checksum: opts.checksum, + copies: opts.copies, + } + } +} + +/// See Proxmox::Install::Config +#[derive(Serialize)] +pub struct InstallConfig { + pub autoreboot: usize, + + #[serde(serialize_with =3D "serialize_fstype")] + pub filesys: FsType, + pub hdsize: f64, + #[serde(skip_serializing_if =3D "Option::is_none")] + pub swapsize: Option, + #[serde(skip_serializing_if =3D "Option::is_none")] + pub maxroot: Option, + #[serde(skip_serializing_if =3D "Option::is_none")] + pub minfree: Option, + #[serde(skip_serializing_if =3D "Option::is_none")] + pub maxvz: Option, + + #[serde(skip_serializing_if =3D "Option::is_none")] + pub zfs_opts: Option, + + #[serde( + serialize_with =3D "serialize_disk_opt", + skip_serializing_if =3D "Option::is_none" + )] + pub target_hd: Option, + #[serde(skip_serializing_if =3D "BTreeMap::is_empty")] + pub disk_selection: BTreeMap, + + pub country: String, + pub timezone: String, + pub keymap: String, + + pub password: String, + pub mailto: String, + + pub mngmt_nic: String, + + pub hostname: String, + pub domain: String, + #[serde(serialize_with =3D "serialize_as_display")] + pub cidr: CidrAddress, + pub gateway: IpAddr, + pub dns: IpAddr, +} + +impl From for InstallConfig { + fn from(options: InstallerOptions) -> Self { + let mut config =3D Self { + autoreboot: options.autoreboot as usize, + + filesys: options.bootdisk.fstype, + hdsize: 0., + swapsize: None, + maxroot: None, + minfree: None, + maxvz: None, + zfs_opts: None, + target_hd: None, + disk_selection: BTreeMap::new(), + + country: options.timezone.country, + timezone: options.timezone.timezone, + keymap: options.timezone.kb_layout, + + password: options.password.root_password, + mailto: options.password.email, + + mngmt_nic: options.network.ifname, + + hostname: options + .network + .fqdn + .host() + .unwrap_or_else(|| crate::current_product().default_hostna= me()) + .to_owned(), + domain: options.network.fqdn.domain(), + cidr: options.network.address, + gateway: options.network.gateway, + dns: options.network.dns_server, + }; + + match &options.bootdisk.advanced { + AdvancedBootdiskOptions::Lvm(lvm) =3D> { + config.hdsize =3D lvm.total_size; + config.target_hd =3D Some(options.bootdisk.disks[0].clone(= )); + config.swapsize =3D lvm.swap_size; + config.maxroot =3D lvm.max_root_size; + config.minfree =3D lvm.min_lvm_free; + config.maxvz =3D lvm.max_data_size; + } + AdvancedBootdiskOptions::Zfs(zfs) =3D> { + config.hdsize =3D zfs.disk_size; + config.zfs_opts =3D Some(zfs.clone().into()); + + for (i, disk) in options.bootdisk.disks.iter().enumerate()= { + config + .disk_selection + .insert(i.to_string(), disk.index.clone()); + } + } + AdvancedBootdiskOptions::Btrfs(btrfs) =3D> { + config.hdsize =3D btrfs.disk_size; + + for (i, disk) in options.bootdisk.disks.iter().enumerate()= { + config + .disk_selection + .insert(i.to_string(), disk.index.clone()); + } + } + } + + config + } +} + +pub fn read_json Deserialize<'de>, P: AsRef>(path: P) ->= Result { + let file =3D File::open(path).map_err(|err| err.to_string())?; + let reader =3D BufReader::new(file); + + serde_json::from_reader(reader).map_err(|err| format!("failed to parse= JSON: {err}")) +} + +fn deserialize_bool_from_int<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let val: u32 =3D Deserialize::deserialize(deserializer)?; + Ok(val !=3D 0) +} + +fn deserialize_cczones_map<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let map: HashMap> =3D Deserialize::deseri= alize(deserializer)?; + + let mut result =3D HashMap::new(); + for (cc, list) in map.into_iter() { + result.insert(cc, list.into_keys().collect()); + } + + Ok(result) +} + +fn deserialize_disks_map<'de, D>(deserializer: D) -> Result, D::= Error> +where + D: Deserializer<'de>, +{ + let disks =3D >::dese= rialize(deserializer)?; + Ok(disks + .into_iter() + .map( + |(index, device, size_mb, model, logical_bsize, _syspath)| Dis= k { + index: index.to_string(), + size: (size_mb * logical_bsize as f64) / 1024. / 1024. / 1= 024., + block_size: logical_bsize, + path: device, + model: (!model.is_empty()).then_some(model), + }, + ) + .collect()) +} + +fn deserialize_cidr_list<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + struct CidrDescriptor { + address: String, + prefix: usize, + // family is implied anyway by parsing the address + } + + let list: Vec =3D Deserialize::deserialize(deserialize= r)?; + + let mut result =3D Vec::with_capacity(list.len()); + for desc in list { + let ip_addr =3D desc + .address + .parse::() + .map_err(|err| de::Error::custom(format!("{:?}", err)))?; + + result.push( + CidrAddress::new(ip_addr, desc.prefix) + .map_err(|err| de::Error::custom(format!("{:?}", err)))?, + ); + } + + Ok(Some(result)) +} + +fn serialize_disk_opt(value: &Option, serializer: S) -> Result +where + S: Serializer, +{ + if let Some(disk) =3D value { + serializer.serialize_str(&disk.path) + } else { + serializer.serialize_none() + } +} + +fn serialize_fstype(value: &FsType, serializer: S) -> Result +where + S: Serializer, +{ + use FsType::*; + let value =3D match value { + // proxinstall::$fssetup + Ext4 =3D> "ext4", + Xfs =3D> "xfs", + // proxinstall::get_zfs_raid_setup() + Zfs(ZfsRaidLevel::Raid0) =3D> "zfs (RAID0)", + Zfs(ZfsRaidLevel::Raid1) =3D> "zfs (RAID1)", + Zfs(ZfsRaidLevel::Raid10) =3D> "zfs (RAID10)", + Zfs(ZfsRaidLevel::RaidZ) =3D> "zfs (RAIDZ-1)", + Zfs(ZfsRaidLevel::RaidZ2) =3D> "zfs (RAIDZ-2)", + Zfs(ZfsRaidLevel::RaidZ3) =3D> "zfs (RAIDZ-3)", + // proxinstall::get_btrfs_raid_setup() + Btrfs(BtrfsRaidLevel::Raid0) =3D> "btrfs (RAID0)", + Btrfs(BtrfsRaidLevel::Raid1) =3D> "btrfs (RAID1)", + Btrfs(BtrfsRaidLevel::Raid10) =3D> "btrfs (RAID10)", + }; + + serializer.collect_str(value) +} + +fn serialize_as_display(value: &T, serializer: S) -> Result +where + S: Serializer, + T: fmt::Display, +{ + serializer.collect_str(value) +} + +#[derive(Clone, Deserialize, Debug)] +pub struct RuntimeInfo { + /// Whether is system was booted in (legacy) BIOS or UEFI mode. + pub boot_type: BootType, + + /// Detected country if available. + pub country: Option, + + /// Maps devices to their information. + #[serde(deserialize_with =3D "deserialize_disks_map")] + pub disks: Vec, + + /// Network addresses, gateways and DNS info. + pub network: NetworkInfo, + + /// Total memory of the system in MiB. + pub total_memory: usize, + + /// Whether the CPU supports hardware-accelerated virtualization + #[serde(deserialize_with =3D "deserialize_bool_from_int")] + pub hvm_supported: bool, +} + +#[derive(Clone, Eq, Deserialize, PartialEq, Debug)] +#[serde(rename_all =3D "lowercase")] +pub enum BootType { + Bios, + Efi, +} + +#[derive(Clone, Deserialize, Debug)] +pub struct NetworkInfo { + pub dns: Dns, + pub routes: Option, + + /// Maps devices to their configuration, if it has a usable configurat= ion. + /// (Contains no entries for devices with only link-local addresses.) + #[serde(default)] + pub interfaces: BTreeMap, +} + +#[derive(Clone, Deserialize, Debug)] +pub struct Dns { + pub domain: Option, + + /// List of stringified IP addresses. + #[serde(default)] + pub dns: Vec, +} + +#[derive(Clone, Deserialize, Debug)] +pub struct Routes { + /// Ipv4 gateway. + pub gateway4: Option, + + /// Ipv6 gateway. + pub gateway6: Option, +} + +#[derive(Clone, Deserialize, Debug)] +pub struct Gateway { + /// Outgoing network device. + pub dev: String, + + /// Stringified gateway IP address. + pub gateway: IpAddr, +} + +#[derive(Clone, Deserialize, Debug)] +pub struct Interface { + pub name: String, + + pub index: usize, + + pub mac: String, + + #[serde(default)] + #[serde(deserialize_with =3D "deserialize_cidr_list")] + pub addresses: Option>, +} diff --git a/proxmox-auto-installer/src/tui/utils.rs b/proxmox-auto-install= er/src/tui/utils.rs new file mode 100644 index 0000000..516f9c6 --- /dev/null +++ b/proxmox-auto-installer/src/tui/utils.rs @@ -0,0 +1,268 @@ +use std::{ + fmt, + net::{AddrParseError, IpAddr}, + num::ParseIntError, + str::FromStr, +}; + +use serde::Deserialize; + +/// Possible errors that might occur when parsing CIDR addresses. +#[derive(Debug)] +pub enum CidrAddressParseError { + /// No delimiter for separating address and mask was found. + NoDelimiter, + /// The IP address part could not be parsed. + InvalidAddr(AddrParseError), + /// The mask could not be parsed. + InvalidMask(Option), +} + +/// An IP address (IPv4 or IPv6), including network mask. +/// +/// See the [`IpAddr`] type for more information how IP addresses are hand= led. +/// The mask is appropriately enforced to be `0 <=3D mask <=3D 32` for IPv= 4 or +/// `0 <=3D mask <=3D 128` for IPv6 addresses. +/// +/// # Examples +/// ``` +/// use std::net::{Ipv4Addr, Ipv6Addr}; +/// let ipv4 =3D CidrAddress::new(Ipv4Addr::new(192, 168, 0, 1), 24).unwra= p(); +/// let ipv6 =3D CidrAddress::new(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0,= 0xc0a8, 1), 32).unwrap(); +/// +/// assert_eq!(ipv4.to_string(), "192.168.0.1/24"); +/// assert_eq!(ipv6.to_string(), "2001:db8::c0a8:1/32"); +/// ``` +#[derive(Clone, Debug)] +pub struct CidrAddress { + addr: IpAddr, + mask: usize, +} + +impl CidrAddress { + /// Constructs a new CIDR address. + /// + /// It fails if the mask is invalid for the given IP address. + pub fn new>(addr: T, mask: usize) -> Result { + let addr =3D addr.into(); + + if mask > mask_limit(&addr) { + Err(CidrAddressParseError::InvalidMask(None)) + } else { + Ok(Self { addr, mask }) + } + } + + /// Returns only the IP address part of the address. + pub fn addr(&self) -> IpAddr { + self.addr + } + + /// Returns `true` if this address is an IPv4 address, `false` otherwi= se. + pub fn is_ipv4(&self) -> bool { + self.addr.is_ipv4() + } + + /// Returns `true` if this address is an IPv6 address, `false` otherwi= se. + pub fn is_ipv6(&self) -> bool { + self.addr.is_ipv6() + } + + /// Returns only the mask part of the address. + pub fn mask(&self) -> usize { + self.mask + } +} + +impl FromStr for CidrAddress { + type Err =3D CidrAddressParseError; + + fn from_str(s: &str) -> Result { + let (addr, mask) =3D s + .split_once('/') + .ok_or(CidrAddressParseError::NoDelimiter)?; + + let addr =3D addr.parse().map_err(CidrAddressParseError::InvalidAd= dr)?; + + let mask =3D mask + .parse() + .map_err(|err| CidrAddressParseError::InvalidMask(Some(err)))?; + + if mask > mask_limit(&addr) { + Err(CidrAddressParseError::InvalidMask(None)) + } else { + Ok(Self { addr, mask }) + } + } +} + +impl fmt::Display for CidrAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.addr, self.mask) + } +} + +fn mask_limit(addr: &IpAddr) -> usize { + if addr.is_ipv4() { + 32 + } else { + 128 + } +} + +/// Possible errors that might occur when parsing FQDNs. +#[derive(Debug, Eq, PartialEq)] +pub enum FqdnParseError { + MissingHostname, + NumericHostname, + InvalidPart(String), +} + +impl fmt::Display for FqdnParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use FqdnParseError::*; + match self { + MissingHostname =3D> write!(f, "missing hostname part"), + NumericHostname =3D> write!(f, "hostname cannot be purely nume= ric"), + InvalidPart(part) =3D> write!( + f, + "FQDN must only consist of alphanumeric characters and das= hes. Invalid part: '{part}'", + ), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Fqdn { + parts: Vec, +} + +impl Fqdn { + pub fn from(fqdn: &str) -> Result { + let parts =3D fqdn + .split('.') + .map(ToOwned::to_owned) + .collect::>(); + + for part in &parts { + if !Self::validate_single(part) { + return Err(FqdnParseError::InvalidPart(part.clone())); + } + } + + if parts.len() < 2 { + Err(FqdnParseError::MissingHostname) + } else if parts[0].chars().all(|c| c.is_ascii_digit()) { + // Not allowed/supported on Debian systems. + Err(FqdnParseError::NumericHostname) + } else { + Ok(Self { parts }) + } + } + + pub fn host(&self) -> Option<&str> { + self.has_host().then_some(&self.parts[0]) + } + + pub fn domain(&self) -> String { + let parts =3D if self.has_host() { + &self.parts[1..] + } else { + &self.parts + }; + + parts.join(".") + } + + /// Checks whether the FQDN has a hostname associated with it, i.e. is= has more than 1 part. + fn has_host(&self) -> bool { + self.parts.len() > 1 + } + + fn validate_single(s: &String) -> bool { + !s.is_empty() + // First character must be alphanumeric + && s.chars() + .next() + .map(|c| c.is_ascii_alphanumeric()) + .unwrap_or_default() + // .. last character as well, + && s.chars() + .last() + .map(|c| c.is_ascii_alphanumeric()) + .unwrap_or_default() + // and anything between must be alphanumeric or - + && s.chars() + .skip(1) + .take(s.len().saturating_sub(2)) + .all(|c| c.is_ascii_alphanumeric() || c =3D=3D '-') + } +} + +impl FromStr for Fqdn { + type Err =3D FqdnParseError; + + fn from_str(value: &str) -> Result { + Self::from(value) + } +} + +impl fmt::Display for Fqdn { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.parts.join(".")) + } +} + +impl<'de> Deserialize<'de> for Fqdn { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String =3D Deserialize::deserialize(deserializer)?; + s.parse() + .map_err(|_| serde::de::Error::custom("invalid FQDN")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fqdn_construct() { + use FqdnParseError::*; + assert!(Fqdn::from("foo.example.com").is_ok()); + assert!(Fqdn::from("foo-bar.com").is_ok()); + assert!(Fqdn::from("a-b.com").is_ok()); + + assert_eq!(Fqdn::from("foo"), Err(MissingHostname)); + + assert_eq!(Fqdn::from("-foo.com"), Err(InvalidPart("-foo".to_owned= ()))); + assert_eq!(Fqdn::from("foo-.com"), Err(InvalidPart("foo-".to_owned= ()))); + assert_eq!(Fqdn::from("foo.com-"), Err(InvalidPart("com-".to_owned= ()))); + assert_eq!(Fqdn::from("-o-.com"), Err(InvalidPart("-o-".to_owned()= ))); + + assert_eq!(Fqdn::from("123.com"), Err(NumericHostname)); + assert!(Fqdn::from("foo123.com").is_ok()); + assert!(Fqdn::from("123foo.com").is_ok()); + } + + #[test] + fn fqdn_parts() { + let fqdn =3D Fqdn::from("pve.example.com").unwrap(); + assert_eq!(fqdn.host().unwrap(), "pve"); + assert_eq!(fqdn.domain(), "example.com"); + assert_eq!( + fqdn.parts, + &["pve".to_owned(), "example".to_owned(), "com".to_owned()] + ); + } + + #[test] + fn fqdn_display() { + assert_eq!( + Fqdn::from("foo.example.com").unwrap().to_string(), + "foo.example.com" + ); + } +} diff --git a/proxmox-auto-installer/src/udevinfo.rs b/proxmox-auto-installe= r/src/udevinfo.rs new file mode 100644 index 0000000..a6b61b5 --- /dev/null +++ b/proxmox-auto-installer/src/udevinfo.rs @@ -0,0 +1,9 @@ +use serde::Deserialize; +use std::collections::BTreeMap; + +#[derive(Clone, Deserialize, Debug)] +pub struct UdevInfo { + // use BTreeMap to have keys sorted + pub disks: BTreeMap>, + pub nics: BTreeMap>, +} diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/s= rc/utils.rs new file mode 100644 index 0000000..6ff78ac --- /dev/null +++ b/proxmox-auto-installer/src/utils.rs @@ -0,0 +1,325 @@ +use std::collections::BTreeMap; +use std::net::IpAddr; +use std::str::FromStr; +use std::process::Command; + +use crate::answer::{Answer, FilterMatch}; +use crate::tui::options::{FsType, NetworkOptions}; +use crate::tui::setup::{InstallConfig, LocaleInfo, RuntimeInfo}; +use crate::tui::utils::{CidrAddress, Fqdn}; +use crate::udevinfo::UdevInfo; + +/// Supports the globbing character '*' at the beginning, end or both of t= he pattern. +/// Globbing within the pattern is not supported +fn find_with_glob(pattern: &str, value: &str) -> bool { + let globbing_symbol =3D '*'; + let mut start_glob =3D false; + let mut end_glob =3D false; + let mut pattern =3D pattern; + + if pattern.starts_with(globbing_symbol) { + start_glob =3D true; + pattern =3D &pattern[1..]; + } + + if pattern.ends_with(globbing_symbol) { + end_glob =3D true; + pattern =3D &pattern[..pattern.len() - 1] + } + + match (start_glob, end_glob) { + (true, true) =3D> value.contains(pattern), + (true, false) =3D> value.ends_with(pattern), + (false, true) =3D> value.starts_with(pattern), + _ =3D> value =3D=3D pattern, + } +} + +pub fn get_network_settings( + answer: &Answer, + udev_info: &UdevInfo, + runtime_info: &RuntimeInfo, +) -> Result { + let mut network_options =3D NetworkOptions::from(&runtime_info.network= ); + + // Always use the FQDN from the answer file + network_options.fqdn =3D Fqdn::from(answer.global.fqdn.as_str()).expec= t("Error parsing FQDN"); + + if answer.network.use_dhcp.is_none() || !answer.network.use_dhcp.unwra= p() { + network_options.address =3D CidrAddress::from_str( + answer + .network + .cidr + .clone() + .expect("No CIDR defined") + .as_str(), + ) + .expect("Error parsing CIDR"); + network_options.dns_server =3D IpAddr::from_str( + answer + .network + .dns + .clone() + .expect("No DNS server defined") + .as_str(), + ) + .expect("Error parsing DNS server"); + network_options.gateway =3D IpAddr::from_str( + answer + .network + .gateway + .clone() + .expect("No gateway defined") + .as_str(), + ) + .expect("Error parsing gateway"); + network_options.ifname =3D + get_single_udev_index(answer.network.filter.clone().unwrap(), = &udev_info.nics)? + } + + Ok(network_options) +} + +fn get_single_udev_index( + filter: BTreeMap, + udev_list: &BTreeMap>, +) -> Result { + if filter.is_empty() { + return Err(String::from("no filter defined")); + } + let mut dev_index: Option =3D None; + 'outer: for (dev, dev_values) in udev_list { + for (filter_key, filter_value) in &filter { + for (udev_key, udev_value) in dev_values { + if udev_key =3D=3D filter_key && find_with_glob(filter_val= ue, udev_value) { + dev_index =3D Some(dev.clone()); + break 'outer; // take first match + } + } + } + } + if dev_index.is_none() { + return Err(String::from("filter did not match any device")); + } + + Ok(dev_index.unwrap()) +} + +fn get_matched_udev_indexes( + filter: BTreeMap, + udev_list: &BTreeMap>, + match_all: bool, +) -> Result, String> { + let mut matches =3D vec![]; + for (dev, dev_values) in udev_list { + let mut did_match_once =3D false; + let mut did_match_all =3D true; + for (filter_key, filter_value) in &filter { + for (udev_key, udev_value) in dev_values { + if udev_key =3D=3D filter_key && find_with_glob(filter_val= ue, udev_value) { + did_match_once =3D true; + } else if udev_key =3D=3D filter_key { + did_match_all =3D false; + } + } + } + if (match_all && did_match_all) || (!match_all && did_match_once) { + matches.push(dev.clone()); + } + } + if matches.is_empty() { + return Err(String::from("filter did not match any devices")); + } + matches.sort(); + Ok(matches) +} + +pub fn set_disks( + answer: &Answer, + udev_info: &UdevInfo, + runtime_info: &RuntimeInfo, + config: &mut InstallConfig, +) -> Result<(), String> { + match config.filesys { + FsType::Ext4 | FsType::Xfs =3D> set_single_disk(answer, udev_info,= runtime_info, config), + FsType::Zfs(_) | FsType::Btrfs(_) =3D> { + set_selected_disks(answer, udev_info, runtime_info, config) + } + } +} + +fn set_single_disk( + answer: &Answer, + udev_info: &UdevInfo, + runtime_info: &RuntimeInfo, + config: &mut InstallConfig, +) -> Result<(), String> { + match &answer.disks.disk_selection { + Some(selection) =3D> { + let disk_name =3D selection[0].clone(); + let disk =3D runtime_info + .disks + .iter() + .find(|item| item.path.ends_with(disk_name.as_str())); + match disk { + Some(disk) =3D> config.target_hd =3D Some(disk.clone()), + None =3D> return Err("disk in 'disk_selection' not found".= to_string()), + } + } + None =3D> { + let disk_index =3D + get_single_udev_index(answer.disks.filter.clone().unwrap()= , &udev_info.disks)?; + let disk =3D runtime_info + .disks + .iter() + .find(|item| item.index =3D=3D disk_index); + config.target_hd =3D disk.cloned(); + } + } + Ok(()) +} + +fn set_selected_disks( + answer: &Answer, + udev_info: &UdevInfo, + runtime_info: &RuntimeInfo, + config: &mut InstallConfig, +) -> Result<(), String> { + match &answer.disks.disk_selection { + Some(selection) =3D> { + for disk_name in selection { + let disk =3D runtime_info + .disks + .iter() + .find(|item| item.path.ends_with(disk_name.as_str())); + if let Some(disk) =3D disk { + config + .disk_selection + .insert(disk.index.clone(), disk.index.clone()); + } + } + } + None =3D> { + let filter_match =3D answer + .disks + .filter_match + .clone() + .unwrap_or(FilterMatch::Any); + let selected_disk_indexes =3D get_matched_udev_indexes( + answer.disks.filter.clone().unwrap(), + &udev_info.disks, + filter_match =3D=3D FilterMatch::All, + )?; + + for i in selected_disk_indexes.into_iter() { + let disk =3D runtime_info + .disks + .iter() + .find(|item| item.index =3D=3D i) + .unwrap(); + config + .disk_selection + .insert(disk.index.clone(), disk.index.clone()); + } + } + } + if config.disk_selection.is_empty() { + return Err("No disks found matching selection.".to_string()); + } + Ok(()) +} + +pub fn get_first_selected_disk(config: &InstallConfig) -> usize { + config + .disk_selection + .iter() + .next() + .expect("no disks found") + .0 + .parse::() + .expect("could not parse key to usize") +} + +pub fn verify_locale_settings(answer: &Answer, locales: &LocaleInfo) -> Re= sult<(), String> { + if !locales + .countries + .keys() + .any(|i| i =3D=3D &answer.global.country) + { + return Err(format!( + "country code '{}' is not valid", + &answer.global.country + )); + } + if !locales.kmap.keys().any(|i| i =3D=3D &answer.global.keyboard) { + return Err(format!( + "keyboard layout '{}' is not valid", + &answer.global.keyboard + )); + } + if !locales + .cczones + .iter() + .any(|(_, zones)| zones.contains(&answer.global.timezone)) + { + return Err(format!( + "timezone '{}' is not valid", + &answer.global.timezone + )); + } + Ok(()) +} + +pub fn run_cmds(step: &str, cmd_vec: &Option>) -> Result<(), S= tring> { + if let Some(cmds) =3D cmd_vec { + if !cmds.is_empty() { + println!("Running {step}-Commands:"); + run_cmd(cmds)?; + println!("{step}-Commands finished"); + } + } + Ok(()) +} + +fn run_cmd(cmds: &Vec) -> Result<(), String> { + for cmd in cmds { + #[cfg(debug_assertions)] + println!("Would run command '{}'", cmd); + +// #[cfg(not(debug_assertions))] + { + println!("Command '{}':", cmd); + let output =3D Command::new("/bin/bash") + .arg("-c") + .arg(cmd.clone()) + .output() + .map_err(|err| format!("error running command {}: {err}", = cmd))?; + print!( + "{}", + String::from_utf8(output.stdout).map_err(|err| format!("{e= rr}"))? + ); + print!( + "{}", + String::from_utf8(output.stderr).map_err(|err| format!("{e= rr}"))? + ); + if !output.status.success() { + return Err("command failed".to_string()); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_glob_patterns() { + let test_value =3D "foobar"; + assert_eq!(find_with_glob("*bar", test_value), true); + assert_eq!(find_with_glob("foo*", test_value), true); + assert_eq!(find_with_glob("foobar", test_value), true); + assert_eq!(find_with_glob("oobar", test_value), false); + } +} --=20 2.39.2