[pve-devel] [PATCH 02/12] common: copy common code from tui-installer

Aaron Lauterer a.lauterer at proxmox.com
Wed Oct 25 18:00:01 CEST 2023


Copy code that is common to its own crate.

Signed-off-by: Aaron Lauterer <a.lauterer at proxmox.com>
---
 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<Disk> {
+        (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<f64>,
+    pub max_root_size: Option<f64>,
+    pub max_data_size: Option<f64>,
+    pub min_lvm_free: Option<f64>,
+}
+
+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<usize>,
+}
+
+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<usize>,
+}
+
+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<String>,
+    pub size: f64,
+    pub block_size: Option<usize>,
+}
+
+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<cmp::Ordering> {
+        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<Disk>,
+    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 at 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<cmp::Ordering> {
+        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<String, Vec<String>>,
+    #[serde(rename = "country")]
+    pub countries: HashMap<String, CountryInfo>,
+    pub kmap: HashMap<String, KeyboardMapping>,
+}
+
+#[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<ZfsBootdiskOptions> for InstallZfsOption {
+    fn from(opts: ZfsBootdiskOptions) -> Self {
+        InstallZfsOption {
+            ashift: opts.ashift,
+            compress: opts.compress,
+            checksum: opts.checksum,
+            copies: opts.copies,
+        }
+    }
+}
+
+pub fn read_json<T: for<'de> Deserialize<'de>, P: AsRef<Path>>(path: P) -> Result<T, String> {
+    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<bool, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let val: u32 = Deserialize::deserialize(deserializer)?;
+    Ok(val != 0)
+}
+
+fn deserialize_cczones_map<'de, D>(
+    deserializer: D,
+) -> Result<HashMap<String, Vec<String>>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let map: HashMap<String, HashMap<String, u32>> = 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<Vec<Disk>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let disks =
+        <Vec<(usize, String, f64, String, Option<usize>, 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<Option<Vec<CidrAddress>>, 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<CidrDescriptor> = Deserialize::deserialize(deserializer)?;
+
+    let mut result = Vec::with_capacity(list.len());
+    for desc in list {
+        let ip_addr = desc
+            .address
+            .parse::<IpAddr>()
+            .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<S, T>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
+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<String>,
+
+    /// Maps devices to their information.
+    #[serde(deserialize_with = "deserialize_disks_map")]
+    pub disks: Vec<Disk>,
+
+    /// 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<Routes>,
+
+    /// 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<String, Interface>,
+
+    /// The hostname of this machine, if set by the DHCP server.
+    pub hostname: Option<String>,
+}
+
+#[derive(Clone, Deserialize)]
+pub struct Dns {
+    pub domain: Option<String>,
+
+    /// List of stringified IP addresses.
+    #[serde(default)]
+    pub dns: Vec<IpAddr>,
+}
+
+#[derive(Clone, Deserialize)]
+pub struct Routes {
+    /// Ipv4 gateway.
+    pub gateway4: Option<Gateway>,
+
+    /// Ipv6 gateway.
+    pub gateway6: Option<Gateway>,
+}
+
+#[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<Vec<CidrAddress>>,
+}
+
+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<ParseIntError>),
+}
+
+/// 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<T: Into<IpAddr>>(addr: T, mask: usize) -> Result<Self, CidrAddressParseError> {
+        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<Self, Self::Err> {
+        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<String>,
+}
+
+impl Fqdn {
+    pub fn from(fqdn: &str) -> Result<Self, FqdnParseError> {
+        let parts = fqdn
+            .split('.')
+            .map(ToOwned::to_owned)
+            .collect::<Vec<String>>();
+
+        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, Self::Err> {
+        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<D>(deserializer: D) -> Result<Self, D::Error>
+    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






More information about the pve-devel mailing list