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 A2C971FF0B4 for ; Tue, 08 Sep 2026 12:17:23 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 07636215A0; Tue, 08 Sep 2026 12:17:19 +0200 (CEST) From: Christoph Heiss To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox 02/15] installer-types: answer: add options for configuring management bond Date: Tue, 8 Sep 2026 12:16:16 +0200 Message-ID: <20260908101647.1057780-3-c.heiss@proxmox.com> X-Mailer: git-send-email 2.55.0 In-Reply-To: <20260908101647.1057780-1-c.heiss@proxmox.com> References: <20260908101647.1057780-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: 1788862620289 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.419 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: KXOUC75KMFC5XUPQUFM5KF72BL7QLVP5 X-Message-ID-Hash: KXOUC75KMFC5XUPQUFM5KF72BL7QLVP5 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 = ["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" Signed-off-by: Christoph Heiss --- Cargo.toml | 1 + proxmox-installer-types/Cargo.toml | 1 + proxmox-installer-types/debian/control | 2 + proxmox-installer-types/src/answer.rs | 188 ++++++++++++++++++++++++- 4 files changed, 191 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 16e91c94..13f62856 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -178,6 +178,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 45f436e9..b208b0b7 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 e6f2ad6a..9dfe0017 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.85) , 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.3.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.3.0-~~), diff --git a/proxmox-installer-types/src/answer.rs b/proxmox-installer-types/src/answer.rs index e608b4cb..93dc32a4 100644 --- a/proxmox-installer-types/src/answer.rs +++ b/proxmox-installer-types/src/answer.rs @@ -14,7 +14,8 @@ use std::{ str::FromStr, }; -use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr}; +use proxmox_network_api::{BondXmitHashPolicy, LinuxBondMode}; +use proxmox_network_types::{MacAddress, fqdn::Fqdn, ip_address::Cidr}; #[cfg(feature = "api-types")] use proxmox_schema::{ @@ -438,6 +439,106 @@ pub struct NetworkInterfacePinningOptionsAnswer { pub mapping: HashMap, } +#[cfg_attr(feature = "api-types", api( + properties: { + members: { + description: "List of interface MAC addresses to add as bond members.", + type: Array, + items: { + type: MacAddress, + }, + }, + }, +))] +#[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. + pub members: Vec, + /// 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 is interpreted as disabled by the low-level installer + members: Vec::new(), + mode: LinuxBondMode::ActiveBackup, + hash_policy: BondXmitHashPolicy::Layer2, + primary_interface: None, + } + } + + /// 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<()> { + if self.members.len() < 2 { + bail!( + "bond setup requires at least 2 interfaces, got {}", + self.members.len() + ); + } + + 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"); + } + + if !self.members.contains(primary_if) { + bail!( + "primary bond interface {primary_if} must be a member of the bond (members: {})", + self.members + .iter() + .fold(String::new(), |acc, mac| format!("{acc}, {mac}")) + .trim_start_matches(", ") + ); + } + } + + Ok(()) + } +} + #[cfg_attr(feature = "api-types", api( properties: { filter: { @@ -464,6 +565,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)] @@ -475,6 +579,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( @@ -503,6 +610,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)] @@ -1475,4 +1590,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