all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Aaron Lauterer <a.lauterer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH 02/12] common: copy common code from tui-installer
Date: Wed, 25 Oct 2023 18:00:01 +0200	[thread overview]
Message-ID: <20231025160011.3617524-3-a.lauterer@proxmox.com> (raw)
In-Reply-To: <20231025160011.3617524-1-a.lauterer@proxmox.com>

Copy code that is common to its own crate.

Signed-off-by: Aaron Lauterer <a.lauterer@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@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





  parent reply	other threads:[~2023-10-25 16:00 UTC|newest]

Thread overview: 20+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2023-10-25 15:59 [pve-devel] [PATCH 00/12] installer: add crate for common code Aaron Lauterer
2023-10-25 16:00 ` [pve-devel] [PATCH 01/12] add proxmox-installer-common crate Aaron Lauterer
2023-10-27 10:59   ` Christoph Heiss
2023-10-25 16:00 ` Aaron Lauterer [this message]
2023-10-27 11:14   ` [pve-devel] [PATCH 02/12] common: copy common code from tui-installer Christoph Heiss
2023-10-25 16:00 ` [pve-devel] [PATCH 03/12] common: utils: add dependency for doc test Aaron Lauterer
2023-10-25 16:00 ` [pve-devel] [PATCH 04/12] common: make InstallZfsOption public Aaron Lauterer
2023-10-25 16:00 ` [pve-devel] [PATCH 05/12] common: disk_checks: make functions public Aaron Lauterer
2023-10-25 16:00 ` [pve-devel] [PATCH 06/12] tui-installer: add dependency for new common crate Aaron Lauterer
2023-10-25 16:00 ` [pve-devel] [PATCH 07/12] tui: switch to " Aaron Lauterer
2023-10-25 16:00 ` [pve-devel] [PATCH 08/12] tui: remove now unused utils.rs Aaron Lauterer
2023-10-25 16:00 ` [pve-devel] [PATCH 09/12] common: add installer_setup method Aaron Lauterer
2023-10-25 16:00 ` [pve-devel] [PATCH 10/12] common: document " Aaron Lauterer
2023-10-25 16:00 ` [pve-devel] [PATCH 11/12] tui: use installer_setup from common cate Aaron Lauterer
2023-10-25 16:00 ` [pve-devel] [PATCH 12/12] tui: remove unused read_json function Aaron Lauterer
2023-10-27 11:06   ` Christoph Heiss
2023-10-27 11:39 ` [pve-devel] [PATCH installer] buildsys: copy over `proxmox-installer-common` crate to build directory Christoph Heiss
2023-10-27 11:41 ` [pve-devel] [PATCH 00/12] installer: add crate for common code Christoph Heiss
2023-10-30  9:45   ` Aaron Lauterer
2023-11-02 19:05 ` [pve-devel] applied-series: " Thomas Lamprecht

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20231025160011.3617524-3-a.lauterer@proxmox.com \
    --to=a.lauterer@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal