From: Christoph Heiss <c.heiss@proxmox.com>
To: Aaron Lauterer <a.lauterer@proxmox.com>
Cc: Proxmox VE development discussion <pve-devel@lists.proxmox.com>
Subject: Re: [pve-devel] [PATCH 02/12] common: copy common code from tui-installer
Date: Fri, 27 Oct 2023 13:14:03 +0200 [thread overview]
Message-ID: <mmjfqquxsblixzr7z4fgi7udv2xreafcyqarhyntmqyjixvxoc@vjoglb2x6q4h> (raw)
In-Reply-To: <20231025160011.3617524-3-a.lauterer@proxmox.com>
Simple code move/copy, so LGTM.
Reviewed-by: Christoph Heiss <c.heiss@proxmox.com>
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 <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
>
>
>
> _______________________________________________
> pve-devel mailing list
> pve-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
>
>
next prev parent reply other threads:[~2023-10-27 11:14 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 ` [pve-devel] [PATCH 02/12] common: copy common code from tui-installer Aaron Lauterer
2023-10-27 11:14 ` Christoph Heiss [this message]
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=mmjfqquxsblixzr7z4fgi7udv2xreafcyqarhyntmqyjixvxoc@vjoglb2x6q4h \
--to=c.heiss@proxmox.com \
--cc=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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox