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 37F099D8C4 for ; Fri, 27 Oct 2023 13:14:11 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 198CE3855B for ; Fri, 27 Oct 2023 13:14:11 +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 ; Fri, 27 Oct 2023 13:14:08 +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 1FE1042506 for ; Fri, 27 Oct 2023 13:14:08 +0200 (CEST) Date: Fri, 27 Oct 2023 13:14:03 +0200 From: Christoph Heiss To: Aaron Lauterer Cc: Proxmox VE development discussion Message-ID: References: <20231025160011.3617524-1-a.lauterer@proxmox.com> <20231025160011.3617524-3-a.lauterer@proxmox.com> MIME-Version: 1.0 Content-Type: text/plain; charset=us-ascii Content-Disposition: inline In-Reply-To: <20231025160011.3617524-3-a.lauterer@proxmox.com> X-SPAM-LEVEL: Spam detection results: 0 AWL -0.020 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 URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [proxmox.com, 123foo.com, options.rs, a-b.com, lib.rs, foo-bar.com, self.parts, 123.com, foo123.com, setup.rs, utils.rs] Subject: Re: [pve-devel] [PATCH 02/12] common: copy common code from tui-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: Fri, 27 Oct 2023 11:14:11 -0000 Simple code move/copy, so LGTM. Reviewed-by: Christoph Heiss On Wed, Oct 25, 2023 at 06:00:01PM +0200, Aaron Lauterer wrote: > > Copy code that is common to its own crate. > > Signed-off-by: Aaron Lauterer > --- > proxmox-installer-common/Cargo.toml | 2 + > proxmox-installer-common/src/disk_checks.rs | 237 ++++++++++++ > proxmox-installer-common/src/lib.rs | 4 + > proxmox-installer-common/src/options.rs | 387 ++++++++++++++++++++ > proxmox-installer-common/src/setup.rs | 330 +++++++++++++++++ > proxmox-installer-common/src/utils.rs | 268 ++++++++++++++ > 6 files changed, 1228 insertions(+) > create mode 100644 proxmox-installer-common/src/disk_checks.rs > create mode 100644 proxmox-installer-common/src/options.rs > create mode 100644 proxmox-installer-common/src/setup.rs > create mode 100644 proxmox-installer-common/src/utils.rs > > diff --git a/proxmox-installer-common/Cargo.toml b/proxmox-installer-common/Cargo.toml > index b8762e8..bde5457 100644 > --- a/proxmox-installer-common/Cargo.toml > +++ b/proxmox-installer-common/Cargo.toml > @@ -8,3 +8,5 @@ exclude = [ "build", "debian" ] > homepage = "https://www.proxmox.com" > > [dependencies] > +serde = { version = "1.0", features = ["derive"] } > +serde_json = "1.0" > diff --git a/proxmox-installer-common/src/disk_checks.rs b/proxmox-installer-common/src/disk_checks.rs > new file mode 100644 > index 0000000..15b5928 > --- /dev/null > +++ b/proxmox-installer-common/src/disk_checks.rs > @@ -0,0 +1,237 @@ > +use std::collections::HashSet; > + > +use crate::options::{BtrfsRaidLevel, Disk, ZfsRaidLevel}; > +use crate::setup::BootType; > + > +/// Checks a list of disks for duplicate entries, using their index as key. > +/// > +/// # Arguments > +/// > +/// * `disks` - A list of disks to check for duplicates. > +fn check_for_duplicate_disks(disks: &[Disk]) -> Result<(), &Disk> { > + let mut set = HashSet::new(); > + > + for disk in disks { > + if !set.insert(&disk.index) { > + return Err(disk); > + } > + } > + > + Ok(()) > +} > + > +/// Simple wrapper which returns an descriptive error if the list of disks is too short. > +/// > +/// # Arguments > +/// > +/// * `disks` - A list of disks to check the lenght of. > +/// * `min` - Minimum number of disks > +fn check_raid_min_disks(disks: &[Disk], min: usize) -> Result<(), String> { > + if disks.len() < min { > + Err(format!("Need at least {min} disks")) > + } else { > + Ok(()) > + } > +} > + > +/// Checks all disks for legacy BIOS boot compatibility and reports an error as appropriate. 4Kn > +/// disks are generally broken with legacy BIOS and cannot be booted from. > +/// > +/// # Arguments > +/// > +/// * `runinfo` - `RuntimeInfo` instance of currently running system > +/// * `disks` - List of disks designated as bootdisk targets. > +fn check_disks_4kn_legacy_boot(boot_type: BootType, disks: &[Disk]) -> Result<(), &str> { > + let is_blocksize_4096 = |disk: &Disk| disk.block_size.map(|s| s == 4096).unwrap_or(false); > + > + if boot_type == BootType::Bios && disks.iter().any(is_blocksize_4096) { > + return Err("Booting from 4Kn drive in legacy BIOS mode is not supported."); > + } > + > + Ok(()) > +} > + > +/// Checks whether a user-supplied ZFS RAID setup is valid or not, such as disk sizes andminimum > +/// number of disks. > +/// > +/// # Arguments > +/// > +/// * `level` - The targeted ZFS RAID level by the user. > +/// * `disks` - List of disks designated as RAID targets. > +fn check_zfs_raid_config(level: ZfsRaidLevel, disks: &[Disk]) -> Result<(), String> { > + // See also Proxmox/Install.pm:get_zfs_raid_setup() > + > + let check_mirror_size = |disk1: &Disk, disk2: &Disk| { > + if (disk1.size - disk2.size).abs() > disk1.size / 10. { > + Err(format!( > + "Mirrored disks must have same size:\n\n * {disk1}\n * {disk2}" > + )) > + } else { > + Ok(()) > + } > + }; > + > + match level { > + ZfsRaidLevel::Raid0 => check_raid_min_disks(disks, 1)?, > + ZfsRaidLevel::Raid1 => { > + check_raid_min_disks(disks, 2)?; > + for disk in disks { > + check_mirror_size(&disks[0], disk)?; > + } > + } > + ZfsRaidLevel::Raid10 => { > + check_raid_min_disks(disks, 4)?; > + // Pairs need to have the same size > + for i in (0..disks.len()).step_by(2) { > + check_mirror_size(&disks[i], &disks[i + 1])?; > + } > + } > + // For RAID-Z: minimum disks number is level + 2 > + ZfsRaidLevel::RaidZ => { > + check_raid_min_disks(disks, 3)?; > + for disk in disks { > + check_mirror_size(&disks[0], disk)?; > + } > + } > + ZfsRaidLevel::RaidZ2 => { > + check_raid_min_disks(disks, 4)?; > + for disk in disks { > + check_mirror_size(&disks[0], disk)?; > + } > + } > + ZfsRaidLevel::RaidZ3 => { > + check_raid_min_disks(disks, 5)?; > + for disk in disks { > + check_mirror_size(&disks[0], disk)?; > + } > + } > + } > + > + Ok(()) > +} > + > +/// Checks whether a user-supplied Btrfs RAID setup is valid or not, such as minimum > +/// number of disks. > +/// > +/// # Arguments > +/// > +/// * `level` - The targeted Btrfs RAID level by the user. > +/// * `disks` - List of disks designated as RAID targets. > +fn check_btrfs_raid_config(level: BtrfsRaidLevel, disks: &[Disk]) -> Result<(), String> { > + // See also Proxmox/Install.pm:get_btrfs_raid_setup() > + > + match level { > + BtrfsRaidLevel::Raid0 => check_raid_min_disks(disks, 1)?, > + BtrfsRaidLevel::Raid1 => check_raid_min_disks(disks, 2)?, > + BtrfsRaidLevel::Raid10 => check_raid_min_disks(disks, 4)?, > + } > + > + Ok(()) > +} > + > +#[cfg(test)] > +mod tests { > + use super::*; > + > + fn dummy_disk(index: usize) -> Disk { > + Disk { > + index: index.to_string(), > + path: format!("/dev/dummy{index}"), > + model: Some("Dummy disk".to_owned()), > + size: 1024. * 1024. * 1024. * 8., > + block_size: Some(512), > + } > + } > + > + fn dummy_disks(num: usize) -> Vec { > + (0..num).map(dummy_disk).collect() > + } > + > + #[test] > + fn duplicate_disks() { > + assert!(check_for_duplicate_disks(&dummy_disks(2)).is_ok()); > + assert_eq!( > + check_for_duplicate_disks(&[ > + dummy_disk(0), > + dummy_disk(1), > + dummy_disk(2), > + dummy_disk(2), > + dummy_disk(3), > + ]), > + Err(&dummy_disk(2)), > + ); > + } > + > + #[test] > + fn raid_min_disks() { > + let disks = dummy_disks(10); > + > + assert!(check_raid_min_disks(&disks[..1], 2).is_err()); > + assert!(check_raid_min_disks(&disks[..1], 1).is_ok()); > + assert!(check_raid_min_disks(&disks, 1).is_ok()); > + } > + > + #[test] > + fn bios_boot_compat_4kn() { > + for i in 0..10 { > + let mut disks = dummy_disks(10); > + disks[i].block_size = Some(4096); > + > + // Must fail if /any/ of the disks are 4Kn > + assert!(check_disks_4kn_legacy_boot(BootType::Bios, &disks).is_err()); > + // For UEFI, we allow it for every configuration > + assert!(check_disks_4kn_legacy_boot(BootType::Efi, &disks).is_ok()); > + } > + } > + > + #[test] > + fn btrfs_raid() { > + let disks = dummy_disks(10); > + > + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid0, &[]).is_err()); > + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid0, &disks[..1]).is_ok()); > + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid0, &disks).is_ok()); > + > + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid1, &[]).is_err()); > + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid1, &disks[..1]).is_err()); > + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid1, &disks[..2]).is_ok()); > + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid1, &disks).is_ok()); > + > + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid10, &[]).is_err()); > + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid10, &disks[..3]).is_err()); > + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid10, &disks[..4]).is_ok()); > + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid10, &disks).is_ok()); > + } > + > + #[test] > + fn zfs_raid() { > + let disks = dummy_disks(10); > + > + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid0, &[]).is_err()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid0, &disks[..1]).is_ok()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid0, &disks).is_ok()); > + > + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid1, &[]).is_err()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid1, &disks[..2]).is_ok()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid1, &disks).is_ok()); > + > + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid10, &[]).is_err()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid10, &dummy_disks(4)).is_ok()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid10, &disks).is_ok()); > + > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ, &[]).is_err()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ, &disks[..2]).is_err()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ, &disks[..3]).is_ok()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ, &disks).is_ok()); > + > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ2, &[]).is_err()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ2, &disks[..3]).is_err()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ2, &disks[..4]).is_ok()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ2, &disks).is_ok()); > + > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ3, &[]).is_err()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ3, &disks[..4]).is_err()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ3, &disks[..5]).is_ok()); > + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ3, &disks).is_ok()); > + } > +} > diff --git a/proxmox-installer-common/src/lib.rs b/proxmox-installer-common/src/lib.rs > index e69de29..f0093f5 100644 > --- a/proxmox-installer-common/src/lib.rs > +++ b/proxmox-installer-common/src/lib.rs > @@ -0,0 +1,4 @@ > +pub mod disk_checks; > +pub mod options; > +pub mod setup; > +pub mod utils; > diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs > new file mode 100644 > index 0000000..185be2e > --- /dev/null > +++ b/proxmox-installer-common/src/options.rs > @@ -0,0 +1,387 @@ > +use std::net::{IpAddr, Ipv4Addr}; > +use std::{cmp, fmt}; > + > +use crate::setup::{LocaleInfo, NetworkInfo, RuntimeInfo, SetupInfo}; > +use crate::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 => write!(f, "RAID0"), > + Raid1 => write!(f, "RAID1"), > + Raid10 => 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 => write!(f, "RAID0"), > + Raid1 => write!(f, "RAID1"), > + Raid10 => write!(f, "RAID10"), > + RaidZ => write!(f, "RAIDZ-1"), > + RaidZ2 => write!(f, "RAIDZ-2"), > + RaidZ3 => write!(f, "RAIDZ-3"), > + } > + } > +} > + > +#[derive(Copy, Clone, Debug, Eq, PartialEq)] > +pub enum FsType { > + Ext4, > + Xfs, > + Zfs(ZfsRaidLevel), > + Btrfs(BtrfsRaidLevel), > +} > + > +impl FsType { > + pub fn is_btrfs(&self) -> bool { > + matches!(self, FsType::Btrfs(_)) > + } > +} > + > +impl fmt::Display for FsType { > + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { > + use FsType::*; > + match self { > + Ext4 => write!(f, "ext4"), > + Xfs => write!(f, "XFS"), > + Zfs(level) => write!(f, "ZFS ({level})"), > + Btrfs(level) => 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, > +} > + > +impl LvmBootdiskOptions { > + pub fn defaults_from(disk: &Disk) -> Self { > + Self { > + total_size: disk.size, > + swap_size: None, > + max_root_size: None, > + max_data_size: None, > + min_lvm_free: None, > + } > + } > +} > + > +#[derive(Clone, Debug)] > +pub struct BtrfsBootdiskOptions { > + pub disk_size: f64, > + pub selected_disks: Vec, > +} > + > +impl BtrfsBootdiskOptions { > + /// This panics if the provided slice is empty. > + pub fn defaults_from(disks: &[Disk]) -> Self { > + let disk = &disks[0]; > + Self { > + disk_size: disk.size, > + selected_disks: (0..disks.len()).collect(), > + } > + } > +} > + > +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] > +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() > + } > +} > + > +pub const ZFS_COMPRESS_OPTIONS: &[ZfsCompressOption] = { > + use ZfsCompressOption::*; > + &[On, Off, Lzjb, Lz4, Zle, Gzip, Zstd] > +}; > + > +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] > +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() > + } > +} > + > +pub const ZFS_CHECKSUM_OPTIONS: &[ZfsChecksumOption] = { > + use ZfsChecksumOption::*; > + &[On, Off, Fletcher2, Fletcher4, Sha256] > +}; > + > +#[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, > +} > + > +impl ZfsBootdiskOptions { > + /// This panics if the provided slice is empty. > + pub fn defaults_from(disks: &[Disk]) -> Self { > + let disk = &disks[0]; > + Self { > + ashift: 12, > + compress: ZfsCompressOption::default(), > + checksum: ZfsChecksumOption::default(), > + copies: 1, > + disk_size: disk.size, > + selected_disks: (0..disks.len()).collect(), > + } > + } > +} > + > +#[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: Option, > +} > + > +impl fmt::Display for Disk { > + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { > + // TODO: Format sizes properly with `proxmox-human-byte` once merged > + // https://lists.proxmox.com/pipermail/pbs-devel/2023-May/006125.html > + f.write_str(&self.path)?; > + if let Some(model) = &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, > +} > + > +impl BootdiskOptions { > + pub fn defaults_from(disk: &Disk) -> Self { > + Self { > + disks: vec![disk.clone()], > + fstype: FsType::Ext4, > + advanced: AdvancedBootdiskOptions::Lvm(LvmBootdiskOptions::defaults_from(disk)), > + } > + } > +} > + > +#[derive(Clone, Debug)] > +pub struct TimezoneOptions { > + pub country: String, > + pub timezone: String, > + pub kb_layout: String, > +} > + > +impl TimezoneOptions { > + pub fn defaults_from(runtime: &RuntimeInfo, locales: &LocaleInfo) -> Self { > + let country = runtime.country.clone().unwrap_or_else(|| "at".to_owned()); > + > + let timezone = locales > + .cczones > + .get(&country) > + .and_then(|zones| zones.get(0)) > + .cloned() > + .unwrap_or_else(|| "UTC".to_owned()); > + > + let kb_layout = locales > + .countries > + .get(&country) > + .and_then(|c| { > + if c.kmap.is_empty() { > + None > + } else { > + Some(c.kmap.clone()) > + } > + }) > + .unwrap_or_else(|| "en-us".to_owned()); > + > + Self { > + country, > + timezone, > + kb_layout, > + } > + } > +} > + > +#[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, PartialEq)] > +pub struct NetworkOptions { > + pub ifname: String, > + pub fqdn: Fqdn, > + pub address: CidrAddress, > + pub gateway: IpAddr, > + pub dns_server: IpAddr, > +} > + > +impl NetworkOptions { > + const DEFAULT_DOMAIN: &str = "example.invalid"; > + > + pub fn defaults_from(setup: &SetupInfo, network: &NetworkInfo) -> Self { > + let mut this = Self { > + ifname: String::new(), > + fqdn: Self::construct_fqdn(network, setup.config.product.default_hostname()), > + // Safety: The provided mask will always be valid. > + address: CidrAddress::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(), > + gateway: Ipv4Addr::UNSPECIFIED.into(), > + dns_server: Ipv4Addr::UNSPECIFIED.into(), > + }; > + > + if let Some(ip) = network.dns.dns.first() { > + this.dns_server = *ip; > + } > + > + if let Some(routes) = &network.routes { > + let mut filled = false; > + if let Some(gw) = &routes.gateway4 { > + if let Some(iface) = network.interfaces.get(&gw.dev) { > + this.ifname = iface.name.clone(); > + if let Some(addresses) = &iface.addresses { > + if let Some(addr) = addresses.iter().find(|addr| addr.is_ipv4()) { > + this.gateway = gw.gateway; > + this.address = addr.clone(); > + filled = true; > + } > + } > + } > + } > + if !filled { > + if let Some(gw) = &routes.gateway6 { > + if let Some(iface) = network.interfaces.get(&gw.dev) { > + if let Some(addresses) = &iface.addresses { > + if let Some(addr) = addresses.iter().find(|addr| addr.is_ipv6()) { > + this.ifname = iface.name.clone(); > + this.gateway = gw.gateway; > + this.address = addr.clone(); > + } > + } > + } > + } > + } > + } > + > + this > + } > + > + fn construct_fqdn(network: &NetworkInfo, default_hostname: &str) -> Fqdn { > + let hostname = network.hostname.as_deref().unwrap_or(default_hostname); > + > + let domain = network > + .dns > + .domain > + .as_deref() > + .unwrap_or(Self::DEFAULT_DOMAIN); > + > + Fqdn::from(&format!("{hostname}.{domain}")).unwrap_or_else(|_| { > + // Safety: This will always result in a valid FQDN, as we control & know > + // the values of default_hostname (one of "pve", "pmg" or "pbs") and > + // constant-defined DEFAULT_DOMAIN. > + Fqdn::from(&format!("{}.{}", default_hostname, Self::DEFAULT_DOMAIN)).unwrap() > + }) > + } > +} > diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs > new file mode 100644 > index 0000000..a4947f1 > --- /dev/null > +++ b/proxmox-installer-common/src/setup.rs > @@ -0,0 +1,330 @@ > +use std::{ > + cmp, > + collections::HashMap, > + fmt, > + fs::File, > + io::BufReader, > + net::IpAddr, > + path::{Path, PathBuf}, > +}; > + > +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; > + > +use crate::{ > + options::{Disk, ZfsBootdiskOptions, ZfsChecksumOption, ZfsCompressOption}, > + utils::CidrAddress, > +}; > + > +#[allow(clippy::upper_case_acronyms)] > +#[derive(Clone, Copy, Deserialize, PartialEq)] > +#[serde(rename_all = "lowercase")] > +pub enum ProxmoxProduct { > + PVE, > + PBS, > + PMG, > +} > + > +impl ProxmoxProduct { > + pub fn default_hostname(self) -> &'static str { > + match self { > + Self::PVE => "pve", > + Self::PMG => "pmg", > + Self::PBS => "pbs", > + } > + } > +} > + > +#[derive(Clone, Deserialize)] > +pub struct ProductConfig { > + pub fullname: String, > + pub product: ProxmoxProduct, > + #[serde(deserialize_with = "deserialize_bool_from_int")] > + pub enable_btrfs: bool, > +} > + > +#[derive(Clone, Deserialize)] > +pub struct IsoInfo { > + pub release: String, > + pub isorelease: String, > +} > + > +/// Paths in the ISO environment containing installer data. > +#[derive(Clone, Deserialize)] > +pub struct IsoLocations { > + pub iso: PathBuf, > +} > + > +#[derive(Clone, Deserialize)] > +pub struct SetupInfo { > + #[serde(rename = "product-cfg")] > + pub config: ProductConfig, > + #[serde(rename = "iso-info")] > + pub iso_info: IsoInfo, > + pub locations: IsoLocations, > +} > + > +#[derive(Clone, Deserialize)] > +pub struct CountryInfo { > + pub name: String, > + #[serde(default)] > + pub zone: String, > + pub kmap: String, > +} > + > +#[derive(Clone, Deserialize, Eq, PartialEq)] > +pub struct KeyboardMapping { > + pub name: String, > + #[serde(rename = "kvm")] > + pub id: String, > + #[serde(rename = "x11")] > + pub xkb_layout: String, > + #[serde(rename = "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)] > +pub struct LocaleInfo { > + #[serde(deserialize_with = "deserialize_cczones_map")] > + pub cczones: HashMap>, > + #[serde(rename = "country")] > + pub countries: HashMap, > + pub kmap: HashMap, > +} > + > +#[derive(Serialize)] > +struct InstallZfsOption { > + ashift: usize, > + #[serde(serialize_with = "serialize_as_display")] > + compress: ZfsCompressOption, > + #[serde(serialize_with = "serialize_as_display")] > + checksum: ZfsChecksumOption, > + copies: usize, > +} > + > +impl From for InstallZfsOption { > + fn from(opts: ZfsBootdiskOptions) -> Self { > + InstallZfsOption { > + ashift: opts.ashift, > + compress: opts.compress, > + checksum: opts.checksum, > + copies: opts.copies, > + } > + } > +} > + > +pub fn read_json Deserialize<'de>, P: AsRef>(path: P) -> Result { > + let file = File::open(path).map_err(|err| err.to_string())?; > + let reader = 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 = Deserialize::deserialize(deserializer)?; > + Ok(val != 0) > +} > + > +fn deserialize_cczones_map<'de, D>( > + deserializer: D, > +) -> Result>, D::Error> > +where > + D: Deserializer<'de>, > +{ > + let map: HashMap> = Deserialize::deserialize(deserializer)?; > + > + let mut result = 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 = > + , String)>>::deserialize(deserializer)?; > + Ok(disks > + .into_iter() > + .map( > + |(index, device, size_mb, model, logical_bsize, _syspath)| Disk { > + index: index.to_string(), > + // Linux always reports the size of block devices in sectors, where one sector is > + // defined as being 2^9 = 512 bytes in size. > + // https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/linux/blk_types.h?h=v6.4#n30 > + size: (size_mb * 512.) / 1024. / 1024. / 1024., > + 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 = Deserialize::deserialize(deserializer)?; > + > + let mut result = Vec::with_capacity(list.len()); > + for desc in list { > + let ip_addr = 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_as_display(value: &T, serializer: S) -> Result > +where > + S: Serializer, > + T: fmt::Display, > +{ > + serializer.collect_str(value) > +} > + > +#[derive(Clone, Deserialize)] > +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 = "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 = "deserialize_bool_from_int")] > + pub hvm_supported: bool, > +} > + > +#[derive(Copy, Clone, Eq, Deserialize, PartialEq)] > +#[serde(rename_all = "lowercase")] > +pub enum BootType { > + Bios, > + Efi, > +} > + > +#[derive(Clone, Deserialize)] > +pub struct NetworkInfo { > + pub dns: Dns, > + pub routes: Option, > + > + /// Maps devices to their configuration, if it has a usable configuration. > + /// (Contains no entries for devices with only link-local addresses.) > + #[serde(default)] > + pub interfaces: HashMap, > + > + /// The hostname of this machine, if set by the DHCP server. > + pub hostname: Option, > +} > + > +#[derive(Clone, Deserialize)] > +pub struct Dns { > + pub domain: Option, > + > + /// List of stringified IP addresses. > + #[serde(default)] > + pub dns: Vec, > +} > + > +#[derive(Clone, Deserialize)] > +pub struct Routes { > + /// Ipv4 gateway. > + pub gateway4: Option, > + > + /// Ipv6 gateway. > + pub gateway6: Option, > +} > + > +#[derive(Clone, Deserialize)] > +pub struct Gateway { > + /// Outgoing network device. > + pub dev: String, > + > + /// Stringified gateway IP address. > + pub gateway: IpAddr, > +} > + > +#[derive(Clone, Deserialize)] > +#[serde(rename_all = "UPPERCASE")] > +pub enum InterfaceState { > + Up, > + Down, > + #[serde(other)] > + Unknown, > +} > + > +impl InterfaceState { > + // avoid display trait as this is not the string representation for a serializer > + pub fn render(&self) -> String { > + match self { > + Self::Up => "\u{25CF}", > + Self::Down | Self::Unknown => " ", > + } > + .into() > + } > +} > + > +#[derive(Clone, Deserialize)] > +pub struct Interface { > + pub name: String, > + > + pub index: usize, > + > + pub mac: String, > + > + pub state: InterfaceState, > + > + #[serde(default)] > + #[serde(deserialize_with = "deserialize_cidr_list")] > + pub addresses: Option>, > +} > + > +impl Interface { > + // avoid display trait as this is not the string representation for a serializer > + pub fn render(&self) -> String { > + format!("{} {}", self.state.render(), self.name) > + } > +} > + > diff --git a/proxmox-installer-common/src/utils.rs b/proxmox-installer-common/src/utils.rs > new file mode 100644 > index 0000000..89349ed > --- /dev/null > +++ b/proxmox-installer-common/src/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 handled. > +/// The mask is appropriately enforced to be `0 <= mask <= 32` for IPv4 or > +/// `0 <= mask <= 128` for IPv6 addresses. > +/// > +/// # Examples > +/// ``` > +/// use std::net::{Ipv4Addr, Ipv6Addr}; > +/// let ipv4 = CidrAddress::new(Ipv4Addr::new(192, 168, 0, 1), 24).unwrap(); > +/// let ipv6 = 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, PartialEq)] > +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 = 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` otherwise. > + pub fn is_ipv4(&self) -> bool { > + self.addr.is_ipv4() > + } > + > + /// Returns `true` if this address is an IPv6 address, `false` otherwise. > + 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 = CidrAddressParseError; > + > + fn from_str(s: &str) -> Result { > + let (addr, mask) = s > + .split_once('/') > + .ok_or(CidrAddressParseError::NoDelimiter)?; > + > + let addr = addr.parse().map_err(CidrAddressParseError::InvalidAddr)?; > + > + let mask = 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 => write!(f, "missing hostname part"), > + NumericHostname => write!(f, "hostname cannot be purely numeric"), > + InvalidPart(part) => write!( > + f, > + "FQDN must only consist of alphanumeric characters and dashes. Invalid part: '{part}'", > + ), > + } > + } > +} > + > +#[derive(Clone, Debug, Eq, PartialEq)] > +pub struct Fqdn { > + parts: Vec, > +} > + > +impl Fqdn { > + pub fn from(fqdn: &str) -> Result { > + let parts = 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 = 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 == '-') > + } > +} > + > +impl FromStr for Fqdn { > + type Err = 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 = 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 = 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" > + ); > + } > +} > -- > 2.39.2 > > > > _______________________________________________ > pve-devel mailing list > pve-devel@lists.proxmox.com > https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel > >