Fix copy-mac-from for down interfaces

- Fix copy-mac-from failing when source interface is down. RHEL-214096
- Fix OriginalName link match for stacked interfaces. RHEL-223630

- Skip alt-name removal for deleted virtual interfaces.
- Pin physical non ethernet alt-name match.
- Derive alt-name link match ffrom interface identifier.

Resolves: RHEL-214096 RHEL-223630
Signed-off-by: Rahul Rajesh <rrajesh@redhat.com>
This commit is contained in:
Rahul Rajesh 2026-07-29 22:31:33 -04:00
parent ec9991b370
commit afb6081cc1
6 changed files with 1124 additions and 1 deletions

View File

@ -0,0 +1,134 @@
From 69df418c0a918163d84cddaa0cf45e6c302c8103 Mon Sep 17 00:00:00 2001
From: Jan Vaclav <jvaclav@redhat.com>
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 <jvaclav@redhat.com>
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<String> = 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"));
+}

View File

@ -0,0 +1,275 @@
From 45e5ed38d1f157e5963628041a461f4073c3295d Mon Sep 17 00:00:00 2001
From: Josephine Pfeiffer <josie@redhat.com>
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 <josie@redhat.com>
---
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<String, NmstateError> {
+ 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<String> {
+ 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

View File

@ -0,0 +1,405 @@
From 43a8ad166ac0abffd1c0df047819b538bd68c3d5 Mon Sep 17 00:00:00 2001
From: Josephine Pfeiffer <josie@redhat.com>
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 <josie@redhat.com>
---
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

View File

@ -0,0 +1,192 @@
From a1f5311a0bdc3f582dd54004ee8b67e90733bd0e Mon Sep 17 00:00:00 2001
From: Josephine Pfeiffer <josie@redhat.com>
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 <josie@redhat.com>
---
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<String> {
+ 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

View File

@ -0,0 +1,102 @@
From 1527741c551a7ac54d5111ee264b3557fd3ad110 Mon Sep 17 00:00:00 2001
From: Josephine Pfeiffer <josie@redhat.com>
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 <josie@redhat.com>
---
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

View File

@ -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 <rrajesh@redhat.com> - 2.2.61-2
- Fix copy-mac-from failing when source interface is down. RHEL-214096
* Thu Jul 23 2026 Rahul Rajesh <rrajesh@redhat.com> - 2.2.61-1
- Upgrade to 2.2.61
- Support referring alt-name in `copy-mac-from` property. RHEL-214096