From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id B223F1FF09C for ; Mon, 02 Nov 2026 13:46:32 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id EF142217B0; Mon, 02 Nov 2026 13:46:18 +0100 (CET) From: Christoph Heiss To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox v4 04/30] installer-types: answer: add options for configuring management bond Date: Mon, 2 Nov 2026 13:45:10 +0100 Message-ID: <20261102124539.1008075-5-c.heiss@proxmox.com> X-Mailer: git-send-email 2.55.0 In-Reply-To: <20261102124539.1008075-1-c.heiss@proxmox.com> References: <20261102124539.1008075-1-c.heiss@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793623565195 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.226 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: SUFTUAHTO3ICMLOTPE2V5O6EXBWRVHFB X-Message-ID-Hash: SUFTUAHTO3ICMLOTPE2V5O6EXBWRVHFB X-MailFrom: c.heiss@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Adds a new subsection `bond` in [network]. This allows for configuring a bond for the management network interface during the installation. E.g. [network.bond] members-list = ["ab:cd:ef:12:34:56", "ab:cd:ef:12:34:57"] mode = "active-backup" hash-policy = "layer2+3" primary-interface = "ab:cd:ef:12:34:57" Or to use udev filters for selecting the members: [network.bond] members-filter.ID_NET_DRIVER = "igc" members-filter-match = "all" mode = "active-backup" hash-policy = "layer2+3" primary-interface = "ab:cd:ef:12:34:57" Signed-off-by: Christoph Heiss --- Changes v3 -> v4: * separate [bond.members] into separate attributes for selecting by either MAC addresses or filters. Changes v2 -> v3: * no changes Changes v1 -> v2: * no changes Cargo.toml | 1 + proxmox-installer-types/Cargo.toml | 1 + proxmox-installer-types/debian/control | 2 + proxmox-installer-types/src/answer.rs | 238 +++++++++++++++++++++++++ 4 files changed, 242 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 09ef21b4..c35d7fd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -180,6 +180,7 @@ proxmox-lang = { version = "1.5", path = "proxmox-lang" } proxmox-log = { version = "1.0.0", path = "proxmox-log" } proxmox-login = { version = "1.0.0", path = "proxmox-login" } proxmox-network-types = { version = "1.0.2", path = "proxmox-network-types" } +proxmox-network-api = { version = "1.0.5", path = "proxmox-network-api" } proxmox-parallel-handler = { version = "1.0.0", path = "proxmox-parallel-handler" } proxmox-pgp = { version = "1.0.0", path = "proxmox-pgp" } proxmox-procfs = { version = "0.1.0", path = "proxmox-procfs" } diff --git a/proxmox-installer-types/Cargo.toml b/proxmox-installer-types/Cargo.toml index 8ac4708b..6b634c19 100644 --- a/proxmox-installer-types/Cargo.toml +++ b/proxmox-installer-types/Cargo.toml @@ -17,6 +17,7 @@ serde = { workspace = true, features = ["derive"] } serde_plain.workspace = true regex = { workspace = true } proxmox-network-types.workspace = true +proxmox-network-api.workspace = true proxmox-schema = { workspace = true, features = ["api-macro"] } proxmox-section-config = { workspace = true, optional = true } proxmox-node-status.workspace = true diff --git a/proxmox-installer-types/debian/control b/proxmox-installer-types/debian/control index e75f349c..0d12dcf6 100644 --- a/proxmox-installer-types/debian/control +++ b/proxmox-installer-types/debian/control @@ -7,6 +7,7 @@ Build-Depends-Arch: cargo:native , rustc:native (>= 1.88) , libstd-rust-dev , librust-anyhow-1+default-dev , + librust-proxmox-network-api-1+default-dev (>= 1.0.5-~~) , librust-proxmox-network-types-1+default-dev (>= 1.0.2-~~) , librust-proxmox-node-status-1+default-dev , librust-proxmox-schema-5+api-macro-dev (>= 5.5.0-~~) , @@ -28,6 +29,7 @@ Multi-Arch: same Depends: ${misc:Depends}, librust-anyhow-1+default-dev, + librust-proxmox-network-api-1+default-dev (>= 1.0.5-~~), librust-proxmox-network-types-1+default-dev (>= 1.0.2-~~), librust-proxmox-node-status-1+default-dev, librust-proxmox-schema-5+api-macro-dev (>= 5.5.0-~~), diff --git a/proxmox-installer-types/src/answer.rs b/proxmox-installer-types/src/answer.rs index d4c4acce..8482356c 100644 --- a/proxmox-installer-types/src/answer.rs +++ b/proxmox-installer-types/src/answer.rs @@ -14,6 +14,7 @@ use std::{ str::FromStr, }; +use proxmox_network_api::{BondXmitHashPolicy, LinuxBondMode}; use proxmox_network_types::{MacAddress, fqdn::Fqdn, ip_address::Cidr}; #[cfg(feature = "api-types")] @@ -440,6 +441,158 @@ pub struct NetworkInterfacePinningOptionsAnswer { pub mapping: HashMap, } +/// Specifies how members for the management network bond are selected. +#[derive(Clone, Debug, PartialEq)] +pub enum NetworkBondMemberSelection { + /// List of interface MAC addresses to add as bond members. + Fixed(Vec), + /// Udev filter list for dynamically select interfaces as bond members. + Filter(BTreeMap), +} + +#[cfg_attr(feature = "api-types", api( + properties: { + "members-list": { + description: "List or filter of interface MAC addresses to add as bond members.", + type: Array, + items: { + type: MacAddress, + }, + }, + "members-filter": { + type: Object, + properties: {}, + additional_properties: true, + } + }, +))] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +/// Options for creating a bond for the management interface with the given +/// physical interfaces and mode. +pub struct NetworkBondOptions { + /// List of interface MAC addresses to add as bond members. Mutual exclusive with the + /// 'members_filter' option. + #[serde(default)] + pub members_list: Vec, + /// Udev filter list for dynamically select interfaces as bond members. Mutual exclusive with + /// the 'members' option. + #[serde(default)] + pub members_filter: BTreeMap, + /// Set whether it is enough that any filter matches on a disk or all given + /// filters must match to select a disk. Only useful in combination with 'members-filter'. + #[serde(skip_serializing_if = "Option::is_none")] + pub members_filter_match: Option, + /// Mode for the bond to set. + pub mode: LinuxBondMode, + /// Bond hash policy. Only used with '802.3ad' and 'balance-xor' mode. + #[serde(default = "NetworkBondOptions::default_hash_policy")] + pub hash_policy: BondXmitHashPolicy, + /// Primary bond interface. Only used with 'active-backup' mode. + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_interface: Option, +} + +impl NetworkBondOptions { + /// List of all available bond modes. + pub const MODES: &[LinuxBondMode] = { + use LinuxBondMode::*; + &[ + ActiveBackup, + BalanceAlb, + BalanceRr, + BalanceTlb, + BalanceXor, + Broadcast, + Ieee802_3ad, + ] + }; + + /// List of all available hash policies. + pub const HASH_POLICIES: &[BondXmitHashPolicy] = { + use BondXmitHashPolicy::*; + &[Layer2, Layer2_3, Layer3_4] + }; + + /// Returns the default hash policy for new bonds. + const fn default_hash_policy() -> BondXmitHashPolicy { + BondXmitHashPolicy::Layer2 + } + + /// Returns a bond configuration instance that disables any bonding setup in the low-level + /// installer. + pub const fn disabled() -> Self { + Self { + // an empty interface list and filter is interpreted as disabled by the low-level installer + members_list: Vec::new(), + members_filter: BTreeMap::new(), + members_filter_match: None, + mode: LinuxBondMode::ActiveBackup, + hash_policy: BondXmitHashPolicy::Layer2, + primary_interface: None, + } + } + + /// Returns the given selection of bond members. + /// + /// If `Ok(None)` is returned, bonding is disabled. + pub fn members(&self) -> Result> { + if self.members_list.is_empty() && self.members_filter.is_empty() { + Ok(None) + } else if !self.members_list.is_empty() && !self.members_filter.is_empty() { + bail!("Bond options 'members-list' and 'members-filter' are mutual exclusive"); + } else if !self.members_list.is_empty() { + Ok(Some(NetworkBondMemberSelection::Fixed( + self.members_list.clone(), + ))) + } else { + Ok(Some(NetworkBondMemberSelection::Filter( + self.members_filter.clone(), + ))) + } + } + + /// Does some basic checks on the options. + /// + /// This includes checks for: + /// - There are at least to members + /// - Whether the primary interface is applicable to the mode + /// - Whether the primary interface (if given) is a member + pub fn verify(&self) -> Result<()> { + let members = self.members()?; + let members = match members { + Some(NetworkBondMemberSelection::Fixed(list)) if list.len() < 2 => { + bail!( + "bond setup requires at least 2 interfaces, got {}", + list.len() + ); + } + Some(sel) => sel, + None => bail!("bond setup requires either fixed list of members or udev filter list"), + }; + + if let Some(primary_if) = &self.primary_interface { + if self.mode != LinuxBondMode::ActiveBackup { + bail!("primary bond interface can only be set in active-backup mode"); + } + + match members { + NetworkBondMemberSelection::Fixed(list) if !list.contains(primary_if) => { + bail!( + "primary bond interface {primary_if} must be a member of the bond (members: {})", + list.iter() + .fold(String::new(), |acc, mac| format!("{acc}, {mac}")) + .trim_start_matches(", ") + ); + } + _ => {} + } + } + + Ok(()) + } +} + #[cfg_attr(feature = "api-types", api( properties: { filter: { @@ -466,6 +619,9 @@ pub struct NetworkConfigFromAnswer { /// Off by default. Allowed for both `from-dhcp` and `from-answer` modes. #[serde(default, skip_serializing_if = "Option::is_none")] pub interface_name_pinning: Option, + /// If set, sets up a Linux bond for the management interface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bond: Option, } #[cfg_attr(feature = "api-types", api)] @@ -477,6 +633,9 @@ pub struct NetworkConfigFromDhcp { /// Off by default. Allowed for both `from-dhcp` and `from-answer` modes. #[serde(default, skip_serializing_if = "Option::is_none")] pub interface_name_pinning: Option, + /// If set, sets up a Linux bond for the management interface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bond: Option, } #[cfg_attr(feature = "api-types", api( @@ -505,6 +664,14 @@ impl NetworkConfig { Self::FromAnswer(answer) => answer.interface_name_pinning.as_ref(), } } + + /// Returns the management network Linux bond options, if any. + pub const fn bond(&self) -> Option<&NetworkBondOptions> { + match self { + Self::FromDhcp(dhcp) => dhcp.bond.as_ref(), + Self::FromAnswer(answer) => answer.bond.as_ref(), + } + } } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] @@ -1477,4 +1644,75 @@ mod tests { }; assert!(setup.filesystem_details().is_ok()); } + + #[test] + fn network_bond_requires_at_least_two_members() { + let mut bond = NetworkBondOptions::disabled(); + let res = bond.verify(); + assert!(res.is_err()); + assert_eq!( + res.unwrap_err().to_string(), + "bond setup requires at least 2 interfaces, got 0" + ); + + bond.members = vec!["12:34:56:ab:cd:df".parse().unwrap()]; + + let res = bond.verify(); + assert!(res.is_err()); + assert_eq!( + res.unwrap_err().to_string(), + "bond setup requires at least 2 interfaces, got 1" + ); + + bond.members = vec![ + "12:34:56:ab:cd:df".parse().unwrap(), + "ab:cd:df:12:34:56".parse().unwrap(), + ]; + assert!(bond.verify().is_ok()); + } + + #[test] + fn network_bond_primary_if_only_with_active_backup() { + let mut bond = NetworkBondOptions::disabled(); + bond.members = vec![ + "12:34:56:ab:cd:df".parse().unwrap(), + "ab:cd:df:12:34:56".parse().unwrap(), + ]; + bond.primary_interface = Some("12:34:56:ab:cd:df".parse().unwrap()); + + for m in NetworkBondOptions::MODES { + bond.mode = *m; + match m { + LinuxBondMode::ActiveBackup => assert!(bond.verify().is_ok()), + _ => { + let res = bond.verify(); + assert!(res.is_err()); + assert_eq!( + res.unwrap_err().to_string(), + "primary bond interface can only be set in active-backup mode" + ); + } + } + } + } + + #[test] + fn network_bond_primary_if_must_be_member() { + let mut bond = NetworkBondOptions::disabled(); + bond.members = vec![ + "12:34:56:ab:cd:df".parse().unwrap(), + "ab:cd:df:12:34:56".parse().unwrap(), + ]; + bond.primary_interface = Some("12:34:56:ab:cd:df".parse().unwrap()); + assert!(bond.verify().is_ok()); + + bond.primary_interface = Some("ab:12:cd:34:ef:56".parse().unwrap()); + + let res = bond.verify(); + assert!(res.is_err()); + assert_eq!( + res.unwrap_err().to_string(), + "primary bond interface AB:12:CD:34:EF:56 must be a member of the bond (members: 12:34:56:AB:CD:DF, AB:CD:DF:12:34:56)" + ); + } } -- 2.55.0