diff --git a/0002-iface-include-existing-interfaces-in-name-search.patch b/0002-iface-include-existing-interfaces-in-name-search.patch new file mode 100644 index 0000000..0f995ae --- /dev/null +++ b/0002-iface-include-existing-interfaces-in-name-search.patch @@ -0,0 +1,134 @@ +From 69df418c0a918163d84cddaa0cf45e6c302c8103 Mon Sep 17 00:00:00 2001 +From: Jan Vaclav +Date: Wed, 29 Jul 2026 11:07:22 +0200 +Subject: [PATCH] iface: include existing interfaces in name search + +InterfaceNameSearch filtered kernel_nics to only up interfaces, which +excluded existing interfaces that were not UP in the merged state, +which broke copy-mac-from when referencing an interface that exists in +current state but is being added as a bond/bridge port in the same +transaction. The port's merged state isn't Up, so it gets filtered out +of kernel_nics. + +Include all existing interfaces in kernel_nics, and only filter desired +interfaces to those that will be up. + +Fixes: aaf11db38e97 ("iface: support matching against alt-name in copy-mac-from") +Signed-off-by: Jan Vaclav +Assisted-by: Claude Sonnet 4.5 +--- + rust/src/lib/ifaces/inter_ifaces.rs | 3 +- + rust/src/lib/unit_tests/alt_name.rs | 82 +++++++++++++++++++++++++++++ + 2 files changed, 84 insertions(+), 1 deletion(-) + +diff --git a/rust/src/lib/ifaces/inter_ifaces.rs b/rust/src/lib/ifaces/inter_ifaces.rs +index 09c5a10f82..76b83a273b 100644 +--- a/rust/src/lib/ifaces/inter_ifaces.rs ++++ b/rust/src/lib/ifaces/inter_ifaces.rs +@@ -1248,7 +1248,7 @@ impl InterfaceNameSearch { + { + let mut kernel_nics: HashSet = HashSet::new(); + +- for iface in merged_ifaces.clone().filter(|i| i.merged.is_up()) { ++ for iface in merged_ifaces.clone() { + if let Some(cur_iface) = iface.current.as_ref() { + // Existing kernel interface + kernel_nics.insert(cur_iface.name().to_string()); +@@ -1256,6 +1256,7 @@ impl InterfaceNameSearch { + // Creating new kernel interface + if des_iface.base_iface().identifier.as_ref() + != Some(&InterfaceIdentifier::MacAddress) ++ && des_iface.is_up() + { + kernel_nics.insert(des_iface.name().to_string()); + } +diff --git a/rust/src/lib/unit_tests/alt_name.rs b/rust/src/lib/unit_tests/alt_name.rs +index 4fa417df7c..8ff35f7d96 100644 +--- a/rust/src/lib/unit_tests/alt_name.rs ++++ b/rust/src/lib/unit_tests/alt_name.rs +@@ -420,3 +420,85 @@ fn test_do_not_resolve_mac_identifer_iface_to_alt_name() { + assert!(merged_iface.for_verify.is_none()); + assert!(merged_iface.desired.is_none()); + } ++ ++#[test] ++fn test_copy_mac_from_existing_down_port() { ++ let desired: NetworkState = serde_yaml::from_str( ++ r"--- ++ interfaces: ++ - name: bond99 ++ type: bond ++ state: up ++ copy-mac-from: eth1 ++ link-aggregation: ++ mode: balance-rr ++ port: ++ - eth1 ++ - eth2", ++ ) ++ .unwrap(); ++ let current: NetworkState = serde_yaml::from_str( ++ r"--- ++ interfaces: ++ - name: eth1 ++ type: ethernet ++ state: down ++ mac-address: AA:BB:CC:DD:EE:FF ++ - name: eth2 ++ type: ethernet ++ state: up ++ mac-address: 11:22:33:44:55:66", ++ ) ++ .unwrap(); ++ ++ let merged_state = ++ MergedNetworkState::new(desired, current, Default::default(), false) ++ .unwrap(); ++ ++ let bond_iface = ++ merged_state.interfaces.kernel_ifaces.get("bond99").unwrap(); ++ let bond_mac = bond_iface ++ .for_apply ++ .as_ref() ++ .and_then(|i| i.base_iface().mac_address.as_ref()); ++ ++ assert_eq!(bond_mac, Some(&"AA:BB:CC:DD:EE:FF".to_string())); ++} ++ ++#[test] ++fn test_copy_mac_from_new_down_interface_fails() { ++ let desired: NetworkState = serde_yaml::from_str( ++ r"--- ++ interfaces: ++ - name: eth1 ++ type: ethernet ++ state: down ++ mac-address: AA:BB:CC:DD:EE:FF ++ - name: bond99 ++ type: bond ++ state: up ++ copy-mac-from: eth1 ++ link-aggregation: ++ mode: balance-rr ++ port: ++ - eth2", ++ ) ++ .unwrap(); ++ let current: NetworkState = serde_yaml::from_str( ++ r"--- ++ interfaces: ++ - name: eth2 ++ type: ethernet ++ state: up ++ mac-address: 11:22:33:44:55:66", ++ ) ++ .unwrap(); ++ ++ let result = ++ MergedNetworkState::new(desired, current, Default::default(), false); ++ ++ let e = result.unwrap_err(); ++ assert_eq!(e.kind(), ErrorKind::InvalidArgument); ++ assert!(e.msg().contains("eth1")); ++ assert!(e.msg().contains("copy-mac-from")); ++} diff --git a/0003-nispor-derive-alt-name-link-match-from-interface-ide.patch b/0003-nispor-derive-alt-name-link-match-from-interface-ide.patch new file mode 100644 index 0000000..c761047 --- /dev/null +++ b/0003-nispor-derive-alt-name-link-match-from-interface-ide.patch @@ -0,0 +1,275 @@ +From 45e5ed38d1f157e5963628041a461f4073c3295d Mon Sep 17 00:00:00 2001 +From: Josephine Pfeiffer +Date: Mon, 6 Jul 2026 20:02:47 +0200 +Subject: [PATCH 3/6] nispor: derive alt-name link match from interface + identifier + +Route systemd `.link` `[Match]` generation through a single +`gen_systemd_match_rules` that dispatches on the interface identifier, +resolve the identifier in `effective_identifier`, and move the shared +`PermanentMACAddress`/`MACAddress`/`Driver` formatting into +`mac_match_rules`. + +When the desired state omits the identifier, `effective_identifier` +falls back to the current interface's identifier. The guard that makes +this safe for stacked interfaces comes next. + +Signed-off-by: Josephine Pfeiffer +--- + rust/src/lib/nispor/alt_name.rs | 213 +++++++++++++++++--------------- + 1 file changed, 116 insertions(+), 97 deletions(-) + +diff --git a/rust/src/lib/nispor/alt_name.rs b/rust/src/lib/nispor/alt_name.rs +index a8188934..3251f31c 100644 +--- a/rust/src/lib/nispor/alt_name.rs ++++ b/rust/src/lib/nispor/alt_name.rs +@@ -6,7 +6,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use crate::{ + BaseInterface, ErrorKind, InterfaceIdentifier, InterfaceState, +- InterfaceType, MergedInterfaces, NmstateError, ++ MergedInterfaces, NmstateError, + }; + + /// Comment added into our generated link files +@@ -199,70 +199,133 @@ async fn remove_systemd_network_link_file( + } + } + +-// The `OriginalName` match rule is unstable because kernel interface +-// name can not be predicted. +-// For ethernet interface, using `OriginalName` match rule is unstable because +-// kernel interface name cannot be predicted. Hence use try in these order: +-// * permanent MAC address + driver name[1] +-// * permanent MAC address +-// * PCI address +-// * MAC address + driver defined in current +-// * MAC address defined in desired +-// * Interface name defined in desired +-// For non-ethernet interface, we use `OriginalName` match rule. +-// [1]: Azure VM with `Microsoft Azure Network Adapter` has two +-// NICs holding the same MAC address. We need to make sure we matched +-// the correct by including driver information also. +-fn gen_systemd_network_link_match_rules( ++fn gen_systemd_match_rules( ++ des_iface: &BaseInterface, ++ cur_iface: Option<&BaseInterface>, ++) -> Result { ++ let (identifier, src_iface) = effective_identifier(des_iface, cur_iface); ++ ++ match identifier { ++ InterfaceIdentifier::MacAddress => { ++ // Include driver in case two NICs hold the same MAC. ++ // (e.g. Microsoft Azure Network Adapter) ++ let driver = cur_iface.and_then(|i| i.driver.as_ref()); ++ mac_match_rules( ++ src_iface.permanent_mac_address.as_ref(), ++ src_iface.mac_address.as_ref(), ++ driver, ++ ) ++ .ok_or_else(|| { ++ NmstateError::new( ++ ErrorKind::Bug, ++ format!( ++ "Got no MAC address for `identifier:mac-address` on \ ++ interface {}, should be failed by previous checker", ++ des_iface.name ++ ), ++ ) ++ }) ++ } ++ InterfaceIdentifier::PciAddress => { ++ if let Some(pci_addr) = src_iface.pci_address.as_ref() { ++ Ok(format!("Path=pci-{pci_addr}")) ++ } else { ++ Err(NmstateError::new( ++ ErrorKind::Bug, ++ format!( ++ "Got no PCI address for `identifier:pci-address` on \ ++ interface {}, should be failed by previous checker", ++ des_iface.name ++ ), ++ )) ++ } ++ } ++ InterfaceIdentifier::Name => { ++ Ok(gen_systemd_name_match_rules(des_iface, cur_iface)) ++ } ++ } ++} ++ ++// Resolve the identifier to match on and the interface that supplies its ++// MAC/PCI value. Desired state's explicit identifier is honoured as-is; when it ++// is absent the current interface's identifier is inherited. ++fn effective_identifier<'a>( ++ des_iface: &'a BaseInterface, ++ cur_iface: Option<&'a BaseInterface>, ++) -> (InterfaceIdentifier, &'a BaseInterface) { ++ if let Some(id) = des_iface.identifier { ++ return (id, des_iface); ++ } ++ if let Some(cur) = cur_iface { ++ match cur.identifier { ++ Some(InterfaceIdentifier::MacAddress) => { ++ return (InterfaceIdentifier::MacAddress, cur); ++ } ++ Some(InterfaceIdentifier::PciAddress) => { ++ return (InterfaceIdentifier::PciAddress, cur); ++ } ++ _ => {} ++ } ++ } ++ (InterfaceIdentifier::Name, des_iface) ++} ++ ++// Fallback used when the resolved identifier is `name` (the default). ++// ++// The kernel name is unpredictable, so match on the most stable attribute ++// available: permanent MAC, then PCI path, then current MAC, each with driver ++// when known to disambiguate NICs sharing a MAC (e.g. Azure's Microsoft Azure ++// Network Adapter). ++fn gen_systemd_name_match_rules( + des_iface: &BaseInterface, + cur_iface: Option<&BaseInterface>, + ) -> String { + if let Some(cur_iface) = cur_iface { +- match ( ++ if let Some(rules) = mac_match_rules( + cur_iface.permanent_mac_address.as_ref(), ++ None, + cur_iface.driver.as_ref(), +- cur_iface.pci_address.as_ref(), ++ ) { ++ rules ++ } else if let Some(pci_addr) = cur_iface.pci_address.as_ref() { ++ format!("Path=pci-{pci_addr}") ++ } else if let Some(rules) = mac_match_rules( ++ None, + cur_iface.mac_address.as_ref(), ++ cur_iface.driver.as_ref(), + ) { +- (Some(perm_mac), Some(driver), _pci_addr, _mac) => { +- format!("PermanentMACAddress={perm_mac}\nDriver={driver}") +- } +- (Some(perm_mac), None, _pci_addr, _mac) => { +- format!("PermanentMACAddress={perm_mac}") +- } +- (None, _driver, Some(pci_addr), _mac) => { +- format!("Path=pci-{pci_addr}") +- } +- (None, Some(driver), None, Some(mac)) => { +- format!("MACAddress={mac}\nDriver={driver}") +- } +- (None, None, None, Some(mac)) => { +- format!("MACAddress={mac}") +- } +- _ => { +- format!("OriginalName={}", cur_iface.name) +- } +- } +- } else { +- // User is creating software interface or gen_conf mode +- if des_iface.iface_type == InterfaceType::Ethernet { +- match (des_iface.mac_address.as_ref(), des_iface.driver.as_ref()) { +- (Some(mac), Some(driver)) => { +- format!("MACAddress={mac}\nDriver={driver}") +- } +- (Some(mac), None) => { +- format!("MACAddress={mac}") +- } +- (None, _) => { +- format!("OriginalName={}", des_iface.name) +- } +- } ++ rules + } else { +- format!("OriginalName={}", des_iface.name) ++ format!("OriginalName={}", cur_iface.name) + } ++ } else { ++ // User is creating the ethernet interface or gen_conf mode ++ mac_match_rules( ++ None, ++ des_iface.mac_address.as_ref(), ++ des_iface.driver.as_ref(), ++ ) ++ .unwrap_or_else(|| format!("OriginalName={}", des_iface.name)) + } + } + ++fn mac_match_rules( ++ permanent_mac: Option<&String>, ++ mac: Option<&String>, ++ driver: Option<&String>, ++) -> Option { ++ let mut rules = if let Some(mac) = permanent_mac { ++ format!("PermanentMACAddress={mac}") ++ } else { ++ let mac = mac?; ++ format!("MACAddress={mac}") ++ }; ++ if let Some(driver) = driver { ++ write!(rules, "\nDriver={driver}").ok(); ++ } ++ Some(rules) ++} ++ + async fn save_systemd_network_link_file( + des_base_iface: &BaseInterface, + cur_base_iface: Option<&BaseInterface>, +@@ -283,51 +346,7 @@ async fn save_systemd_network_link_file( + } + let iface_name = des_base_iface.name.as_str(); + +- let match_rules = match des_base_iface.identifier.as_ref() { +- Some(InterfaceIdentifier::MacAddress) => { +- let mut match_rules = if let Some(mac) = +- des_base_iface.permanent_mac_address.as_ref() +- { +- format!("PermanentMACAddress={mac}") +- } else if let Some(mac) = des_base_iface.mac_address.as_ref() { +- format!("MACAddress={mac}") +- } else { +- return Err(NmstateError::new( +- ErrorKind::Bug, +- format!( +- "Got no MAC address for `identifier:mac-address` on \ +- interface {}, should be failed by previous checker", +- des_base_iface.name +- ), +- )); +- }; +- // Include driver in match rule in case we have two NIC holding the +- // same MAC. (e.g. M$ Azure - Microsoft Azure Network Adapter) +- if let Some(cur_base_iface) = cur_base_iface.as_ref() +- && let Some(driver) = cur_base_iface.driver.as_ref() +- { +- write!(match_rules, "\nDriver={driver}").ok(); +- } +- match_rules +- } +- Some(InterfaceIdentifier::PciAddress) => { +- if let Some(pci_addr) = des_base_iface.pci_address.as_ref() { +- format!("Path=pci-{pci_addr}") +- } else { +- return Err(NmstateError::new( +- ErrorKind::Bug, +- format!( +- "Got no PCI address for `identifier:pci-address` on \ +- interface {}, should be failed by previous checker", +- des_base_iface.name +- ), +- )); +- } +- } +- _ => { +- gen_systemd_network_link_match_rules(des_base_iface, cur_base_iface) +- } +- }; ++ let match_rules = gen_systemd_match_rules(des_base_iface, cur_base_iface)?; + + let file_path = gen_systemd_link_file_path(iface_name); + let mut content = { +-- +2.54.0 + diff --git a/0004-nispor-keep-OriginalName-link-match-for-stacked-inte.patch b/0004-nispor-keep-OriginalName-link-match-for-stacked-inte.patch new file mode 100644 index 0000000..a1b8b6f --- /dev/null +++ b/0004-nispor-keep-OriginalName-link-match-for-stacked-inte.patch @@ -0,0 +1,405 @@ +From 43a8ad166ac0abffd1c0df047819b538bd68c3d5 Mon Sep 17 00:00:00 2001 +From: Josephine Pfeiffer +Date: Mon, 6 Jul 2026 20:03:33 +0200 +Subject: [PATCH 4/6] nispor: keep OriginalName link match for stacked + interfaces + +A VLAN takes the MAC of its lower device, and on a bond that MAC is +borrowed from the first port and shifts with the port set. On an +alt-names-only reapply the interface already exists, so falling back to +its identifier would pin the persisted `.link` `[Match]` to that +borrowed MAC and lose the match once the ports change. + +Fall back to a current mac-address/pci-address identifier only when the +live interface owns it (a permanent MAC or a PCI address); otherwise use +`OriginalName` on non-ethernet interfaces. An explicit +`identifier: mac-address` is still honoured. + +Resolves: https://redhat.atlassian.net/browse/RHEL-167955 +Resolves: https://redhat.atlassian.net/browse/NMT-2193 +Signed-off-by: Josephine Pfeiffer +--- + rust/src/lib/nispor/alt_name.rs | 269 ++++++++++++++++++++++++++++- + tests/integration/alt_name_test.py | 41 +++++ + 2 files changed, 301 insertions(+), 9 deletions(-) + +diff --git a/rust/src/lib/nispor/alt_name.rs b/rust/src/lib/nispor/alt_name.rs +index 3251f31c..4471e9e4 100644 +--- a/rust/src/lib/nispor/alt_name.rs ++++ b/rust/src/lib/nispor/alt_name.rs +@@ -6,7 +6,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use crate::{ + BaseInterface, ErrorKind, InterfaceIdentifier, InterfaceState, +- MergedInterfaces, NmstateError, ++ InterfaceType, MergedInterfaces, NmstateError, + }; + + /// Comment added into our generated link files +@@ -247,8 +247,13 @@ fn gen_systemd_match_rules( + } + + // Resolve the identifier to match on and the interface that supplies its +-// MAC/PCI value. Desired state's explicit identifier is honoured as-is; when it +-// is absent the current interface's identifier is inherited. ++// MAC/PCI value. Desired state's explicit identifier is honoured as-is. When it ++// is absent, a current `mac-address`/`pci-address` identifier is inherited only ++// if the live interface owns that attribute (a permanent MAC or a PCI address), ++// so a stacked interface never inherits a borrowed, unstable MAC; it falls ++// through to the name match instead. A persisted `.link` file outlives the ++// current port set, so it must never pin to a borrowed MAC even though NM ++// profile matching may. + fn effective_identifier<'a>( + des_iface: &'a BaseInterface, + cur_iface: Option<&'a BaseInterface>, +@@ -258,10 +263,14 @@ fn effective_identifier<'a>( + } + if let Some(cur) = cur_iface { + match cur.identifier { +- Some(InterfaceIdentifier::MacAddress) => { ++ Some(InterfaceIdentifier::MacAddress) ++ if cur.permanent_mac_address.is_some() => ++ { + return (InterfaceIdentifier::MacAddress, cur); + } +- Some(InterfaceIdentifier::PciAddress) => { ++ Some(InterfaceIdentifier::PciAddress) ++ if cur.pci_address.is_some() => ++ { + return (InterfaceIdentifier::PciAddress, cur); + } + _ => {} +@@ -272,14 +281,20 @@ fn effective_identifier<'a>( + + // Fallback used when the resolved identifier is `name` (the default). + // +-// The kernel name is unpredictable, so match on the most stable attribute +-// available: permanent MAC, then PCI path, then current MAC, each with driver +-// when known to disambiguate NICs sharing a MAC (e.g. Azure's Microsoft Azure +-// Network Adapter). ++// For ethernet the kernel name is unpredictable, so match on the most stable ++// attribute available: permanent MAC, then PCI path, then current MAC, each ++// with driver when known to disambiguate NICs sharing a MAC (e.g. Azure's ++// Microsoft Azure Network Adapter). For non-ethernet the kernel name is ++// user-assigned and the MAC is borrowed from a lower device (a VLAN takes its ++// bond's MAC, itself from the first enslaved port), so use `OriginalName`. + fn gen_systemd_name_match_rules( + des_iface: &BaseInterface, + cur_iface: Option<&BaseInterface>, + ) -> String { ++ if des_iface.iface_type != InterfaceType::Ethernet { ++ return format!("OriginalName={}", des_iface.name); ++ } ++ + if let Some(cur_iface) = cur_iface { + if let Some(rules) = mac_match_rules( + cur_iface.permanent_mac_address.as_ref(), +@@ -391,3 +406,239 @@ async fn save_systemd_network_link_file( + })?; + Ok(()) + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ // Desired VLAN has no MAC; the live one carries the inherited bond MAC. ++ #[test] ++ fn vlan_reapply_uses_original_name() { ++ let mut des = BaseInterface::new(); ++ des.name = "external0".to_string(); ++ des.iface_type = InterfaceType::Vlan; ++ ++ let mut cur = des.clone(); ++ cur.mac_address = Some("52:54:00:80:77:C9".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "OriginalName=external0"); ++ } ++ ++ // Create path (no current iface), non-VLAN software type, MAC ignored. ++ #[test] ++ fn software_iface_create_uses_original_name() { ++ let mut des = BaseInterface::new(); ++ des.name = "bond0".to_string(); ++ des.iface_type = InterfaceType::Bond; ++ des.mac_address = Some("52:54:00:80:77:C9".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, None).unwrap(); ++ assert_eq!(rules, "OriginalName=bond0"); ++ } ++ ++ #[test] ++ fn ethernet_reapply_uses_permanent_mac() { ++ let mut des = BaseInterface::new(); ++ des.name = "eth0".to_string(); ++ des.iface_type = InterfaceType::Ethernet; ++ ++ let mut cur = des.clone(); ++ cur.permanent_mac_address = Some("52:54:00:11:22:33".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "PermanentMACAddress=52:54:00:11:22:33"); ++ } ++ ++ // A live mac-address identifier without a permanent MAC (borrowed from a ++ // lower device) is not inherited. ++ #[test] ++ fn inherited_mac_ignored_for_virtual_iface() { ++ let mut des = BaseInterface::new(); ++ des.name = "vlan0".to_string(); ++ des.iface_type = InterfaceType::Vlan; ++ ++ let mut cur = des.clone(); ++ cur.identifier = Some(InterfaceIdentifier::MacAddress); ++ cur.mac_address = Some("52:54:00:80:77:C9".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "OriginalName=vlan0"); ++ } ++ ++ // A physical NIC with a live mac-address identifier owns a permanent MAC, ++ // so an alt-names-only reapply inherits it and matches by permanent MAC. ++ #[test] ++ fn inherited_mac_uses_permanent_for_physical() { ++ let mut des = BaseInterface::new(); ++ des.name = "eth0".to_string(); ++ des.iface_type = InterfaceType::Ethernet; ++ ++ let mut cur = des.clone(); ++ cur.identifier = Some(InterfaceIdentifier::MacAddress); ++ cur.permanent_mac_address = Some("52:54:00:11:22:33".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "PermanentMACAddress=52:54:00:11:22:33"); ++ } ++ ++ // A live pci-address identifier is inherited over the name fallback's ++ // default order, pinning the PCI path the user originally chose. ++ #[test] ++ fn inherited_pci_identifier_preserved() { ++ let mut des = BaseInterface::new(); ++ des.name = "eth0".to_string(); ++ des.iface_type = InterfaceType::Ethernet; ++ ++ let mut cur = des.clone(); ++ cur.identifier = Some(InterfaceIdentifier::PciAddress); ++ cur.permanent_mac_address = Some("52:54:00:11:22:33".to_string()); ++ cur.pci_address = ++ Some(crate::PciAddress::try_from("0000:00:1f.6").unwrap()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "Path=pci-0000:00:1f.6"); ++ } ++ ++ // Explicit `identifier: name` wins over the live mac-address identifier. ++ #[test] ++ fn explicit_name_identifier_keeps_original_name() { ++ let mut des = BaseInterface::new(); ++ des.name = "vlan0".to_string(); ++ des.iface_type = InterfaceType::Vlan; ++ des.identifier = Some(InterfaceIdentifier::Name); ++ ++ let mut cur = des.clone(); ++ cur.identifier = Some(InterfaceIdentifier::MacAddress); ++ cur.mac_address = Some("52:54:00:80:77:C9".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "OriginalName=vlan0"); ++ } ++ ++ // mac-address identifier appends the live driver to disambiguate two NICs ++ // sharing one MAC. ++ #[test] ++ fn mac_identifier_appends_current_driver() { ++ let mut des = BaseInterface::new(); ++ des.name = "eth0".to_string(); ++ des.iface_type = InterfaceType::Ethernet; ++ des.identifier = Some(InterfaceIdentifier::MacAddress); ++ des.mac_address = Some("52:54:00:11:22:33".to_string()); ++ ++ let mut cur = des.clone(); ++ cur.driver = Some("e1000e".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "MACAddress=52:54:00:11:22:33\nDriver=e1000e"); ++ } ++ ++ #[test] ++ fn pci_identifier_uses_path() { ++ let mut des = BaseInterface::new(); ++ des.name = "eth0".to_string(); ++ des.iface_type = InterfaceType::Ethernet; ++ des.identifier = Some(InterfaceIdentifier::PciAddress); ++ des.pci_address = ++ Some(crate::PciAddress::try_from("0000:00:1f.6").unwrap()); ++ ++ let rules = gen_systemd_match_rules(&des, None).unwrap(); ++ assert_eq!(rules, "Path=pci-0000:00:1f.6"); ++ } ++ ++ #[test] ++ fn mac_identifier_without_mac_is_bug() { ++ let mut des = BaseInterface::new(); ++ des.name = "eth0".to_string(); ++ des.iface_type = InterfaceType::Ethernet; ++ des.identifier = Some(InterfaceIdentifier::MacAddress); ++ ++ let e = gen_systemd_match_rules(&des, None).unwrap_err(); ++ assert_eq!(e.kind(), ErrorKind::Bug); ++ } ++ ++ #[test] ++ fn pci_identifier_without_pci_is_bug() { ++ let mut des = BaseInterface::new(); ++ des.name = "eth0".to_string(); ++ des.iface_type = InterfaceType::Ethernet; ++ des.identifier = Some(InterfaceIdentifier::PciAddress); ++ ++ let e = gen_systemd_match_rules(&des, None).unwrap_err(); ++ assert_eq!(e.kind(), ErrorKind::Bug); ++ } ++ ++ #[test] ++ fn name_fallback_uses_permanent_mac_and_driver() { ++ let mut des = BaseInterface::new(); ++ des.name = "eth0".to_string(); ++ des.iface_type = InterfaceType::Ethernet; ++ ++ let mut cur = des.clone(); ++ cur.permanent_mac_address = Some("52:54:00:11:22:33".to_string()); ++ cur.driver = Some("e1000e".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!( ++ rules, ++ "PermanentMACAddress=52:54:00:11:22:33\nDriver=e1000e" ++ ); ++ } ++ ++ #[test] ++ fn name_fallback_uses_current_mac_and_driver() { ++ let mut des = BaseInterface::new(); ++ des.name = "eth0".to_string(); ++ des.iface_type = InterfaceType::Ethernet; ++ ++ let mut cur = des.clone(); ++ cur.mac_address = Some("52:54:00:11:22:33".to_string()); ++ cur.driver = Some("e1000e".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "MACAddress=52:54:00:11:22:33\nDriver=e1000e"); ++ } ++ ++ // Explicit desired mac-address identifier is honoured on a non-ethernet ++ // iface too, matching by MAC rather than falling back to OriginalName. ++ #[test] ++ fn non_eth_explicit_mac_identifier_uses_mac() { ++ let mut des = BaseInterface::new(); ++ des.name = "vlan0".to_string(); ++ des.iface_type = InterfaceType::Vlan; ++ des.identifier = Some(InterfaceIdentifier::MacAddress); ++ des.mac_address = Some("52:54:00:11:22:33".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, None).unwrap(); ++ assert_eq!(rules, "MACAddress=52:54:00:11:22:33"); ++ } ++ ++ // Permanent MAC wins when both a permanent and a current MAC are present. ++ #[test] ++ fn permanent_mac_preferred_over_current() { ++ let mut des = BaseInterface::new(); ++ des.name = "eth0".to_string(); ++ des.iface_type = InterfaceType::Ethernet; ++ ++ let mut cur = des.clone(); ++ cur.permanent_mac_address = Some("52:54:00:11:22:33".to_string()); ++ cur.mac_address = Some("52:54:00:AA:BB:CC".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "PermanentMACAddress=52:54:00:11:22:33"); ++ } ++ ++ // Ethernet reapply with no MAC, PCI, or permanent MAC on the live iface ++ // falls back to its current kernel name. ++ #[test] ++ fn eth_name_fallback_with_no_attributes() { ++ let mut des = BaseInterface::new(); ++ des.name = "eth0".to_string(); ++ des.iface_type = InterfaceType::Ethernet; ++ ++ let cur = des.clone(); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "OriginalName=eth0"); ++ } ++} +diff --git a/tests/integration/alt_name_test.py b/tests/integration/alt_name_test.py +index a72d4ad0..f53bfe62 100644 +--- a/tests/integration/alt_name_test.py ++++ b/tests/integration/alt_name_test.py +@@ -1,6 +1,7 @@ + # SPDX-License-Identifier: Apache-2.0 + + import copy ++import os + + import pytest + +@@ -73,6 +74,13 @@ def eth1_with_alt_names(eth1_up): + assert get_ip_link_alt_names("eth1") == [] + + ++def read_systemd_link_file(iface_name): ++ path = f"/etc/systemd/network/98-nmstate-{iface_name}.link" ++ assert os.path.exists(path), f"link file {path} not found" ++ with open(path) as f: ++ return f.read() ++ ++ + def udev_trigger_check_alt_names(iface_name, expected_alt_names): + exec_cmd( + "udevadm trigger --settle --action add " +@@ -235,6 +243,39 @@ class TestAltNames: + assert iface[VLAN.CONFIG_SUBTREE][VLAN.BASE_IFACE] == "eth1" + assert iface[VLAN.CONFIG_SUBTREE][VLAN.ID] == 101 + ++ # https://issues.redhat.com/browse/RHEL-167955 ++ @pytest.mark.tier1 ++ def test_vlan_on_bond_alt_name_uses_original_name_on_reapply( ++ self, eth1_up, eth2_up ++ ): ++ vlan_name = f"{TEST_BOND_NIC}.101" ++ alt_name = "altone" ++ with bond_interface(TEST_BOND_NIC, ["eth1", "eth2"]), vlan_interface( ++ vlan_name, 101, TEST_BOND_NIC ++ ) as vlan_state: ++ desired_state = copy.deepcopy(vlan_state) ++ iface = desired_state[Interface.KEY][0] ++ iface[InterfaceAltName.KEY] = [{InterfaceAltName.NAME: alt_name}] ++ # The VLAN already exists, so persisting alt-names takes the ++ # current-state path. Apply twice: the reapply, not the first ++ # write, is what must keep OriginalName. ++ for _ in range(2): ++ libnmstate.apply(desired_state) ++ content = read_systemd_link_file(vlan_name) ++ lines = content.splitlines() ++ assert f"OriginalName={vlan_name}" in lines ++ assert f"Name={vlan_name}" in lines ++ # Any hardware key shifts the match off the kernel name; ++ # "MACAddress=" also catches "PermanentMACAddress=". ++ for key in ("MACAddress=", "Path=", "Driver="): ++ assert key not in content ++ assert retry_till_true_or_timeout( ++ RETRY_TIMEOUT, ++ udev_trigger_check_alt_names, ++ vlan_name, ++ [alt_name], ++ ) ++ + # https://issues.redhat.com/browse/RHEL-126508 + @pytest.mark.tier1 + def test_ref_alt_name_in_route(self, eth1_with_alt_names): +-- +2.54.0 + diff --git a/0005-nispor-pin-physical-non-ethernet-alt-name-match-to-o.patch b/0005-nispor-pin-physical-non-ethernet-alt-name-match-to-o.patch new file mode 100644 index 0000000..23bdd6e --- /dev/null +++ b/0005-nispor-pin-physical-non-ethernet-alt-name-match-to-o.patch @@ -0,0 +1,192 @@ +From a1f5311a0bdc3f582dd54004ee8b67e90733bd0e Mon Sep 17 00:00:00 2001 +From: Josephine Pfeiffer +Date: Tue, 7 Jul 2026 08:20:26 +0200 +Subject: [PATCH 5/6] nispor: pin physical non-ethernet alt-name match to owned + identifier + +The name fallback matched every non-ethernet interface on OriginalName, +on the assumption that a non-ethernet kernel name is user-assigned and +its MAC borrowed. That holds for VLAN, bond, and bridge, but not for +physical InfiniBand: its kernel name comes from probe order and udev can +rename it, so an OriginalName match can silently break after a reboot. + +Prefer a permanent MAC, then a PCI address the live interface owns, +before falling back to OriginalName, and never match on a borrowed +current MAC. A VLAN over a bond owns neither, so it still matches on +OriginalName; a physical InfiniBand pins to its permanent MAC or PCI +path. + +Signed-off-by: Josephine Pfeiffer +--- + rust/src/lib/nispor/alt_name.rs | 119 +++++++++++++++++++++++--------- + 1 file changed, 85 insertions(+), 34 deletions(-) + +diff --git a/rust/src/lib/nispor/alt_name.rs b/rust/src/lib/nispor/alt_name.rs +index 4471e9e4..220e6919 100644 +--- a/rust/src/lib/nispor/alt_name.rs ++++ b/rust/src/lib/nispor/alt_name.rs +@@ -246,14 +246,10 @@ fn gen_systemd_match_rules( + } + } + +-// Resolve the identifier to match on and the interface that supplies its +-// MAC/PCI value. Desired state's explicit identifier is honoured as-is. When it +-// is absent, a current `mac-address`/`pci-address` identifier is inherited only +-// if the live interface owns that attribute (a permanent MAC or a PCI address), +-// so a stacked interface never inherits a borrowed, unstable MAC; it falls +-// through to the name match instead. A persisted `.link` file outlives the +-// current port set, so it must never pin to a borrowed MAC even though NM +-// profile matching may. ++// Pick the identifier and the interface holding its value. An explicit desired ++// identifier wins; a live mac/pci identifier is inherited only when the iface ++// owns it (permanent MAC or PCI), never a stacked iface's borrowed MAC, since ++// the persisted `.link` file outlives the port set that MAC came from. + fn effective_identifier<'a>( + des_iface: &'a BaseInterface, + cur_iface: Option<&'a BaseInterface>, +@@ -279,40 +275,33 @@ fn effective_identifier<'a>( + (InterfaceIdentifier::Name, des_iface) + } + +-// Fallback used when the resolved identifier is `name` (the default). +-// +-// For ethernet the kernel name is unpredictable, so match on the most stable +-// attribute available: permanent MAC, then PCI path, then current MAC, each +-// with driver when known to disambiguate NICs sharing a MAC (e.g. Azure's +-// Microsoft Azure Network Adapter). For non-ethernet the kernel name is +-// user-assigned and the MAC is borrowed from a lower device (a VLAN takes its +-// bond's MAC, itself from the first enslaved port), so use `OriginalName`. ++// Fallback for the `name` identifier: match the most stable attribute the live ++// interface owns (permanent MAC, then PCI, with driver to disambiguate NICs ++// sharing a MAC). A borrowed MAC is never used, so software interfaces fall ++// through to `OriginalName`; ethernet also accepts its own current MAC first. + fn gen_systemd_name_match_rules( + des_iface: &BaseInterface, + cur_iface: Option<&BaseInterface>, + ) -> String { + if des_iface.iface_type != InterfaceType::Ethernet { +- return format!("OriginalName={}", des_iface.name); ++ return cur_iface.and_then(owned_hw_match_rules).unwrap_or_else(|| { ++ let name = cur_iface ++ .map(|i| i.name.as_str()) ++ .unwrap_or(des_iface.name.as_str()); ++ format!("OriginalName={name}") ++ }); + } + + if let Some(cur_iface) = cur_iface { +- if let Some(rules) = mac_match_rules( +- cur_iface.permanent_mac_address.as_ref(), +- None, +- cur_iface.driver.as_ref(), +- ) { +- rules +- } else if let Some(pci_addr) = cur_iface.pci_address.as_ref() { +- format!("Path=pci-{pci_addr}") +- } else if let Some(rules) = mac_match_rules( +- None, +- cur_iface.mac_address.as_ref(), +- cur_iface.driver.as_ref(), +- ) { +- rules +- } else { +- format!("OriginalName={}", cur_iface.name) +- } ++ owned_hw_match_rules(cur_iface) ++ .or_else(|| { ++ mac_match_rules( ++ None, ++ cur_iface.mac_address.as_ref(), ++ cur_iface.driver.as_ref(), ++ ) ++ }) ++ .unwrap_or_else(|| format!("OriginalName={}", cur_iface.name)) + } else { + // User is creating the ethernet interface or gen_conf mode + mac_match_rules( +@@ -324,6 +313,22 @@ fn gen_systemd_name_match_rules( + } + } + ++// A stable hardware identifier the interface owns: permanent MAC (with driver) ++// or PCI path. None for a borrowed MAC (e.g. a VLAN), which matches by name. ++fn owned_hw_match_rules(cur_iface: &BaseInterface) -> Option { ++ mac_match_rules( ++ cur_iface.permanent_mac_address.as_ref(), ++ None, ++ cur_iface.driver.as_ref(), ++ ) ++ .or_else(|| { ++ cur_iface ++ .pci_address ++ .as_ref() ++ .map(|pci_addr| format!("Path=pci-{pci_addr}")) ++ }) ++} ++ + fn mac_match_rules( + permanent_mac: Option<&String>, + mac: Option<&String>, +@@ -425,6 +430,21 @@ mod tests { + assert_eq!(rules, "OriginalName=external0"); + } + ++ // The name fallback keys on the live kernel name, not the desired name, so ++ // udev matches the interface it names. ++ #[test] ++ fn non_eth_original_name_uses_current_name() { ++ let mut des = BaseInterface::new(); ++ des.name = "bond-vlan".to_string(); ++ des.iface_type = InterfaceType::Vlan; ++ ++ let mut cur = des.clone(); ++ cur.name = "external0".to_string(); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "OriginalName=external0"); ++ } ++ + // Create path (no current iface), non-VLAN software type, MAC ignored. + #[test] + fn software_iface_create_uses_original_name() { +@@ -450,6 +470,37 @@ mod tests { + assert_eq!(rules, "PermanentMACAddress=52:54:00:11:22:33"); + } + ++ // A physical non-ethernet iface (InfiniBand) owns a permanent MAC, so an ++ // alt-names-only reapply pins to it instead of its renameable kernel name. ++ #[test] ++ fn infiniband_reapply_uses_permanent_mac() { ++ let mut des = BaseInterface::new(); ++ des.name = "mlx0".to_string(); ++ des.iface_type = InterfaceType::InfiniBand; ++ ++ let mut cur = des.clone(); ++ cur.permanent_mac_address = Some("52:54:00:11:22:33".to_string()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "PermanentMACAddress=52:54:00:11:22:33"); ++ } ++ ++ // A physical non-ethernet iface owning a PCI address but no permanent MAC ++ // pins to the PCI path rather than the kernel name. ++ #[test] ++ fn non_eth_reapply_uses_pci_path() { ++ let mut des = BaseInterface::new(); ++ des.name = "mlx0".to_string(); ++ des.iface_type = InterfaceType::InfiniBand; ++ ++ let mut cur = des.clone(); ++ cur.pci_address = ++ Some(crate::PciAddress::try_from("0000:00:1f.6").unwrap()); ++ ++ let rules = gen_systemd_match_rules(&des, Some(&cur)).unwrap(); ++ assert_eq!(rules, "Path=pci-0000:00:1f.6"); ++ } ++ + // A live mac-address identifier without a permanent MAC (borrowed from a + // lower device) is not inherited. + #[test] +-- +2.54.0 + diff --git a/0006-nispor-skip-alt-name-removal-for-deleted-virtual-int.patch b/0006-nispor-skip-alt-name-removal-for-deleted-virtual-int.patch new file mode 100644 index 0000000..a828c6f --- /dev/null +++ b/0006-nispor-skip-alt-name-removal-for-deleted-virtual-int.patch @@ -0,0 +1,102 @@ +From 1527741c551a7ac54d5111ee264b3557fd3ad110 Mon Sep 17 00:00:00 2001 +From: Josephine Pfeiffer +Date: Fri, 10 Jul 2026 14:56:09 +0200 +Subject: [PATCH 6/6] nispor: skip alt-name removal for deleted virtual + interfaces + +Deleting a virtual interface (e.g. a VLAN) that carries an nmstate-managed +alt-name failed because NetworkManager removes the interface first, then the +nispor alt-name removal runs against the now-absent interface and the kernel +rejects it with EOPNOTSUPP. + +A deleted virtual interface takes its alt-names with it, so skip the removal. +Physical interfaces survive state:absent, so they still get their alt-names +cleared. + +Signed-off-by: Josephine Pfeiffer +--- + rust/src/lib/nispor/alt_name.rs | 16 +++++++++---- + tests/integration/alt_name_test.py | 36 ++++++++++++++++++++++++++++++ + 2 files changed, 48 insertions(+), 4 deletions(-) + +diff --git a/rust/src/lib/nispor/alt_name.rs b/rust/src/lib/nispor/alt_name.rs +index 220e6919..3fc575f4 100644 +--- a/rust/src/lib/nispor/alt_name.rs ++++ b/rust/src/lib/nispor/alt_name.rs +@@ -80,14 +80,22 @@ pub(crate) async fn apply_ifaces_alt_names( + + for merged_iface in merged_ifaces.kernel_ifaces.values() { + let apply_iface = if let Some(i) = merged_iface.for_apply.as_ref() { +- i.base_iface() ++ i + } else { + continue; + }; +- let cur_iface = merged_iface.current.as_ref().map(|i| i.base_iface()); +- if let Some(np_iface) = +- gen_nispor_iface_conf_for_alt_name(apply_iface, cur_iface)? ++ // A virtual interface being deleted takes its alt-names with it, so ++ // skip the nispor removal, which would otherwise race the deletion. ++ if apply_iface.base_iface().state == InterfaceState::Absent ++ && apply_iface.is_virtual() + { ++ continue; ++ } ++ let cur_iface = merged_iface.current.as_ref().map(|i| i.base_iface()); ++ if let Some(np_iface) = gen_nispor_iface_conf_for_alt_name( ++ apply_iface.base_iface(), ++ cur_iface, ++ )? { + np_ifaces.push(np_iface); + } + } +diff --git a/tests/integration/alt_name_test.py b/tests/integration/alt_name_test.py +index f53bfe62..df41f18e 100644 +--- a/tests/integration/alt_name_test.py ++++ b/tests/integration/alt_name_test.py +@@ -276,6 +276,42 @@ class TestAltNames: + [alt_name], + ) + ++ # https://issues.redhat.com/browse/RHEL-167955 ++ @pytest.mark.tier1 ++ def test_delete_vlan_on_bond_with_alt_name(self, eth1_up, eth2_up): ++ vlan_name = f"{TEST_BOND_NIC}.102" ++ link_file = f"/etc/systemd/network/98-nmstate-{vlan_name}.link" ++ with bond_interface(TEST_BOND_NIC, ["eth1", "eth2"]): ++ libnmstate.apply( ++ load_yaml( ++ f"""--- ++ interfaces: ++ - name: {vlan_name} ++ type: vlan ++ state: up ++ vlan: ++ base-iface: {TEST_BOND_NIC} ++ id: 102 ++ alt-names: ++ - name: altdel ++ """ ++ ) ++ ) ++ assert os.path.exists(link_file) ++ # Deleting the VLAN must not fail trying to remove an alt-name ++ # from an interface it just deleted; the link file goes with it. ++ libnmstate.apply( ++ load_yaml( ++ f"""--- ++ interfaces: ++ - name: {vlan_name} ++ type: vlan ++ state: absent ++ """ ++ ) ++ ) ++ assert not os.path.exists(link_file) ++ + # https://issues.redhat.com/browse/RHEL-126508 + @pytest.mark.tier1 + def test_ref_alt_name_in_route(self, eth1_with_alt_names): +-- +2.54.0 + diff --git a/nmstate.spec b/nmstate.spec index 1dc029d..4dda6be 100644 --- a/nmstate.spec +++ b/nmstate.spec @@ -4,15 +4,27 @@ Name: nmstate Version: 2.2.61 -Release: 1%{?dist} +Release: 2%{?dist} Summary: Declarative network manager API License: LGPLv2+ URL: https://github.com/%{srcname}/%{srcname} + Source0: https://github.com/nmstate/nmstate/releases/download/v%{version}/nmstate-%{version}.tar.gz +# Keep these patches until next rebase +Patch2: 0002-iface-include-existing-interfaces-in-name-search.patch +Patch0003: 0003-nispor-derive-alt-name-link-match-from-interface-ide.patch +Patch0004: 0004-nispor-keep-OriginalName-link-match-for-stacked-inte.patch +Patch0005: 0005-nispor-pin-physical-non-ethernet-alt-name-match-to-o.patch +Patch0006: 0006-nispor-skip-alt-name-removal-for-deleted-virtual-int.patch + Source1: https://github.com/nmstate/nmstate/releases/download/v%{version}/nmstate-%{version}.tar.gz.asc Source2: https://nmstate.io/nmstate.gpg + Source3: https://github.com/nmstate/nmstate/releases/download/v%{version}/nmstate-vendor-%{version}.tar.xz +# Vendored dependency patch, see %prep section for more details +# on how it's applied. Patch1: 0001-nispor-fix-ipoib-iface-type.patch + BuildRequires: python3-devel BuildRequires: python3-setuptools BuildRequires: gnupg2 @@ -164,6 +176,9 @@ popd /sbin/ldconfig %changelog +* Wed Jul 29 2026 Rahul Rajesh - 2.2.61-2 +- Fix copy-mac-from failing when source interface is down. RHEL-214096 + * Thu Jul 23 2026 Rahul Rajesh - 2.2.61-1 - Upgrade to 2.2.61 - Support referring alt-name in `copy-mac-from` property. RHEL-214096