public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Christoph Heiss <c.heiss@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH installer 07/14] auto: add support for pinning network interface names
Date: Tue, 14 Oct 2025 15:21:52 +0200	[thread overview]
Message-ID: <20251014132207.1171073-8-c.heiss@proxmox.com> (raw)
In-Reply-To: <20251014132207.1171073-1-c.heiss@proxmox.com>

Introduce a new `[network.interface-name-pinning]` section in the answer
file, which is just a (TOML) table mapping MAC addresses to interface
names.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 proxmox-auto-installer/src/answer.rs          | 63 ++++++++++++++-----
 proxmox-auto-installer/src/utils.rs           | 40 ++++++++++--
 proxmox-auto-installer/tests/parse-answer.rs  |  2 +
 .../network_interface_pinning.json            | 30 +++++++++
 .../network_interface_pinning.toml            | 22 +++++++
 ...rface_pinning_overlong_interface_name.json |  3 +
 ...rface_pinning_overlong_interface_name.toml | 18 ++++++
 7 files changed, 159 insertions(+), 19 deletions(-)
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.toml
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.toml

diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs
index 88f4c87..1e455ca 100644
--- a/proxmox-auto-installer/src/answer.rs
+++ b/proxmox-auto-installer/src/answer.rs
@@ -1,13 +1,17 @@
-use anyhow::{Result, format_err};
+use anyhow::{Result, bail, format_err};
 use proxmox_installer_common::{
     options::{
-        BtrfsCompressOption, BtrfsRaidLevel, FsType, ZfsChecksumOption, ZfsCompressOption,
-        ZfsRaidLevel,
+        BtrfsCompressOption, BtrfsRaidLevel, FsType, NetworkInterfacePinningOptions,
+        ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel,
     },
     utils::{CidrAddress, Fqdn},
 };
 use serde::{Deserialize, Serialize};
-use std::{collections::BTreeMap, io::BufRead, net::IpAddr};
+use std::{
+    collections::{BTreeMap, HashMap},
+    io::BufRead,
+    net::IpAddr,
+};
 
 // NOTE New answer file properties must use kebab-case, but should allow snake_case for backwards
 // compatibility. TODO Remove the snake_cased variants in a future major version (e.g. PVE 10).
@@ -178,6 +182,18 @@ enum NetworkConfigMode {
     FromAnswer,
 }
 
