From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 683091FF09C for ; Mon, 02 Nov 2026 13:50:46 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 9674021878; Mon, 02 Nov 2026 13:50:37 +0100 (CET) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager 3/5] api: auto-installer: wire up new vlan and bond network options Date: Mon, 2 Nov 2026 13:49:35 +0100 Message-ID: <20261102124939.1023342-4-c.heiss@proxmox.com> X-Mailer: git-send-email 2.55.0 In-Reply-To: <20261102124939.1023342-1-c.heiss@proxmox.com> References: <20261102124939.1023342-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: 1793623804977 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.177 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: MI23DC2G7KFPYYM56NXJNJ3KRBZ6AE7B X-Message-ID-Hash: MI23DC2G7KFPYYM56NXJNJ3KRBZ6AE7B 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 Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Overall pretty straight-forward; adds the required handling in the update and configuration rendering path. Signed-off-by: Christoph Heiss --- server/src/api/auto_installer/mod.rs | 238 +++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/server/src/api/auto_installer/mod.rs b/server/src/api/auto_installer/mod.rs index 7c8ef0fb..96264d12 100644 --- a/server/src/api/auto_installer/mod.rs +++ b/server/src/api/auto_installer/mod.rs @@ -25,6 +25,7 @@ use proxmox_installer_types::{ }, post_hook::PostHookInfo, }; +use proxmox_network_api::{BondXmitHashPolicy, LinuxBondMode}; use proxmox_network_types::fqdn::Fqdn; use proxmox_router::{ ApiHandler, ApiMethod, ApiResponseFuture, Permission, Router, RpcEnvironment, SubdirMap, @@ -427,6 +428,7 @@ async fn create_prepared_answer( validate_udev_filter_map(&config.netdev_filter)?; validate_udev_filter_map(&config.disk_filter)?; + validate_udev_filter_map(&config.bond_members_filter)?; validate_template_map(&config.template_counters)?; prepared.insert(config.id.clone(), config.clone().try_into()?); @@ -546,6 +548,18 @@ async fn update_prepared_answer( DeletablePreparedInstallationConfigProperty::NetdevFilter => { p.netdev_filter.clear(); } + DeletablePreparedInstallationConfigProperty::BondMembersFilter => { + p.bond_members_filter.clear(); + } + DeletablePreparedInstallationConfigProperty::BondMode => { + p.bond_mode = None; + } + DeletablePreparedInstallationConfigProperty::BondHashPolicy => { + p.bond_hash_policy = None; + } + DeletablePreparedInstallationConfigProperty::VlanId => { + p.vlan_id = None; + } DeletablePreparedInstallationConfigProperty::DiskFilter => { p.disk_filter.clear(); } @@ -589,6 +603,12 @@ async fn update_prepared_answer( dns, netdev_filter, netif_name_pinning_enabled, + bond_enabled, + bond_members_filter, + bond_members_filter_match, + bond_mode, + bond_hash_policy, + vlan_id, filesystem, disk_mode, disk_list, @@ -687,6 +707,31 @@ async fn update_prepared_answer( p.netif_name_pinning_enabled = enabled; } + if let Some(enabled) = bond_enabled { + p.bond_enabled = enabled; + } + + if let Some(filter) = bond_members_filter { + validate_udev_filter_map(&filter)?; + **p.bond_members_filter = filter; + } + + if let Some(filter_match) = bond_members_filter_match { + p.bond_members_filter_match = Some(filter_match); + } + + if let Some(mode) = bond_mode { + p.bond_mode = Some(mode); + } + + if let Some(hash_policy) = bond_hash_policy { + p.bond_hash_policy = Some(hash_policy); + } + + if let Some(vid) = vlan_id { + p.vlan_id = Some(vid); + } + if let Some(fs) = filesystem { *p.filesystem = fs; } @@ -1168,6 +1213,40 @@ fn find_config( default_answer.map(|a| a.try_into()).transpose() } +/// Collects both bond and VLAN options of a prepared configuration into the corresponding +/// answer types and doing some sanity checks. +/// +/// Bonding is considered off if no members are set, mirroring the meaning of an empty member list +/// in the low-level installer. +fn render_prepared_advanced_network_config( + conf: &PreparedInstallationConfig, +) -> Result<( + Option, + Option, +)> { + let bond = if conf.bond_enabled { + let bond = answer::NetworkBondOptions { + members_list: vec![], + members_filter: conf.bond_members_filter.clone(), + members_filter_match: conf.bond_members_filter_match, + mode: conf.bond_mode.unwrap_or(LinuxBondMode::ActiveBackup), + hash_policy: conf.bond_hash_policy.unwrap_or(BondXmitHashPolicy::Layer2), + primary_interface: None, + }; + bond.verify()?; + Some(bond) + } else { + None + }; + + let vlan = conf.vlan_id.map(|vid| answer::NetworkVlanOptions { vid }); + if let Some(vlan) = &vlan { + vlan.verify()?; + } + + Ok((bond, vlan)) +} + /// Renders a given [`PreparedInstallationConfig`] into the target [`AutoInstallerConfig`] struct. /// /// Converts all types as needed and renders out MiniJinja templates in applicable fields. @@ -1237,9 +1316,13 @@ fn render_prepared_config( }, ); + let (bond, vlan) = render_prepared_advanced_network_config(conf)?; + if conf.use_dhcp_network { answer::NetworkConfig::FromDhcp(answer::NetworkConfigFromDhcp { interface_name_pinning, + bond, + vlan, }) } else { let cidr = conf @@ -1284,6 +1367,8 @@ fn render_prepared_config( gateway, filter: conf.netdev_filter.clone(), interface_name_pinning, + bond, + vlan, }) } }; @@ -1367,3 +1452,156 @@ fn generate_token( rpcenv, ) } + +#[cfg(test)] +mod tests { + use pdm_api_types::auto_installer::DiskSelectionMode; + use proxmox_installer_types::{ + IsoInfo, NetworkInterface, ProductConfig, ProxmoxProduct, SystemDMI, + answer::NetworkVlanOptions, + }; + + use super::*; + + fn config() -> PreparedInstallationConfig { + PreparedInstallationConfig { + id: "test".to_owned(), + authorized_tokens: Vec::new(), + // target filter + is_default: false, + target_filter: BTreeMap::new(), + // global options + country: "at".to_owned(), + fqdn: "test.example.com".to_owned(), + use_dhcp_fqdn: false, + keyboard: answer::KeyboardLayout::default(), + mailto: String::new(), + timezone: "Europe/Vienna".to_owned(), + root_password_hashed: None, + reboot_on_error: false, + reboot_mode: answer::RebootMode::default(), + root_ssh_keys: Vec::new(), + // network options + use_dhcp_network: true, + cidr: None, + gateway: None, + dns: None, + netdev_filter: BTreeMap::new(), + netif_name_pinning_enabled: true, + bond_enabled: false, + bond_members_filter: BTreeMap::new(), + bond_members_filter_match: None, + bond_mode: None, + bond_hash_policy: None, + vlan_id: None, + // disk options + filesystem: answer::FilesystemOptions::Ext4(answer::LvmOptions::default()), + disk_mode: DiskSelectionMode::default(), + disk_list: Vec::new(), + disk_filter: BTreeMap::new(), + disk_filter_match: None, + // post hook + post_hook_base_url: None, + post_hook_cert_fp: None, + // templating + template_counters: BTreeMap::new(), + // subscription + subscription_key: None, + } + } + + fn sysinfo() -> SystemInfo { + SystemInfo { + product: ProductConfig { + fullname: "Proxmox VE".to_owned(), + product: ProxmoxProduct::Pve, + enable_btrfs: true, + }, + iso: IsoInfo { + release: "9.1".to_owned(), + isorelease: "1".to_owned(), + }, + dmi: SystemDMI { + baseboard: HashMap::new(), + chassis: HashMap::new(), + system: HashMap::new(), + }, + network_interfaces: vec![ + NetworkInterface { + link: "ens18".to_owned(), + mac: "ab:cd:ef:12:34:56".parse().unwrap(), + }, + NetworkInterface { + link: "ens19".to_owned(), + mac: "12:34:56:ab:cd:ef".parse().unwrap(), + }, + ], + } + } + + #[test] + fn netwoork_bond_and_vlan_unset_stays_disabled() { + let prepared = render_prepared_config(&config(), &sysinfo()).unwrap(); + assert!(prepared.network.bond().is_none()); + assert!(prepared.network.vlan().is_none()); + } + + #[test] + fn network_bond_renders_default_options() { + let mut config = config(); + config.bond_enabled = true; + config + .bond_members_filter + .insert("ID_NET_DRIVER".to_owned(), "igc".to_owned()); + + let prepared = render_prepared_config(&config, &sysinfo()).unwrap(); + let bond = prepared.network.bond().unwrap(); + assert_eq!(bond.mode, LinuxBondMode::ActiveBackup); + assert_eq!(bond.hash_policy, BondXmitHashPolicy::Layer2); + } + + #[test] + fn bond_options_without_members_filter_are_rejected() { + let mut config = config(); + config.bond_enabled = true; + + let prepared = render_prepared_config(&config, &sysinfo()); + assert!(prepared.is_err()); + assert_eq!( + prepared.unwrap_err().to_string(), + "bond setup requires either fixed list of members or udev filter list" + ); + } + + #[test] + fn vlan_id_is_validated() { + let mut config = config(); + let sysinfo = sysinfo(); + + assert_eq!( + render_prepared_config(&config, &sysinfo) + .unwrap() + .network + .vlan(), + None + ); + + config.vlan_id = Some(7); + assert_eq!( + render_prepared_config(&config, &sysinfo) + .unwrap() + .network + .vlan(), + Some(&NetworkVlanOptions { vid: 7 }), + ); + + // 1 is the untagged default PVID, thus not accepted + config.vlan_id = Some(1); + assert_eq!( + render_prepared_config(&config, &sysinfo) + .unwrap_err() + .to_string(), + "VLAN id must be between 2 and 4094 inclusive, got 1" + ); + } +} -- 2.55.0