+/// Options controlling the behaviour of the network interface pinning (by
+/// creating appropriate systemd.link files) during the installation.
+#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
+#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+pub struct NetworkInterfacePinningOptionsAnswer {
+    /// Whether interfaces should be pinned during the installation.
+    pub enabled: bool,
+    /// Maps MAC address to custom name
+    #[serde(default)]
+    pub mapping: HashMap<String, String>,
+}
+
 #[derive(Clone, Deserialize, Debug)]
 #[serde(rename_all = "kebab-case", deny_unknown_fields)]
 struct NetworkInAnswer {
@@ -188,30 +204,47 @@ struct NetworkInAnswer {
     pub gateway: Option<IpAddr>,
     #[serde(default)]
     pub filter: BTreeMap<String, String>,
+    /// Controls network interface pinning behaviour during installation.
+    /// Off by default. Allowed for both `from-dhcp` and `from-answer` modes.
+    #[serde(default)]
+    pub interface_name_pinning: Option<NetworkInterfacePinningOptionsAnswer>,
 }
 
 #[derive(Clone, Deserialize, Debug)]
 #[serde(try_from = "NetworkInAnswer", deny_unknown_fields)]
 pub struct Network {
     pub network_settings: NetworkSettings,
+    /// Controls network interface pinning behaviour during installation.
+    pub interface_name_pinning: Option<NetworkInterfacePinningOptions>,
 }
 
 impl TryFrom<NetworkInAnswer> for Network {
-    type Error = &'static str;
+    type Error = anyhow::Error;
+
+    fn try_from(network: NetworkInAnswer) -> Result<Self> {
+        let interface_name_pinning = match network.interface_name_pinning {
+            Some(opts) if opts.enabled => {
+                let opts = NetworkInterfacePinningOptions {
+                    mapping: opts.mapping,
+                };
+                opts.verify()?;
+                Some(opts)
+            }
+            _ => None,
+        };
 
-    fn try_from(network: NetworkInAnswer) -> Result<Self, Self::Error> {
         if network.source == NetworkConfigMode::FromAnswer {
             if network.cidr.is_none() {
-                return Err("Field 'cidr' must be set.");
+                bail!("Field 'cidr' must be set.");
             }
             if network.dns.is_none() {
-                return Err("Field 'dns' must be set.");
+                bail!("Field 'dns' must be set.");
             }
             if network.gateway.is_none() {
-                return Err("Field 'gateway' must be set.");
+                bail!("Field 'gateway' must be set.");
             }
             if network.filter.is_empty() {
-                return Err("Field 'filter' must be set.");
+                bail!("Field 'filter' must be set.");
             }
 
             Ok(Network {
@@ -221,23 +254,25 @@ impl TryFrom<NetworkInAnswer> for Network {
                     gateway: network.gateway.unwrap(),
                     filter: network.filter,
                 }),
+                interface_name_pinning,
             })
         } else {
             if network.cidr.is_some() {
-                return Err("Field 'cidr' not supported for 'from-dhcp' config.");
+                bail!("Field 'cidr' not supported for 'from-dhcp' config.");
             }
             if network.dns.is_some() {
-                return Err("Field 'dns' not supported for 'from-dhcp' config.");
+                bail!("Field 'dns' not supported for 'from-dhcp' config.");
             }
             if network.gateway.is_some() {
-                return Err("Field 'gateway' not supported for 'from-dhcp' config.");
+                bail!("Field 'gateway' not supported for 'from-dhcp' config.");
             }
             if !network.filter.is_empty() {
-                return Err("Field 'filter' not supported for 'from-dhcp' config.");
+                bail!("Field 'filter' not supported for 'from-dhcp' config.");
             }
 
             Ok(Network {
                 network_settings: NetworkSettings::FromDhcp,
+                interface_name_pinning,
             })
         }
     }
diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs
index eb666d1..14085a4 100644
--- a/proxmox-auto-installer/src/utils.rs
+++ b/proxmox-auto-installer/src/utils.rs
@@ -2,14 +2,14 @@ use anyhow::{Context, Result, bail};
 use glob::Pattern;
 use log::info;
 use std::{
-    collections::{BTreeMap, HashMap, HashSet},
+    collections::{BTreeMap, HashSet},
     process::Command,
 };
 
 use crate::{
     answer::{
         self, Answer, DiskSelection, FirstBootHookSourceMode, FqdnConfig, FqdnExtendedConfig,
-        FqdnSourceMode,
+        FqdnSourceMode, Network,
     },
     udevinfo::UdevInfo,
 };
@@ -35,7 +35,12 @@ fn get_network_settings(
     let mut network_options = match &answer.global.fqdn {
         // If the user set a static FQDN in the answer file, override it
         FqdnConfig::Simple(name) => {
-            let mut opts = NetworkOptions::defaults_from(setup_info, &runtime_info.network, None);
+            let mut opts = NetworkOptions::defaults_from(
+                setup_info,
+                &runtime_info.network,
+                None,
+                answer.network.interface_name_pinning.as_ref(),
+            );
             opts.fqdn = name.to_owned();
             opts
         }
@@ -58,7 +63,12 @@ fn get_network_settings(
                 bail!("no domain received from DHCP server and `global.fqdn.domain` is unset!");
             }
 
-            NetworkOptions::defaults_from(setup_info, &runtime_info.network, domain.as_deref())
+            NetworkOptions::defaults_from(
+                setup_info,
+                &runtime_info.network,
+                domain.as_deref(),
+                answer.network.interface_name_pinning.as_ref(),
+            )
         }
     };
 
@@ -68,6 +78,12 @@ fn get_network_settings(
         network_options.gateway = settings.gateway;
         network_options.ifname = get_single_udev_index(&settings.filter, &udev_info.nics)?;
     }
+
+    if let Some(opts) = &network_options.pinning_opts {
+        info!("Network interface name pinning is enabled");
+        opts.verify()?;
+    }
+
     info!("Network interface used is '{}'", &network_options.ifname);
     Ok(network_options)
 }
@@ -430,6 +446,16 @@ pub fn verify_first_boot_settings(answer: &Answer) -> Result<()> {
     Ok(())
 }
 
+pub fn verify_network_settings(network: &Network) -> Result<()> {
+    info!("Verifying network settings");
+
+    if let Some(pin_opts) = &network.interface_name_pinning {
+        pin_opts.verify()?;
+    }
+
+    Ok(())
+}
+
 pub fn parse_answer(
     answer: &Answer,
     udev_info: &UdevInfo,
@@ -451,6 +477,7 @@ pub fn parse_answer(
     verify_disks_settings(answer)?;
     verify_email_and_root_password_settings(answer)?;
     verify_first_boot_settings(answer)?;
+    verify_network_settings(&answer.network)?;
 
     let root_password = match (
         &answer.global.root_password,
@@ -485,7 +512,10 @@ pub fn parse_answer(
         root_ssh_keys: answer.global.root_ssh_keys.clone(),
 
         mngmt_nic: network_settings.ifname,
-        network_interface_pin_map: HashMap::new(),
+        network_interface_pin_map: network_settings
+            .pinning_opts
+            .map(|o| o.mapping)
+            .unwrap_or_default(),
 
         hostname: network_settings
             .fqdn
diff --git a/proxmox-auto-installer/tests/parse-answer.rs b/proxmox-auto-installer/tests/parse-answer.rs
index 6754374..696fe1f 100644
--- a/proxmox-auto-installer/tests/parse-answer.rs
+++ b/proxmox-auto-installer/tests/parse-answer.rs
@@ -129,6 +129,7 @@ mod tests {
             full_fqdn_from_dhcp_with_default_domain,
             hashed_root_password,
             minimal,
+            network_interface_pinning,
             nic_matching,
             specific_nic,
             zfs,
@@ -149,6 +150,7 @@ mod tests {
             fqdn_hostname_only,
             ipv4_and_subnet_mask_33,
             lvm_swapsize_greater_than_hdsize,
+            network_interface_pinning_overlong_interface_name,
             no_fqdn_from_dhcp,
             no_root_password_set,
             short_password,
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.json b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.json
new file mode 100644
index 0000000..76723c8
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.json
@@ -0,0 +1,30 @@
+{
+  "autoreboot": 1,
+  "cidr": "192.168.1.114/24",
+  "country": "at",
+  "dns": "192.168.1.254",
+  "domain": "testinstall",
+  "filesys": "ext4",
+  "gateway": "192.168.1.1",
+  "hdsize": 223.57088470458984,
+  "existing_storage_auto_rename": 1,
+  "hostname": "pveauto",
+  "keymap": "de",
+  "mailto": "mail@no.invalid",
+  "mngmt_nic": "mgmt",
+  "network_interface_pin_map": {
+    "1c:34:da:5c:5e:24": "nic2",
+    "1c:34:da:5c:5e:25": "nic3",
+    "24:8a:07:1e:05:bc": "lan0",
+    "24:8a:07:1e:05:bd": "lan1",
+    "5a:47:32:dd:c7:47": "nic8",
+    "a0:36:9f:0a:b3:82": "nic6",
+    "a0:36:9f:0a:b3:83": "nic7",
+    "b4:2e:99:ac:ad:b4": "mgmt",
+    "b4:2e:99:ac:ad:b5": "nic1"
+  },
+  "root_password": { "plain": "12345678" },
+  "target_hd": "/dev/sda",
+  "timezone": "Europe/Vienna",
+  "first_boot": { "enabled": 0 }
+}
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.toml b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.toml
new file mode 100644
index 0000000..d9a2110
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.toml
@@ -0,0 +1,22 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root-password = "12345678"
+
+[network]
+source = "from-dhcp"
+
+[network.interface-name-pinning]
+enabled = true
+
+[network.interface-name-pinning.mapping]
+"24:8a:07:1e:05:bc" = "lan0"
+"24:8a:07:1e:05:bd" = "lan1"
+"b4:2e:99:ac:ad:b4" = "mgmt"
+
+[disk-setup]
+filesystem = "ext4"
+disk-list = ["sda"]
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json
new file mode 100644
index 0000000..70e196c
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json
@@ -0,0 +1,3 @@
+{
+  "parse-error": "error parsing answer.toml: interface name mapping 'waytoolonginterfacename' for 'ab:cd:ef:12:34:56' cannot be longer than 15 characters"
+}
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.toml b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.toml
new file mode 100644
index 0000000..e82b47d
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.toml
@@ -0,0 +1,18 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.fail.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root-password = "12345678"
+
+[network]
+source = "from-dhcp"
+interface-name-pinning.enabled = true
+
+[network.interface-name-pinning.mapping]
+"ab:cd:ef:12:34:56" = "waytoolonginterfacename"
+
+[disk-setup]
+filesystem = "ext4"
+disk-list = ["sda"]
-- 
2.51.0



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


  parent reply	other threads:[~2025-10-14 13:23 UTC|newest]

Thread overview: 17+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-10-14 13:21 [pve-devel] [PATCH installer 00/14] support network interface name pinning Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 01/14] test: parse-kernel-cmdline: fix module import statement Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 02/14] install: add support for network interface name pinning Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 03/14] run env: network: add kernel driver name to network interface info Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 04/14] common: utils: fix clippy warnings Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 05/14] common: setup: simplify network address list serialization Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 06/14] common: implement support for `network_interface_pin_map` config Christoph Heiss
2025-10-14 13:21 ` Christoph Heiss [this message]
2025-10-14 13:21 ` [pve-devel] [PATCH installer 08/14] assistant: verify network settings in `validate-answer` subcommand Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 09/14] post-hook: avoid redundant Option<bool> for (de-)serialization Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 10/14] post-hook: add network interface name and pinning status Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 11/14] tui: views: move network options view to own module Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 12/14] tui: views: form: allow attaching user-defined data to children Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 13/14] tui: add support for pinning network interface names Christoph Heiss
2025-10-14 13:21 ` [pve-devel] [PATCH installer 14/14] gui: " Christoph Heiss
2025-10-14 15:04   ` Maximiliano Sandoval
2025-10-16 12:01     ` Christoph Heiss

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=20251014132207.1171073-8-c.heiss@proxmox.com \
    --to=c.heiss@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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal