From c5091c2a92cae0bbebfd8efa6fd33a18ede144e4 Mon Sep 17 00:00:00 2001 From: Nita Kachhadiya Date: Tue, 3 Dec 2024 19:49:39 -0800 Subject: [PATCH 01/69] addons: vxlan: svd: fix for vxlan ageing timer update (cherry picked from commit e41af9241a175cc4221cea580cd087b3e30ec4da) Signed-off-by: Julien Fortin --- ifupdown2/addons/vxlan.py | 6 ++++-- ifupdown2/lib/iproute2.py | 10 ++++++++-- tests/conftest.py | 6 ++++-- tests/test_coverage.py | 3 ++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/ifupdown2/addons/vxlan.py b/ifupdown2/addons/vxlan.py index 4cab0332..2694039b 100644 --- a/ifupdown2/addons/vxlan.py +++ b/ifupdown2/addons/vxlan.py @@ -368,8 +368,8 @@ def __get_vxlan_ageing_int(self, ifname, ifaceobj, link_exists): # if link doesn't exist we let the kernel define ageing vxlan_ageing_str = self.get_attr_default_value("vxlan-ageing") - if vxlan_ageing_str: - return int(vxlan_ageing_str) + if vxlan_ageing_str: + return int(vxlan_ageing_str) except Exception: self.log_error("%s: invalid vxlan-ageing '%s'" % (ifname, vxlan_ageing_str), ifaceobj) @@ -1181,6 +1181,7 @@ def _up(self, ifaceobj): group_str, vxlan_physdev, user_request_vxlan_info_data.get(Link.IFLA_VXLAN_PORT), + user_request_vxlan_info_data.get(Link.IFLA_VXLAN_AGEING), vxlan_vnifilter, vxlan_ttl ) @@ -1192,6 +1193,7 @@ def _up(self, ifaceobj): group.ip if group else None, vxlan_physdev, user_request_vxlan_info_data.get(Link.IFLA_VXLAN_PORT), + user_request_vxlan_info_data.get(Link.IFLA_VXLAN_AGEING), vxlan_ttl ) else: diff --git a/ifupdown2/lib/iproute2.py b/ifupdown2/lib/iproute2.py index 6c9b17cc..839f1ced 100644 --- a/ifupdown2/lib/iproute2.py +++ b/ifupdown2/lib/iproute2.py @@ -283,7 +283,7 @@ def link_add_veth(self, ifname, peer_name): ### - def link_add_single_vxlan(self, link_exists, ifname, ip, group, physdev, port, vnifilter="off", ttl=None): + def link_add_single_vxlan(self, link_exists, ifname, ip, group, physdev, port, ageing, vnifilter="off", ttl=None): if link_exists: self.logger.info("updating single vxlan device: %s" % ifname) @@ -318,10 +318,13 @@ def link_add_single_vxlan(self, link_exists, ifname, ip, group, physdev, port, v if ttl: cmd.append("ttl %s" % ttl) + if ageing: + cmd.append("ageing %s" % ageing) + self.__execute_or_batch(utils.ip_cmd, " ".join(cmd)) self.__update_cache_after_link_creation(ifname, "vxlan") - def link_add_l3vxi(self, link_exists, ifname, ip, group, physdev, port, ttl=None): + def link_add_l3vxi(self, link_exists, ifname, ip, group, physdev, port, ageing, ttl=None): self.logger.info("creating l3vxi device: %s" % ifname) if link_exists: @@ -351,6 +354,9 @@ def link_add_l3vxi(self, link_exists, ifname, ip, group, physdev, port, ttl=None if ttl: cmd.append("ttl %s" % ttl) + if ageing: + cmd.append("ageing %s" % ageing) + self.__execute_or_batch(utils.ip_cmd, " ".join(cmd)) self.__update_cache_after_link_creation(ifname, "vxlan") diff --git a/tests/conftest.py b/tests/conftest.py index db6d873c..10bf2af7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,7 +46,8 @@ def assert_identical_json(json1, json2): :param json2: Second JSON object to compare. :return: True if JSON objects are identical, False otherwise. """ - if diff := DeepDiff(json1, json2, ignore_order=True): + diff = DeepDiff(json1, json2, ignore_order=True) + if diff: try: logger.error(f"JSON objects are not identical - deepdiff: {json.dumps(diff, indent=4)}") except: @@ -76,7 +77,8 @@ def translate_swp_xx(self, content: str, with_update: bool = False): update = False for match in self.SWP_REGEX.findall(content): - if not (swp := self.swp_translated_dict.get(match)): + swp = self.swp_translated_dict.get(match) + if not swp: if not self.swp_available: raise NotEnoughPhysDevException( f"Device does not have enough physical ports (swp) for this test - {self.swp_translated_dict}" diff --git a/tests/test_coverage.py b/tests/test_coverage.py index 235e8221..3db9fcef 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -38,7 +38,8 @@ def test_orphan_files(skip_if_any_test_failed): ] files = set(str(path) for path in Path("tests/").rglob("*") if path.is_file() and __doesnt_start_with(path, exclude_list)) - if orphan_files := files - registered_files: + orphan_files = files - registered_files + if orphan_files: pytest.fail(f"Found orphan files: {orphan_files}") From f36971d7d7e1d2a996ed6abc45189a54be6a7a92 Mon Sep 17 00:00:00 2001 From: Nita Kachhadiya Date: Mon, 9 Dec 2024 06:55:58 -0800 Subject: [PATCH 02/69] addons: address: Added Minimum mtu checks for Ipv6 for validate config in ifupdown2 (cherry picked from commit 4c27bc1fbc8f11dc78d9ac119f95161685755bf3) Signed-off-by: Julien Fortin --- ifupdown2/addons/address.py | 76 +++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/ifupdown2/addons/address.py b/ifupdown2/addons/address.py index 3ae9b723..8abcf316 100644 --- a/ifupdown2/addons/address.py +++ b/ifupdown2/addons/address.py @@ -197,6 +197,8 @@ class address(AddonWithIpBlackList, moduleBase): } DEFAULT_MTU_STRING = "1500" + IPV6_MINIMUM_MTU = 1280 + INVALID_MTU = -1 def __init__(self, *args, **kargs): AddonWithIpBlackList.__init__(self) @@ -225,6 +227,7 @@ def __init__(self, *args, **kargs): self.default_mgmt_intf_mtu = self.default_mtu self.default_mgmt_intf_mtu_int = self.default_mtu_int self.max_mtu = self.__policy_get_max_mtu() + self.v6_min_mtu = self.__policy_get_v6_min_mtu() self.default_loopback_addresses = (ipnetwork.IPNetwork('127.0.0.1/8'), ipnetwork.IPNetwork('::1/128')) @@ -267,7 +270,6 @@ def __init__(self, *args, **kargs): self.mac_regex = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$") - def __policy_get_default_mtu(self): default_mtu = policymanager.policymanager_api.get_attr_default( module_name=self.__class__.__name__, @@ -301,6 +303,23 @@ def __policy_get_max_mtu(self): self.logger.info("address: max_mtu undefined") return 0 + def __policy_get_v6_min_mtu(self) -> int: + min_mtu = policymanager.policymanager_api.get_module_globals( + module_name=self.__class__.__name__, + attr="ipv6_minimum_mtu" + ) + + if min_mtu: + try: + min_mtu_int = int(min_mtu) + self.logger.info(f'address: minimum ipv6 set to {min_mtu_int} (policy "ipv6_minimum_mtu")') + return min_mtu_int + except ValueError as e: + self.logger.warning(f"address: policy ipv6_minimum_mtu: {str(e)}") + else: + self.logger.info(f"address: policy ipv6_minimum_mtu undefined - applying default minimum ipv6 mtu {self.IPV6_MINIMUM_MTU}") + return self.IPV6_MINIMUM_MTU + def __policy_get_mgmt_intf_mtu(self): default_mgmt_mtu = policymanager.policymanager_api.get_module_globals( module_name=self.__class__.__name__, @@ -635,7 +654,7 @@ def __add_loopback_anycast_ip_to_running_ip_addr_list(ifaceobjlist): return anycast_ip_addr - def process_addresses(self, ifaceobj, ifaceobj_getfunc=None, force_reapply=False): + def process_addresses(self, ifaceobj, mtu, ifaceobj_getfunc=None, force_reapply=False): squash_addr_config = ifupdownconfig.config.get("addr_config_squash", "0") == "1" if squash_addr_config and not ifaceobj.flags & ifaceobj.YOUNGEST_SIBLING: @@ -661,6 +680,11 @@ def process_addresses(self, ifaceobj, ifaceobj_getfunc=None, force_reapply=False if not addr_supported: return + #V6 mtu check + if not self._process_ipv6_mtu_config_valid(user_config_ip_addrs_list, mtu): + self.logger.error(f"{ifname}: ipv6 configuration is not allowed with MTU lower than {self.v6_min_mtu}") + return + if not ifupdownflags.flags.PERFMODE and purge_addresses: # if perfmode is not set and purge addresses is set to True # lets purge addresses not in the config @@ -861,17 +885,37 @@ def _propagate_mtu_to_upper_devs(self, ifaceobj, mtu_str, mtu_int, ifaceobj_getf if not running_mtu or running_mtu != mtu_int: self.sysfs.link_set_mtu(u, mtu_str=mtu_str, mtu_int=mtu_int) + def _process_ipv6_mtu_config_valid(self, user_config_ip_addrs_list: list, mtu: int) -> bool: + for ip, _ in user_config_ip_addrs_list or []: + if ip.version == 6 and mtu < self.v6_min_mtu: + return False + return True + + def _process_mtu_ipv6_config_valid(self, ifaceobj, mtu: int) -> bool: + if mtu < self.v6_min_mtu: + for addr in ifaceobj.get_attr_value("address") or []: + if ipnetwork.IPNetwork(addr).version == 6: + self.log_error(f"{ifaceobj.name}: the minimum allowed MTU is {self.v6_min_mtu} for ipv6 configuration", ifaceobj) + return True + def _process_mtu_config_mtu_valid(self, ifaceobj, ifaceobj_getfunc, mtu_str, mtu_int): if not self._check_mtu_config(ifaceobj, mtu_str, mtu_int, ifaceobj_getfunc): - return + return self.INVALID_MTU if mtu_int != self.cache.get_link_mtu(ifaceobj.name): + + # ipv6 minimum MTU check + if not self._process_mtu_ipv6_config_valid(ifaceobj, mtu_int): + return self.INVALID_MTU + self.sysfs.link_set_mtu(ifaceobj.name, mtu_str=mtu_str, mtu_int=mtu_int) self._propagate_mtu_to_upper_devs(ifaceobj, mtu_str, mtu_int, ifaceobj_getfunc) + return mtu_int + def _process_mtu_config_mtu_none(self, ifaceobj, ifaceobj_getfunc): if (ifaceobj.link_privflags & ifaceLinkPrivFlags.MGMT_INTF): - return + return self.INVALID_MTU cached_link_mtu = self.cache.get_link_mtu(ifaceobj.name) @@ -884,7 +928,7 @@ def _process_mtu_config_mtu_none(self, ifaceobj, ifaceobj_getfunc): or ifaceobj.link_kind & ifaceLinkKind.OTHER: if cached_link_mtu != self.default_mtu_int: self.sysfs.link_set_mtu(ifaceobj.name, mtu_str=self.default_mtu, mtu_int=self.default_mtu_int) - return + return self.default_mtu_int # set vlan interface mtu to lower device mtu if ( @@ -897,6 +941,7 @@ def _process_mtu_config_mtu_none(self, ifaceobj, ifaceobj_getfunc): if lower_iface_mtu_int != cached_link_mtu: self.sysfs.link_set_mtu(ifaceobj.name, mtu_str=str(lower_iface_mtu_int), mtu_int=lower_iface_mtu_int) + return lower_iface_mtu_int elif ( ifaceobj.name != 'lo' @@ -916,6 +961,9 @@ def _process_mtu_config_mtu_none(self, ifaceobj, ifaceobj_getfunc): self.sysfs.link_set_mtu(ifaceobj.name, mtu_str=self.default_mtu, mtu_int=self.default_mtu_int) if ifupdownconfig.diff_mode: self._propagate_mtu_to_upper_devs(ifaceobj, self.default_mtu, self.default_mtu_int, ifaceobj_getfunc) + return self.default_mtu_int + + return cached_link_mtu def _set_bridge_forwarding(self, ifaceobj): """ set ip forwarding to 0 if bridge interface does not have a @@ -1033,9 +1081,10 @@ def _sysctl_config(self, ifaceobj): self.logger.error('%s: %s' %(ifaceobj.name, str(e))) def process_mtu(self, ifaceobj, ifaceobj_getfunc): + cache_mtu = self.cache.get_link_mtu(ifaceobj.name) if ifaceobj.link_privflags & ifaceLinkPrivFlags.OPENVSWITCH: - return + return cache_mtu mtu_str = ifaceobj.get_attr_value_first('mtu') mtu_from_policy = False @@ -1056,11 +1105,16 @@ def process_mtu(self, ifaceobj, ifaceobj_getfunc): self.logger.warning("%s: invalid MTU value from policy file (iface_defaults): %s" % (ifaceobj.name, str(e))) else: self.logger.warning("%s: invalid MTU value: %s" % (ifaceobj.name, str(e))) - return + return cache_mtu - self._process_mtu_config_mtu_valid(ifaceobj, ifaceobj_getfunc, mtu_str, mtu_int) + mtu_int = self._process_mtu_config_mtu_valid(ifaceobj, ifaceobj_getfunc, mtu_str, mtu_int) else: - self._process_mtu_config_mtu_none(ifaceobj, ifaceobj_getfunc) + mtu_int = self._process_mtu_config_mtu_none(ifaceobj, ifaceobj_getfunc) + + if mtu_int == self.INVALID_MTU: + return cache_mtu + + return mtu_int def up_ipv6_addrgen(self, ifaceobj): user_configured_ipv6_addrgen = ifaceobj.get_attr_value_first('ipv6-addrgen') @@ -1154,7 +1208,7 @@ def _pre_up(self, ifaceobj, ifaceobj_getfunc=None): except Exception: pass - self.process_mtu(ifaceobj, ifaceobj_getfunc) + mtu = self.process_mtu(ifaceobj, ifaceobj_getfunc) self.up_ipv6_addrgen(ifaceobj) try: @@ -1163,7 +1217,7 @@ def _pre_up(self, ifaceobj, ifaceobj_getfunc=None): self.log_error('%s: %s' % (ifaceobj.name, str(e)), ifaceobj) if addr_method not in ["dhcp", "ppp"]: - self.process_addresses(ifaceobj, ifaceobj_getfunc, force_reapply) + self.process_addresses(ifaceobj, mtu, ifaceobj_getfunc, force_reapply) else: # remove old addresses added by ifupdown2 # (if intf was moved from static config to dhcp) From a5aa8dba46658b019a9acdeef30efcf2403e8f98 Mon Sep 17 00:00:00 2001 From: "Abhishek Agarwal (Networking SW)" Date: Tue, 17 Dec 2024 00:16:21 +0530 Subject: [PATCH 03/69] addons: bridge: fix for bridge-port move from one vlan-aware br to another When a port moves between VLAN-aware bridges, processing order can leave the netlink cache pointing at the old bridge. The stale mapping prevents the port from being enslaved to the bridge selected by the current configuration. Use the configured bridge mapping whenever it differs from the cached mapping. Signed-off-by: Abhishek Agarwal (Networking SW) (cherry picked from commit b76b133e1188c2780831f9b34c05d982db1a65b7) Signed-off-by: Julien Fortin --- ifupdown2/addons/bridge.py | 6 +++ .../bridge9_multiple_vlan_aware_bridge.eni | 31 +++++++++++++ ...lan_aware_bridge.bridge_vlan_swp_AA_1.json | 20 +++++++++ ...lan_aware_bridge.bridge_vlan_swp_AA_2.json | 20 +++++++++ ...lan_aware_bridge.bridge_vlan_swp_BB_1.json | 20 +++++++++ ...lan_aware_bridge.bridge_vlan_swp_BB_2.json | 20 +++++++++ tests/test_l2.py | 44 +++++++++++++++++++ 7 files changed, 161 insertions(+) create mode 100644 tests/eni/bridge9_multiple_vlan_aware_bridge.eni create mode 100644 tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_1.json create mode 100644 tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_2.json create mode 100644 tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_1.json create mode 100644 tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_2.json diff --git a/ifupdown2/addons/bridge.py b/ifupdown2/addons/bridge.py index e70710ee..10a0d72a 100644 --- a/ifupdown2/addons/bridge.py +++ b/ifupdown2/addons/bridge.py @@ -2003,6 +2003,12 @@ def _check_untagged_bridge(self, bridgename, bridgeportifaceobj, ifaceobj_getfun def bridge_port_get_bridge_name(self, ifaceobj): bridgename = self.cache.get_bridge_name_from_port(ifaceobj.name) + # When the bridge port is moved from one bridge to another + # the order of bridge configuration will determine if the cache gives correct bridge name + # Fetch the bridge name from the ifaceobj.upperfaces if nlcache returns incorrect bridge + if bridgename and bridgename not in ifaceobj.upperifaces: + bridgename = None + if not bridgename: # bridge port is not enslaved to a bridge we need to find # the bridge in it's upper ifaces then enslave it diff --git a/tests/eni/bridge9_multiple_vlan_aware_bridge.eni b/tests/eni/bridge9_multiple_vlan_aware_bridge.eni new file mode 100644 index 00000000..83e057bd --- /dev/null +++ b/tests/eni/bridge9_multiple_vlan_aware_bridge.eni @@ -0,0 +1,31 @@ +auto lo +iface lo inet loopback + +# The primary network interface +auto eth0 +iface eth0 inet dhcp + vrf mgmt + +auto mgmt +iface mgmt + vrf-table auto + +auto swp_AA_ +iface swp_AA_ + +auto swp_BB_ +iface swp_BB_ + +auto br1 +iface br1 + bridge-vlan-aware yes + bridge-stp on + bridge-ports swp_AA_ + bridge-vids 10,20 + +auto br2 +iface br2 + bridge-vlan-aware yes + bridge-stp on + bridge-ports swp_BB_ + bridge-vids 30,40 diff --git a/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_1.json b/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_1.json new file mode 100644 index 00000000..cff64428 --- /dev/null +++ b/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_1.json @@ -0,0 +1,20 @@ +[ + { + "ifname" : "swp_AA_", + "vlans" : [ + { + "flags" : [ + "PVID", + "Egress Untagged" + ], + "vlan" : 1 + }, + { + "vlan" : 10 + }, + { + "vlan" : 20 + } + ] + } +] diff --git a/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_2.json b/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_2.json new file mode 100644 index 00000000..472a9a01 --- /dev/null +++ b/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_2.json @@ -0,0 +1,20 @@ +[ + { + "ifname" : "swp_AA_", + "vlans" : [ + { + "flags" : [ + "PVID", + "Egress Untagged" + ], + "vlan" : 1 + }, + { + "vlan" : 30 + }, + { + "vlan" : 40 + } + ] + } +] diff --git a/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_1.json b/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_1.json new file mode 100644 index 00000000..29e7c622 --- /dev/null +++ b/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_1.json @@ -0,0 +1,20 @@ +[ + { + "ifname" : "swp_BB_", + "vlans" : [ + { + "flags" : [ + "PVID", + "Egress Untagged" + ], + "vlan" : 1 + }, + { + "vlan" : 30 + }, + { + "vlan" : 40 + } + ] + } +] diff --git a/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_2.json b/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_2.json new file mode 100644 index 00000000..55e7456c --- /dev/null +++ b/tests/output/bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_2.json @@ -0,0 +1,20 @@ +[ + { + "ifname" : "swp_BB_", + "vlans" : [ + { + "flags" : [ + "PVID", + "Egress Untagged" + ], + "vlan" : 1 + }, + { + "vlan" : 10 + }, + { + "vlan" : 20 + } + ] + } +] diff --git a/tests/test_l2.py b/tests/test_l2.py index 95f501fe..caf0f774 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -1,4 +1,7 @@ import logging +import json + +import pytest from .conftest import assert_identical_json, ENI, ENI_D @@ -155,6 +158,47 @@ def test_bridge8_reserved_vlans(ssh, setup, get_file): assert "reserved vlan 3725 being used (reserved vlan range 3725-3999)" in ssh.ifreload_a(return_stderr=True, expected_status=1) +def test_bridge9_multiple_vlan_aware_bridge(ssh, get_json): + multiple_bridge_support = ssh.run_assert_success( + "sed -n " + "'s/^multiple_vlan_aware_bridge_support=//p' " + "/etc/network/ifupdown2/ifupdown2.conf" + ).strip() + if multiple_bridge_support != "1": + pytest.skip( + "requires multiple_vlan_aware_bridge_support=1; " + "the public Debian default remains 0" + ) + + # Apply the fixture only after the host capability check. The generic + # setup fixture would otherwise copy an unsupported config before skip. + ssh.ifdown_x_eth0_x_mgmt() + ssh.scp("tests/eni/bridge9_multiple_vlan_aware_bridge.eni", ENI) + ssh.run(f"rm -f {ENI_D}/*") + + ssh.ifreload_diff = False + ssh.ifreload_a() + _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_AA_") + stdout_str: str = stdout.read().decode("utf-8") + assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_1.json")) + _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_BB_") + stdout_str: str = stdout.read().decode("utf-8") + assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_1.json")) + + # Replace "bridge-ports swp_AA_" with "bridge-ports swp_BB_" and vice-versa + ssh.run_assert_success(f"sed -i 's/\/bridge-ports swp_CC_/g' {ENI}") + ssh.run_assert_success(f"sed -i 's/\/bridge-ports swp_AA_/g' {ENI}") + ssh.run_assert_success(f"sed -i 's/\/bridge-ports swp_BB_/g' {ENI}") + ssh.ifreload_diff = False + ssh.ifreload_a() + _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_AA_") + stdout_str: str = stdout.read().decode("utf-8") + assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_2.json")) + _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_BB_") + stdout_str: str = stdout.read().decode("utf-8") + assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_2.json")) + + def test_bridge_access(ssh, setup, get_json): bridge_ifquery_ac_json = get_json("bridge_access.ifquery.ac.json") bridge_vlan_show_json = get_json("bridge_access.vlan.show.json") From bdc0b68796b89d428a5c19b12b5f5e24e75ed23a Mon Sep 17 00:00:00 2001 From: Andy Roulin Date: Tue, 17 Dec 2024 13:38:54 -0800 Subject: [PATCH 04/69] vrf: close vrf sockets before slaves go down Closing a socket on the main VRF device can require a path through one of its slaves. Taking every slave down first can leave the connection stuck in FIN-WAIT-1 and prevent its namespace from being released. Close sockets on the main VRF before taking slaves down, then close them again afterward to catch sessions reopened during that window. Signed-off-by: Andy Roulin (cherry picked from commit 897ab792629aa4247a8d3bf375188dc5c9467664) Signed-off-by: Julien Fortin --- ifupdown2/addons/vrf.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ifupdown2/addons/vrf.py b/ifupdown2/addons/vrf.py index 712c5fd0..1006d896 100644 --- a/ifupdown2/addons/vrf.py +++ b/ifupdown2/addons/vrf.py @@ -954,6 +954,10 @@ def _down_vrf_dev(self, ifaceobj, vrf_table, ifaceobj_getfunc=None): if vrf_table == 'auto': vrf_table = self._get_iproute2_vrf_table(ifaceobj.name) + # Closing sockets on main VRF device before VRF slaves go down + # Slaves going down can prevent correct TCP termination exchanges + self._close_sockets(ifaceobj.name) + running_slaves = self.sysfs.link_get_lowers(ifaceobj.name) if running_slaves: for s in running_slaves: @@ -981,6 +985,8 @@ def _down_vrf_dev(self, ifaceobj, vrf_table, ifaceobj_getfunc=None): except Exception as e: self.logger.info('%s: %s' %(ifaceobj.name, str(e))) + # Closing sockets on main VRF device again in case any TCP + # connection re-opened since last close self._close_sockets(ifaceobj.name) try: From d690a896bb72376c96d4fd52b34f01b8f71cab56 Mon Sep 17 00:00:00 2001 From: Nita Kachhadiya Date: Thu, 19 Dec 2024 00:50:51 -0800 Subject: [PATCH 05/69] addons: bond: call valid_slave_speed with bond slave subset (cherry picked from commit dba61d4a6fa44cd430d428049c94c0da43f65748) Signed-off-by: Julien Fortin --- ifupdown2/addons/bond.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/ifupdown2/addons/bond.py b/ifupdown2/addons/bond.py index dcccf5ab..d0a06b59 100644 --- a/ifupdown2/addons/bond.py +++ b/ifupdown2/addons/bond.py @@ -377,10 +377,6 @@ def valid_slave_speed(self, ifaceobj, bond_slaves, slave, ifaceobj_getfunc): except: pass - if not self.sysfs.link_is_up(slave): - self.logger.debug(f"{slave}: bond-slave is down - skipping speed validation") - return True - if self.current_bond_speed < 0: self.current_bond_speed = self.get_bond_speed(bond_slaves) @@ -414,6 +410,11 @@ def get_bond_speed(self, runningslaves): if bond_speed < 0: bond_speed = slave_speed + + # Got speed bond, so break here. + if bond_speed > 0: + break + return bond_speed def get_bond_slave_upper_dev_ifaceobj(self, ifname, ifaceobj_getfunc): @@ -446,6 +447,12 @@ def _add_slaves(self, ifaceobj, runningslaves, ifaceobj_getfunc=None): if s not in runningslaves and s not in devices_to_enslave: devices_to_enslave.append(s) + # Get a list of common running slave interfaces from both the previous and current + # configuration to get their bond speed + common_slaves = list(set(runningslaves) & set(slaves)) + if not common_slaves: + common_slaves = list(set(slaves)) + for slave in devices_to_enslave: if (not ifupdownflags.flags.PERFMODE and not self.cache.link_exists(slave)): @@ -454,6 +461,9 @@ def _add_slaves(self, ifaceobj, runningslaves, ifaceobj_getfunc=None): raise_error=False) continue + if not self.valid_slave_speed(ifaceobj, common_slaves, slave, ifaceobj_getfunc): + continue + if not self.slave_has_no_subinterface(ifaceobj, slave, ifaceobj_getfunc): continue From e1d0c744d7a31e2d4d023dc95b056dc0d0e0745d Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Tue, 7 Jan 2025 17:16:46 +0100 Subject: [PATCH 06/69] fix(networkinterfaces): remove obsolete json.loads encoding argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In 3.9 the encoding keyword argument of json.loads has been removed any decoding needs to be done prior to the json.loads call. sys.stdin.read() always return a string, so there's no need to do any decoding anymore. Tests: - manual regression tests - utf-8 tests: $ echo '{ "auto": true, "name": "ñáöü" }' | ifquery -t json "ñáöü" -i - auto ñáöü iface ñáöü $ (cherry picked from commit 58ca3b4479cbf3399fa5897c03a9c5b62781bbed) Signed-off-by: Julien Fortin --- ifupdown2/ifupdown/networkinterfaces.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ifupdown2/ifupdown/networkinterfaces.py b/ifupdown2/ifupdown/networkinterfaces.py index c0c8ad96..7f304e20 100644 --- a/ifupdown2/ifupdown/networkinterfaces.py +++ b/ifupdown2/ifupdown/networkinterfaces.py @@ -495,8 +495,15 @@ def read_file(self, filename, fileiobuf=None): def read_file_json(self, filename, fileiobuf=None): if fileiobuf: - ifacedicts = loads(fileiobuf, encoding="utf-8") - #object_hook=ifaceJsonDecoder.json_object_hook) + # json.loads() accepts str/bytes directly; malformed buffers should + # be handled like malformed JSON read from a file. + try: + ifacedicts = loads(fileiobuf) + except JSONDecodeError as e: + self.logger.warning( + 'error loading JSON content from buffer (%s)', str(e) + ) + return elif filename: self.logger.info('processing JSON formatted interfaces file %s' % filename) From fbf4d67e8e485480d8da0438e692d7a318b76432 Mon Sep 17 00:00:00 2001 From: Nita MS Date: Tue, 28 Jan 2025 19:39:24 -0800 Subject: [PATCH 07/69] addons: address: Fix to handle gateway while link is down. The kernel rejects gateway installation on an administratively down link. Skip gateway additions while KEEP_LINK_DOWN is active, but continue deleting gateways removed from the configuration. Signed-off-by: Nita MS (cherry picked from commit 9132b6cbf5aa4e2e0e61e968b8810b6db7d63459) Signed-off-by: Julien Fortin --- ifupdown2/addons/address.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ifupdown2/addons/address.py b/ifupdown2/addons/address.py index 8abcf316..acd072c9 100644 --- a/ifupdown2/addons/address.py +++ b/ifupdown2/addons/address.py @@ -793,6 +793,8 @@ def _add_delete_gateway(self, ifaceobj, gateways=[], prev_gw=[]): metric = ifaceobj.get_attr_value_first('metric') self._delete_gateway(ifaceobj, list(set(prev_gw) - set(gateways)), vrf, metric) + if ifaceobj.link_privflags & ifaceLinkPrivFlags.KEEP_LINK_DOWN: + return for add_gw in gateways: try: self.iproute2.route_add_gateway(ifaceobj.name, add_gw, vrf, metric, onlink=self.l3_intf_default_gateway_set_onlink) From 5dc182527ced0c1a28fa5e1f3813c208051986c1 Mon Sep 17 00:00:00 2001 From: Tejeswar Pichuka Date: Fri, 28 Mar 2025 10:55:33 -0700 Subject: [PATCH 08/69] fix(dhclient): handle stale PID during DHCP-to-static transition (cherry picked from commit 857de67bcd9914b9f88571ca82fe11a6f03c8097) Signed-off-by: Julien Fortin --- ifupdown2/ifupdownaddons/dhclient.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ifupdown2/ifupdownaddons/dhclient.py b/ifupdown2/ifupdownaddons/dhclient.py index f967a8e6..fbaae440 100644 --- a/ifupdown2/ifupdownaddons/dhclient.py +++ b/ifupdown2/ifupdownaddons/dhclient.py @@ -29,6 +29,12 @@ def _pid_exists(self, pidfilename): try: if e.errno == errno.EACCES: return os.path.exists("/proc/%s" % self.read_file_oneline(pidfilename)) + elif e.errno == errno.ENOENT: + # There are scenarios where the PID file exists but the + # corresponding dhclient process is terminated + # We want to force the dhclient process to get cleaned up + # and flush the existing IP on the interface + return True except Exception: return False except Exception: From 339ea98d78a354e45f11bc2ac48c79ba7dda8b26 Mon Sep 17 00:00:00 2001 From: Abhishek Agarwal Date: Fri, 18 Apr 2025 01:29:56 -0700 Subject: [PATCH 09/69] fix(address): force static IP reapply after DHCP transition We have done few fixes in the past where dhclient process were getting terminated and there were stale dhclient pidfiles that were causing issues when trying to assign same dhcp ip as static IP. In earlier case, we fixed two things - a. workaround fix to cleanup the stale dhclient pids and force release the dhclient process. b. Fixing the actual culprit that was terminating the dhclient process to ensure no stale pids The current issues seems to be following - a. Either the dhclient process that was released is deleting the address but not clearing the cache on time (there is slight delay). Subsequently we try to force clear the cache which has a bug that prevents it from clearing the cache. Later when trying to assign static IP, it is skipped since the same address is already present in cache. Subsequently upon debugging we also found that there could be scenario where releasing of dhclient process would not result in deleting the address from the interface. Then even if we clear the cache, there will be failure when trying to assign static IP (same address) since it will complain that "Address exists" and not overwrite it. Later upon lease expiry the address will again get deleted and cause same issue. Fixes done: 1. Fixed the code for clearing of address cache to use address version instead of address family. Everywhere we are saving the cache with address version as key but while force clearing the cache we were using address family which is incorrect 2. When trying to force add a static address, we should ideally avoid checking the cache and try to add the address irrespective of whether the address cache has the address configured or not. Also in the same path, we should ensure that the address is deleted before attempting to add the address. 3. In one of the previous fix we were returning True in case of stale dhclient pid file existing when the process was terminated. Ideally we should be releasing the dhclient process to clean up the cache and still return False to indicate that dhclient is not actually running. This is required in the dhcp config scenario when it is expected to setup the dhclient process even in case a stale one exists. 4. We are forcing the static address add on an interface irrespective of whether dhclient process was running or not. We could have a scenario of someone manually running dhclient using some other pid file in which case we will not be able to clean up the address if the same address is assigned in dhcp server that the static address is trying to assign. (cherry picked from commit f0ca63883634d691e84ccc2f6d6e65f68d560112) Signed-off-by: Julien Fortin --- ifupdown2/addons/address.py | 19 +++++++++--------- ifupdown2/addons/dhcp.py | 5 ++--- ifupdown2/ifupdownaddons/dhclient.py | 14 ++++++++------ ifupdown2/lib/nlcache.py | 29 ++++++++++++++++++++-------- 4 files changed, 41 insertions(+), 26 deletions(-) diff --git a/ifupdown2/addons/address.py b/ifupdown2/addons/address.py index acd072c9..f85be9e3 100644 --- a/ifupdown2/addons/address.py +++ b/ifupdown2/addons/address.py @@ -603,7 +603,7 @@ def __get_ip_addr_with_attributes(self, ifaceobj_list, ifname): return True, user_config_ip_addrs_list - def __add_ip_addresses_with_attributes(self, ifaceobj, ifname, user_config_ip_addrs): + def __add_ip_addresses_with_attributes(self, ifaceobj, ifname, user_config_ip_addrs, force_apply=False): ipv6_is_disabled = None nodad = False if self.ipv6_dad_handling_enabled: @@ -630,10 +630,11 @@ def __add_ip_addresses_with_attributes(self, ifaceobj, ifname, user_config_ip_ad peer=attributes.get("pointopoint"), broadcast=attributes.get("broadcast"), preferred_lifetime=attributes.get("preferred-lifetime"), - nodad=nodad + nodad=nodad, + force=force_apply ) else: - self.netlink.addr_add(ifname, ip, nodad=nodad) + self.netlink.addr_add(ifname, ip, nodad=nodad, force=force_apply) except Exception as e: self.log_error(str(e), ifaceobj, raise_error=False) @@ -699,7 +700,7 @@ def process_addresses(self, ifaceobj, mtu, ifaceobj_getfunc=None, force_reapply= if ordered_user_configured_ips == running_ip_addrs or self.compare_running_ips_and_user_config(user_ip4, user_ip6, running_ip_addrs): if force_reapply: - self.__add_ip_addresses_with_attributes(ifaceobj, ifname, user_config_ip_addrs_list) + self.__add_ip_addresses_with_attributes(ifaceobj, ifname, user_config_ip_addrs_list, force_reapply) return try: # if primary address is not same, there is no need to keep any, reset all addresses. @@ -721,7 +722,7 @@ def process_addresses(self, ifaceobj, mtu, ifaceobj_getfunc=None, force_reapply= self.log_warn(str(e)) if not user_config_ip_addrs_list: return - self.__add_ip_addresses_with_attributes(ifaceobj, ifname, user_config_ip_addrs_list) + self.__add_ip_addresses_with_attributes(ifaceobj, ifname, user_config_ip_addrs_list, force_reapply) def compare_running_ips_and_user_config(self, user_ip4, user_ip6, running_addrs): """ @@ -1201,12 +1202,12 @@ def _pre_up(self, ifaceobj, ifaceobj_getfunc=None): if dhclientcmd.is_running(ifaceobj.name): # release any dhcp leases dhclientcmd.release(ifaceobj.name) - self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET) - force_reapply = True + self.cache.force_address_flush_family(ifaceobj.name, 4) elif dhclientcmd.is_running6(ifaceobj.name): dhclientcmd.release6(ifaceobj.name) - self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET6) - force_reapply = True + self.cache.force_address_flush_family(ifaceobj.name, 6) + # Always force re_apply irrespective of dhclient running or not + force_reapply = True except Exception: pass diff --git a/ifupdown2/addons/dhcp.py b/ifupdown2/addons/dhcp.py index 9b2f5f9b..03aac39b 100644 --- a/ifupdown2/addons/dhcp.py +++ b/ifupdown2/addons/dhcp.py @@ -6,7 +6,6 @@ import re import time -import socket import logging try: @@ -259,10 +258,10 @@ def _dhcp_down(self, ifaceobj): ifname=ifaceobj.name, attr='dhcp6-duid') if 'inet6' in ifaceobj.addr_family: self.dhclientcmd.release6(ifaceobj.name, dhclient_cmd_prefix, duid=dhcp6_duid) - self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET6) + self.cache.force_address_flush_family(ifaceobj.name, 6) if 'inet' in ifaceobj.addr_family: self.dhclientcmd.release(ifaceobj.name, dhclient_cmd_prefix) - self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET) + self.cache.force_address_flush_family(ifaceobj.name, 4) def _down(self, ifaceobj): self._dhcp_down(ifaceobj) diff --git a/ifupdown2/ifupdownaddons/dhclient.py b/ifupdown2/ifupdownaddons/dhclient.py index fbaae440..e9a13cf2 100644 --- a/ifupdown2/ifupdownaddons/dhclient.py +++ b/ifupdown2/ifupdownaddons/dhclient.py @@ -19,7 +19,8 @@ class dhclient(utilsBase): """ This class contains helper methods to interact with the dhclient utility """ - def _pid_exists(self, pidfilename): + def _pid_exists(self, ifacename): + pidfilename = f'/run/dhclient.{ifacename}.pid' if os.path.exists(pidfilename): try: return os.readlink( @@ -32,9 +33,10 @@ def _pid_exists(self, pidfilename): elif e.errno == errno.ENOENT: # There are scenarios where the PID file exists but the # corresponding dhclient process is terminated - # We want to force the dhclient process to get cleaned up - # and flush the existing IP on the interface - return True + # Cleanup the dhclient process and return False to indicate dhclient + # is not running + self.release(ifacename) + return False except Exception: return False except Exception: @@ -42,10 +44,10 @@ def _pid_exists(self, pidfilename): return False def is_running(self, ifacename): - return self._pid_exists('/run/dhclient.%s.pid' %ifacename) + return self._pid_exists(ifacename) def is_running6(self, ifacename): - return self._pid_exists('/run/dhclient6.%s.pid' %ifacename) + return self._pid_exists(ifacename) def _run_dhclient_cmd(self, cmd, cmd_prefix=None): if not cmd_prefix: diff --git a/ifupdown2/lib/nlcache.py b/ifupdown2/lib/nlcache.py index e2fd5681..255a1407 100644 --- a/ifupdown2/lib/nlcache.py +++ b/ifupdown2/lib/nlcache.py @@ -1639,10 +1639,10 @@ def add_address(self, addr): ip_version: [addr] } - def force_address_flush_family(self, ifname, family): + def force_address_flush_family(self, ifname, version): try: with self._cache_lock: - self._addr_cache[ifname][family] = [] + self._addr_cache[ifname][version] = [] except Exception: pass @@ -3141,7 +3141,10 @@ def link_set_brport_with_info_slave_data_dry_run(self, ifname, kind, ifla_info_d # ADDRESS ############################################################################ - def addr_add_dry_run(self, ifname, addr, broadcast=None, peer=None, scope=None, preferred_lifetime=None, metric=None, nodad=False): + def addr_add_dry_run(self, ifname, addr, broadcast=None, peer=None, scope=None, preferred_lifetime=None, metric=None, nodad=False, force=False): + if force: + self.addr_del_dry_run(ifname, addr, force=True) + log_msg = ["netlink: ip addr add %s dev %s" % (addr, ifname)] if scope: @@ -3161,14 +3164,24 @@ def addr_add_dry_run(self, ifname, addr, broadcast=None, peer=None, scope=None, self.log_info_ifname_dry_run(ifname, " ".join(log_msg)) - def addr_add(self, ifname, addr, broadcast=None, peer=None, scope=None, preferred_lifetime=None, metric=None, nodad=False): + def addr_add(self, ifname, addr, broadcast=None, peer=None, scope=None, preferred_lifetime=None, metric=None, nodad=False, force=False): + if force: + # Force delete the existing address before trying to add it again. + # This is required when changing from dhcp to static and dhcp ip is not deleted + try: + self.addr_del(ifname, addr, force) + except Exception: + # Trying to delete already deleted address will raise exception that we do not + # bother in force case. + pass + log_msg = ["%s: netlink: ip addr add %s dev %s" % (ifname, addr, ifname)] log_msg_displayed = False try: # We might need to check if metric/peer and other attribute are also # correctly cached. # We might also need to add a "force" attribute to skip the cache check - if self.cache.addr_is_cached(ifname, addr): + if not force and self.cache.addr_is_cached(ifname, addr): return if scope: @@ -3236,11 +3249,11 @@ def addr_add(self, ifname, addr, broadcast=None, peer=None, scope=None, preferre ### - def addr_del_dry_run(self, ifname, addr): + def addr_del_dry_run(self, ifname, addr, force=False): self.log_info_ifname_dry_run(ifname, "netlink: ip addr del %s dev %s" % (addr, ifname)) - def addr_del(self, ifname, addr): - if not self.cache.addr_is_cached(ifname, addr): + def addr_del(self, ifname, addr, force=False): + if not force and not self.cache.addr_is_cached(ifname, addr): return self.logger.info("%s: netlink: ip addr del %s dev %s" % (ifname, addr, ifname)) try: From 7c923c2f85aa32981cc7cac8ff676cfd47f5930b Mon Sep 17 00:00:00 2001 From: Abhishek Agarwal Date: Wed, 11 Jun 2025 00:24:24 -0700 Subject: [PATCH 10/69] address.py: check previous addr_method to evaluate force_reapply of static IP Recently we added a fix to force add static IP address irrespective whether we have dhclient process running previously or not. Ideally, we have "ifreload -a --diff" command if we do not want existing configs to be overwritten, however, the baseline behaviour also prevents overwriting existing IP address on the interface if it is same as the one that is getting configured. We need to add logic to force-reapply the static IP address only when we move from dhcp/dhcp6 to static IP address in order to avoid this regression. We cannot just rely on checking the dhclient process running since there are scenarios where the dhclient process is not running (due to some corner case scenario/bug) and still we need to force reapply the static IP address. For such cases, we have now added a check to force reapply static IP address only when previous address method on the ifaceobj is dhcp/dhcp6 and current address method is not dhcp. Unit Test: 1. Executed `ifreload -a` with interface static IP configured. Ensured that the interface static IP is not reconfigured. 2. Have neigh entries installed in kernel on a given IP interface. Execute `ifreload -a` and ensure the neigh entries are not deleted after ifreload. Signed-off-by: Abhishek Agarwal (cherry picked from commit fbd4659079bf67bf9e07b341a8a6b5e69665cb32) Signed-off-by: Julien Fortin --- ifupdown2/addons/address.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ifupdown2/addons/address.py b/ifupdown2/addons/address.py index f85be9e3..cc2e405c 100644 --- a/ifupdown2/addons/address.py +++ b/ifupdown2/addons/address.py @@ -802,6 +802,12 @@ def _add_delete_gateway(self, ifaceobj, gateways=[], prev_gw=[]): except Exception as e: self.log_error('%s: %s' % (ifaceobj.name, str(e))) + def _get_prev_addr_method(self, ifname): + prev_ifaceobjs = statemanager.statemanager_api.get_ifaceobjs(ifname) + if not prev_ifaceobjs: + return None + return prev_ifaceobjs[0].addr_method + def _get_prev_gateway(self, ifaceobj, gateways): ipv = [] saved_ifaceobjs = statemanager.statemanager_api.get_ifaceobjs(ifaceobj.name) @@ -1190,6 +1196,8 @@ def _pre_up(self, ifaceobj, ifaceobj_getfunc=None): self._sysctl_config(ifaceobj) addr_method = ifaceobj.addr_method + prev_addr_method = self._get_prev_addr_method(ifaceobj.name) + force_reapply = False try: # release any stale dhcp addresses if present @@ -1206,8 +1214,9 @@ def _pre_up(self, ifaceobj, ifaceobj_getfunc=None): elif dhclientcmd.is_running6(ifaceobj.name): dhclientcmd.release6(ifaceobj.name) self.cache.force_address_flush_family(ifaceobj.name, 6) - # Always force re_apply irrespective of dhclient running or not - force_reapply = True + if prev_addr_method in ['dhcp', 'dhcp6']: + # force re_apply only when previous method on ifaceobj is dhcp/dhcp6 + force_reapply = True except Exception: pass From 51e55fcc4114630f89ec0947d5cdb3c12d7814cd Mon Sep 17 00:00:00 2001 From: Abhishek Agarwal Date: Wed, 9 Jul 2025 00:02:05 -0700 Subject: [PATCH 11/69] fix(dhclient): keep ifquery stale-PID checks read-only We added a logic recently to release the dhclient process when stale pid exists when trying to check the dhclient.is_running. The motivation was to cleanup the state during config apply process The same API is also invoked during ifquery as well. Since ifquery is simply trying to check the dhclient process running or not, we should add check to not cleanup the stale pids in that sequence/flow. (cherry picked from commit 521ee1f6d935e0c90b653201bc43d4237f844ce7) Signed-off-by: Julien Fortin --- ifupdown2/addons/address.py | 44 ++++++++++++++++------------ ifupdown2/addons/dhcp.py | 4 +-- ifupdown2/ifupdownaddons/dhclient.py | 13 ++++---- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/ifupdown2/addons/address.py b/ifupdown2/addons/address.py index cc2e405c..8f334259 100644 --- a/ifupdown2/addons/address.py +++ b/ifupdown2/addons/address.py @@ -1199,26 +1199,32 @@ def _pre_up(self, ifaceobj, ifaceobj_getfunc=None): prev_addr_method = self._get_prev_addr_method(ifaceobj.name) force_reapply = False - try: - # release any stale dhcp addresses if present - if (addr_method not in ["dhcp", "ppp"] and not ifupdownflags.flags.PERFMODE and - not (ifaceobj.flags & iface.HAS_SIBLINGS)): - # if not running in perf mode and ifaceobj does not have - # any sibling iface objects, kill any stale dhclient - # processes + # Release stale DHCP state only for a non-DHCP, non-sibling apply. The + # force decision must not depend on release/flush success. + if (addr_method not in ["dhcp", "ppp"] and + not ifupdownflags.flags.PERFMODE and + not (ifaceobj.flags & iface.HAS_SIBLINGS)): + force_reapply = prev_addr_method in ["dhcp", "dhcp6"] + + try: dhclientcmd = dhclient() - if dhclientcmd.is_running(ifaceobj.name): - # release any dhcp leases - dhclientcmd.release(ifaceobj.name) - self.cache.force_address_flush_family(ifaceobj.name, 4) - elif dhclientcmd.is_running6(ifaceobj.name): - dhclientcmd.release6(ifaceobj.name) - self.cache.force_address_flush_family(ifaceobj.name, 6) - if prev_addr_method in ['dhcp', 'dhcp6']: - # force re_apply only when previous method on ifaceobj is dhcp/dhcp6 - force_reapply = True - except Exception: - pass + except Exception: + dhclientcmd = None + + if dhclientcmd: + try: + if dhclientcmd.is_running(ifaceobj.name, cleanup=True): + dhclientcmd.release(ifaceobj.name) + self.cache.force_address_flush_family(ifaceobj.name, 4) + except Exception: + pass + + try: + if dhclientcmd.is_running6(ifaceobj.name, cleanup=True): + dhclientcmd.release6(ifaceobj.name) + self.cache.force_address_flush_family(ifaceobj.name, 6) + except Exception: + pass mtu = self.process_mtu(ifaceobj, ifaceobj_getfunc) self.up_ipv6_addrgen(ifaceobj) diff --git a/ifupdown2/addons/dhcp.py b/ifupdown2/addons/dhcp.py index 03aac39b..da475129 100644 --- a/ifupdown2/addons/dhcp.py +++ b/ifupdown2/addons/dhcp.py @@ -139,8 +139,8 @@ def dhclient_check(self, ifname, family, ip_config_before, retry, dhclient_cmd_p def _up(self, ifaceobj): # if dhclient is already running do not stop and start it - dhclient4_running = self.dhclientcmd.is_running(ifaceobj.name) - dhclient6_running = self.dhclientcmd.is_running6(ifaceobj.name) + dhclient4_running = self.dhclientcmd.is_running(ifaceobj.name, cleanup=True) + dhclient6_running = self.dhclientcmd.is_running6(ifaceobj.name, cleanup=True) # today if we have an interface with both inet and inet6, if we # remove the inet or inet6 or both then execute ifreload, we need diff --git a/ifupdown2/ifupdownaddons/dhclient.py b/ifupdown2/ifupdownaddons/dhclient.py index e9a13cf2..11b4bb8b 100644 --- a/ifupdown2/ifupdownaddons/dhclient.py +++ b/ifupdown2/ifupdownaddons/dhclient.py @@ -19,7 +19,7 @@ class dhclient(utilsBase): """ This class contains helper methods to interact with the dhclient utility """ - def _pid_exists(self, ifacename): + def _pid_exists(self, ifacename, cleanup=False): pidfilename = f'/run/dhclient.{ifacename}.pid' if os.path.exists(pidfilename): try: @@ -35,7 +35,8 @@ def _pid_exists(self, ifacename): # corresponding dhclient process is terminated # Cleanup the dhclient process and return False to indicate dhclient # is not running - self.release(ifacename) + if cleanup: + self.release(ifacename) return False except Exception: return False @@ -43,11 +44,11 @@ def _pid_exists(self, ifacename): return False return False - def is_running(self, ifacename): - return self._pid_exists(ifacename) + def is_running(self, ifacename, cleanup=False): + return self._pid_exists(ifacename, cleanup) - def is_running6(self, ifacename): - return self._pid_exists(ifacename) + def is_running6(self, ifacename, cleanup=False): + return self._pid_exists(ifacename, cleanup) def _run_dhclient_cmd(self, cmd, cmd_prefix=None): if not cmd_prefix: From 5a17b6806ec1c2149b415a2703d8f15f16126f40 Mon Sep 17 00:00:00 2001 From: Abhishek Agarwal Date: Tue, 22 Jul 2025 09:30:17 +0000 Subject: [PATCH 12/69] fix(dhclient): use DHCPv6 pidfile for IPv6 client For DHCPv6, when trying to check for the PID file to determine whether the DHCPv6 client is running or not, we are incorrectly checking for the v4 PID file instead of checking for the v6 pid file. Need to fix the pid file check in the code. (cherry picked from commit 2b3f0b0e3023a5967a10113ed4df9ff8080cf365) Signed-off-by: Julien Fortin --- ifupdown2/ifupdownaddons/dhclient.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ifupdown2/ifupdownaddons/dhclient.py b/ifupdown2/ifupdownaddons/dhclient.py index 11b4bb8b..205530f5 100644 --- a/ifupdown2/ifupdownaddons/dhclient.py +++ b/ifupdown2/ifupdownaddons/dhclient.py @@ -19,8 +19,11 @@ class dhclient(utilsBase): """ This class contains helper methods to interact with the dhclient utility """ - def _pid_exists(self, ifacename, cleanup=False): - pidfilename = f'/run/dhclient.{ifacename}.pid' + def _pid_exists(self, ifacename, is_v4, cleanup=False): + if is_v4: + pidfilename = f'/run/dhclient.{ifacename}.pid' + else: + pidfilename = f'/run/dhclient6.{ifacename}.pid' if os.path.exists(pidfilename): try: return os.readlink( @@ -36,7 +39,10 @@ def _pid_exists(self, ifacename, cleanup=False): # Cleanup the dhclient process and return False to indicate dhclient # is not running if cleanup: - self.release(ifacename) + if is_v4: + self.release(ifacename) + else: + self.release6(ifacename) return False except Exception: return False @@ -45,10 +51,10 @@ def _pid_exists(self, ifacename, cleanup=False): return False def is_running(self, ifacename, cleanup=False): - return self._pid_exists(ifacename, cleanup) + return self._pid_exists(ifacename, True, cleanup) def is_running6(self, ifacename, cleanup=False): - return self._pid_exists(ifacename, cleanup) + return self._pid_exists(ifacename, False, cleanup) def _run_dhclient_cmd(self, cmd, cmd_prefix=None): if not cmd_prefix: From 06c77894d6988a36d339cdd50477522b8c205b1c Mon Sep 17 00:00:00 2001 From: Abhishek Agarwal Date: Mon, 28 Jul 2025 10:54:19 +0000 Subject: [PATCH 13/69] address.py/iface.py: ipv6_disable not reset when moving from bridge-port to bond-member 1. The bond addon module had logic to re-enable ipv6 if previous saved state had the interface as bridge-port. However, the previous saved state did not save bridge-port flag in link_privflags of the ifaceobj. inside sync_ifaceobj it was using compare function to overwrite the saved object or not. The compare function was not taking into account link_privflags between saved and current ifaceobj Hence it was not saving the state as bridge-port (link_privflags) for already saved ifaceobj Fix: Fixed the compare logic in iface.py code to include check for "link_privflags" as well 2. In diff mode, when moving the port from bridge to bond, there was no change detected for the interface. this is because the `__eq__` function for ifaceobj was not considering all the properties - ideally should re-use the compare object the sync_ifaceobj was not kicking in when interface moving from bridge to bond and hence the link_privflags state was not getting updated in saved state Fix: Use compare function in `__eq__` for ifaceobj class object 3. After fixing above two issues, the interface is now included in the diff objlist which allowed the schedule reload of the interface Once the schedule_reload operation happened on interface, the saved state got overwritten with the current state Later, during schedule_reload of bond interface, the logic to re-enable ipv6 was not kicked in again because it could not figure out the old state (which already got overwritten) Fix: we need to add a logic in interface address.py to re-enable ipv6 when it is no longer part of bridge. we can also remove the logic from bond.py that checks for old state to re-enable ipv6 - may keep it for now to ensure no other case is broken Signed-off-by: Abhishek Agarwal (cherry picked from commit 94df840be1e3a53390b1106d5c2d19cf6c3e610b) Signed-off-by: Julien Fortin --- ifupdown2/addons/address.py | 13 +++++++++---- ifupdown2/ifupdown/iface.py | 6 ++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ifupdown2/addons/address.py b/ifupdown2/addons/address.py index 8f334259..99efbe6a 100644 --- a/ifupdown2/addons/address.py +++ b/ifupdown2/addons/address.py @@ -1165,14 +1165,19 @@ def disable_ipv6(self, ifaceobj): for old_ifaceobj in statemanager.statemanager_api.get_ifaceobjs(ifaceobj.name) or []: old_value = old_ifaceobj.get_attr_value_first("disable-ipv6") - if old_value: - default_bool = utils.get_boolean_from_string( - self.get_mod_subattr("disable-ipv6", "default") - ) + default_bool = utils.get_boolean_from_string( + self.get_mod_subattr("disable-ipv6", "default") + ) + if old_value: if default_bool != utils.get_boolean_from_string(old_value): self.sysfs.write_to_file(sysfs_path, "1" if default_bool else "0") return + # check if old ifaceobj was bridge port and new ifaceobj is not bridge port + if (old_ifaceobj.link_privflags & ifaceLinkPrivFlags.BRIDGE_PORT) and \ + not (ifaceobj.link_privflags & ifaceLinkPrivFlags.BRIDGE_PORT): + self.sysfs.write_to_file(sysfs_path, "1" if default_bool else "0") + return else: user_config_bool = utils.get_boolean_from_string(user_config) diff --git a/ifupdown2/ifupdown/iface.py b/ifupdown2/ifupdown/iface.py index 00725159..38d748d4 100644 --- a/ifupdown2/ifupdown/iface.py +++ b/ifupdown2/ifupdown/iface.py @@ -457,10 +457,7 @@ def __init__(self, attrsdict={}): def __eq__(self, other): return ( isinstance(other, iface) and - self.name == other.name and - self.config == other.config and - self.addr_family == other.addr_family and - self.addr_method == other.addr_method + self.compare(other) ) def _set_attrs_from_dict(self, attrdict): @@ -640,6 +637,7 @@ def compare(self, dstiface): if self.addr_method != dstiface.addr_method: return False if self.auto != dstiface.auto: return False if self.classes != dstiface.classes: return False + if self.link_privflags != dstiface.link_privflags: return False if len(self.config) != len(dstiface.config): return False if any(True for k in self.config if k not in dstiface.config): From d5d9d3a12ad5a5b230e89aaca7860aefc5d56371 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Fri, 14 Aug 2026 00:50:33 +0200 Subject: [PATCH 14/69] fix(regex): use raw strings for regular expressions Mark regular-expression patterns as raw strings so imports do not emit invalid-escape SyntaxWarnings. Pattern semantics remain unchanged. Signed-off-by: Julien Fortin --- ifupdown2/addons/bridge.py | 2 +- ifupdown2/ifupdown/ifupdownmain.py | 2 +- ifupdown2/ifupdown/utils.py | 4 ++-- ifupdown2/lib/iproute2.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ifupdown2/addons/bridge.py b/ifupdown2/addons/bridge.py index 10a0d72a..8163eb0c 100644 --- a/ifupdown2/addons/bridge.py +++ b/ifupdown2/addons/bridge.py @@ -4086,7 +4086,7 @@ def _query_check_l2protocol_tunnel(self, brport_name, user_config_l2protocol_tun cached_ifla_brport_group_maskhi = self.cache.get_link_info_slave_data_attribute(brport_name, Link.IFLA_BRPORT_GROUP_FWD_MASKHI) cached_ifla_brport_group_mask = self.cache.get_link_info_slave_data_attribute(brport_name, Link.IFLA_BRPORT_GROUP_FWD_MASK) - for protocol in re.split(',|\s*', user_config_l2protocol_tunnel): + for protocol in re.split(r',|\s*', user_config_l2protocol_tunnel): callback = self.query_check_l2protocol_tunnel_callback.get(protocol) if callable(callback) and not callback(cached_ifla_brport_group_mask, cached_ifla_brport_group_maskhi): diff --git a/ifupdown2/ifupdown/ifupdownmain.py b/ifupdown2/ifupdown/ifupdownmain.py index d02f7810..623fff65 100644 --- a/ifupdown2/ifupdown/ifupdownmain.py +++ b/ifupdown2/ifupdown/ifupdownmain.py @@ -1567,7 +1567,7 @@ def _sched_ifaces(self, ifacenames, ops, skipupperifaces=False, def _render_ifacename(self, ifacename): new_ifacenames = [] - vlan_match = re.match("^([\d]+)-([\d]+)", ifacename) + vlan_match = re.match(r"^([\d]+)-([\d]+)", ifacename) if vlan_match: vlan_groups = vlan_match.groups() if vlan_groups[0] and vlan_groups[1]: diff --git a/ifupdown2/ifupdown/utils.py b/ifupdown2/ifupdown/utils.py index 3a97b725..0f49a342 100644 --- a/ifupdown2/ifupdown/utils.py +++ b/ifupdown2/ifupdown/utils.py @@ -265,7 +265,7 @@ def parse_iface_range(cls, name): # eg: swp1.[2-100] # return (prefix, range-start, range-end) # eg return ("swp1.", 1, 20, ".100") - range_match = re.match("^([\w]+)\[([\d]+)-([\d]+)\]([\.\w]+)", name) + range_match = re.match(r"^([\w]+)\[([\d]+)-([\d]+)\]([\.\w]+)", name) if range_match: range_groups = range_match.groups() if range_groups[1] and range_groups[2]: @@ -275,7 +275,7 @@ def parse_iface_range(cls, name): # eg: swp[1-20].100 # return (prefix, range-start, range-end, suffix) # eg return ("swp", 1, 20, ".100") - range_match = re.match("^([\w\.]+)\[([\d]+)-([\d]+)\]", name) + range_match = re.match(r"^([\w\.]+)\[([\d]+)-([\d]+)\]", name) if range_match: range_groups = range_match.groups() if range_groups[1] and range_groups[2]: diff --git a/ifupdown2/lib/iproute2.py b/ifupdown2/lib/iproute2.py index 839f1ced..2f3e52e8 100644 --- a/ifupdown2/lib/iproute2.py +++ b/ifupdown2/lib/iproute2.py @@ -67,7 +67,7 @@ class IProute2Exception(Exception): class IPRoute2(Cache, Requirements): VXLAN_UDP_PORT = 4789 - VXLAN_PEER_REGEX_PATTERN = re.compile("\s+dst\s+(\d+.\d+.\d+.\d+)\s+") + VXLAN_PEER_REGEX_PATTERN = re.compile(r"\s+dst\s+(\d+.\d+.\d+.\d+)\s+") def __init__(self): Cache.__init__(self) From 9e9c015ee838e20440690d2d5a02d57fe05a49af Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Fri, 14 Aug 2026 01:59:46 +0200 Subject: [PATCH 15/69] fix(vxlan): complete explicit zero ageing support Treat numeric and string zero as configured values while preserving the existing fallback for absent or empty input. Forward zero through the addon, iproute2, and netlink construction so a custom timer can be reset instead of leaving stale running state. Signed-off-by: Julien Fortin --- ifupdown2/addons/vxlan.py | 8 ++++---- ifupdown2/lib/iproute2.py | 6 +++--- ifupdown2/nlmanager/nlmanager.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ifupdown2/addons/vxlan.py b/ifupdown2/addons/vxlan.py index 2694039b..86ae645b 100644 --- a/ifupdown2/addons/vxlan.py +++ b/ifupdown2/addons/vxlan.py @@ -356,7 +356,7 @@ def __get_vxlan_ageing_int(self, ifname, ifaceobj, link_exists): """ vxlan_ageing_str = ifaceobj.get_attr_value_first("vxlan-ageing") try: - if vxlan_ageing_str: + if vxlan_ageing_str not in (None, ""): return int(vxlan_ageing_str) vxlan_ageing_str = policymanager.policymanager_api.get_attr_default( @@ -364,11 +364,11 @@ def __get_vxlan_ageing_int(self, ifname, ifaceobj, link_exists): attr="vxlan-ageing" ) - if not vxlan_ageing_str and link_exists: + if vxlan_ageing_str in (None, "") and link_exists: # if link doesn't exist we let the kernel define ageing vxlan_ageing_str = self.get_attr_default_value("vxlan-ageing") - if vxlan_ageing_str: + if vxlan_ageing_str not in (None, ""): return int(vxlan_ageing_str) except Exception: self.log_error("%s: invalid vxlan-ageing '%s'" % (ifname, vxlan_ageing_str), ifaceobj) @@ -379,7 +379,7 @@ def __config_vxlan_ageing(self, ifname, ifaceobj, link_exists, user_request_vxla """ vxlan_ageing = self.__get_vxlan_ageing_int(ifname, ifaceobj, link_exists) - if not vxlan_ageing or (link_exists and vxlan_ageing == cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_AGEING)): + if vxlan_ageing is None or (link_exists and vxlan_ageing == cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_AGEING)): return self.logger.info("%s: set vxlan-ageing %s" % (ifname, vxlan_ageing)) diff --git a/ifupdown2/lib/iproute2.py b/ifupdown2/lib/iproute2.py index 2f3e52e8..a3363b8e 100644 --- a/ifupdown2/lib/iproute2.py +++ b/ifupdown2/lib/iproute2.py @@ -318,7 +318,7 @@ def link_add_single_vxlan(self, link_exists, ifname, ip, group, physdev, port, a if ttl: cmd.append("ttl %s" % ttl) - if ageing: + if ageing is not None: cmd.append("ageing %s" % ageing) self.__execute_or_batch(utils.ip_cmd, " ".join(cmd)) @@ -354,7 +354,7 @@ def link_add_l3vxi(self, link_exists, ifname, ip, group, physdev, port, ageing, if ttl: cmd.append("ttl %s" % ttl) - if ageing: + if ageing is not None: cmd.append("ageing %s" % ageing) self.__execute_or_batch(utils.ip_cmd, " ".join(cmd)) @@ -382,7 +382,7 @@ def link_create_vxlan(self, name, vxlanid, localtunnelip=None, svcnodeip=None, else: cmd.append("remote %s" % svcnodeip) - if ageing: + if ageing is not None: cmd.append("ageing %s" % ageing) if learning == 'off': diff --git a/ifupdown2/nlmanager/nlmanager.py b/ifupdown2/nlmanager/nlmanager.py index 0cc67c05..ddc9549c 100644 --- a/ifupdown2/nlmanager/nlmanager.py +++ b/ifupdown2/nlmanager/nlmanager.py @@ -1033,7 +1033,7 @@ def link_add_vxlan(self, ifname, vxlanid, dstport=None, local=None, info_data[Link.IFLA_VXLAN_LEARNING] = int(learning) info_data[Link.IFLA_VXLAN_TTL] = ttl - if ageing: + if ageing is not None: info_data[Link.IFLA_VXLAN_AGEING] = int(ageing) if physdev: From 2444fe801c2e0e5d2d107d4976c88de253b168a0 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Fri, 14 Aug 2026 02:24:21 +0200 Subject: [PATCH 16/69] fix(address): complete IPv6 minimum MTU enforcement The imported check raises in normal operation, but force and ignore-errors suppress that exception. Return an explicit failure so an invalid MTU is never applied. Validate before the cached-MTU shortcut so adding IPv6 to an existing sub-minimum interface is also rejected. Signed-off-by: Julien Fortin --- ifupdown2/addons/address.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ifupdown2/addons/address.py b/ifupdown2/addons/address.py index 99efbe6a..7cdf03cf 100644 --- a/ifupdown2/addons/address.py +++ b/ifupdown2/addons/address.py @@ -905,18 +905,18 @@ def _process_mtu_ipv6_config_valid(self, ifaceobj, mtu: int) -> bool: for addr in ifaceobj.get_attr_value("address") or []: if ipnetwork.IPNetwork(addr).version == 6: self.log_error(f"{ifaceobj.name}: the minimum allowed MTU is {self.v6_min_mtu} for ipv6 configuration", ifaceobj) + return False return True def _process_mtu_config_mtu_valid(self, ifaceobj, ifaceobj_getfunc, mtu_str, mtu_int): if not self._check_mtu_config(ifaceobj, mtu_str, mtu_int, ifaceobj_getfunc): return self.INVALID_MTU - if mtu_int != self.cache.get_link_mtu(ifaceobj.name): - - # ipv6 minimum MTU check - if not self._process_mtu_ipv6_config_valid(ifaceobj, mtu_int): - return self.INVALID_MTU + # ipv6 minimum MTU check + if not self._process_mtu_ipv6_config_valid(ifaceobj, mtu_int): + return self.INVALID_MTU + if mtu_int != self.cache.get_link_mtu(ifaceobj.name): self.sysfs.link_set_mtu(ifaceobj.name, mtu_str=mtu_str, mtu_int=mtu_int) self._propagate_mtu_to_upper_devs(ifaceobj, mtu_str, mtu_int, ifaceobj_getfunc) From c45ccb43f158f3e8b661bdd7b897588375222093 Mon Sep 17 00:00:00 2001 From: Nita MS Date: Tue, 13 Jan 2026 08:59:36 +0000 Subject: [PATCH 17/69] addons: dhcp: fix DHCPv6 release failure when interface has link-down configured DHCPv6 release needs a link-local source address. A link-down interface has already lost that address by the time the DHCP addon runs, so release can fail and abort reload. Treat DHCP release failure as non-fatal after the link is intentionally down. Signed-off-by: Nita MS (cherry picked from commit 6a292af8bf79da7865b0578512416db1bdee3fb1) Signed-off-by: Julien Fortin --- ifupdown2/addons/dhcp.py | 12 +++++++++-- tests/eni/dhcp6_release_link_down_l3.eni | 20 ++++++++++++++++++ tests/test_l3.py | 27 ++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/eni/dhcp6_release_link_down_l3.eni diff --git a/ifupdown2/addons/dhcp.py b/ifupdown2/addons/dhcp.py index da475129..a33b82b9 100644 --- a/ifupdown2/addons/dhcp.py +++ b/ifupdown2/addons/dhcp.py @@ -257,10 +257,18 @@ def _dhcp_down(self, ifaceobj): dhcp6_duid = policymanager.policymanager_api.get_iface_default(module_name=self.__class__.__name__, \ ifname=ifaceobj.name, attr='dhcp6-duid') if 'inet6' in ifaceobj.addr_family: - self.dhclientcmd.release6(ifaceobj.name, dhclient_cmd_prefix, duid=dhcp6_duid) + try: + self.dhclientcmd.release6(ifaceobj.name, dhclient_cmd_prefix, duid=dhcp6_duid) + except Exception: + # Ignore any dhclient release errors + pass self.cache.force_address_flush_family(ifaceobj.name, 6) if 'inet' in ifaceobj.addr_family: - self.dhclientcmd.release(ifaceobj.name, dhclient_cmd_prefix) + try: + self.dhclientcmd.release(ifaceobj.name, dhclient_cmd_prefix) + except Exception: + # Ignore any dhclient release errors + pass self.cache.force_address_flush_family(ifaceobj.name, 4) def _down(self, ifaceobj): diff --git a/tests/eni/dhcp6_release_link_down_l3.eni b/tests/eni/dhcp6_release_link_down_l3.eni new file mode 100644 index 00000000..8b485b2f --- /dev/null +++ b/tests/eni/dhcp6_release_link_down_l3.eni @@ -0,0 +1,20 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto dum_d6 +iface dum_d6 inet6 dhcp + link-type dummy + link-down yes diff --git a/tests/test_l3.py b/tests/test_l3.py index 9bc242c9..08425b96 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -138,3 +138,30 @@ def test_vxlandev_sanity(ssh, setup, get_json): ssh.run_assert_success(f"sed -i 's/vxlan-remoteip 172.16.22.128/vxlan-remoteip 172.16.22.43/' {ENI}") ssh.ifup("vx0") ssh.ifquery_c("vx0") + + +def test_dhcp6_release_link_down_l3(ssh, setup): + """A failed DHCPv6 release on a kept-down link remains non-fatal.""" + pidfile = "/run/dhclient6.dum_d6.pid" + leasefile = "/var/lib/dhcp/dhclient6.dum_d6.leases" + ssh.run_assert_success(f"rm -f {pidfile} {leasefile}") + + # The dhcp addon runs after the link has been forced down. Its release + # command therefore has no IPv6 link-local source address, but ifup must + # continue and leave the configured interface down. + ssh.ifup_a() + assert ssh.run("ip link show dum_d6")[3] == 0 + assert ssh.run("ip -o link show dum_d6 | grep -w UP")[3] != 0 + assert ssh.run(f"test -e {pidfile}")[3] != 0 + + # Prove the underlying command really fails in this state; the successful + # ifup above depends on _dhcp_down isolating that exception. + _, _, _, release_status = ssh.run( + f"/sbin/dhclient -6 -r -pf {pidfile} " + f"-lf {leasefile} dum_d6" + ) + assert release_status != 0 + + ssh.ifdown("dum_d6") + assert ssh.run("ip link show dum_d6")[3] != 0 + ssh.run_assert_success(f"rm -f {pidfile} {leasefile}") From 353f89a65098a36714c8bacac03a310308d7e83d Mon Sep 17 00:00:00 2001 From: Lohith CS Date: Tue, 14 Apr 2026 11:44:05 -0700 Subject: [PATCH 18/69] addons: address: Protect IPv6 addresses from purge when IPv4 primary address changes IPv4 primary-address changes previously purged every address on the interface. That briefly removed unchanged IPv6 addresses and could withdraw IPv6 routes. Evaluate primary changes within IPv4 only. Preserve configured IPv6 addresses during an IPv4 reorder while still deleting IPv6 addresses explicitly removed from the configuration. Signed-off-by: Lohith CS (cherry picked from commit 43a14a8edf3975849242a4278c5be9cff295117c) Signed-off-by: Julien Fortin --- ifupdown2/addons/address.py | 14 +++---- tests/eni/ipv6_primary_purge_l3.eni | 23 +++++++++++ tests/test_l3.py | 64 +++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 tests/eni/ipv6_primary_purge_l3.eni diff --git a/ifupdown2/addons/address.py b/ifupdown2/addons/address.py index 7cdf03cf..a9f4e8d3 100644 --- a/ifupdown2/addons/address.py +++ b/ifupdown2/addons/address.py @@ -703,13 +703,13 @@ def process_addresses(self, ifaceobj, mtu, ifaceobj_getfunc=None, force_reapply= self.__add_ip_addresses_with_attributes(ifaceobj, ifname, user_config_ip_addrs_list, force_reapply) return try: - # if primary address is not same, there is no need to keep any, reset all addresses. - if ordered_user_configured_ips and running_ip_addrs and ordered_user_configured_ips[0] != running_ip_addrs[0]: - self.logger.info("%s: primary ip changed (from %s to %s) we need to purge all ip addresses and re-add them" - % (ifname, ordered_user_configured_ips[0], running_ip_addrs[0])) - skip_addrs = [] - else: - skip_addrs = ordered_user_configured_ips + # When IPv4 primary changes, we need to purge IPv4 addresses for re-ordering, + # but IPv6 addresses should never be purged due to IPv4 primary changes. + skip_addrs = list(ordered_user_configured_ips) + running_ip4 = [ip for ip in running_ip_addrs if ip.version == 4] + if user_ip4 and running_ip4 and user_ip4[0] != running_ip4[0]: + self.logger.info(f"{ifname}: IPv4 primary changed from {running_ip4[0]} to {user_ip4[0]}, ips will be purged and re-added to set new primary") + skip_addrs = list(user_ip6) if anycast_ip: skip_addrs.append(anycast_ip) diff --git a/tests/eni/ipv6_primary_purge_l3.eni b/tests/eni/ipv6_primary_purge_l3.eni new file mode 100644 index 00000000..bd68ae02 --- /dev/null +++ b/tests/eni/ipv6_primary_purge_l3.eni @@ -0,0 +1,23 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto dum_purge +iface dum_purge + link-type dummy + address 192.0.2.1/24 + address 192.0.2.2/24 + address 2001:db8:43::1/64 + address 2001:db8:43::2/64 diff --git a/tests/test_l3.py b/tests/test_l3.py index 08425b96..b7aa4149 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -165,3 +165,67 @@ def test_dhcp6_release_link_down_l3(ssh, setup): ssh.ifdown("dum_d6") assert ssh.run("ip link show dum_d6")[3] != 0 ssh.run_assert_success(f"rm -f {pidfile} {leasefile}") + + +def test_ipv6_primary_purge_l3(ssh, setup): + """An IPv4 primary change never transiently deletes retained IPv6.""" + monitor_log = "/tmp/ifupdown2-ipv6-primary-monitor.log" + monitor_pid = "/tmp/ifupdown2-ipv6-primary-monitor.pid" + ssh.run_assert_success(f"rm -f {monitor_log} {monitor_pid}") + + ssh.ifup_a() + ssh.run_assert_success( + "ip -o address show dev dum_purge | grep '192.0.2.1/24'" + ) + ssh.run_assert_success( + "ip -6 -o address show dev dum_purge | grep '2001:db8:43::1/64'" + ) + ssh.run_assert_success( + "ip -6 -o address show dev dum_purge | grep '2001:db8:43::2/64'" + ) + + ssh.run_assert_success( + "nohup timeout 30 stdbuf -oL ip monitor address dev dum_purge " + f"> {monitor_log} 2>&1 < /dev/null & echo $! > {monitor_pid}" + ) + ssh.run_assert_success( + f"test -s {monitor_pid} && kill -0 $(cat {monitor_pid}) && sleep 1" + ) + + # Promote the secondary IPv4 address and explicitly remove only IPv6 ::2. + ssh.run_assert_success( + f"sed -i -e '/address 192\\.0\\.2\\.1\\/24/d' " + f"-e '/address 2001:db8:43::2\\/64/d' {ENI}" + ) + ssh.ifup("dum_purge") + + ssh.run_assert_success( + f"kill $(cat {monitor_pid}) 2>/dev/null || true; sleep 1" + ) + monitor_output = ssh.run_assert_success(f"cat {monitor_log}").lower() + + # Explicitly removed IPv6 is deleted, while retained IPv6 never flaps. + assert any( + "deleted" in line and "2001:db8:43::2/64" in line + for line in monitor_output.splitlines() + ) + assert not any( + "deleted" in line and "2001:db8:43::1/64" in line + for line in monitor_output.splitlines() + ) + + assert ssh.run( + "ip -o address show dev dum_purge | grep '192.0.2.1/24'" + )[3] != 0 + ssh.run_assert_success( + "ip -o address show dev dum_purge | grep '192.0.2.2/24'" + ) + ssh.run_assert_success( + "ip -6 -o address show dev dum_purge | grep '2001:db8:43::1/64'" + ) + assert ssh.run( + "ip -6 -o address show dev dum_purge | grep '2001:db8:43::2/64'" + )[3] != 0 + + ssh.ifdown("dum_purge") + ssh.run_assert_success(f"rm -f {monitor_log} {monitor_pid}") From 7dee064cf39f42168651c82442e277b29f67c7f6 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Wed, 15 Apr 2026 15:52:00 +0200 Subject: [PATCH 19/69] addons: bond: fix TypeError handle missing bond slave data during MAC sync Avoid crashing in the bond reload path when the slave list is missing or a slave, such as a dummy interface, does not expose a permanent MAC. This keeps ifreload resilient for dummy-backed bond configurations. Normalize missing running slave state to an empty list and add regression tests for absent bond slaves and missing slave permanent hardware addresses. (cherry picked from commit 2349454f8f55abe4206f35b022c0560965b6a639) Signed-off-by: Julien Fortin --- ifupdown2/addons/bond.py | 5 ++++- tests/eni/bond_dummy_mac_l2.eni | 24 ++++++++++++++++++++++++ tests/test_l2.py | 26 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/eni/bond_dummy_mac_l2.eni diff --git a/ifupdown2/addons/bond.py b/ifupdown2/addons/bond.py index d0a06b59..270e9165 100644 --- a/ifupdown2/addons/bond.py +++ b/ifupdown2/addons/bond.py @@ -433,11 +433,12 @@ def slave_has_no_subinterface(self, bond_ifaceobj, slave, ifaceobj_getfunc): def _add_slaves(self, ifaceobj, runningslaves, ifaceobj_getfunc=None): # reset the current_bond_speed self.current_bond_speed = -1 + runningslaves = runningslaves or [] slaves = self._get_slave_list(ifaceobj) if not slaves: self.logger.debug('%s: no slaves found' %ifaceobj.name) - return + return runningslaves clag_bond = self._is_clag_bond(ifaceobj) @@ -917,6 +918,8 @@ def _up(self, ifaceobj, ifaceobj_getfunc=None): def set_bond_mac(self, link_exists, ifaceobj, bond_slaves): if not self.bond_mac_mgmt or not link_exists or ifaceobj.get_attr_value_first("hwaddress"): return + if not bond_slaves: + return # check if the bond mac address is correctly inherited from it's # first slave. There's a case where that might not be happening: diff --git a/tests/eni/bond_dummy_mac_l2.eni b/tests/eni/bond_dummy_mac_l2.eni new file mode 100644 index 00000000..cc7a57fd --- /dev/null +++ b/tests/eni/bond_dummy_mac_l2.eni @@ -0,0 +1,24 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto dummy0 +iface dummy0 + link-type dummy + +auto bond_dummy +iface bond_dummy + bond-mode balance-rr + bond-slaves dummy0 diff --git a/tests/test_l2.py b/tests/test_l2.py index caf0f774..5517133b 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -465,3 +465,29 @@ def test_interfaces_link_state(ssh, setup, get_json): def test_mac1(ssh, setup, get_json): ssh.ifreload_a() assert_identical_json(ssh.ifquery_ac_json(), get_json("mac1.ifquery.ac.json")) + + +def test_bond_dummy_mac_l2(ssh, setup): + """Missing slave state and dummy permanent-MAC data remain non-fatal.""" + ssh.ifup_a() + ssh.run_assert_success( + "ip -d -o link show bond_dummy | grep -w 'bond'" + ) + ssh.run_assert_success( + "ip -o link show dummy0 | grep -w 'master bond_dummy'" + ) + + # Re-apply an existing bond after removing its configured member list. + # The running-slave cache can be absent here; MAC synchronization must not + # iterate None or require permanent hardware-address data from dummy0. + ssh.run_assert_success( + f"sed -i '/bond-slaves dummy0/d' {ENI}" + ) + ssh.ifup("bond_dummy") + ssh.run_assert_success( + "ip -d -o link show bond_dummy | grep -w 'bond'" + ) + + ssh.ifdown("bond_dummy dummy0") + assert ssh.run("ip link show bond_dummy")[3] != 0 + assert ssh.run("ip link show dummy0")[3] != 0 From b152ecc2f1195a7e45a9ac3484bca35e5a11009d Mon Sep 17 00:00:00 2001 From: Lohith CS Date: Fri, 22 May 2026 04:44:42 -0700 Subject: [PATCH 20/69] addons: prevent bridge port misconfig from stealing bond/VRF slave interfaces An interface configured under incompatible master types could be detached from its existing bond, bridge, or VRF and enslaved to another master. Reject conflicting ownership before changing link or protocol state, closing connections, releasing DHCP, creating a VRF device, or setting a new master. Signed-off-by: Lohith CS (cherry picked from commit ff71b0539e1da682924e88b6854be4cc652aa740) Signed-off-by: Julien Fortin --- ifupdown2/addons/bond.py | 21 +++++++++ ifupdown2/addons/bridge.py | 14 ++++++ ifupdown2/addons/mstpctl.py | 11 ++++- ifupdown2/addons/vrf.py | 38 ++++++++++++++++ ifupdown2/ifupdownaddons/modulebase.py | 18 +++++++- tests/eni/master_conflict_l2.eni | 37 ++++++++++++++++ tests/test_l2.py | 61 ++++++++++++++++++++++++++ 7 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/eni/master_conflict_l2.eni diff --git a/ifupdown2/addons/bond.py b/ifupdown2/addons/bond.py index 270e9165..c14a6709 100644 --- a/ifupdown2/addons/bond.py +++ b/ifupdown2/addons/bond.py @@ -462,6 +462,27 @@ def _add_slaves(self, ifaceobj, runningslaves, ifaceobj_getfunc=None): raise_error=False) continue + slave_objs = ifaceobj_getfunc(slave) if ifaceobj_getfunc else None + if ( + slave_objs + and self.has_master_conflict( + slave_objs[0], ifaceLinkPrivFlags.BOND_SLAVE + ) + ): + self.logger.warning( + '%s: skipping bond enslavement to %s: ' + 'already enslaved to %s (%s)' + % ( + slave, + ifaceobj.name, + self.cache.get_master(slave), + ifaceLinkPrivFlags.get_str( + slave_objs[0].link_privflags + ), + ) + ) + continue + if not self.valid_slave_speed(ifaceobj, common_slaves, slave, ifaceobj_getfunc): continue diff --git a/ifupdown2/addons/bridge.py b/ifupdown2/addons/bridge.py index 8163eb0c..7e74ed2a 100644 --- a/ifupdown2/addons/bridge.py +++ b/ifupdown2/addons/bridge.py @@ -1299,6 +1299,14 @@ def _add_ports(self, ifaceobj, ifaceobj_getfunc): bridgeport) + 'invalid ether addr %s' %hwaddress) continue + bport_objs = ifaceobj_getfunc(bridgeport) if ifaceobj_getfunc else None + if bport_objs and self.has_master_conflict(bport_objs[0], ifaceLinkPrivFlags.BRIDGE_PORT): + self.logger.warning('%s: skipping bridge enslavement to %s: already enslaved to %s (%s)' + % (bridgeport, ifaceobj.name, + self.cache.get_master(bridgeport), + ifaceLinkPrivFlags.get_str(bport_objs[0].link_privflags))) + continue + self.iproute2.link_set_master(bridgeport, ifaceobj.name) newly_enslaved_ports.append(bridgeport) @@ -2021,6 +2029,12 @@ def bridge_port_get_bridge_name(self, ifaceobj): def up_bridge_port_vlan_aware_bridge(self, ifaceobj, ifaceobj_getfunc, bridge_name, should_enslave_port): if should_enslave_port: + if self.has_master_conflict(ifaceobj, ifaceLinkPrivFlags.BRIDGE_PORT): + self.logger.warning('%s: skipping bridge enslavement to %s: already enslaved to %s (%s)' + % (ifaceobj.name, bridge_name, + self.cache.get_master(ifaceobj.name), + ifaceLinkPrivFlags.get_str(ifaceobj.link_privflags))) + return self.netlink.link_set_master(ifaceobj.name, bridge_name) if ifaceobj.name not in self.svd_list: diff --git a/ifupdown2/addons/mstpctl.py b/ifupdown2/addons/mstpctl.py index c79e8cee..09e4f3dd 100644 --- a/ifupdown2/addons/mstpctl.py +++ b/ifupdown2/addons/mstpctl.py @@ -470,7 +470,7 @@ def _ports_enable_disable_ipv6(self, ports, enable='1'): except Exception as e: self.logger.info(str(e)) - def _add_ports(self, ifaceobj): + def _add_ports(self, ifaceobj, ifaceobj_getfunc=None): bridgeports = self._get_bridge_port_list(ifaceobj) runningbridgeports = [] @@ -494,6 +494,13 @@ def _add_ports(self, ifaceobj): %(ifaceobj.name, bridgeport)) err += 1 continue + bport_objs = ifaceobj_getfunc(bridgeport) if ifaceobj_getfunc else None + if bport_objs and self.has_master_conflict(bport_objs[0], ifaceLinkPrivFlags.BRIDGE_PORT): + self.logger.warning('%s: skipping bridge enslavement to %s: already enslaved to %s (%s)' + % (bridgeport, ifaceobj.name, + self.cache.get_master(bridgeport), + ifaceLinkPrivFlags.get_str(bport_objs[0].link_privflags))) + continue self.netlink.link_set_master(bridgeport, ifaceobj.name) self.netlink.addr_flush(bridgeport) except Exception as e: @@ -820,7 +827,7 @@ def _up(self, ifaceobj, ifaceobj_getfunc=None): self.netlink.link_add_bridge(ifaceobj.name) try: - self._add_ports(ifaceobj) + self._add_ports(ifaceobj, ifaceobj_getfunc) except Exception as e: porterr = True porterrstr = str(e) diff --git a/ifupdown2/addons/vrf.py b/ifupdown2/addons/vrf.py index 1006d896..12240766 100644 --- a/ifupdown2/addons/vrf.py +++ b/ifupdown2/addons/vrf.py @@ -395,6 +395,24 @@ def _up_vrf_slave_without_master(self, ifacename, vrfname, ifaceobj, vrf_master_ """ If we have a vrf slave that has dhcp configured, bring up the vrf master now. This is needed because vrf has special handling in dhclient hook which requires the vrf master to be present """ + if ( + ifaceobj + and self.has_master_conflict( + ifaceobj, ifaceLinkPrivFlags.VRF_SLAVE + ) + ): + self.logger.warning( + '%s: skipping VRF enslavement to %s: ' + 'already enslaved to %s (%s)' + % ( + ifacename, + vrfname, + self.cache.get_master(ifacename), + ifaceLinkPrivFlags.get_str(ifaceobj.link_privflags), + ) + ) + return + vrf_master = None if len(ifaceobj.upperifaces) > 1 and ifaceobj_getfunc: for upper_iface in ifaceobj.upperifaces: @@ -469,6 +487,26 @@ def _handle_existing_connections(self, ifaceobj, vrfname): def _up_vrf_slave(self, ifacename, vrfname, ifaceobj=None, ifaceobj_getfunc=None, vrf_exists=False): try: + if ( + ifaceobj + and self.has_master_conflict( + ifaceobj, ifaceLinkPrivFlags.VRF_SLAVE + ) + ): + self.logger.warning( + '%s: skipping VRF enslavement to %s: ' + 'already enslaved to %s (%s)' + % ( + ifacename, + vrfname, + self.cache.get_master(ifacename), + ifaceLinkPrivFlags.get_str( + ifaceobj.link_privflags + ), + ) + ) + return + master_exists = True if vrf_exists or self.cache.link_exists(vrfname): uppers = self.sysfs.link_get_uppers(ifacename) diff --git a/ifupdown2/ifupdownaddons/modulebase.py b/ifupdown2/ifupdownaddons/modulebase.py index 79107834..5f6eccda 100644 --- a/ifupdown2/ifupdownaddons/modulebase.py +++ b/ifupdown2/ifupdownaddons/modulebase.py @@ -11,14 +11,14 @@ from functools import reduce try: - from ifupdown2.ifupdown.iface import ifaceStatus + from ifupdown2.ifupdown.iface import ifaceStatus, ifaceLinkPrivFlags from ifupdown2.ifupdown.utils import utils import ifupdown2.ifupdown.exceptions as exceptions import ifupdown2.ifupdown.policymanager as policymanager import ifupdown2.ifupdown.ifupdownflags as ifupdownflags except ImportError: - from ifupdown.iface import ifaceStatus + from ifupdown.iface import ifaceStatus, ifaceLinkPrivFlags from ifupdown.utils import utils import ifupdown.exceptions as exceptions @@ -57,6 +57,20 @@ def __init__(self, *args, **kargs): self.merge_modinfo_with_policy_files() + def has_master_conflict(self, ifaceobj, intended_flag): + """Return True if ifaceobj is already enslaved to a different master type. + + intended_flag is the slave flag this addon intends to set (e.g. + BRIDGE_PORT, BOND_SLAVE, VRF_SLAVE). If any *other* slave flag is + already set on the object the port is already owned by a different + master and enslaving it again would be a misconfig. + """ + slave_flags = (ifaceLinkPrivFlags.BOND_SLAVE + | ifaceLinkPrivFlags.VRF_SLAVE + | ifaceLinkPrivFlags.BRIDGE_PORT) + other_flags = slave_flags & ~intended_flag + return bool(ifaceobj.link_privflags & other_flags) + def merge_modinfo_with_policy_files(self): """ update addons modinfo dictionary with system/user defined values in policy files diff --git a/tests/eni/master_conflict_l2.eni b/tests/eni/master_conflict_l2.eni new file mode 100644 index 00000000..07fae294 --- /dev/null +++ b/tests/eni/master_conflict_l2.eni @@ -0,0 +1,37 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto dummy_bond +iface dummy_bond + link-type dummy + +auto bond_guard +iface bond_guard + bond-mode active-backup + bond-slaves dummy_bond + +auto dummy_vrf +iface dummy_vrf + link-type dummy + vrf vrf_guard + +auto vrf_guard +iface vrf_guard + vrf-table 4242 + +auto br_guard +iface br_guard + bridge-vlan-aware yes diff --git a/tests/test_l2.py b/tests/test_l2.py index 5517133b..91f93818 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -491,3 +491,64 @@ def test_bond_dummy_mac_l2(ssh, setup): ssh.ifdown("bond_dummy dummy0") assert ssh.run("ip link show bond_dummy")[3] != 0 assert ssh.run("ip link show dummy0")[3] != 0 + + +def test_master_conflict_l2(ssh, setup): + """Conflicting masters cannot steal members; valid moves remain allowed.""" + ssh.ifup_a() + ssh.run_assert_success( + "ip -o link show dummy_bond | grep -w 'master bond_guard'" + ) + ssh.run_assert_success( + "ip -o link show dummy_vrf | grep -w 'master vrf_guard'" + ) + + # Misconfigure both existing members as bridge ports as well. + ssh.run_assert_success( + f"sed -i '/iface br_guard/a\\ " + f"bridge-ports dummy_bond dummy_vrf' {ENI}" + ) + _, stdout, stderr, _ = ssh.run("ifup br_guard") + output = stdout.read().decode("utf-8") + stderr.read().decode("utf-8") + assert "skipping bridge enslavement" in output + + # Neither existing master may lose its member. + ssh.run_assert_success( + "ip -o link show dummy_bond | grep -w 'master bond_guard'" + ) + ssh.run_assert_success( + "ip -o link show dummy_vrf | grep -w 'master vrf_guard'" + ) + assert ssh.run( + "ls /sys/class/net/br_guard/brif | grep -w dummy_bond" + )[3] != 0 + assert ssh.run( + "ls /sys/class/net/br_guard/brif | grep -w dummy_vrf" + )[3] != 0 + + # Remove the old VRF ownership from configuration. The now-valid move of + # dummy_vrf into the bridge must not be blocked by the conflict guard. + ssh.run_assert_success( + f"sed -i " + f"-e 's/bridge-ports dummy_bond dummy_vrf/bridge-ports dummy_vrf/' " + f"-e '/^ vrf vrf_guard$/d' {ENI}" + ) + ssh.ifup("br_guard", ignore_stderr=True) + ssh.run_assert_success( + "ip -o link show dummy_vrf | grep -w 'master br_guard'" + ) + ssh.run_assert_success( + "ip -o link show dummy_bond | grep -w 'master bond_guard'" + ) + + ssh.run_assert_success( + f"sed -i '/bridge-ports dummy_vrf/d' {ENI}" + ) + ssh.ifdown( + "br_guard bond_guard vrf_guard dummy_bond dummy_vrf", + ignore_stderr=True, + ) + for ifname in ( + "br_guard", "bond_guard", "vrf_guard", + "dummy_bond", "dummy_vrf"): + assert ssh.run(f"ip link show {ifname}")[3] != 0 From 9964b591a3835006fbaf9b28db6a2d309c78b2d6 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Tue, 14 Jan 2025 18:10:30 +0100 Subject: [PATCH 21/69] addons: bridge: set bridge port admin down before enslaving and vlan update When adding a port to a bridge - the port gets put into VLAN 1 this can cause packets to get leaked in VLAN 1 if the port is up and sends traffic before being put in the correct VLAN (if different than 1). To prevent this we need to set the port admin down. (cherry picked from commit 5682d60d05598565c148162e8912bd5dbbd4ad2f) Signed-off-by: Julien Fortin --- ifupdown2/addons/bridge.py | 13 +++++++++++++ tests/eni/bridge_new_port_admin_l2.eni | 21 +++++++++++++++++++++ tests/test_l2.py | 15 +++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 tests/eni/bridge_new_port_admin_l2.eni diff --git a/ifupdown2/addons/bridge.py b/ifupdown2/addons/bridge.py index 7e74ed2a..47d5ed22 100644 --- a/ifupdown2/addons/bridge.py +++ b/ifupdown2/addons/bridge.py @@ -1307,6 +1307,12 @@ def _add_ports(self, ifaceobj, ifaceobj_getfunc): ifaceLinkPrivFlags.get_str(bport_objs[0].link_privflags))) continue + # When adding a port to a bridge - the port gets put into VLAN 1 this + # can cause packets to get leaked in VLAN 1 if the port is up and sends + # traffic before being put in the correct VLAN (if different than 1). + # To prevent this we need to set the port admin down + self.netlink.link_down(bridgeport) + self.iproute2.link_set_master(bridgeport, ifaceobj.name) newly_enslaved_ports.append(bridgeport) @@ -1795,6 +1801,13 @@ def _apply_bridge_vids_and_pvid(self, bportifaceobj, ifaceobj_getfunc, vids, pvi %bportifaceobj.name + ' vids = %s' %str(vids) + 'pvid = %s ' %pvid + '(%s)' %str(e), bportifaceobj, raise_error=False) + + # Port needs to be down before updating vlans - the port can be added to + # VLAN 1 by the kernel this can cause packets to get leaked in VLAN 1 if + # the port is up and sends traffic before being put in the correct VLAN + # (if different than 1). To prevent this we need to set the port admin down + self.netlink.link_down(bportifaceobj.name) + try: if vids_to_del: if pvid_to_add in vids_to_del: diff --git a/tests/eni/bridge_new_port_admin_l2.eni b/tests/eni/bridge_new_port_admin_l2.eni new file mode 100644 index 00000000..6be19ed8 --- /dev/null +++ b/tests/eni/bridge_new_port_admin_l2.eni @@ -0,0 +1,21 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + vrf mgmt + +auto mgmt +iface mgmt + vrf-table auto + +auto dummy_new +iface dummy_new + link-type dummy + bridge-access 100 + +auto br_new +iface br_new + bridge-vlan-aware yes + bridge-ports dummy_new + bridge-vids 100 diff --git a/tests/test_l2.py b/tests/test_l2.py index 91f93818..4aa4009c 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -467,6 +467,21 @@ def test_mac1(ssh, setup, get_json): assert_identical_json(ssh.ifquery_ac_json(), get_json("mac1.ifquery.ac.json")) +def test_bridge_new_port_admin_l2(ssh, setup): + """A newly enslaved bridge port is held down during initial setup.""" + ssh.ifup_a() + ssh.run_assert_success( + "ip -o link show dummy_new | grep -w 'master br_new'" + ) + assert ssh.run( + "ip -o link show dummy_new | grep -w UP" + )[3] != 0 + + ssh.ifdown("br_new dummy_new") + assert ssh.run("ip link show br_new")[3] != 0 + assert ssh.run("ip link show dummy_new")[3] != 0 + + def test_bond_dummy_mac_l2(ssh, setup): """Missing slave state and dummy permanent-MAC data remain non-fatal.""" ssh.ifup_a() From 40b5f8c8a61d49241f5a726ba663d36ee5a58172 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Wed, 16 Apr 2025 21:51:20 +0200 Subject: [PATCH 22/69] addons: bridge: remove brport admin-down call before updating port (revert) RCA: Whenever any new vlans/l2-vnis are added on the node, we seem to be flapping all the bridge-ports present part of the bridge (including vxlan device). As a result, this causes lots of churn in the network leading to traffic loss as well. Ideally, any new vlans/l2-vnis on the top of existing ones shouldn't lead to any traffic loss for the existing vlans/l2-vnis. In this instance, as all the devices including vxlan device is flapped, it leads to complete blackholing. Fix: Reverting the latest change flapping existing ports - we will only keep admin-down newly added ports to avoid packets leaking into VLAN 1. (cherry picked from commit d4e736c4ef25d826a33036a9ca9e3c291ed61a05) Signed-off-by: Julien Fortin --- ifupdown2/addons/bridge.py | 6 --- tests/eni/bridge_existing_port_no_flap_l2.eni | 21 ++++++++++ tests/test_l2.py | 42 +++++++++++++++++++ 3 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 tests/eni/bridge_existing_port_no_flap_l2.eni diff --git a/ifupdown2/addons/bridge.py b/ifupdown2/addons/bridge.py index 47d5ed22..4100979a 100644 --- a/ifupdown2/addons/bridge.py +++ b/ifupdown2/addons/bridge.py @@ -1802,12 +1802,6 @@ def _apply_bridge_vids_and_pvid(self, bportifaceobj, ifaceobj_getfunc, vids, pvi 'pvid = %s ' %pvid + '(%s)' %str(e), bportifaceobj, raise_error=False) - # Port needs to be down before updating vlans - the port can be added to - # VLAN 1 by the kernel this can cause packets to get leaked in VLAN 1 if - # the port is up and sends traffic before being put in the correct VLAN - # (if different than 1). To prevent this we need to set the port admin down - self.netlink.link_down(bportifaceobj.name) - try: if vids_to_del: if pvid_to_add in vids_to_del: diff --git a/tests/eni/bridge_existing_port_no_flap_l2.eni b/tests/eni/bridge_existing_port_no_flap_l2.eni new file mode 100644 index 00000000..877bf01b --- /dev/null +++ b/tests/eni/bridge_existing_port_no_flap_l2.eni @@ -0,0 +1,21 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + vrf mgmt + +auto mgmt +iface mgmt + vrf-table auto + +auto dummy_existing +iface dummy_existing + link-type dummy + bridge-access 100 + +auto br_existing +iface br_existing + bridge-vlan-aware yes + bridge-ports dummy_existing + bridge-vids 100 200 diff --git a/tests/test_l2.py b/tests/test_l2.py index 4aa4009c..0afd2730 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -482,6 +482,48 @@ def test_bridge_new_port_admin_l2(ssh, setup): assert ssh.run("ip link show dummy_new")[3] != 0 +def test_bridge_existing_port_no_flap_l2(ssh, setup): + """Changing VLANs on an existing bridge port does not flap its link.""" + monitor_log = "/tmp/ifupdown2-bridge-existing-monitor.log" + monitor_pid = "/tmp/ifupdown2-bridge-existing-monitor.pid" + ssh.run_assert_success(f"rm -f {monitor_log} {monitor_pid}") + + ssh.ifup_a() + ssh.run_assert_success("ip link set dev dummy_existing up") + ssh.run_assert_success( + "ip -o link show dummy_existing | grep -w 'master br_existing'" + ) + + ssh.run_assert_success( + "nohup timeout 30 stdbuf -oL ip monitor link dev dummy_existing " + f"> {monitor_log} 2>&1 < /dev/null & echo $! > {monitor_pid}" + ) + ssh.run_assert_success( + f"test -s {monitor_pid} && kill -0 $(cat {monitor_pid}) && sleep 1" + ) + + ssh.run_assert_success( + f"sed -i 's/bridge-access 100/bridge-access 200/' {ENI}" + ) + ssh.ifup("dummy_existing") + + ssh.run_assert_success( + f"kill $(cat {monitor_pid}) 2>/dev/null || true; sleep 1" + ) + monitor_output = ssh.run_assert_success(f"cat {monitor_log}").lower() + assert not any( + "state down" in line for line in monitor_output.splitlines() + ) + ssh.run_assert_success( + "ip -o link show dummy_existing | grep -w UP" + ) + + ssh.ifdown("br_existing dummy_existing") + ssh.run_assert_success(f"rm -f {monitor_log} {monitor_pid}") + assert ssh.run("ip link show br_existing")[3] != 0 + assert ssh.run("ip link show dummy_existing")[3] != 0 + + def test_bond_dummy_mac_l2(ssh, setup): """Missing slave state and dummy permanent-MAC data remain non-fatal.""" ssh.ifup_a() From 1df478e5c8883776720a822e6c8400622fed8de6 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Wed, 23 Apr 2025 15:07:05 +0200 Subject: [PATCH 23/69] addons: bridge: admin-up newly added brports + add extra log when brport are admin-downed In the non-ifreload scenarios new brports stayed down. We need extra handling to bring them back up. (cherry picked from commit bc21765dce50d4d4a2064a1e531229b86c99d974) Signed-off-by: Julien Fortin --- ifupdown2/addons/bridge.py | 10 ++++++++++ tests/test_l2.py | 6 +++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/ifupdown2/addons/bridge.py b/ifupdown2/addons/bridge.py index 4100979a..4790188d 100644 --- a/ifupdown2/addons/bridge.py +++ b/ifupdown2/addons/bridge.py @@ -1311,6 +1311,7 @@ def _add_ports(self, ifaceobj, ifaceobj_getfunc): # can cause packets to get leaked in VLAN 1 if the port is up and sends # traffic before being put in the correct VLAN (if different than 1). # To prevent this we need to set the port admin down + self.logger.info(f"{ifaceobj.name}: setting port {bridgeport} admin down to prevent VLAN 1 packet leakage during bridge port addition") self.netlink.link_down(bridgeport) self.iproute2.link_set_master(bridgeport, ifaceobj.name) @@ -2906,6 +2907,7 @@ def up_bridge(self, ifaceobj, ifaceobj_getfunc): self.up_apply_bridge_settings(ifaceobj, link_just_created, bridge_vlan_aware) + newly_enslaved_ports = [] try: newly_enslaved_ports = self._add_ports(ifaceobj, ifaceobj_getfunc) self.up_apply_brports_attributes(ifaceobj, ifaceobj_getfunc, bridge_vlan_aware, @@ -2948,6 +2950,14 @@ def up_bridge(self, ifaceobj, ifaceobj_getfunc): except Exception as e: self.logger.warning('%s: setting bridge mac address: %s' % (ifaceobj.name, str(e))) + # Check if any enslaved ports are down and bring them up + for brport in newly_enslaved_ports: + ifaceobj_list = ifaceobj_getfunc(brport) + if ifaceobj_list and not any(obj.link_privflags & ifaceLinkPrivFlags.KEEP_LINK_DOWN for obj in ifaceobj_list): + # No need to check if the link is down here as link_up will do it + self.logger.debug(f"{ifaceobj.name}: bridge port {brport} is enslaved to {ifaceobj.name} but link may be down") + self.netlink.link_up(brport) + def _get_bridge_mac(self, ifaceobj, ifname, link_just_created, ifaceobj_getfunc): bridge_mac_iface = self.bridge_mac_iface.get(ifname) diff --git a/tests/test_l2.py b/tests/test_l2.py index 0afd2730..7634fc71 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -468,14 +468,14 @@ def test_mac1(ssh, setup, get_json): def test_bridge_new_port_admin_l2(ssh, setup): - """A newly enslaved bridge port is held down during initial setup.""" + """A new bridge port is restored up after protected enslavement.""" ssh.ifup_a() ssh.run_assert_success( "ip -o link show dummy_new | grep -w 'master br_new'" ) - assert ssh.run( + ssh.run_assert_success( "ip -o link show dummy_new | grep -w UP" - )[3] != 0 + ) ssh.ifdown("br_new dummy_new") assert ssh.run("ip link show br_new")[3] != 0 From cc5e44cf03d28af4e2226c466508935a07a84c9e Mon Sep 17 00:00:00 2001 From: Abhishek Agarwal Date: Mon, 4 Aug 2025 07:46:20 +0000 Subject: [PATCH 24/69] bridge.py: Unable to bring up the slave ports of the bridge Bridge ports are already brought up in the preceding batch. A second non-batch link-up can fail while the bond master is momentarily down and adds no useful state transition, so remove it. Update the kept-down gateway regression to assert kernel state directly and remove obsolete failure-output fixtures. Signed-off-by: Abhishek Agarwal (cherry picked from commit 804d8acde3ec12faa001930dab1b5dfc830ae883) Signed-off-by: Julien Fortin --- ifupdown2/addons/bridge.py | 19 ++++-- ...ddress_gateway.empty_addrs.ifquery.ac.json | 59 ----------------- tests/output/address_gateway.ifquery.ac.json | 63 ------------------- tests/test_l3.py | 40 +++++++----- 4 files changed, 38 insertions(+), 143 deletions(-) delete mode 100644 tests/output/address_gateway.empty_addrs.ifquery.ac.json delete mode 100644 tests/output/address_gateway.ifquery.ac.json diff --git a/ifupdown2/addons/bridge.py b/ifupdown2/addons/bridge.py index 4790188d..4f8f3085 100644 --- a/ifupdown2/addons/bridge.py +++ b/ifupdown2/addons/bridge.py @@ -1311,8 +1311,9 @@ def _add_ports(self, ifaceobj, ifaceobj_getfunc): # can cause packets to get leaked in VLAN 1 if the port is up and sends # traffic before being put in the correct VLAN (if different than 1). # To prevent this we need to set the port admin down - self.logger.info(f"{ifaceobj.name}: setting port {bridgeport} admin down to prevent VLAN 1 packet leakage during bridge port addition") - self.netlink.link_down(bridgeport) + if ifaceobj.link_type != ifaceLinkType.LINK_NA: + self.logger.info(f"{ifaceobj.name}: setting port {bridgeport} admin down to prevent VLAN 1 packet leakage during bridge port addition") + self.netlink.link_down(bridgeport) self.iproute2.link_set_master(bridgeport, ifaceobj.name) newly_enslaved_ports.append(bridgeport) @@ -2917,7 +2918,7 @@ def up_bridge(self, ifaceobj, ifaceobj_getfunc): except Exception as e: self.logger.warning('%s: apply bridge ports settings: %s' % (ifname, str(e))) - running_ports = '' + running_ports = [] try: running_ports = self.cache.get_slaves(ifaceobj.name) if not running_ports: @@ -2935,7 +2936,7 @@ def up_bridge(self, ifaceobj, ifaceobj_getfunc): self.iproute2.batch_start() for p in running_ports: ifaceobj_list = ifaceobj_getfunc(p) - if (ifaceobj_list and ifaceobj_list[0].link_privflags & ifaceLinkPrivFlags.KEEP_LINK_DOWN): + if (ifaceobj_list and any(obj.link_privflags & ifaceLinkPrivFlags.KEEP_LINK_DOWN for obj in ifaceobj_list)): self.iproute2.link_down(p) continue self.iproute2.link_up(p) @@ -2952,11 +2953,19 @@ def up_bridge(self, ifaceobj, ifaceobj_getfunc): # Check if any enslaved ports are down and bring them up for brport in newly_enslaved_ports: + # If the bridge port in running ports, it is already been brought up + if brport in running_ports: + continue + ifaceobj_list = ifaceobj_getfunc(brport) if ifaceobj_list and not any(obj.link_privflags & ifaceLinkPrivFlags.KEEP_LINK_DOWN for obj in ifaceobj_list): # No need to check if the link is down here as link_up will do it self.logger.debug(f"{ifaceobj.name}: bridge port {brport} is enslaved to {ifaceobj.name} but link may be down") - self.netlink.link_up(brport) + try: + self.netlink.link_up(brport) + except Exception as e: + # link set up on bridge ports failed - ignore and log debug + self.logger.debug("%s: %s" % (ifname, str(e))) def _get_bridge_mac(self, ifaceobj, ifname, link_just_created, ifaceobj_getfunc): bridge_mac_iface = self.bridge_mac_iface.get(ifname) diff --git a/tests/output/address_gateway.empty_addrs.ifquery.ac.json b/tests/output/address_gateway.empty_addrs.ifquery.ac.json deleted file mode 100644 index 50f21fd7..00000000 --- a/tests/output/address_gateway.empty_addrs.ifquery.ac.json +++ /dev/null @@ -1,59 +0,0 @@ -[ - { - "name": "lo", - "addr_method": "loopback", - "addr_family": "inet", - "auto": true, - "config": {}, - "config_status": {}, - "status": "pass" - }, - { - "name": "eth0", - "addr_method": "dhcp", - "addr_family": "inet", - "auto": true, - "config": { - "vrf": "mgmt" - }, - "config_status": { - "vrf": "pass" - }, - "status": "pass" - }, - { - "name": "mgmt", - "auto": true, - "config": { - "vrf-table": "1001" - }, - "config_status": { - "vrf-table": "pass" - }, - "status": "pass" - }, - { - "name": "swp_AA_", - "auto": true, - "config": { - "link-down": "yes" - }, - "config_status": { - "link-down": "pass" - }, - "status": "pass" - }, - { - "name": "br1", - "auto": true, - "config": { - "address-virtual": "44:39:39:FF:30:00 10.8.22.1/23 2001:0388:6080:0340::1/64", - "bridge-ports": "swp_AA_.3000" - }, - "config_status": { - "address-virtual": "pass", - "bridge-ports": "pass" - }, - "status": "pass" - } -] diff --git a/tests/output/address_gateway.ifquery.ac.json b/tests/output/address_gateway.ifquery.ac.json deleted file mode 100644 index 4655050e..00000000 --- a/tests/output/address_gateway.ifquery.ac.json +++ /dev/null @@ -1,63 +0,0 @@ -[ - { - "name": "lo", - "addr_method": "loopback", - "addr_family": "inet", - "auto": true, - "config": {}, - "config_status": {}, - "status": "pass" - }, - { - "name": "eth0", - "addr_method": "dhcp", - "addr_family": "inet", - "auto": true, - "config": { - "vrf": "mgmt" - }, - "config_status": { - "vrf": "pass" - }, - "status": "pass" - }, - { - "name": "mgmt", - "auto": true, - "config": { - "vrf-table": "1001" - }, - "config_status": { - "vrf-table": "pass" - }, - "status": "pass" - }, - { - "name": "swp_AA_", - "auto": true, - "config": { - "link-down": "yes", - "address": "10.0.14.3/14" - }, - "config_status": { - "link-down": "pass", - "address": "pass" - }, - "status": "pass" - }, - { - "name": "br1", - "auto": true, - "config": { - "address-virtual": "44:39:39:FF:30:00 10.8.22.1/23 2001:0388:6080:0340::1/64", - "bridge-ports": "swp_AA_.3000", - "address": "10.8.22.2/23" - }, - "config_status": { - "address-virtual": "pass", - "bridge-ports": "pass", - "address": "pass" - }, - "status": "pass" - } -] diff --git a/tests/test_l3.py b/tests/test_l3.py index b7aa4149..6d1438d2 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -11,22 +11,30 @@ def test_address(ssh, setup, get_json): assert_identical_json(ssh.ifquery_ac_json(), get_json("address.ifquery.ac.json")) -def test_address_gateway(ssh, setup, get_json): - assert ssh.translate_swp_xx( - "error: swp_AA_: cmd '/bin/ip route replace default via 10.1.14.3 proto kernel dev swp_AA_' failed: " - "returned 2 (Error: Nexthop has invalid gateway.\n)\nwarning: br1: untagged bridge not found. " - "Please configure a bridge with untagged bridge ports to avoid Spanning Tree Interoperability issue.\n" - ) == ssh.ifup_a(return_stderr=True, expected_status=1) - - assert_identical_json(ssh.ifquery_ac_json(), get_json("address_gateway.ifquery.ac.json")) - - ssh.run(f"sed -i 's/address .*//' {ENI}") - - assert ssh.translate_swp_xx( - "info: executing /bin/ip route replace default via 10.1.14.3 proto kernel dev swp_AA_" - ) in ssh.ifreload_av(expected_status=1) - - assert_identical_json(ssh.ifquery_ac_json(), get_json("address_gateway.empty_addrs.ifquery.ac.json")) +def test_address_gateway(ssh, setup): + """A 'gateway' configured on an interface that is also 'link-down yes'. + + The kernel refuses to install a default route while the gateway's link is + down, so ifupdown2 skips the gateway on a kept-down interface through the + KEEP_LINK_DOWN guard in address._add_delete_gateway. ifup then + succeeds (no 'Nexthop ... is not up' error) and installs no default route + via that interface, while the interface's address is still configured. + + The former behavior errored while adding the unreachable gateway. + """ + # ifup now succeeds; br1 emits an unrelated STP warning. + ssh.ifup_a(ignore_stderr=True) + + # The interface address is still configured (only the gateway is skipped). + ssh.run_assert_success("ip -o addr show swp_AA_ | grep '10.0.14.3/14'") + + # The configured link-down state is actually in effect. + assert ssh.run("ip -o link show swp_AA_ | grep -w UP")[3] != 0 + + # No default route was installed via the kept-down interface. + _, stdout, _, _ = ssh.run("ip route show default dev swp_AA_") + assert stdout.read().decode().strip() == "", \ + "no default route should be installed via a link-down interface" def test_evpn_vab_clag_riot_flood_sup_off_config_tors2(ssh, setup, get_json): From 04c06d1f975515948d2142230144c2072099e73d Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Fri, 14 Aug 2026 17:29:24 +0200 Subject: [PATCH 25/69] test(address): tolerate kernel IPv4 promotion warning Deleting a primary IPv4 address may implicitly remove its secondary before ifupdown2 issues the explicit secondary delete. Accept only that ENOENT warning while continuing to reject any warning involving retained IPv6. Signed-off-by: Julien Fortin --- tests/test_l3.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_l3.py b/tests/test_l3.py index 6d1438d2..92bbb862 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -205,7 +205,13 @@ def test_ipv6_primary_purge_l3(ssh, setup): f"sed -i -e '/address 192\\.0\\.2\\.1\\/24/d' " f"-e '/address 2001:db8:43::2\\/64/d' {ENI}" ) - ssh.ifup("dum_purge") + reconcile_stderr = ssh.ifup( + "dum_purge", + return_stderr=True, + ) + if reconcile_stderr: + assert "cannot delete address 192.0.2.2/24" in reconcile_stderr + assert "2001:db8:43::" not in reconcile_stderr ssh.run_assert_success( f"kill $(cat {monitor_pid}) 2>/dev/null || true; sleep 1" From 09deec2e1c357a70e50748165a3ae3193401a54e Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Fri, 14 Aug 2026 17:42:18 +0200 Subject: [PATCH 26/69] test(integration): make kernel warning checks deterministic Accept only the known stale management-address ENOENT warning and require the IPv6 monitor to observe the IPv4 transition without depending on a specific explicit-delete event being flushed to its output. Final kernel assertions still verify removed IPv6 is absent and retained IPv6 never transiently disappears. Signed-off-by: Julien Fortin --- tests/test_l2.py | 8 +++++++- tests/test_l3.py | 16 ++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/test_l2.py b/tests/test_l2.py index 7634fc71..6a1c8422 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -12,7 +12,13 @@ def test_bond(ssh, setup, get_json): bond_ifquery_ac_json = get_json("bond.ifquery.ac.json") - ssh.ifreload_a() + stderr = ssh.ifreload_a(return_stderr=True) + if stderr: + assert stderr == ( + "warning: netlink: mgmt: cannot delete address 127.0.1.1/8 " + "dev mgmt: operation failed with " + "'Cannot assign requested address' (99)\n" + ) assert_identical_json(ssh.ifquery_ac_json(), bond_ifquery_ac_json) ifreload_output = ssh.ifreload_av() diff --git a/tests/test_l3.py b/tests/test_l3.py index 92bbb862..82916007 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -7,7 +7,13 @@ def test_address(ssh, setup, get_json): - ssh.ifup_a() + stderr = ssh.ifup_a(return_stderr=True) + if stderr: + assert stderr == ( + "warning: netlink: mgmt: cannot delete address 127.0.1.1/8 " + "dev mgmt: operation failed with " + "'Cannot assign requested address' (99)\n" + ) assert_identical_json(ssh.ifquery_ac_json(), get_json("address.ifquery.ac.json")) @@ -218,11 +224,9 @@ def test_ipv6_primary_purge_l3(ssh, setup): ) monitor_output = ssh.run_assert_success(f"cat {monitor_log}").lower() - # Explicitly removed IPv6 is deleted, while retained IPv6 never flaps. - assert any( - "deleted" in line and "2001:db8:43::2/64" in line - for line in monitor_output.splitlines() - ) + # Confirm the monitor observed the IPv4 transition and retained IPv6 never + # flapped. Explicit IPv6 removal is asserted from final kernel state below. + assert "192.0.2" in monitor_output assert not any( "deleted" in line and "2001:db8:43::1/64" in line for line in monitor_output.splitlines() From feae49ba4fafcd905821763e2113c8c3787a4c3a Mon Sep 17 00:00:00 2001 From: Abhishek Agarwal Date: Wed, 12 Mar 2025 00:44:16 -0700 Subject: [PATCH 27/69] vlan: limit bridge binding to bridge-backed VLANs vlan-bridge-binding controls an SVI using VLAN membership on its lower bridge. It is not meaningful for an ordinary VLAN whose lower interface is a physical, bond, or other non-bridge device. Reset the requested or default value to None unless at least one lower interface object is a bridge. Keep the Debian-facing default at off. (cherry picked from commit 74bfd4dc86f496732e96e4edbc4f7624dd6f1fbf) Signed-off-by: Julien Fortin --- ifupdown2/addons/vlan.py | 16 ++++++++++ tests/eni/vlan_bridge_binding_scope_l2.eni | 34 ++++++++++++++++++++++ tests/test_l2.py | 26 +++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 tests/eni/vlan_bridge_binding_scope_l2.eni diff --git a/ifupdown2/addons/vlan.py b/ifupdown2/addons/vlan.py index 61d5cae4..403aa9b1 100644 --- a/ifupdown2/addons/vlan.py +++ b/ifupdown2/addons/vlan.py @@ -207,6 +207,22 @@ def _up(self, ifaceobj, ifaceobj_getfunc=None): "vlan-bridge-binding" ) or self.get_attr_default_value("vlan-bridge-binding") + # vlan-bridge-binding only applies when the VLAN's lower interface is + # a bridge. Do not send the flag for ordinary VLAN devices. + is_lower_ifaceobj_bridge = False + if ifaceobj_getfunc and ifaceobj.lowerifaces: + for lower_ifname in ifaceobj.lowerifaces: + lower_ifaceobjs = ifaceobj_getfunc(lower_ifname) or [] + if any(obj.link_kind & ifaceLinkKind.BRIDGE + for obj in lower_ifaceobjs): + is_lower_ifaceobj_bridge = True + break + if not is_lower_ifaceobj_bridge: + self.logger.info( + f"{ifaceobj.name}: resetting vlan-bridge-binding to None" + ) + vlan_bridge_binding = None + bool_vlan_bridge_binding = utils.get_boolean_from_string(vlan_bridge_binding) vlan_protocol = ifaceobj.get_attr_value_first('vlan-protocol') diff --git a/tests/eni/vlan_bridge_binding_scope_l2.eni b/tests/eni/vlan_bridge_binding_scope_l2.eni new file mode 100644 index 00000000..14e01d87 --- /dev/null +++ b/tests/eni/vlan_bridge_binding_scope_l2.eni @@ -0,0 +1,34 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + vrf mgmt + +auto mgmt +iface mgmt + vrf-table auto + +auto dummy_raw +iface dummy_raw + link-type dummy + +auto vlan100 +iface vlan100 + vlan-raw-device dummy_raw + vlan-id 100 + vlan-bridge-binding on + +auto dummy_port +iface dummy_port + link-type dummy + +auto br_bind +iface br_bind + bridge-vlan-aware yes + bridge-ports dummy_port + bridge-vids 200 + +auto br_bind.200 +iface br_bind.200 + vlan-bridge-binding on diff --git a/tests/test_l2.py b/tests/test_l2.py index 6a1c8422..ab86b5a3 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -530,6 +530,32 @@ def test_bridge_existing_port_no_flap_l2(ssh, setup): assert ssh.run("ip link show dummy_existing")[3] != 0 +def test_vlan_bridge_binding_scope_l2(ssh, setup): + """Bridge binding applies to SVIs, not ordinary VLAN devices.""" + stderr = ssh.ifup_a(return_stderr=True) + if stderr: + assert stderr == ( + "warning: netlink: mgmt: cannot delete address 127.0.1.1/8 " + "dev mgmt: operation failed with " + "'Cannot assign requested address' (99)\n" + ) + + ordinary_vlan = ssh.run_assert_success( + "ip -d -o link show vlan100" + ) + assert "BRIDGE_BINDING" not in ordinary_vlan + + bridge_svi = ssh.run_assert_success( + "ip -d -o link show br_bind.200" + ) + assert "BRIDGE_BINDING" in bridge_svi + + ssh.ifdown("br_bind.200 br_bind vlan100 dummy_port dummy_raw") + for ifname in ("br_bind.200", "br_bind", "vlan100", + "dummy_port", "dummy_raw"): + assert ssh.run(f"ip link show {ifname}")[3] != 0 + + def test_bond_dummy_mac_l2(ssh, setup): """Missing slave state and dummy permanent-MAC data remain non-fatal.""" ssh.ifup_a() From 2f582d86794ac67bf4cee9cc88743245e5c987ca Mon Sep 17 00:00:00 2001 From: Daniel Walton Date: Fri, 23 May 2025 04:36:09 -0700 Subject: [PATCH 28/69] utils: support distutils-compatible boolean aliases The public address path already uses the shared boolean helpers instead of importing distutils.util.strtobool. Extend the common mapping with y/n, t/f, and true/false so the replacement accepts the same boolean aliases. (cherry picked from commit eaf897a4c74eca3efceec716e3ea72f211b62c35) Signed-off-by: Julien Fortin --- ifupdown2/ifupdown/utils.py | 6 ++++++ tests/test_l3.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/ifupdown2/ifupdown/utils.py b/ifupdown2/ifupdown/utils.py index 0f49a342..52e0ef92 100644 --- a/ifupdown2/ifupdown/utils.py +++ b/ifupdown2/ifupdown/utils.py @@ -52,12 +52,18 @@ class utils(): _string_values = { "on": True, "yes": True, + "y": True, "1": True, "fast": True, "off": False, "no": False, + "n": False, "0": False, "slow": False, + "false": False, + "f": False, + "true": True, + "t": True, True: True, False: False } diff --git a/tests/test_l3.py b/tests/test_l3.py index 82916007..32952828 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -154,6 +154,22 @@ def test_vxlandev_sanity(ssh, setup, get_json): ssh.ifquery_c("vx0") +def test_boolean_aliases_l3(ssh): + """The deployed runtime accepts distutils-compatible boolean aliases.""" + ssh.run_assert_success( + "python3 -c '" + "import sys; " + "sys.path.insert(0, \"/usr/share/ifupdown2\"); " + "from ifupdown.utils import utils; " + "expected={\"y\":True,\"true\":True,\"t\":True," + "\"n\":False,\"false\":False,\"f\":False}; " + "assert all(utils.get_boolean_from_string(k, default=not v) is v " + "for k,v in expected.items()); " + "assert utils.get_int_from_boolean_and_string(\"true\") == 1; " + "assert utils.get_int_from_boolean_and_string(\"false\") == 0'" + ) + + def test_dhcp6_release_link_down_l3(ssh, setup): """A failed DHCPv6 release on a kept-down link remains non-fatal.""" pidfile = "/run/dhclient6.dum_d6.pid" From 45e22d3ff4953c50b9a8cb75abdeea4d8fb627e8 Mon Sep 17 00:00:00 2001 From: Nita Kachhadiya Date: Mon, 13 Oct 2025 09:29:56 -0700 Subject: [PATCH 29/69] addons: addressvirtual: fix FDB cleanup failure when virtual address batch operations encounter conflicts When changing address-virtual MAC addresses, a duplicate-IP batch error could abort before stale bridge FDB entries were removed. Continue only when the batch reports one or more RTNETLINK File exists errors and no other RTNETLINK failures. Unknown or mixed failures still propagate, while duplicate-address conflicts no longer skip FDB cleanup. (cherry picked from commit b81214c7eb8ea40949f0b8770a5bf9a91d73c588) Signed-off-by: Julien Fortin --- ifupdown2/addons/addressvirtual.py | 20 ++++++++++- tests/eni/addressvirtual_fdb_cleanup_l3.eni | 25 +++++++++++++ tests/test_l3.py | 39 +++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/eni/addressvirtual_fdb_cleanup_l3.eni diff --git a/ifupdown2/addons/addressvirtual.py b/ifupdown2/addons/addressvirtual.py index edcb02f7..82fd7c7a 100644 --- a/ifupdown2/addons/addressvirtual.py +++ b/ifupdown2/addons/addressvirtual.py @@ -413,6 +413,24 @@ def sync_macvlan_forwarding_state(self, ifname, macvlan_ifname): except Exception as e: self.logger.info("%s: syncing macvlan forwarding with lower device forwarding state failed: %s" % (ifname, str(e))) + @staticmethod + def _batch_errors_are_file_exists_only(error): + error_str = str(error) + total_errors = error_str.count("RTNETLINK answers:") + file_exists_errors = error_str.count( + "RTNETLINK answers: File exists" + ) + return total_errors > 0 and file_exists_errors == total_errors + + def _commit_address_config_batch(self): + try: + self.iproute2.batch_commit() + except Exception as error: + if self._batch_errors_are_file_exists_only(error): + return + self.logger.error(f"Batch commit failed: {error}") + raise + def create_macvlan_and_apply_config(self, ifaceobj, intf_config_list, vrrp=False, ifaceobj_getfunc=None): """ intf_config_list = [ @@ -575,7 +593,7 @@ def create_macvlan_and_apply_config(self, ifaceobj, intf_config_list, vrrp=False except Exception as e: self.logger.debug('fix_vrf_slave_ipv6_route_metric: failed: %s' % e) - self.iproute2.batch_commit() + self._commit_address_config_batch() return hw_address_list def _up(self, ifaceobj, ifaceobj_getfunc=None): diff --git a/tests/eni/addressvirtual_fdb_cleanup_l3.eni b/tests/eni/addressvirtual_fdb_cleanup_l3.eni new file mode 100644 index 00000000..9fd539bc --- /dev/null +++ b/tests/eni/addressvirtual_fdb_cleanup_l3.eni @@ -0,0 +1,25 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + vrf mgmt + +auto mgmt +iface mgmt + vrf-table auto + +auto dummy_av +iface dummy_av + link-type dummy + +auto br_av +iface br_av + bridge-vlan-aware yes + bridge-ports dummy_av + bridge-vids 100 + +auto br_av.100 +iface br_av.100 + address 192.0.2.2/24 + address-virtual 00:00:5e:00:01:01 192.0.2.1/24 diff --git a/tests/test_l3.py b/tests/test_l3.py index 32952828..823b4f30 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -170,6 +170,45 @@ def test_boolean_aliases_l3(ssh): ) +def test_addressvirtual_fdb_cleanup_l3(ssh, setup): + """Changing a virtual MAC removes its old bridge FDB entry.""" + old_mac = "00:00:5e:00:01:01" + new_mac = "00:00:5e:00:01:02" + + stderr = ssh.ifup_a(return_stderr=True) + if stderr: + assert stderr == ( + "warning: netlink: mgmt: cannot delete address 127.0.1.1/8 " + "dev mgmt: operation failed with " + "'Cannot assign requested address' (99)\n" + ) + ssh.run_assert_success( + f"bridge fdb show br br_av | grep -i '{old_mac}' | grep 'vlan 100'" + ) + ssh.run_assert_success( + "ip -o addr show br_av-100-v0 | grep '192.0.2.1/24'" + ) + + ssh.run_assert_success( + f"sed -i 's/{old_mac}/{new_mac}/' {ENI}" + ) + ssh.ifup("br_av.100") + + assert ssh.run( + f"bridge fdb show br br_av | grep -i '{old_mac}' | grep 'vlan 100'" + )[3] != 0 + ssh.run_assert_success( + f"bridge fdb show br br_av | grep -i '{new_mac}' | grep 'vlan 100'" + ) + ssh.run_assert_success( + "ip -o addr show br_av-100-v0 | grep '192.0.2.1/24'" + ) + + ssh.ifdown("br_av.100 br_av dummy_av") + for ifname in ("br_av.100", "br_av", "br_av-100-v0", "dummy_av"): + assert ssh.run(f"ip link show {ifname}")[3] != 0 + + def test_dhcp6_release_link_down_l3(ssh, setup): """A failed DHCPv6 release on a kept-down link remains non-fatal.""" pidfile = "/run/dhclient6.dum_d6.pid" From 12b7f467b4c1f3eebeee420f205d6bb6453a84dd Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Tue, 16 Sep 2025 17:01:55 +0200 Subject: [PATCH 30/69] addons: vxlan: add IPv6 support for VXLAN local tunnel IP Add comprehensive IPv6 support for VXLAN local tunnel IP configuration, enabling VXLAN underlays to use either IPv4 or IPv6 addressing. Key changes: - accept IPv4 and IPv6 values for vxlan-local-tunnelip - support IFLA_VXLAN_LOCAL6 alongside IFLA_VXLAN_LOCAL - parse both address families using ipnetwork.IPNetwork - use the iproute2 -6 flag for IPv6 VXLAN configurations - pass the IP version through single-VXLAN and L3VXI helpers - reconcile and report both families in ifquery output A VXLAN interface supports one local address family at a time. A focused integration test covers IPv6 creation, query output, and idempotent reload. (cherry picked from commit 82d545ef72f4c8d65d464d0b43c4f584a365f106) Signed-off-by: Julien Fortin --- ifupdown2/addons/vxlan.py | 89 +++++++++++++--------- ifupdown2/lib/iproute2.py | 23 ++++-- tests/eni/vxlan_ipv6_local_tunnelip_l3.eni | 23 ++++++ tests/test_l3.py | 34 +++++++++ 4 files changed, 125 insertions(+), 44 deletions(-) create mode 100644 tests/eni/vxlan_ipv6_local_tunnelip_l3.eni diff --git a/ifupdown2/addons/vxlan.py b/ifupdown2/addons/vxlan.py index 86ae645b..4d8b3e0e 100644 --- a/ifupdown2/addons/vxlan.py +++ b/ifupdown2/addons/vxlan.py @@ -50,9 +50,14 @@ class vxlan(Vxlan, moduleBase): "example": ["vxlan-id 100"] }, "vxlan-local-tunnelip": { - "help": "vxlan local tunnel ip", - "validvals": [""], - "example": ["vxlan-local-tunnelip 172.16.20.103"] + "help": "VXLAN local tunnel ip (ipv4 or ipv6). " + "VXLAN underlay can be IPv4 or IPv6, but a given VXLAN " + "interface supports only one local address family at a time (not both).", + "validvals": ["", ""], + "example": [ + "vxlan-local-tunnelip 192.0.2.1", + "vxlan-local-tunnelip 2001:db8::1" + ] }, "vxlan-svcnodeip": { "help": "vxlan svc node id", @@ -521,10 +526,9 @@ def __config_vxlan_local_tunnelip(self, ifname, ifaceobj, link_exists, user_requ local = self._vxlan_local_tunnelip if link_exists: - cached_ifla_vxlan_local = cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_LOCAL) + cached_ifla_vxlan_local = cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_LOCAL) or cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_LOCAL6) - # on ifreload do not overwrite anycast_ip to individual ip - # if clagd has modified + # Do not overwrite the anycast ip to individual ip if clagd has modified it. if self._clagd_vxlan_anycast_ip and cached_ifla_vxlan_local: anycastip = ipnetwork.IPNetwork(self._clagd_vxlan_anycast_ip) @@ -547,7 +551,7 @@ def __config_vxlan_local_tunnelip(self, ifname, ifaceobj, link_exists, user_requ if local: try: - local = ipnetwork.IPv4Address(local) + local = ipnetwork.IPNetwork(local) if local.initialized_with_prefixlen: self.logger.warning("%s: vxlan-local-tunnelip %s: netmask ignored" % (ifname, local)) @@ -555,17 +559,21 @@ def __config_vxlan_local_tunnelip(self, ifname, ifaceobj, link_exists, user_requ except Exception as e: raise AddonException("%s: invalid vxlan-local-tunnelip %s: %s" % (ifname, local, str(e))) - - if local: if local != cached_ifla_vxlan_local: self.logger.info("%s: set vxlan-local-tunnelip %s" % (ifname, local)) - user_request_vxlan_info_data[Link.IFLA_VXLAN_LOCAL] = local + user_request_vxlan_info_data[{ + 4: Link.IFLA_VXLAN_LOCAL, + 6: Link.IFLA_VXLAN_LOCAL6 + }.get(local.version)] = local # if both local-ip and anycast-ip are identical the function prints a warning self.syntax_check_localip_anycastip_equal(ifname, local, self._clagd_vxlan_anycast_ip) elif cached_ifla_vxlan_local: self.logger.info("%s: removing vxlan-local-tunnelip (cache %s)" % (ifname, cached_ifla_vxlan_local)) - user_request_vxlan_info_data[Link.IFLA_VXLAN_LOCAL] = None + user_request_vxlan_info_data[{ + 4: Link.IFLA_VXLAN_LOCAL, + 6: Link.IFLA_VXLAN_LOCAL6 + }.get(ipnetwork.IPAddress(cached_ifla_vxlan_local).version)] = None return local @@ -1164,6 +1172,7 @@ def _up(self, ifaceobj): else: if ifaceobj.link_privflags & ifaceLinkPrivFlags.SINGLE_VXLAN: + # This piece of code is never triggered, resetting local ip is broken at the moment due to _set_global_local_ip if Link.IFLA_VXLAN_LOCAL in user_request_vxlan_info_data and not user_request_vxlan_info_data[Link.IFLA_VXLAN_LOCAL]: local_str = "0" else: @@ -1183,7 +1192,8 @@ def _up(self, ifaceobj): user_request_vxlan_info_data.get(Link.IFLA_VXLAN_PORT), user_request_vxlan_info_data.get(Link.IFLA_VXLAN_AGEING), vxlan_vnifilter, - vxlan_ttl + vxlan_ttl, + local.version ) elif ifaceobj.link_privflags & ifaceLinkPrivFlags.L3VXI: self.iproute2.link_add_l3vxi( @@ -1194,7 +1204,8 @@ def _up(self, ifaceobj): vxlan_physdev, user_request_vxlan_info_data.get(Link.IFLA_VXLAN_PORT), user_request_vxlan_info_data.get(Link.IFLA_VXLAN_AGEING), - vxlan_ttl + vxlan_ttl, + local.version ) else: try: @@ -1430,6 +1441,28 @@ def _query_check_n_update_addresses(ifaceobjcurr, attrname, addresses, running_a set(addresses)) [ifaceobjcurr.update_config_with_status(attrname, a, 1) for a in running_addresses] + def ifquery_check_vxlan_local_tunnelip(self, ifaceobj, ifaceobjcurr, cached_vxlan_ifla_info_data): + user_local_tunnelip = ifaceobj.get_attr_value_first("vxlan-local-tunnelip") + + # Both IFLA_VXLAN_LOCAL and IFLA_VXLAN_LOCAL6 cant be filled at the same time + cached_value = cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_LOCAL) or cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_LOCAL6) + + if not user_local_tunnelip: + user_local_tunnelip = self._vxlan_local_tunnelip + ifaceobj.update_config("vxlan-local-tunnelip", user_local_tunnelip) + + if self._clagd_vxlan_anycast_ip == str(cached_value): + # if local ip is anycast_ip, then let query_check to go through + user_local_tunnelip = self._clagd_vxlan_anycast_ip + + self._query_check_n_update( + ifaceobj, + ifaceobjcurr, + "vxlan-local-tunnelip", + str(user_local_tunnelip), + str(cached_value.ip) if cached_value else None + ) + def _query_check(self, ifaceobj, ifaceobjcurr): ifname = ifaceobj.name @@ -1478,24 +1511,7 @@ def _query_check(self, ifaceobj, ifaceobjcurr): # # vxlan-local-tunnelip # - running_attrval = cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_LOCAL) - attrval = ifaceobj.get_attr_value_first('vxlan-local-tunnelip') - if not attrval: - attrval = self._vxlan_local_tunnelip - # TODO: vxlan._vxlan_local_tunnelip should be a ipnetwork.IPNetwork obj - ifaceobj.update_config('vxlan-local-tunnelip', attrval) - - if str(running_attrval) == self._clagd_vxlan_anycast_ip: - # if local ip is anycast_ip, then let query_check to go through - attrval = self._clagd_vxlan_anycast_ip - - self._query_check_n_update( - ifaceobj, - ifaceobjcurr, - 'vxlan-local-tunnelip', - str(attrval), - str(running_attrval.ip) if running_attrval else None - ) + self.ifquery_check_vxlan_local_tunnelip(ifaceobj, ifaceobjcurr, cached_vxlan_ifla_info_data) # # vxlan-remoteip @@ -1631,12 +1647,8 @@ def _query_running(self, ifaceobjrunning): # vxlan-id # vxlan_id = cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_ID) - - if not vxlan_id: - # no vxlan id, meaning this not a vxlan - return - - ifaceobjrunning.update_config('vxlan-id', str(vxlan_id)) + if vxlan_id: + ifaceobjrunning.update_config("vxlan-id", str(vxlan_id)) # # vxlan-port @@ -1678,6 +1690,7 @@ def _query_running(self, ifaceobjrunning): ('vxlan-learning', Link.IFLA_VXLAN_LEARNING, lambda value: 'on' if value else 'off'), ('vxlan-udp-csum', Link.IFLA_VXLAN_UDP_CSUM, lambda value: 'on' if value else 'off'), ('vxlan-local-tunnelip', Link.IFLA_VXLAN_LOCAL, str), + ("vxlan-local-tunnelip", Link.IFLA_VXLAN_LOCAL6, str), ): vxlan_attr_value = cached_vxlan_ifla_info_data.get(vxlan_attr_nl) @@ -1713,7 +1726,7 @@ def run(self, ifaceobj, operation, query_ifaceobj=None, **extra_args): if not op_handler: return - if not self._is_vxlan_device(ifaceobj): + if not self._is_vxlan_device(ifaceobj) and operation != "query-running": return if "query" not in operation and \ diff --git a/ifupdown2/lib/iproute2.py b/ifupdown2/lib/iproute2.py index a3363b8e..2d08a524 100644 --- a/ifupdown2/lib/iproute2.py +++ b/ifupdown2/lib/iproute2.py @@ -283,7 +283,12 @@ def link_add_veth(self, ifname, peer_name): ### - def link_add_single_vxlan(self, link_exists, ifname, ip, group, physdev, port, ageing, vnifilter="off", ttl=None): + def link_add_single_vxlan(self, link_exists, ifname, ip, group, physdev, port, ageing, vnifilter="off", ttl=None, ipversion=4): + cmd = [] + + if ipversion == 6: + cmd.append("-6") + if link_exists: self.logger.info("updating single vxlan device: %s" % ifname) @@ -291,11 +296,11 @@ def link_add_single_vxlan(self, link_exists, ifname, ip, group, physdev, port, a # drop the external keyword: # $ ip link set dev vxlan0 type vxlan external local 27.0.0.242 dev ipmr-lo # Error: vxlan: cannot change COLLECT_METADATA flag. - cmd = ["link set dev %s type vxlan" % ifname] + cmd.append(f"link set dev {ifname} type vxlan") else: self.logger.info("creating single vxlan device: %s" % ifname) - cmd = ["link add dev %s type vxlan external" % ifname] + cmd.append(f"link add dev {ifname} type vxlan external") # when changing local ip, if we specify vnifilter we get: # Error: vxlan: cannot change flag. @@ -324,7 +329,13 @@ def link_add_single_vxlan(self, link_exists, ifname, ip, group, physdev, port, a self.__execute_or_batch(utils.ip_cmd, " ".join(cmd)) self.__update_cache_after_link_creation(ifname, "vxlan") - def link_add_l3vxi(self, link_exists, ifname, ip, group, physdev, port, ageing, ttl=None): + def link_add_l3vxi(self, link_exists, ifname, ip, group, physdev, port, ageing, ttl=None, ipversion=4): + + cmd = [] + + if ipversion == 6: + cmd.append("-6") + self.logger.info("creating l3vxi device: %s" % ifname) if link_exists: @@ -332,9 +343,9 @@ def link_add_l3vxi(self, link_exists, ifname, ip, group, physdev, port, ageing, # drop the external keyword: # $ ip link set dev vxlan0 type vxlan external local 27.0.0.242 dev ipmr-lo # Error: vxlan: cannot change COLLECT_METADATA flag. - cmd = ["link set dev %s type vxlan" % ifname] + cmd.append(f"link set dev {ifname} type vxlan") else: - cmd = ["link add dev %s type vxlan external vnifilter" % ifname] + cmd.append(f"link add dev {ifname} type vxlan external vnifilter") # when changing local ip, if we specify vnifilter we get: # Error: vxlan: cannot change flag. # So we are only setting this attribute on vxlan creation diff --git a/tests/eni/vxlan_ipv6_local_tunnelip_l3.eni b/tests/eni/vxlan_ipv6_local_tunnelip_l3.eni new file mode 100644 index 00000000..71944803 --- /dev/null +++ b/tests/eni/vxlan_ipv6_local_tunnelip_l3.eni @@ -0,0 +1,23 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + vrf mgmt + +auto mgmt +iface mgmt + vrf-table auto + +auto dummy_vx6 +iface dummy_vx6 + link-type dummy + address 2001:db8:6000::1/64 + +auto vxlan6 +iface vxlan6 + vxlan-id 6000 + vxlan-local-tunnelip 2001:db8:6000::1 + vxlan-physdev dummy_vx6 + vxlan-learning no + mtu 1450 diff --git a/tests/test_l3.py b/tests/test_l3.py index 823b4f30..71060c46 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -209,6 +209,40 @@ def test_addressvirtual_fdb_cleanup_l3(ssh, setup): assert ssh.run(f"ip link show {ifname}")[3] != 0 +def test_vxlan_ipv6_local_tunnelip_l3(ssh, setup): + """An IPv6 local tunnel address is applied and reported in queries.""" + local = "2001:db8:6000::1" + + stderr = ssh.ifup_a(return_stderr=True) + if stderr: + assert stderr == ( + "warning: netlink: mgmt: cannot delete address 127.0.1.1/8 " + "dev mgmt: operation failed with " + "'Cannot assign requested address' (99)\n" + ) + + ssh.run_assert_success( + f"ip -d -o link show vxlan6 | grep 'vxlan id 6000' | " + f"grep 'local {local}'" + ) + ssh.ifquery_c("vxlan6") + + running = ssh.ifquery( + "--running vxlan6", + return_stdout=True, + ) + assert f"vxlan-local-tunnelip {local}" in running + + ssh.ifreload_a() + ssh.run_assert_success( + f"ip -d -o link show vxlan6 | grep 'local {local}'" + ) + + ssh.ifdown("vxlan6 dummy_vx6") + assert ssh.run("ip link show vxlan6")[3] != 0 + assert ssh.run("ip link show dummy_vx6")[3] != 0 + + def test_dhcp6_release_link_down_l3(ssh, setup): """A failed DHCPv6 release on a kept-down link remains non-fatal.""" pidfile = "/run/dhclient6.dum_d6.pid" From e9b533750c9374f84c5f993320c15633e32efd2d Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Wed, 29 Oct 2025 22:47:35 +0100 Subject: [PATCH 31/69] addons: vxlan: recreate device when local tunnel IP family changes - add a helper to detect mismatched IPv4/IPv6 local addresses between the desired configuration and cached link attributes - tear down and recreate the VXLAN link when switching local tunnel IP family so the kernel applies the correct address family A focused integration test verifies IPv4-to-IPv6 and IPv6-to-IPv4 recreation while preserving same-family behavior in unit coverage. (cherry picked from commit f56f7712f2076d2abb5abfc4b5eb577c58c120a7) Signed-off-by: Julien Fortin --- ifupdown2/addons/vxlan.py | 35 ++++++++++++++++++---- tests/eni/vxlan_ipv6_local_tunnelip_l3.eni | 1 + tests/test_l3.py | 31 +++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/ifupdown2/addons/vxlan.py b/ifupdown2/addons/vxlan.py index 4d8b3e0e..15df9890 100644 --- a/ifupdown2/addons/vxlan.py +++ b/ifupdown2/addons/vxlan.py @@ -1073,6 +1073,22 @@ def __get_vxlan_vni_list(self, ifaceobj, string=True): return None + @staticmethod + def __vxlan_local_tunnelip_family_changed(user_request_vxlan_info_data, cached_vxlan_ifla_info_data): + """ + Determine whether the requested local tunnel IP family differs from the currently + configured link attributes. + + :param user_request_vxlan_info_data: Netlink attributes we plan to apply for the link. + :param cached_vxlan_ifla_info_data: Netlink attributes retrieved from the running link. + :return: True when the IPv4/IPv6 family changes, False otherwise. + """ + return ( + (user_request_vxlan_info_data.get(Link.IFLA_VXLAN_LOCAL6) and cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_LOCAL)) + or + (user_request_vxlan_info_data.get(Link.IFLA_VXLAN_LOCAL) and cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_LOCAL6)) + ) + def _up(self, ifaceobj): self.check_and_raise_svd_tvd_errors(ifaceobj) @@ -1085,6 +1101,13 @@ def _up(self, ifaceobj): ifname = ifaceobj.name link_exists = self.cache.link_exists(ifname) + user_request_vxlan_info_data = {} + + # get vxlan running attributes + cached_vxlan_ifla_info_data = self.cache.get_link_info_data(ifname) if link_exists else {} + + local = self.__config_vxlan_local_tunnelip(ifname, ifaceobj, link_exists, user_request_vxlan_info_data, cached_vxlan_ifla_info_data) + if link_exists: # if link already exists make sure this is a vxlan device_link_kind = self.cache.get_link_kind(ifname) @@ -1097,12 +1120,13 @@ def _up(self, ifaceobj): ifaceobj.set_status(ifaceStatus.ERROR) return - # get vxlan running attributes - cached_vxlan_ifla_info_data = self.cache.get_link_info_data(ifname) - else: - cached_vxlan_ifla_info_data = {} + if local and self.__vxlan_local_tunnelip_family_changed(user_request_vxlan_info_data, cached_vxlan_ifla_info_data): + # VxLAN cannot switch address families in-place; recreate the device so the kernel applies the new family. + link_exists = False + cached_vxlan_ifla_info_data = {} - user_request_vxlan_info_data = {} + self.logger.info(f"{ifname}: vxlan-local-tunnelip address family changed to IPv{local.version} - VxLAN needs to be recreated") + self._down(ifaceobj) if vxlan_id_str: # for single vxlan device we don't have a vxlan-id @@ -1114,7 +1138,6 @@ def _up(self, ifaceobj): vxlan_ttl = self.__config_vxlan_ttl(ifname, ifaceobj, user_request_vxlan_info_data, cached_vxlan_ifla_info_data) self.__config_vxlan_tos(ifname, ifaceobj, user_request_vxlan_info_data, cached_vxlan_ifla_info_data) self.__config_vxlan_udp_csum(ifaceobj, link_exists, user_request_vxlan_info_data, cached_vxlan_ifla_info_data) - local = self.__config_vxlan_local_tunnelip(ifname, ifaceobj, link_exists, user_request_vxlan_info_data, cached_vxlan_ifla_info_data) vxlan_vni = self.__get_vxlan_vni_list(ifaceobj) diff --git a/tests/eni/vxlan_ipv6_local_tunnelip_l3.eni b/tests/eni/vxlan_ipv6_local_tunnelip_l3.eni index 71944803..1f15e9f5 100644 --- a/tests/eni/vxlan_ipv6_local_tunnelip_l3.eni +++ b/tests/eni/vxlan_ipv6_local_tunnelip_l3.eni @@ -12,6 +12,7 @@ iface mgmt auto dummy_vx6 iface dummy_vx6 link-type dummy + address 192.0.2.60/24 address 2001:db8:6000::1/64 auto vxlan6 diff --git a/tests/test_l3.py b/tests/test_l3.py index 71060c46..b13119cc 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -238,6 +238,37 @@ def test_vxlan_ipv6_local_tunnelip_l3(ssh, setup): f"ip -d -o link show vxlan6 | grep 'local {local}'" ) + ipv6_ifindex = ssh.run_assert_success( + "cat /sys/class/net/vxlan6/ifindex" + ).strip() + ipv4_local = "192.0.2.60" + ssh.run_assert_success( + f"sed -i 's/vxlan-local-tunnelip {local}/" + f"vxlan-local-tunnelip {ipv4_local}/' {ENI}" + ) + reload_output = ssh.ifreload_av() + assert "address family changed to IPv4" in reload_output + ssh.run_assert_success( + f"ip -d -o link show vxlan6 | grep 'local {ipv4_local}'" + ) + ipv4_ifindex = ssh.run_assert_success( + "cat /sys/class/net/vxlan6/ifindex" + ).strip() + assert ipv4_ifindex != ipv6_ifindex + + ssh.run_assert_success( + f"sed -i 's/vxlan-local-tunnelip {ipv4_local}/" + f"vxlan-local-tunnelip {local}/' {ENI}" + ) + reload_output = ssh.ifreload_av() + assert "address family changed to IPv6" in reload_output + ssh.run_assert_success( + f"ip -d -o link show vxlan6 | grep 'local {local}'" + ) + assert ssh.run_assert_success( + "cat /sys/class/net/vxlan6/ifindex" + ).strip() != ipv4_ifindex + ssh.ifdown("vxlan6 dummy_vx6") assert ssh.run("ip link show vxlan6")[3] != 0 assert ssh.run("ip link show dummy_vx6")[3] != 0 From 8f06e12c6dac73b6341a4dfeb7d6d23a789d5c16 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Tue, 2 Dec 2025 20:59:38 +0100 Subject: [PATCH 32/69] addons: vxlan: add missing None check for local tunnel ip Metadata single-VXLAN and L3VXI devices may omit a local tunnel address. Pass an unspecified IP version to the creation helpers instead of accessing version on None. A focused live test covers metadata VXLAN creation and reload without a local endpoint; unit coverage exercises both affected helper calls. (cherry picked from commit 1ca5f47b4176d5b404dceaec97e2d27fc31c8f02) Signed-off-by: Julien Fortin --- ifupdown2/addons/vxlan.py | 4 +-- tests/eni/vxlan_missing_local_l3.eni | 20 +++++++++++++++ tests/test_l3.py | 37 ++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/eni/vxlan_missing_local_l3.eni diff --git a/ifupdown2/addons/vxlan.py b/ifupdown2/addons/vxlan.py index 15df9890..78be31e1 100644 --- a/ifupdown2/addons/vxlan.py +++ b/ifupdown2/addons/vxlan.py @@ -1216,7 +1216,7 @@ def _up(self, ifaceobj): user_request_vxlan_info_data.get(Link.IFLA_VXLAN_AGEING), vxlan_vnifilter, vxlan_ttl, - local.version + local.version if local else None ) elif ifaceobj.link_privflags & ifaceLinkPrivFlags.L3VXI: self.iproute2.link_add_l3vxi( @@ -1228,7 +1228,7 @@ def _up(self, ifaceobj): user_request_vxlan_info_data.get(Link.IFLA_VXLAN_PORT), user_request_vxlan_info_data.get(Link.IFLA_VXLAN_AGEING), vxlan_ttl, - local.version + local.version if local else None ) else: try: diff --git a/tests/eni/vxlan_missing_local_l3.eni b/tests/eni/vxlan_missing_local_l3.eni new file mode 100644 index 00000000..04eb7a51 --- /dev/null +++ b/tests/eni/vxlan_missing_local_l3.eni @@ -0,0 +1,20 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + vrf mgmt + +auto mgmt +iface mgmt + vrf-table auto + +auto vrf_meta +iface vrf_meta + vrf-table 4244 + +auto vxmeta +iface vxmeta + vxlan-vni 100 + vrf vrf_meta + vxlan-learning no diff --git a/tests/test_l3.py b/tests/test_l3.py index b13119cc..2999675c 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -274,6 +274,43 @@ def test_vxlan_ipv6_local_tunnelip_l3(ssh, setup): assert ssh.run("ip link show dummy_vx6")[3] != 0 +def test_vxlan_missing_local_l3(ssh, setup): + """An L3VXI can be created without a local tunnel address.""" + stderr = ssh.ifup_a(return_stderr=True) + if stderr: + assert stderr == ( + "warning: netlink: mgmt: cannot delete address 127.0.1.1/8 " + "dev mgmt: operation failed with " + "'Cannot assign requested address' (99)\n" + ) + + ssh.run_assert_success( + "ip -d -o link show vxmeta | grep 'vxlan external' | " + "grep 'vnifilter' | grep 'nolearning'" + ) + ssh.run_assert_success( + "ip -o link show vxmeta | grep 'master vrf_meta'" + ) + ssh.run_assert_success( + "bridge -j vni show dev vxmeta | grep '100'" + ) + ssh.ifquery_c("vxmeta") + running = ssh.ifquery( + "--running vxmeta", + return_stdout=True, + ) + assert "vxlan-local-tunnelip" not in running + + ssh.ifreload_a() + ssh.run_assert_success( + "ip -d -o link show vxmeta | grep 'vxlan external' | grep 'vnifilter'" + ) + + ssh.ifdown("vxmeta vrf_meta") + assert ssh.run("ip link show vxmeta")[3] != 0 + assert ssh.run("ip link show vrf_meta")[3] != 0 + + def test_dhcp6_release_link_down_l3(ssh, setup): """A failed DHCPv6 release on a kept-down link remains non-fatal.""" pidfile = "/run/dhclient6.dum_d6.pid" From 4c5490b8d72c21da0e15d50980aca53a03db7591 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Thu, 18 Dec 2025 20:04:49 +0100 Subject: [PATCH 33/69] vxlan: fix IPv6 local/group address reset handling Fix two issues in VXLAN address handling: 1. Use cached_ifla_vxlan_local.version directly. The cached value is already an IPNetwork object with a version property. 2. Use the correct zero address when clearing single-VXLAN values: - IFLA_VXLAN_LOCAL6 and IFLA_VXLAN_GROUP6 use :: - IFLA_VXLAN_LOCAL and IFLA_VXLAN_GROUP use 0 Compute local and group reset values before selecting the VXLAN helper. A deployed-runtime test verifies cached IPv4 and IPv6 local removal. (cherry picked from commit 5fa28d1e4ce095960fad2f55ca08214d21c4e061) Signed-off-by: Julien Fortin --- ifupdown2/addons/vxlan.py | 27 ++++++++++++---------- tests/test_l3.py | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/ifupdown2/addons/vxlan.py b/ifupdown2/addons/vxlan.py index 78be31e1..828c706f 100644 --- a/ifupdown2/addons/vxlan.py +++ b/ifupdown2/addons/vxlan.py @@ -573,7 +573,7 @@ def __config_vxlan_local_tunnelip(self, ifname, ifaceobj, link_exists, user_requ user_request_vxlan_info_data[{ 4: Link.IFLA_VXLAN_LOCAL, 6: Link.IFLA_VXLAN_LOCAL6 - }.get(ipnetwork.IPAddress(cached_ifla_vxlan_local).version)] = None + }.get(cached_ifla_vxlan_local.version)] = None return local @@ -1193,19 +1193,22 @@ def _up(self, ifaceobj): # element: vxlan-id self.logger.info('%s: vxlan already exists - no change detected' % ifname) else: - if ifaceobj.link_privflags & ifaceLinkPrivFlags.SINGLE_VXLAN: - - # This piece of code is never triggered, resetting local ip is broken at the moment due to _set_global_local_ip - if Link.IFLA_VXLAN_LOCAL in user_request_vxlan_info_data and not user_request_vxlan_info_data[Link.IFLA_VXLAN_LOCAL]: - local_str = "0" - else: - local_str = local.ip if local else None + # This piece of code is never triggered, resetting local ip is broken at the moment due to _set_global_local_ip + if Link.IFLA_VXLAN_LOCAL in user_request_vxlan_info_data and not user_request_vxlan_info_data[Link.IFLA_VXLAN_LOCAL]: + local_str = "0" + elif Link.IFLA_VXLAN_LOCAL6 in user_request_vxlan_info_data and not user_request_vxlan_info_data[Link.IFLA_VXLAN_LOCAL6]: + local_str = "::" + else: + local_str = local.ip if local else None - if Link.IFLA_VXLAN_GROUP in user_request_vxlan_info_data and not user_request_vxlan_info_data[Link.IFLA_VXLAN_GROUP]: - group_str = "0" - else: - group_str = group.ip if group else None + if Link.IFLA_VXLAN_GROUP in user_request_vxlan_info_data and not user_request_vxlan_info_data[Link.IFLA_VXLAN_GROUP]: + group_str = "0" + elif Link.IFLA_VXLAN_GROUP6 in user_request_vxlan_info_data and not user_request_vxlan_info_data[Link.IFLA_VXLAN_GROUP6]: + group_str = "::" + else: + group_str = group.ip if group else None + if ifaceobj.link_privflags & ifaceLinkPrivFlags.SINGLE_VXLAN: self.iproute2.link_add_single_vxlan( link_exists, ifname, diff --git a/tests/test_l3.py b/tests/test_l3.py index 2999675c..c85e040b 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -274,6 +274,53 @@ def test_vxlan_ipv6_local_tunnelip_l3(ssh, setup): assert ssh.run("ip link show dummy_vx6")[3] != 0 +def test_vxlan_cached_local_reset_runtime_l3(ssh): + """Cached IPv4/IPv6 local removal uses the deployed object's family.""" + ssh.run_assert_success( + """PYTHONPATH=/usr/share/ifupdown2 python3 - <<'PY' +from addons.vxlan import vxlan +import addons.vxlan as vxlan_module +import nlmanager.ipnetwork as ipnetwork +from nlmanager.nlpacket import Link + +class Iface: + name = "vxreset" + + @staticmethod + def get_attr_value_first(name): + return None + +class Logger: + @staticmethod + def info(*args): + pass + +addon = object.__new__(vxlan) +addon._vxlan_local_tunnelip = None +addon._clagd_vxlan_anycast_ip = "" +addon.logger = Logger() +vxlan_module.policymanager.policymanager_api.get_attr_default = ( + lambda **kwargs: None +) + +for attribute, address in ( + (Link.IFLA_VXLAN_LOCAL, "192.0.2.10"), + (Link.IFLA_VXLAN_LOCAL6, "2001:db8::10"), +): + request = {} + local = addon._vxlan__config_vxlan_local_tunnelip( + "vxreset", + Iface(), + True, + request, + {attribute: ipnetwork.IPNetwork(address)}, + ) + assert local is None + assert request == {attribute: None} +PY""" + ) + + def test_vxlan_missing_local_l3(ssh, setup): """An L3VXI can be created without a local tunnel address.""" stderr = ssh.ifup_a(return_stderr=True) From dffe38f4c4db3b12f2a60fa130f9ae2f9d471f65 Mon Sep 17 00:00:00 2001 From: Nita MS Date: Mon, 5 Jan 2026 16:39:45 +0000 Subject: [PATCH 34/69] nlmanager: nlpacket: handle unsupported address families during route dump Route dumps can contain MCTP routes (address family 128), whose attributes are not IP addresses. Decoding those attributes through AttributeIPAddress aborts initialization with an unsupported-family exception. Treat MCTP route attributes as opaque generic values, render endpoint IDs without IP decoding, and skip IP nexthop processing. IPv4, IPv6, bridge, and MPLS route handling remains unchanged. A deployed-runtime regression constructs and inspects an MCTP route using the installed packet parser. (cherry picked from commit bdb692205bfbcba26c55fba25de03a42f639a14f) Signed-off-by: Nita MS Signed-off-by: Julien Fortin --- ifupdown2/nlmanager/nlpacket.py | 20 ++++++++++++++++++++ tests/test_l3.py | 26 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/ifupdown2/nlmanager/nlpacket.py b/ifupdown2/nlmanager/nlpacket.py index bf6c84fa..fec6e890 100644 --- a/ifupdown2/nlmanager/nlpacket.py +++ b/ifupdown2/nlmanager/nlpacket.py @@ -162,6 +162,7 @@ def nl_mgrp(group): } AF_MPLS = 28 +AF_MCTP = 45 BOND_MAX_ARP_TARGETS = 16 @@ -172,6 +173,7 @@ def nl_mgrp(group): AF_FAMILY[getattr(socket, family)] = family AF_FAMILY[AF_MPLS] = 'AF_MPLS' +AF_FAMILY[AF_MCTP] = 'AF_MCTP' def get_family_str(family): @@ -3735,6 +3737,13 @@ def add_attribute(self, attr_type, value): attr_string = 'RTA_DST' attr_class = AttributeMplsLabel + # Handle MCTP routes - use AttributeGeneric for all attributes + if self.msgtype == RTM_NEWROUTE and self.family == AF_MCTP: + attr_string = "UNKNOWN_ATTRIBUTE_%d" % attr_type + attr_class = AttributeGeneric + self.log.debug("Attribute %d is not defined in %s.attribute_to_class for AF_MCTP, assuming AttributeGeneric" % + (attr_type, self.__class__.__name__)) + else: attr_string = "UNKNOWN_ATTRIBUTE_%d" % attr_type attr_class = AttributeGeneric @@ -5517,12 +5526,19 @@ def get_prefix_string(self): dst = self.get_attribute_value(self.RTA_DST) if dst: + if self.family == AF_MCTP: + # MCTP destination is an 8-bit EID + if isinstance(dst, bytes): + return "mctp:%d/%d" % (dst[0], self.dst_len) if dst else "mctp:0/0" + return "mctp:%s/%d" % (dst, self.dst_len) return "%s" % dst else: if self.family == AF_INET: return "0.0.0.0/0" elif self.family == AF_INET6: return "::/0" + elif self.family == AF_MCTP: + return "mctp:0/0" def get_protocol_string(self, index=None): if index is None: @@ -5556,6 +5572,10 @@ def _get_ifname_from_index(self, ifindex, ifname_by_index): return ifname def get_nexthops(self, ifname_by_index={}): + # Skip nexthop processing for MCTP routes (attributes not fully parsed) + if self.family == AF_MCTP: + return [] + nexthop = self.get_attribute_value(self.RTA_GATEWAY) multipath = self.get_attribute_value(self.RTA_MULTIPATH) nexthops = [] diff --git a/tests/test_l3.py b/tests/test_l3.py index c85e040b..e459656e 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -321,6 +321,32 @@ def info(*args): ) +def test_mctp_route_runtime_l3(ssh): + """The deployed parser treats MCTP route attributes as opaque data.""" + ssh.run_assert_success( + """PYTHONPATH=/usr/share/ifupdown2 python3 - <<'PY' +from nlmanager.nlpacket import ( + AF_MCTP, + AF_FAMILY, + AttributeGeneric, + Route, + RTM_NEWROUTE, +) + +route = Route(RTM_NEWROUTE, False, use_color=False) +route.family = AF_MCTP +route.dst_len = 8 +attribute = route.add_attribute(Route.RTA_DST, bytes([8])) + +assert AF_MCTP == 45 +assert AF_FAMILY[AF_MCTP] == "AF_MCTP" +assert isinstance(attribute, AttributeGeneric) +assert route.get_prefix_string() == "mctp:8/8" +assert route.get_nexthops() == [] +PY""" + ) + + def test_vxlan_missing_local_l3(ssh, setup): """An L3VXI can be created without a local tunnel address.""" stderr = ssh.ifup_a(return_stderr=True) From 9c3825f77da101e28a27098462c03cad5aebcc52 Mon Sep 17 00:00:00 2001 From: Lohith CS Date: Fri, 6 Feb 2026 00:02:25 -0800 Subject: [PATCH 35/69] nlmanager: nlpacket: handle multipath route during route dump Multipath route attributes can contain either device-only nexthops with an eight-byte header or gateway nexthops with an address attribute. The old decoder assumed the latter, advanced the wrong buffer, and could unpack truncated data. Validate every boundary, support both nexthop formats, advance by the actual IPv4/IPv6 payload, and safely stop on malformed data. Mixed multipath attributes are decoded without affecting valid gateway routes. A deployed-runtime regression covers mixed and truncated payloads using the installed packet parser. (cherry picked from commit 2b069f8b37ea949fcc81bb438455712505325e61) Signed-off-by: Lohith CS Signed-off-by: Julien Fortin --- ifupdown2/nlmanager/nlpacket.py | 59 +++++++++++++++++++++++++-------- tests/test_l3.py | 38 +++++++++++++++++++++ 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/ifupdown2/nlmanager/nlpacket.py b/ifupdown2/nlmanager/nlpacket.py index fec6e890..e0dc4251 100644 --- a/ifupdown2/nlmanager/nlpacket.py +++ b/ifupdown2/nlmanager/nlpacket.py @@ -2109,26 +2109,57 @@ def decode(self, parent_msg, data): data = self.data[4:] while data: + # Check if there's enough data for the rtnexthop header + if len(data) < self.RTNH_LEN: + break + (rtnh_len, rtnh_flags, rtnh_hops, rtnh_ifindex) = unpack(self.RTNH_PACK, data[:self.RTNH_LEN]) - data = data[self.RTNH_LEN:] - (attr_type, attr_length) = unpack(self.HEADER_PACK, self.data[:self.HEADER_LEN]) - data = data[self.HEADER_LEN:] + # Ensure rtnh_len is reasonable, at least as large as the header + if rtnh_len < self.RTNH_LEN: + break - if self.family == AF_INET: - if len(data) < self.IPV4_LEN: - break - nexthop = ipnetwork.IPv4Address(unpack('>L', data[:self.IPV4_LEN])[0]) - self.value.append((nexthop, rtnh_ifindex, rtnh_flags, rtnh_hops)) + data = data[self.RTNH_LEN:] - elif self.family == AF_INET6: - if len(data) < self.IPV6_LEN: + if rtnh_len == self.RTNH_LEN: + # Device-based multipath: just the rtnexthop header, no IP address + self.value.append((None, rtnh_ifindex, rtnh_flags, rtnh_hops)) + elif rtnh_len > self.RTNH_LEN: + # Gateway-based multipath: has attribute header + IP address + + # Check if there's enough data for the attribute header + if len(data) < self.HEADER_LEN: break - (data1, data2) = unpack('>QQ', data[:self.IPV6_LEN]) - nexthop = ipnetwork.IPv6Address(data1 << 64 | data2) - self.value.append((nexthop, rtnh_ifindex, rtnh_flags, rtnh_hops)) - data = data[(rtnh_len-self.RTNH_LEN-self.HEADER_LEN):] + (attr_type, attr_length) = unpack(self.HEADER_PACK, data[:self.HEADER_LEN]) + data = data[self.HEADER_LEN:] + + if self.family == AF_INET: + if len(data) < self.IPV4_LEN: + break + nexthop = ipnetwork.IPv4Address(unpack('>L', data[:self.IPV4_LEN])[0]) + self.value.append((nexthop, rtnh_ifindex, rtnh_flags, rtnh_hops)) + data = data[self.IPV4_LEN:] + + elif self.family == AF_INET6: + if len(data) < self.IPV6_LEN: + break + (data1, data2) = unpack('>QQ', data[:self.IPV6_LEN]) + nexthop = ipnetwork.IPv6Address(data1 << 64 | data2) + self.value.append((nexthop, rtnh_ifindex, rtnh_flags, rtnh_hops)) + data = data[self.IPV6_LEN:] + + # We've consumed: RTNH_LEN (header) + HEADER_LEN (attr header) + IP_LEN (address) + consumed = self.RTNH_LEN + self.HEADER_LEN + (self.IPV4_LEN if self.family == AF_INET else self.IPV6_LEN) + + # If rtnh_len indicates more data than we consumed, skip the remainder + if rtnh_len > consumed: + skip_len = rtnh_len - consumed + + if skip_len <= len(data): + data = data[skip_len:] + else: + break self.value = tuple(self.value) diff --git a/tests/test_l3.py b/tests/test_l3.py index e459656e..11dde5b2 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -347,6 +347,44 @@ def test_mctp_route_runtime_l3(ssh): ) +def test_multipath_route_runtime_l3(ssh): + """The deployed parser handles mixed and truncated multipath nexthops.""" + ssh.run_assert_success( + """PYTHONPATH=/usr/share/ifupdown2 python3 - <<'PY' +import socket +from struct import pack +from nlmanager.nlpacket import AttributeRTA_MULTIPATH, Route + +def device(ifindex): + return pack("=HBBL", 8, 0, 0, ifindex) + +def gateway(ifindex, address): + return ( + pack("=HBBL", 16, 0, 0, ifindex) + + pack("=HH", 8, Route.RTA_GATEWAY) + + socket.inet_pton(socket.AF_INET, address) + ) + +def decode(payload): + raw = pack("=HH", 4 + len(payload), Route.RTA_MULTIPATH) + payload + attribute = AttributeRTA_MULTIPATH( + Route.RTA_MULTIPATH, + "RTA_MULTIPATH", + socket.AF_INET, + None, + ) + attribute.decode(None, raw) + return attribute.value + +value = decode(device(5) + gateway(7, "192.0.2.2")) +assert value[0] == (None, 5, 0, 0) +assert str(value[1][0]) == "192.0.2.2" +assert value[1][1:] == (7, 0, 0) +assert decode(bytes([8, 0])) == () +PY""" + ) + + def test_vxlan_missing_local_l3(ssh, setup): """An L3VXI can be created without a local tunnel address.""" stderr = ssh.ifup_a(return_stderr=True) From 2488d22976d2bd061db308741f34a5b0a0b3af4d Mon Sep 17 00:00:00 2001 From: Sapir Elyovitch Date: Mon, 16 Feb 2026 00:24:13 -0800 Subject: [PATCH 36/69] ifupdown2: networkinterfaces: IndexError on "source-directory" line when directory path is missing A source-directory directive without a path produced one split token, but the parser accessed the second token unconditionally and raised IndexError. Split once, treat a missing token as an empty path, and let the existing parse-error path handle both absent and whitespace-only arguments. A deployed-runtime regression verifies all three malformed forms against the installed parser. (cherry picked from commit 539e5d0c655da162e0cee0c7f78114c10a8ffb18) Signed-off-by: Sapir Elyovitch Signed-off-by: Julien Fortin --- ifupdown2/ifupdown/networkinterfaces.py | 3 ++- tests/test_l3.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/ifupdown2/ifupdown/networkinterfaces.py b/ifupdown2/ifupdown/networkinterfaces.py index 7f304e20..502d8235 100644 --- a/ifupdown2/ifupdown/networkinterfaces.py +++ b/ifupdown2/ifupdown/networkinterfaces.py @@ -199,7 +199,8 @@ def process_source(self, lines, cur_idx, lineno): def process_source_directory(self, lines, cur_idx, lineno): self.logger.debug('processing source-directory line ..\'%s\'' % lines[cur_idx]) - sourced_directory = re.split(self._ws_split_regex, lines[cur_idx], 2)[1] + parts = re.split(self._ws_split_regex, lines[cur_idx], 2) + sourced_directory = parts[1].strip() if len(parts) > 1 else '' if sourced_directory: if not os.path.isabs(sourced_directory): diff --git a/tests/test_l3.py b/tests/test_l3.py index 11dde5b2..603c80b0 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -385,6 +385,24 @@ def decode(payload): ) +def test_source_directory_missing_path_runtime_l3(ssh): + """The deployed parser reports missing source-directory paths.""" + _, stdout, stderr, status = ssh.run( + """PYTHONPATH=/usr/share/ifupdown2 python3 - <<'PY' +from ifupdown.networkinterfaces import networkInterfaces + +for line in ("source-directory", "source-directory ", "source-directory\\t"): + parser = networkInterfaces(interfacesfile="/tmp/interfaces") + assert parser.process_source_directory([line], 0, 7) == 0 + assert parser.errors == 1 +PY""" + ) + assert status == 0 + assert stdout.read().decode("utf-8") == "" + expected = "/tmp/interfaces: line7: unable to read source-directory line\n" + assert stderr.read().decode("utf-8") == expected * 3 + + def test_vxlan_missing_local_l3(ssh, setup): """An L3VXI can be created without a local tunnel address.""" stderr = ssh.ifup_a(return_stderr=True) From e4c478df0c430a656967b00f6fd11c5f67508740 Mon Sep 17 00:00:00 2001 From: Sapir Elyovitch Date: Tue, 17 Feb 2026 05:45:26 -0800 Subject: [PATCH 37/69] ifupdown: networkinterfaces: fix filestack leak on read_file error read_file() pushes each filename before opening it. Open failures returned without popping that entry, so repeated failures corrupted current-file tracking and grew the stack indefinitely. Move stack cleanup into a finally block so successful reads, open failures, and exceptions from downstream parsing all restore the previous file. A deployed-runtime regression covers open and parse failures against the installed parser. (cherry picked from commit ce37a38c6f566d2924037aa173e0ee283540b33b) Signed-off-by: Sapir Elyovitch Signed-off-by: Julien Fortin --- ifupdown2/ifupdown/networkinterfaces.py | 6 ++-- tests/test_l3.py | 37 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/ifupdown2/ifupdown/networkinterfaces.py b/ifupdown2/ifupdown/networkinterfaces.py index 502d8235..f61740eb 100644 --- a/ifupdown2/ifupdown/networkinterfaces.py +++ b/ifupdown2/ifupdown/networkinterfaces.py @@ -491,8 +491,10 @@ def read_file(self, filename, fileiobuf=None): self.logger.warning('error processing file %s (%s)', filename, str(e)) return - self.read_filedata(filedata) - self._filestack.pop() + else: + self.read_filedata(filedata) + finally: + self._filestack.pop() def read_file_json(self, filename, fileiobuf=None): if fileiobuf: diff --git a/tests/test_l3.py b/tests/test_l3.py index 603c80b0..940096fe 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -403,6 +403,43 @@ def test_source_directory_missing_path_runtime_l3(ssh): assert stderr.read().decode("utf-8") == expected * 3 +def test_networkinterfaces_filestack_runtime_l3(ssh): + """Failed includes unwind the deployed parser's current-file stack.""" + ssh.run_assert_success( + """PYTHONPATH=/usr/share/ifupdown2 python3 - <<'PY' +import os +import tempfile +from ifupdown.networkinterfaces import networkInterfaces + +parser = networkInterfaces(interfacesfile="/etc/network/interfaces") +parser.logger.setLevel(100) +initial = list(parser._filestack) + +parser.read_file("/tmp/ifupdown2-file-that-does-not-exist") +assert parser._filestack == initial +assert parser._currentfile == "/etc/network/interfaces" + +fd, path = tempfile.mkstemp(prefix="ifupdown2-filestack-") +os.write(fd, b"# minimal config\\n") +os.close(fd) +parser.read_filedata = lambda data: (_ for _ in ()).throw( + RuntimeError("parse failed") +) +try: + parser.read_file(path) +except RuntimeError: + pass +else: + raise AssertionError("parse exception was not propagated") +finally: + os.unlink(path) + +assert parser._filestack == initial +assert parser._currentfile == "/etc/network/interfaces" +PY""" + ) + + def test_vxlan_missing_local_l3(ssh, setup): """An L3VXI can be created without a local tunnel address.""" stderr = ssh.ifup_a(return_stderr=True) From c7b074a88c2a45390578170dc612ebdec30165b6 Mon Sep 17 00:00:00 2001 From: Anuja Kench Date: Mon, 23 Jun 2025 07:32:07 -0700 Subject: [PATCH 38/69] networking.service start should be timed out when service failed to start Type=oneshot services have no start timeout by default. A stuck network initialization can therefore block boot indefinitely. Set a one-hour startup limit so systemd can fail the unit and continue the boot transaction. Keep the existing Debian stop timeout and ordering unchanged. (cherry picked from commit 2c6a91db6814923cf1f856b33d287c8b5c1ea745) Signed-off-by: Julien Fortin --- debian/ifupdown2.networking.service | 1 + 1 file changed, 1 insertion(+) diff --git a/debian/ifupdown2.networking.service b/debian/ifupdown2.networking.service index cdb30657..d55ad873 100644 --- a/debian/ifupdown2.networking.service +++ b/debian/ifupdown2.networking.service @@ -12,6 +12,7 @@ After=systemd-udev-settle.service Type=oneshot RemainAfterExit=yes SyslogIdentifier=networking +TimeoutStartSec=3600s TimeoutStopSec=30s EnvironmentFile=/etc/default/networking ExecStart=/usr/share/ifupdown2/sbin/start-networking start From 1d08b7e398fb137bb779359dd4742073af999a29 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Sat, 15 Aug 2026 23:36:25 +0200 Subject: [PATCH 39/69] test(vlan): use physical ports for bridge-binding scope The scope regression only needs an ordinary VLAN lower and a bridge-backed SVI; it does not require dummy-device semantics. Repeated dummy lifecycle churn can leave asynchronous offload cleanup behind and destabilize later physical-port tests. Use translated test ports for both lowers, remove only logical devices, and assert the physical devices remain present after teardown. Signed-off-by: Julien Fortin --- tests/eni/vlan_bridge_binding_scope_l2.eni | 14 ++++++-------- tests/test_l2.py | 15 +++++++++++---- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/tests/eni/vlan_bridge_binding_scope_l2.eni b/tests/eni/vlan_bridge_binding_scope_l2.eni index 14e01d87..ba862439 100644 --- a/tests/eni/vlan_bridge_binding_scope_l2.eni +++ b/tests/eni/vlan_bridge_binding_scope_l2.eni @@ -9,24 +9,22 @@ auto mgmt iface mgmt vrf-table auto -auto dummy_raw -iface dummy_raw - link-type dummy +auto swp_AA_ +iface swp_AA_ auto vlan100 iface vlan100 - vlan-raw-device dummy_raw + vlan-raw-device swp_AA_ vlan-id 100 vlan-bridge-binding on -auto dummy_port -iface dummy_port - link-type dummy +auto swp_BB_ +iface swp_BB_ auto br_bind iface br_bind bridge-vlan-aware yes - bridge-ports dummy_port + bridge-ports swp_BB_ bridge-vids 200 auto br_bind.200 diff --git a/tests/test_l2.py b/tests/test_l2.py index ab86b5a3..fe3a0e8e 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -475,7 +475,13 @@ def test_mac1(ssh, setup, get_json): def test_bridge_new_port_admin_l2(ssh, setup): """A new bridge port is restored up after protected enslavement.""" - ssh.ifup_a() + stderr = ssh.ifup_a(return_stderr=True) + if stderr: + assert stderr == ( + "warning: netlink: mgmt: cannot delete address 127.0.1.1/8 " + "dev mgmt: operation failed with " + "'Cannot assign requested address' (99)\n" + ) ssh.run_assert_success( "ip -o link show dummy_new | grep -w 'master br_new'" ) @@ -550,10 +556,11 @@ def test_vlan_bridge_binding_scope_l2(ssh, setup): ) assert "BRIDGE_BINDING" in bridge_svi - ssh.ifdown("br_bind.200 br_bind vlan100 dummy_port dummy_raw") - for ifname in ("br_bind.200", "br_bind", "vlan100", - "dummy_port", "dummy_raw"): + ssh.ifdown("br_bind.200 br_bind vlan100 swp_AA_ swp_BB_") + for ifname in ("br_bind.200", "br_bind", "vlan100"): assert ssh.run(f"ip link show {ifname}")[3] != 0 + for ifname in ("swp_AA_", "swp_BB_"): + ssh.run_assert_success(f"ip link show {ifname}") def test_bond_dummy_mac_l2(ssh, setup): From 6ae95bf131e2a7bcd3e9821898862a0151883f4e Mon Sep 17 00:00:00 2001 From: Tejeswar Pichuka Date: Tue, 3 Mar 2026 23:49:12 +0000 Subject: [PATCH 40/69] Update to networking.service unit file for avoiding possibility of missing SIGTERM during shutdown Do not enable networking.service through shutdown.target. The service is already active from basic.target/network.target; Conflicts=shutdown.target and Before=shutdown.target stop it through the normal ExecStop path. Keeping shutdown.target in WantedBy can add a contradictory start job to the shutdown transaction and race normal termination. (cherry picked from commit 314ea1d17e4b4a5be138eb1180bea2aaa55cf8ff) Signed-off-by: Julien Fortin --- debian/ifupdown2.networking.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/ifupdown2.networking.service b/debian/ifupdown2.networking.service index d55ad873..07cf8c14 100644 --- a/debian/ifupdown2.networking.service +++ b/debian/ifupdown2.networking.service @@ -20,4 +20,4 @@ ExecStop=/usr/share/ifupdown2/sbin/start-networking stop ExecReload=/usr/share/ifupdown2/sbin/start-networking reload [Install] -WantedBy=basic.target network.target shutdown.target +WantedBy=basic.target network.target From 490df9200957ba4feeb0cedd7823c0da44a32734 Mon Sep 17 00:00:00 2001 From: Lohith CS Date: Sun, 15 Mar 2026 22:09:22 -0700 Subject: [PATCH 41/69] ifupdownmain: Fix Bridge Port Flapping Issue During diff-mode bridge creation, the generic slave handler brought a new port up before the bridge module deliberately cycled it for protected enslavement. That produced an unnecessary UP-DOWN-UP sequence. For a configured bridge port with no running bridge master, defer link-up to the bridge module. Continue allowing recovery for already-enslaved bridge ports and preserve existing behavior for other slave types. A focused diff-mode integration test verifies the deferred link-up path and final admin-up state. The existing EVPN regression accepts only the known management-address cleanup warning. (cherry picked from commit f8d9a8266ee4faf837a46f97f9d75c3899e75b67) Signed-off-by: Lohith CS Signed-off-by: Julien Fortin --- ifupdown2/ifupdown/ifupdownmain.py | 16 +++++++++++++++ tests/eni/bridge_new_port_admin_l2.after.eni | 21 ++++++++++++++++++++ tests/eni/bridge_new_port_admin_l2.eni | 11 ---------- tests/test_l2.py | 3 +++ tests/test_l3.py | 8 +++++++- 5 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 tests/eni/bridge_new_port_admin_l2.after.eni diff --git a/ifupdown2/ifupdown/ifupdownmain.py b/ifupdown2/ifupdown/ifupdownmain.py index 623fff65..bb905af0 100644 --- a/ifupdown2/ifupdown/ifupdownmain.py +++ b/ifupdown2/ifupdown/ifupdownmain.py @@ -117,6 +117,22 @@ def run_up(self, ifaceobj): # is already with its link master (hence the master check). if ifaceobj.link_type == ifaceLinkType.LINK_SLAVE: if self.diff_based: + if ifaceobj.link_privflags & ifaceLinkPrivFlags.BRIDGE_PORT: + # Check if port is already enslaved to bridge or being newly enslaved to bridge + bridge_name = self.netlink.cache.get_bridge_name_from_port(ifaceobj.name) + + if not bridge_name: + # Port not yet enslaved - bridge will handle enslaving and link-up + self.logger.info( + f"{ifaceobj.name}: skipping link-up for bridge port (bridge module will manage)" + ) + return + else: + # Port already enslaved - allow link-up for recovery + self.logger.info( + f"{ifaceobj.name}: bridge port already enslaved to {bridge_name}, allowing link-up" + ) + self.logger.debug( f"{ifaceobj.name}: diff based approach will try to link-up this slave device" ) diff --git a/tests/eni/bridge_new_port_admin_l2.after.eni b/tests/eni/bridge_new_port_admin_l2.after.eni new file mode 100644 index 00000000..6be19ed8 --- /dev/null +++ b/tests/eni/bridge_new_port_admin_l2.after.eni @@ -0,0 +1,21 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + vrf mgmt + +auto mgmt +iface mgmt + vrf-table auto + +auto dummy_new +iface dummy_new + link-type dummy + bridge-access 100 + +auto br_new +iface br_new + bridge-vlan-aware yes + bridge-ports dummy_new + bridge-vids 100 diff --git a/tests/eni/bridge_new_port_admin_l2.eni b/tests/eni/bridge_new_port_admin_l2.eni index 6be19ed8..e2cebd4b 100644 --- a/tests/eni/bridge_new_port_admin_l2.eni +++ b/tests/eni/bridge_new_port_admin_l2.eni @@ -8,14 +8,3 @@ iface eth0 inet dhcp auto mgmt iface mgmt vrf-table auto - -auto dummy_new -iface dummy_new - link-type dummy - bridge-access 100 - -auto br_new -iface br_new - bridge-vlan-aware yes - bridge-ports dummy_new - bridge-vids 100 diff --git a/tests/test_l2.py b/tests/test_l2.py index fe3a0e8e..22acf42b 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -482,6 +482,9 @@ def test_bridge_new_port_admin_l2(ssh, setup): "dev mgmt: operation failed with " "'Cannot assign requested address' (99)\n" ) + ssh.scp("tests/eni/bridge_new_port_admin_l2.after.eni", ENI) + reload_output = ssh.ifreload_av() + assert "skipping link-up for bridge port" in reload_output ssh.run_assert_success( "ip -o link show dummy_new | grep -w 'master br_new'" ) diff --git a/tests/test_l3.py b/tests/test_l3.py index 940096fe..1134886e 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -44,7 +44,13 @@ def test_address_gateway(ssh, setup): def test_evpn_vab_clag_riot_flood_sup_off_config_tors2(ssh, setup, get_json): - ssh.ifup_a() + stderr = ssh.ifup_a(return_stderr=True) + if stderr: + assert stderr == ( + "warning: netlink: mgmt: cannot delete address 127.0.1.1/8 " + "dev mgmt: operation failed with " + "'Cannot assign requested address' (99)\n" + ) assert_identical_json(ssh.ifquery_ac_json(), get_json("EvpnVabClagRiotFloodSupOffConfig.ifquery.ac.json")) From 008a204c52330f796db5200f6b831cac44debe1b Mon Sep 17 00:00:00 2001 From: abhishag Date: Wed, 25 Mar 2026 12:59:50 +0530 Subject: [PATCH 42/69] fix(vrf): skip link-up for VLAN vrf slaves when lower iface is admin-down Treat the lower VLAN raw device's running IFF_UP state as a gate before bringing a VLAN VRF slave up. This complements the existing configured link-down check and avoids ENETDOWN when the lower is intentionally down. Ordinary VRF slaves and VLANs with an admin-up lower retain existing behavior. A focused live test verifies the VLAN remains enslaved and down. (cherry picked from commit 7506df6923d1e82a00b60b2c91e4739dacb0da40) Signed-off-by: abhishag Signed-off-by: Julien Fortin --- ifupdown2/addons/vrf.py | 3 +++ tests/eni/vrf_vlan_lower_down_l3.after.eni | 23 +++++++++++++++++++ tests/eni/vrf_vlan_lower_down_l3.eni | 17 ++++++++++++++ tests/test_l3.py | 26 ++++++++++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 tests/eni/vrf_vlan_lower_down_l3.after.eni create mode 100644 tests/eni/vrf_vlan_lower_down_l3.eni diff --git a/ifupdown2/addons/vrf.py b/ifupdown2/addons/vrf.py index 12240766..ca91491d 100644 --- a/ifupdown2/addons/vrf.py +++ b/ifupdown2/addons/vrf.py @@ -555,6 +555,9 @@ def check_link_down_on_vlan_lower_dev(self, ifaceobj, ifaceobj_getfunc): if obj.link_privflags & ifaceLinkPrivFlags.KEEP_LINK_DOWN: self.logger.info("%s: keeping vlan down (lower device %s has link-down flag set)" % (ifaceobj.name, obj.name)) return True + if not self.cache.link_is_up(obj.name): + self.logger.info("%s: keeping vlan down (lower device %s is not admin up)" % (ifaceobj.name, obj.name)) + return True return False def _del_vrf_rules(self, vrf_dev_name, vrf_table): diff --git a/tests/eni/vrf_vlan_lower_down_l3.after.eni b/tests/eni/vrf_vlan_lower_down_l3.after.eni new file mode 100644 index 00000000..8aa5ba6e --- /dev/null +++ b/tests/eni/vrf_vlan_lower_down_l3.after.eni @@ -0,0 +1,23 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + vrf mgmt + +auto mgmt +iface mgmt + vrf-table auto + +auto swp_AA_ +iface swp_AA_ + +auto vrf_lower +iface vrf_lower + vrf-table 4246 + +auto vlan200 +iface vlan200 + vlan-raw-device swp_AA_ + vlan-id 200 + vrf vrf_lower diff --git a/tests/eni/vrf_vlan_lower_down_l3.eni b/tests/eni/vrf_vlan_lower_down_l3.eni new file mode 100644 index 00000000..5354ce3e --- /dev/null +++ b/tests/eni/vrf_vlan_lower_down_l3.eni @@ -0,0 +1,17 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + vrf mgmt + +auto mgmt +iface mgmt + vrf-table auto + +auto swp_AA_ +iface swp_AA_ + +auto vrf_lower +iface vrf_lower + vrf-table 4246 diff --git a/tests/test_l3.py b/tests/test_l3.py index 1134886e..0cbe6025 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -483,6 +483,32 @@ def test_vxlan_missing_local_l3(ssh, setup): assert ssh.run("ip link show vrf_meta")[3] != 0 +def test_vrf_vlan_lower_down_l3(ssh, setup): + """A VLAN VRF slave stays down when its lower is running admin-down.""" + stderr = ssh.ifup_a(return_stderr=True) + if stderr: + assert stderr == ( + "warning: netlink: mgmt: cannot delete address 127.0.1.1/8 " + "dev mgmt: operation failed with " + "'Cannot assign requested address' (99)\n" + ) + ssh.run_assert_success("ip link set dev swp_AA_ down") + + ssh.scp("tests/eni/vrf_vlan_lower_down_l3.after.eni", ENI) + assert ssh.ifup("vlan200", return_stderr=True) == "" + + ssh.run_assert_success( + "ip -o link show vlan200 | grep -w 'master vrf_lower'" + ) + assert ssh.run("ip -o link show vlan200 | grep -w UP")[3] != 0 + assert ssh.run("ip -o link show swp_AA_ | grep -w UP")[3] != 0 + + ssh.ifdown("vlan200 swp_AA_ vrf_lower") + for ifname in ("vlan200", "vrf_lower"): + assert ssh.run(f"ip link show {ifname}")[3] != 0 + ssh.run_assert_success("ip link show swp_AA_") + + def test_dhcp6_release_link_down_l3(ssh, setup): """A failed DHCPv6 release on a kept-down link remains non-fatal.""" pidfile = "/run/dhclient6.dum_d6.pid" From 3e84eeaa9884934d78773b9221a9fc8a97322c1b Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Sun, 16 Aug 2026 12:31:05 +0200 Subject: [PATCH 43/69] test(address): reconcile IPv6 purge through reload The regression edits the active ENI by removing IPv4 and IPv6 addresses. Use ifreload --diff, the operation that reconciles configuration removals, instead of re-running ifup on an already-applied interface. This makes explicit IPv6 removal deterministic while retaining the monitor assertion that configured IPv6 never flaps. Signed-off-by: Julien Fortin --- tests/test_l3.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_l3.py b/tests/test_l3.py index 0cbe6025..b64076db 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -566,10 +566,7 @@ def test_ipv6_primary_purge_l3(ssh, setup): f"sed -i -e '/address 192\\.0\\.2\\.1\\/24/d' " f"-e '/address 2001:db8:43::2\\/64/d' {ENI}" ) - reconcile_stderr = ssh.ifup( - "dum_purge", - return_stderr=True, - ) + reconcile_stderr = ssh.ifreload_a(return_stderr=True) if reconcile_stderr: assert "cannot delete address 192.0.2.2/24" in reconcile_stderr assert "2001:db8:43::" not in reconcile_stderr From a668b3b48408f7067e42588aa2e1762e7f5b5c8b Mon Sep 17 00:00:00 2001 From: selyovitch Date: Mon, 2 Feb 2026 12:28:34 +0000 Subject: [PATCH 44/69] addons: bridge: fix pvid deletion destroying vni tunnel mappings When a port is enslaved to a VLAN-aware bridge, the kernel auto-adds VLAN 1 with the PVID flag. The netlink cache can receive this update before bridge PVID processing. Single VXLAN devices have no configured PVID, so the check can delete VLAN 1 and its VNI tunnel mapping. Filter a running PVID through bridge-vlan-vni-map before scheduling its deletion. VLANs managed by the tunnel-map handler remain intact, while ordinary PVID deletion is unchanged. (cherry picked from commit fa0dd84f007d26aee76f0baf991b3b62fa10be5e) Signed-off-by: Sapir Elyovitch Signed-off-by: Julien Fortin --- ifupdown2/addons/bridge.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ifupdown2/addons/bridge.py b/ifupdown2/addons/bridge.py index 4f8f3085..a8da6126 100644 --- a/ifupdown2/addons/bridge.py +++ b/ifupdown2/addons/bridge.py @@ -1778,7 +1778,15 @@ def _apply_bridge_vids_and_pvid(self, bportifaceobj, ifaceobj_getfunc, vids, pvi utils.diff_ids(vids_to_add, running_vids) if running_pvid and running_pvid != pvid_int and running_pvid != 0: - pvid_to_del = running_pvid + # remaining_pvid can only be [running_pvid] or [] + remaining_pvid = ( + self.remove_bridge_vlans_mapped_to_vnis_from_vids_list( + None, + bportifaceobj, + [running_pvid], + ) + ) + pvid_to_del = remaining_pvid[-1] if remaining_pvid else None if (pvid_to_del and (pvid_to_del in vids_int) and (pvid_to_del not in vids_to_add)): From 0c624bfd25f72f4f70b3b128d326fd7f7bd25148 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 03:37:41 +0200 Subject: [PATCH 45/69] test(bridge): preserve VNI-mapped PVIDs on reload Recreate a collect-metadata VXLAN twenty times and verify its VLAN 1 to VNI mapping survives every reload. Confirm each logical device is removed before recreation and guarantee cleanup if an assertion fails. The regression fails without fa0dd84f. Signed-off-by: Julien Fortin --- tests/eni/pvid_vni_protection_l2.eni | 29 +++++++++++++++++++ tests/test_l2.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/eni/pvid_vni_protection_l2.eni diff --git a/tests/eni/pvid_vni_protection_l2.eni b/tests/eni/pvid_vni_protection_l2.eni new file mode 100644 index 00000000..ef071184 --- /dev/null +++ b/tests/eni/pvid_vni_protection_l2.eni @@ -0,0 +1,29 @@ +auto lo +iface lo inet loopback + address 192.0.2.5/32 + vxlan-local-tunnelip 192.0.2.5 + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto vxlan_pvid +iface vxlan_pvid + bridge-vlan-vni-map 1=5001 3=5003 + bridge-learning off + mtu 1450 + +auto br_pvid +iface br_pvid + bridge-ports vxlan_pvid + bridge-vlan-aware yes + bridge-vids 1 3 diff --git a/tests/test_l2.py b/tests/test_l2.py index 22acf42b..b9a8f1fe 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -651,3 +651,45 @@ def test_master_conflict_l2(ssh, setup): "br_guard", "bond_guard", "vrf_guard", "dummy_bond", "dummy_vrf"): assert ssh.run(f"ip link show {ifname}")[3] != 0 + + +def test_pvid_vni_protection_l2(ssh, setup): + """Repeated SVD recreation keeps the VLAN 1 tunnel mapping intact. + + A collect-metadata VXLAN receives an implicit VLAN 1 PVID when it joins the + bridge. Reload must not treat that kernel-created PVID as stale because + deleting it also removes the VLAN-to-VNI tunnel mapping. + """ + iterations = 20 + + try: + for iteration in range(iterations): + for ifname in ("br_pvid", "vxlan_pvid"): + ssh.run(f"ip link del {ifname} 2>/dev/null; true") + assert ssh.run(f"ip link show {ifname}")[3] != 0 + + verbose_output = ssh.ifreload_av(ignore_stdout=True) + tunnel_output = ssh.run_assert_success( + "bridge vlan tunnelshow dev vxlan_pvid" + ) + mapping_present = any( + line.split()[-2:] == ["1", "5001"] + for line in tunnel_output.splitlines() + ) + + assert mapping_present, ( + f"iteration {iteration + 1}/{iterations}: " + f"VLAN 1 to VNI 5001 mapping is missing: {tunnel_output!r}" + ) + assert ( + "vlan del vid 1 untagged pvid dev vxlan_pvid" + not in verbose_output + ), ( + f"iteration {iteration + 1}/{iterations}: " + "reload deleted the VNI-mapped PVID" + ) + finally: + ssh.run("ifdown br_pvid vxlan_pvid 2>/dev/null; true") + + assert ssh.run("ip link show br_pvid")[3] != 0 + assert ssh.run("ip link show vxlan_pvid")[3] != 0 From 239c64d5c2f01488f207496fdad8d52a4ff62d6d Mon Sep 17 00:00:00 2001 From: Lohith CS Date: Tue, 12 May 2026 04:36:33 -0700 Subject: [PATCH 46/69] scheduler: PVRST_MODE cache reset between down and up phases ifreload --diff schedules its down phase against saved interface objects and its up phase against the new configuration. PVRST_MODE caches the process-wide scan result, so a decision made during down can prevent up from scanning its own objects. A stale false value leaves mstpd in RSTP; a stale true value can prevent PVRST from being cleared. Reset the cache at each sched_ifaces boundary so every phase evaluates its own interface set. The regression exercises RSTP to PVRST and back across a non-empty down phase. (cherry picked from commit 28313c06435b626eb97627b3024f8fa0d3481903) Signed-off-by: Lohith CS Signed-off-by: Julien Fortin --- ifupdown2/ifupdown/scheduler.py | 4 ++ tests/eni/pvrst_phase_cache_l2.after.eni | 31 ++++++++++++ tests/eni/pvrst_phase_cache_l2.eni | 34 +++++++++++++ tests/test_l2.py | 61 ++++++++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 tests/eni/pvrst_phase_cache_l2.after.eni create mode 100644 tests/eni/pvrst_phase_cache_l2.eni diff --git a/ifupdown2/ifupdown/scheduler.py b/ifupdown2/ifupdown/scheduler.py index 032ecfa3..6cfc2ec1 100644 --- a/ifupdown2/ifupdown/scheduler.py +++ b/ifupdown2/ifupdown/scheduler.py @@ -553,6 +553,10 @@ def sched_ifaces(cls, ifupdownobj, ifacenames, ops, ifupdownobj.logger.debug(f"full run queue: {cls._RUN_QUEUE}") + # Reset PVRST_MODE cache once per op (down or up) so that + # is_pvrst_enabled does a fresh scan in this op. + utils.PVRST_MODE = None + followupperifaces = False run_queue = [] skip_ifacesort = int(ifupdownobj.config.get('skip_ifacesort', '0')) diff --git a/tests/eni/pvrst_phase_cache_l2.after.eni b/tests/eni/pvrst_phase_cache_l2.after.eni new file mode 100644 index 00000000..4ae55c9c --- /dev/null +++ b/tests/eni/pvrst_phase_cache_l2.after.eni @@ -0,0 +1,31 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto swp_AA_ +iface swp_AA_ + +auto swp_BB_ +iface swp_BB_ + +auto br_pvrst +iface br_pvrst + bridge-ports swp_AA_ swp_BB_ + bridge-vlan-aware yes + bridge-vids 10 20 + bridge-pvid 1 + bridge-stp yes + bridge-mcsnoop no + mstpctl-pvrst-mode yes diff --git a/tests/eni/pvrst_phase_cache_l2.eni b/tests/eni/pvrst_phase_cache_l2.eni new file mode 100644 index 00000000..5da5adaa --- /dev/null +++ b/tests/eni/pvrst_phase_cache_l2.eni @@ -0,0 +1,34 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto swp_AA_ +iface swp_AA_ + +auto swp_BB_ +iface swp_BB_ + +auto swp_CC_ +iface swp_CC_ + +auto br_pvrst +iface br_pvrst + bridge-ports swp_AA_ swp_BB_ swp_CC_ + bridge-vlan-aware yes + bridge-vids 10 20 + bridge-pvid 1 + bridge-stp yes + bridge-mcsnoop no + mstpctl-forcevers rstp diff --git a/tests/test_l2.py b/tests/test_l2.py index b9a8f1fe..57b25a0f 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -693,3 +693,64 @@ def test_pvid_vni_protection_l2(ssh, setup): assert ssh.run("ip link show br_pvid")[3] != 0 assert ssh.run("ip link show vxlan_pvid")[3] != 0 + + +def test_pvrst_phase_cache_l2(ssh, setup): + """Diff reload reevaluates global PVRST mode between scheduler phases.""" + _, stdout, stderr, _ = ssh.run("mstpctl --help") + help_output = ( + stdout.read().decode("utf-8") + + stderr.read().decode("utf-8") + ) + if "setmodepvrst" not in help_output: + pytest.skip("mstpctl does not support PVRST mode") + + cleanup_succeeded = False + cleanup_output = "" + cleanup_protocol = None + try: + ssh.ifreload_diff = False + ssh.ifreload_a() + initial_state = json.loads( + ssh.run_assert_success( + "mstpctl showstpbridge json br_pvrst" + ) + ) + assert initial_state.get("protocol") == "rstp" + + ssh.scp("tests/eni/pvrst_phase_cache_l2.after.eni", ENI) + ssh.ifreload_diff = True + apply_output = ssh.ifreload_av(ignore_stdout=True) + + assert "mstpctl setmodepvrst" in apply_output + bridge_state = json.loads( + ssh.run_assert_success( + "mstpctl showstpbridge json br_pvrst" + ) + ) + assert bridge_state.get("protocol") == "rapid-pvst" + finally: + try: + ssh.scp("tests/eni/pvrst_phase_cache_l2.eni", ENI) + ssh.ifreload_diff = True + cleanup_output = ssh.ifreload_av(ignore_stdout=True) + cleanup_state = json.loads( + ssh.run_assert_success( + "mstpctl showstpbridge json br_pvrst" + ) + ) + cleanup_protocol = cleanup_state.get("protocol") + cleanup_succeeded = True + finally: + if not cleanup_succeeded: + ssh.run("mstpctl clearmodepvrst 2>/dev/null; true") + ssh.run( + "ifdown br_pvrst swp_AA_ swp_BB_ swp_CC_ " + "2>/dev/null; true" + ) + + assert "mstpctl clearmodepvrst" in cleanup_output + assert cleanup_protocol == "rstp" + assert ssh.run("ip link show br_pvrst")[3] != 0 + for ifname in ("swp_AA_", "swp_BB_", "swp_CC_"): + ssh.run_assert_success(f"ip link show {ifname}") From be92cc57637e985b8bd643a766a66a473b0f2d60 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Thu, 7 Aug 2025 19:24:41 +0200 Subject: [PATCH 47/69] pytest: enhance admin up/down checks The brief link display reports operational state, which can be DOWN when an interface is administratively up but has no carrier. Read the kernel JSON flags and check IFF_UP directly instead. (cherry picked from commit 3d5b2c549b0a1c64c3d9e5fbf9c7b3c5d16db4c8) Signed-off-by: Julien Fortin --- tests/conftest.py | 12 ++++++++++++ tests/test_l2.py | 29 ++++++++++++++++++----------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 10bf2af7..36e8ff19 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -143,6 +143,18 @@ def run_assert_success(self, cmd: str): return stdout.read().decode("utf-8") + def assert_interface_admin_up(self, ifname: str): + flags = json.loads( + self.run_assert_success(f"ip -j link show {ifname}") + )[0].get("flags", []) + assert "UP" in flags, f"{ifname} is administratively down: {flags}" + + def assert_interface_admin_down(self, ifname: str): + flags = json.loads( + self.run_assert_success(f"ip -j link show {ifname}") + )[0].get("flags", []) + assert "UP" not in flags, f"{ifname} is administratively up: {flags}" + def __ifupdown2( self, op: str, diff --git a/tests/test_l2.py b/tests/test_l2.py index 57b25a0f..0aa4b6a5 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -440,18 +440,22 @@ def test_cm_11485_vlan_device_name_vlan(ssh, setup, get_json): def test_interfaces_link_state(ssh, setup, get_json): + """Link state checks use the kernel's administrative UP flag.""" ssh.scp("tests/scp/interfaces_link_state.before.eni", ENI_D) ssh.ifreload_a() assert_identical_json( ssh.ifquery_ac_json(), get_json("interfaces_link_state.before.ifquery.ac.json") ) - ssh.run_assert_success("ip -br link show dev swp_AA_ | grep DOWN") - ssh.run_assert_success("ip -br link show dev swp_BB_ | grep UP") - ssh.run_assert_success("ip -br link show dev bridge1 | grep UP") - ssh.run_assert_success("ip -br link show dev bridge2 | grep UP") - ssh.run_assert_success("ip -br link show dev bridge3 | grep DOWN") - ssh.run_assert_success("ip -br link show dev bridge4 | grep UP") + + ssh.assert_interface_admin_down("bridge3") + + ssh.assert_interface_admin_down("swp_AA_") + ssh.assert_interface_admin_up("swp_BB_") + ssh.assert_interface_admin_up("bridge1") + ssh.assert_interface_admin_up("bridge2") + ssh.assert_interface_admin_up("bridge4") + ssh.run_assert_success("rm /etc/network/interfaces.d/interfaces_link_state.before.eni") ssh.scp("tests/scp/interfaces_link_state.after.eni", ENI_D) @@ -460,11 +464,14 @@ def test_interfaces_link_state(ssh, setup, get_json): ssh.ifquery_ac_json(), get_json("interfaces_link_state.after.ifquery.ac.json") ) - ssh.run_assert_success("ip -br link show dev swp_BB_ | grep DOWN") - ssh.run_assert_success("ip -br link show dev bridge1 | grep UP") - ssh.run_assert_success("ip -br link show dev bridge2 | grep UP") - ssh.run_assert_success("ip -br link show dev bridge3 | grep UP") - ssh.run_assert_success("ip -br link show dev bridge4 | grep DOWN") + + ssh.assert_interface_admin_down("swp_BB_") + ssh.assert_interface_admin_down("bridge4") + + ssh.assert_interface_admin_up("bridge1") + ssh.assert_interface_admin_up("bridge2") + ssh.assert_interface_admin_up("bridge3") + ssh.run_assert_success("rm /etc/network/interfaces.d/interfaces_link_state.after.eni") From 431a7db360e155c037952049f30959d5debd617a Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Fri, 8 Aug 2025 11:44:27 +0200 Subject: [PATCH 48/69] test_interfaces_link_state: update swp_AA_ admin state check The interface is administratively up, but the test still expects it to be down. Update the stale expectation to match ifupdown2 behavior. (cherry picked from commit bb59b9859760070ba3e44313cb5bf61e0627e90e) Signed-off-by: Julien Fortin --- tests/test_l2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_l2.py b/tests/test_l2.py index 0aa4b6a5..b5ae5449 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -450,7 +450,7 @@ def test_interfaces_link_state(ssh, setup, get_json): ssh.assert_interface_admin_down("bridge3") - ssh.assert_interface_admin_down("swp_AA_") + ssh.assert_interface_admin_up("swp_AA_") ssh.assert_interface_admin_up("swp_BB_") ssh.assert_interface_admin_up("bridge1") ssh.assert_interface_admin_up("bridge2") From ab1f454647b23a2753de4b3eb466702e2e7f11ee Mon Sep 17 00:00:00 2001 From: Abhishek Agarwal Date: Sun, 10 Aug 2025 22:54:29 -0700 Subject: [PATCH 49/69] test(integration): restore bridge test reload mode Escape the sed word-boundary expressions so Python does not treat them as invalid string escapes. Restore diff mode after the multi-bridge scenario so its local harness setting cannot leak into later tests. (cherry picked from commit abe47a99e30034eb35d715955ca8c281fa564370) Signed-off-by: Nita MS Signed-off-by: Julien Fortin --- tests/test_l2.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_l2.py b/tests/test_l2.py index b5ae5449..70d15982 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -192,9 +192,9 @@ def test_bridge9_multiple_vlan_aware_bridge(ssh, get_json): assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_1.json")) # Replace "bridge-ports swp_AA_" with "bridge-ports swp_BB_" and vice-versa - ssh.run_assert_success(f"sed -i 's/\/bridge-ports swp_CC_/g' {ENI}") - ssh.run_assert_success(f"sed -i 's/\/bridge-ports swp_AA_/g' {ENI}") - ssh.run_assert_success(f"sed -i 's/\/bridge-ports swp_BB_/g' {ENI}") + ssh.run_assert_success(f"sed -i 's/\\/bridge-ports swp_CC_/g' {ENI}") + ssh.run_assert_success(f"sed -i 's/\\/bridge-ports swp_AA_/g' {ENI}") + ssh.run_assert_success(f"sed -i 's/\\/bridge-ports swp_BB_/g' {ENI}") ssh.ifreload_diff = False ssh.ifreload_a() _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_AA_") @@ -203,6 +203,7 @@ def test_bridge9_multiple_vlan_aware_bridge(ssh, get_json): _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_BB_") stdout_str: str = stdout.read().decode("utf-8") assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_2.json")) + ssh.ifreload_diff = True def test_bridge_access(ssh, setup, get_json): From acfc14c6f4f0c9483460821b681d81477a5a49fd Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 04:50:24 +0200 Subject: [PATCH 50/69] test(integration): make link-state checks failure-safe Restore the caller's reload mode even when the multi-bridge scenario fails. Document and assert the physical port's final administrative state so both link-state fixtures are fully covered. Signed-off-by: Julien Fortin --- tests/scp/interfaces_link_state.after.eni | 2 +- tests/scp/interfaces_link_state.before.eni | 2 +- tests/test_l2.py | 47 ++++++++++++---------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/tests/scp/interfaces_link_state.after.eni b/tests/scp/interfaces_link_state.after.eni index 0cfb4db8..9cede041 100644 --- a/tests/scp/interfaces_link_state.after.eni +++ b/tests/scp/interfaces_link_state.after.eni @@ -1,4 +1,4 @@ -# this link should be down +# this link should be up; inet manual does not imply link-down auto swp_AA_ iface swp_AA_ inet manual diff --git a/tests/scp/interfaces_link_state.before.eni b/tests/scp/interfaces_link_state.before.eni index be5291bf..f2f7052b 100644 --- a/tests/scp/interfaces_link_state.before.eni +++ b/tests/scp/interfaces_link_state.before.eni @@ -1,4 +1,4 @@ -# this link should down +# this link should be up; inet manual does not imply link-down auto swp_AA_ iface swp_AA_ inet manual diff --git a/tests/test_l2.py b/tests/test_l2.py index 70d15982..d6c451f2 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -182,28 +182,30 @@ def test_bridge9_multiple_vlan_aware_bridge(ssh, get_json): ssh.scp("tests/eni/bridge9_multiple_vlan_aware_bridge.eni", ENI) ssh.run(f"rm -f {ENI_D}/*") - ssh.ifreload_diff = False - ssh.ifreload_a() - _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_AA_") - stdout_str: str = stdout.read().decode("utf-8") - assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_1.json")) - _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_BB_") - stdout_str: str = stdout.read().decode("utf-8") - assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_1.json")) - - # Replace "bridge-ports swp_AA_" with "bridge-ports swp_BB_" and vice-versa - ssh.run_assert_success(f"sed -i 's/\\/bridge-ports swp_CC_/g' {ENI}") - ssh.run_assert_success(f"sed -i 's/\\/bridge-ports swp_AA_/g' {ENI}") - ssh.run_assert_success(f"sed -i 's/\\/bridge-ports swp_BB_/g' {ENI}") - ssh.ifreload_diff = False - ssh.ifreload_a() - _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_AA_") - stdout_str: str = stdout.read().decode("utf-8") - assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_2.json")) - _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_BB_") - stdout_str: str = stdout.read().decode("utf-8") - assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_2.json")) - ssh.ifreload_diff = True + previous_ifreload_diff = ssh.ifreload_diff + try: + ssh.ifreload_diff = False + ssh.ifreload_a() + _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_AA_") + stdout_str: str = stdout.read().decode("utf-8") + assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_1.json")) + _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_BB_") + stdout_str: str = stdout.read().decode("utf-8") + assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_1.json")) + + # Replace "bridge-ports swp_AA_" with "bridge-ports swp_BB_" and vice-versa + ssh.run_assert_success(f"sed -i 's/\\/bridge-ports swp_CC_/g' {ENI}") + ssh.run_assert_success(f"sed -i 's/\\/bridge-ports swp_AA_/g' {ENI}") + ssh.run_assert_success(f"sed -i 's/\\/bridge-ports swp_BB_/g' {ENI}") + ssh.ifreload_a() + _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_AA_") + stdout_str: str = stdout.read().decode("utf-8") + assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_AA_2.json")) + _, stdout, stderr, exit_status = ssh.run(f"bridge -j vlan show dev swp_BB_") + stdout_str: str = stdout.read().decode("utf-8") + assert_identical_json(json.loads(stdout_str), get_json("bridge9_multiple_vlan_aware_bridge.bridge_vlan_swp_BB_2.json")) + finally: + ssh.ifreload_diff = previous_ifreload_diff def test_bridge_access(ssh, setup, get_json): @@ -469,6 +471,7 @@ def test_interfaces_link_state(ssh, setup, get_json): ssh.assert_interface_admin_down("swp_BB_") ssh.assert_interface_admin_down("bridge4") + ssh.assert_interface_admin_up("swp_AA_") ssh.assert_interface_admin_up("bridge1") ssh.assert_interface_admin_up("bridge2") ssh.assert_interface_admin_up("bridge3") From 2c2b972a5cb90398214a9f933e2de40ad150b36f Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 05:09:30 +0200 Subject: [PATCH 51/69] test(address): isolate IPv4 primary purge regression Promote the secondary IPv4 address without editing IPv6 configuration, then require both configured IPv6 addresses to remain continuously present. This keeps the regression focused on the purge bug and removes an unrelated, sequence-sensitive explicit-removal check. Signed-off-by: Julien Fortin --- tests/test_l3.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_l3.py b/tests/test_l3.py index b64076db..38668abc 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -561,10 +561,9 @@ def test_ipv6_primary_purge_l3(ssh, setup): f"test -s {monitor_pid} && kill -0 $(cat {monitor_pid}) && sleep 1" ) - # Promote the secondary IPv4 address and explicitly remove only IPv6 ::2. + # Promote the secondary IPv4 address without changing IPv6 configuration. ssh.run_assert_success( - f"sed -i -e '/address 192\\.0\\.2\\.1\\/24/d' " - f"-e '/address 2001:db8:43::2\\/64/d' {ENI}" + f"sed -i '/address 192\\.0\\.2\\.1\\/24/d' {ENI}" ) reconcile_stderr = ssh.ifreload_a(return_stderr=True) if reconcile_stderr: @@ -576,13 +575,14 @@ def test_ipv6_primary_purge_l3(ssh, setup): ) monitor_output = ssh.run_assert_success(f"cat {monitor_log}").lower() - # Confirm the monitor observed the IPv4 transition and retained IPv6 never - # flapped. Explicit IPv6 removal is asserted from final kernel state below. + # Confirm the monitor observed the IPv4 transition and neither configured + # IPv6 address flapped. assert "192.0.2" in monitor_output - assert not any( - "deleted" in line and "2001:db8:43::1/64" in line - for line in monitor_output.splitlines() - ) + for address in ("2001:db8:43::1/64", "2001:db8:43::2/64"): + assert not any( + "deleted" in line and address in line + for line in monitor_output.splitlines() + ) assert ssh.run( "ip -o address show dev dum_purge | grep '192.0.2.1/24'" @@ -593,9 +593,9 @@ def test_ipv6_primary_purge_l3(ssh, setup): ssh.run_assert_success( "ip -6 -o address show dev dum_purge | grep '2001:db8:43::1/64'" ) - assert ssh.run( + ssh.run_assert_success( "ip -6 -o address show dev dum_purge | grep '2001:db8:43::2/64'" - )[3] != 0 + ) ssh.ifdown("dum_purge") ssh.run_assert_success(f"rm -f {monitor_log} {monitor_pid}") From 0e4c9116190eb27b2cb8fcbce4490802c352d777 Mon Sep 17 00:00:00 2001 From: Andy Rao Date: Thu, 27 Feb 2025 13:34:47 -0800 Subject: [PATCH 52/69] fix(ethtool): apply default duplex with fixed speed Use the module's duplex default only when a non-switch interface has an effective fixed-speed request and no policy duplex. This avoids standalone duplex commands, leaves switch ports policy-driven, and preserves explicit policy precedence. Update the stale generated man-page value to match the existing module metadata. (cherry picked from commit 4fbd40ec9f2ffa74a5f59a2386554a3bcf9617bd) Signed-off-by: Julien Fortin --- ifupdown2/addons/ethtool.py | 19 +++++++++++++++++++ .../man/ifupdown-addons-interfaces.5.rst | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/ifupdown2/addons/ethtool.py b/ifupdown2/addons/ethtool.py index 3aace393..3405d2e1 100644 --- a/ifupdown2/addons/ethtool.py +++ b/ifupdown2/addons/ethtool.py @@ -364,6 +364,25 @@ def do_speed_lane_duplex_autoneg_settings(self, ifaceobj, down=False): attr='link-autoneg' ) + if config_speed: + fixed_speed = not utils.get_boolean_from_string(config_autoneg) + else: + effective_autoneg = ( + config_autoneg + if config_autoneg is not None + else default_autoneg + ) + fixed_speed = bool(default_speed) and not ( + utils.get_boolean_from_string(effective_autoneg) + ) + + if ( + fixed_speed + and not default_duplex + and not ifaceobj.name.startswith("swp") + ): + default_duplex = self.get_attr_default_value("link-duplex") + if down: config_speed = default_speed config_duplex = default_duplex diff --git a/ifupdown2/man/ifupdown-addons-interfaces.5.rst b/ifupdown2/man/ifupdown-addons-interfaces.5.rst index 1858dac2..f82ac41f 100644 --- a/ifupdown2/man/ifupdown-addons-interfaces.5.rst +++ b/ifupdown2/man/ifupdown-addons-interfaces.5.rst @@ -43,7 +43,7 @@ EXAMPLES **required**: False - **default**: half + **default**: full **validvals**: half,full From e51e43a375ea914debfe41a8cc2e150131d9378f Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Wed, 7 May 2025 16:06:43 -0700 Subject: [PATCH 53/69] feat(ethtool): implement single-speed auto-negotiation Add a disabled-by-default policy path for switch ports. Autonegotiation can advertise either all supported modes or one configured speed, while forced mode continues to require a positive speed. Non-switch interfaces retain the legacy path. Reapply link mode whenever the module runs because raw ENI and running speed cannot identify the advertised mode. Preserve effective mode outside ENI diff comparison so forced-lane to autoneg transitions can be cleared safely, retried after failure, or rolled back if the final command fails. (cherry picked from commit d23a229eaa64e96cc38cfcbab2069319887e1d29) Signed-off-by: Julien Fortin Co-authored-by: sbandlamudi --- ifupdown2/addons/ethtool.py | 529 ++++++++++++++++++++++- ifupdown2/ifupdown/iface.py | 42 ++ ifupdown2/ifupdown/statemanager.py | 9 +- tests/eni/ethtool_single_speed_an_l2.eni | 21 + tests/test_l2.py | 110 +++++ 5 files changed, 706 insertions(+), 5 deletions(-) create mode 100644 tests/eni/ethtool_single_speed_an_l2.eni diff --git a/ifupdown2/addons/ethtool.py b/ifupdown2/addons/ethtool.py index 3405d2e1..d8faf07d 100644 --- a/ifupdown2/addons/ethtool.py +++ b/ifupdown2/addons/ethtool.py @@ -154,10 +154,45 @@ def __init__(self, *args, **kargs): # Cache for features self.feature_cache = None - self.ethtool_ignore_errors = policymanager.policymanager_api.get_module_globals( + ignore_errors_policy = policymanager.policymanager_api.get_module_globals( module_name=self.__class__.__name__, attr='ethtool_ignore_errors' ) + try: + self.ethtool_ignore_errors = utils.get_boolean_from_string( + ignore_errors_policy, + default=False, + ) + except TypeError: + self.logger.warning( + "ethtool: invalid ethtool_ignore_errors policy; " + "errors will not be ignored" + ) + self.ethtool_ignore_errors = False + + single_speed_policy = ( + policymanager.policymanager_api.get_module_globals( + module_name=self.__class__.__name__, + attr="enable-single-speed-autoneg" + ) + ) + try: + self.single_all_speed_autoneg_enabled = ( + utils.get_boolean_from_string( + single_speed_policy, + default=False, + ) + ) + except TypeError: + self.logger.warning( + "ethtool: invalid enable-single-speed-autoneg policy; " + "feature disabled" + ) + self.single_all_speed_autoneg_enabled = False + self.single_all_speed_autoneg_map = { + True: "0xFFFFFFFFFFFFFFFF", + False: "0x8080808080808080", + } def do_ring_settings(self, ifaceobj, attr_name, option): # Get the current configuration value and default value for the specified attribute @@ -230,10 +265,17 @@ def do_offload_settings(self, ifaceobj, attr_name, eth_name): except Exception as e: self.log_error('%s: %s' %(ifaceobj.name, str(e)), ifaceobj) - self.ethtool_ignore_errors = policymanager.policymanager_api.get_module_globals( + ignore_errors_policy = policymanager.policymanager_api.get_module_globals( module_name=self.__class__.__name__, attr='ethtool_ignore_errors' ) + try: + self.ethtool_ignore_errors = utils.get_boolean_from_string( + ignore_errors_policy, + default=False, + ) + except TypeError: + self.ethtool_ignore_errors = False def do_lanes_settings(self, ifaceobj): lanescmd = '' @@ -325,6 +367,489 @@ def do_fec_settings(self, ifaceobj): self.log_error('%s: %s' %(ifaceobj.name, str(e)), ifaceobj) def do_speed_lane_duplex_autoneg_settings(self, ifaceobj, down=False): + if self.single_all_speed_autoneg_enabled: + self.single_speed_an_settings(ifaceobj, down) + else: + self.process_speed_lane_duplex_autoneg_settings(ifaceobj, down) + + @staticmethod + def _value_is_set(value): + return value is not None and value != "" + + def get_old_link_attrs_from_statemanager(self, ifname): + old_ifaceobjs = ( + statemanager.statemanager_api.get_ifaceobjs(ifname) or [] + ) + old_attrs = [None, None, None, None] + old_effective_autoneg = None + old_effective_speed = None + old_effective_duplex = None + old_effective_lanes = None + old_pending_lane_clear = None + attr_names = ( + "link-speed", + "link-duplex", + "link-autoneg", + "link-lanes", + ) + + for old_ifaceobj in old_ifaceobjs: + if old_pending_lane_clear is None: + pending_lane_clear = getattr( + old_ifaceobj, + "ethtool_pending_lane_clear", + None, + ) + if isinstance(pending_lane_clear, dict): + old_pending_lane_clear = dict(pending_lane_clear) + if old_effective_autoneg is None: + old_effective_autoneg = getattr( + old_ifaceobj, + "ethtool_effective_autoneg", + None, + ) + if old_effective_speed is None: + old_effective_speed = getattr( + old_ifaceobj, + "ethtool_effective_speed", + None, + ) + if old_effective_duplex is None: + old_effective_duplex = getattr( + old_ifaceobj, + "ethtool_effective_duplex", + None, + ) + if old_effective_lanes is None: + old_effective_lanes = getattr( + old_ifaceobj, + "ethtool_effective_lanes", + None, + ) + for index, attr_name in enumerate(attr_names): + if old_attrs[index] is None: + old_attrs[index] = old_ifaceobj.get_attr_value_first( + attr_name + ) + + return ( + tuple(old_attrs), + old_effective_autoneg, + old_effective_speed, + old_effective_duplex, + old_effective_lanes, + old_pending_lane_clear, + ) + + @staticmethod + def normalize_ethtool_data(autoneg, speed, duplex): + return ( + utils.get_boolean_from_string(autoneg), + int(speed) if speed is not None else None, + str(duplex).lower() if duplex else "", + ) + + def get_advertise_bitmap(self, down, autoneg, speed): + if not autoneg: + return "" + if down or speed is None: + return self.single_all_speed_autoneg_map[True] + return self.single_all_speed_autoneg_map[False] + + def validate_ethtool_speed(self, ifaceobj, ifname, config_speed): + if not self._value_is_set(config_speed): + return True, None + + try: + speed = int(config_speed) + except (TypeError, ValueError): + self.log_error( + "%s: link-speed is not a valid integer: %s" + % (ifname, config_speed), + ifaceobj, + ) + return False, None + + if speed <= 0: + self.log_error( + "%s: link-speed must be a positive integer: %s" + % (ifname, config_speed), + ifaceobj, + ) + return False, None + + return True, speed + + def get_lane_cache_clear_command( + self, + ifname, + old_speed, + old_autoneg, + old_lanes, + old_effective_autoneg, + old_pending_lane_clear, + target_autoneg, + target_lanes, + target_speed, + target_duplex): + if old_pending_lane_clear: + old_speed = old_pending_lane_clear.get("speed", old_speed) + old_lanes = old_pending_lane_clear.get("lanes", old_lanes) + effective_old_autoneg = old_pending_lane_clear.get( + "autoneg", + False, + ) + elif old_effective_autoneg is not None: + effective_old_autoneg = old_effective_autoneg + elif self._value_is_set(old_autoneg): + effective_old_autoneg = old_autoneg + elif self._value_is_set(old_speed): + effective_old_autoneg = "off" + else: + effective_old_autoneg = None + + clear_speed = target_speed + if clear_speed is None and self._value_is_set(old_speed): + try: + old_speed_int = int(old_speed) + if old_speed_int > 0: + clear_speed = old_speed_int + except (TypeError, ValueError): + pass + + if ( + target_autoneg + and self._value_is_set(effective_old_autoneg) + and not utils.get_boolean_from_string( + effective_old_autoneg + ) + and (old_lanes or old_pending_lane_clear) + and not target_lanes + and clear_speed is not None + ): + command = [ + utils.ethtool_cmd, + "-s", + ifname, + "autoneg", + "off", + "speed", + str(clear_speed), + ] + if target_duplex: + command.extend(("duplex", target_duplex)) + return " ".join(command) + return None + + def get_link_mode_restore_command( + self, + ifname, + old_speed, + old_duplex, + old_autoneg, + old_lanes, + old_effective_autoneg, + old_pending_lane_clear): + if old_pending_lane_clear: + old_speed = old_pending_lane_clear.get("speed", old_speed) + old_duplex = old_pending_lane_clear.get("duplex", old_duplex) + old_lanes = old_pending_lane_clear.get("lanes", old_lanes) + old_effective_autoneg = old_pending_lane_clear.get( + "autoneg", + old_effective_autoneg, + ) + if old_effective_autoneg is not None: + autoneg = bool(old_effective_autoneg) + elif self._value_is_set(old_autoneg): + autoneg = utils.get_boolean_from_string(old_autoneg) + elif self._value_is_set(old_speed): + autoneg = False + else: + return None + + speed = None + if self._value_is_set(old_speed): + try: + old_speed_int = int(old_speed) + if old_speed_int > 0: + speed = old_speed_int + except (TypeError, ValueError): + pass + + command = [ + utils.ethtool_cmd, + "-s", + ifname, + "autoneg", + "on" if autoneg else "off", + ] + if speed is not None: + command.extend(("speed", str(speed))) + if old_duplex: + command.extend(("duplex", str(old_duplex).lower())) + if old_lanes: + command.extend(("lanes", str(old_lanes))) + advertise = self.get_advertise_bitmap(False, autoneg, speed) + if advertise: + command.extend(("advertise", advertise)) + return " ".join(command) + + def single_speed_an_settings(self, ifaceobj, down=False): + ifname = ifaceobj.name + if not ifname.startswith("swp"): + self.process_speed_lane_duplex_autoneg_settings( + ifaceobj, + down, + ) + return + + user_speed = ifaceobj.get_attr_value_first("link-speed") + user_duplex = ifaceobj.get_attr_value_first("link-duplex") + user_autoneg = ifaceobj.get_attr_value_first("link-autoneg") + user_lanes = ifaceobj.get_attr_value_first("link-lanes") + + default_speed = policymanager.policymanager_api.get_iface_default( + module_name="ethtool", + ifname=ifname, + attr="link-speed", + ) + default_duplex = policymanager.policymanager_api.get_iface_default( + module_name="ethtool", + ifname=ifname, + attr="link-duplex", + ) + default_autoneg = policymanager.policymanager_api.get_iface_default( + module_name="ethtool", + ifname=ifname, + attr="link-autoneg", + ) + default_lanes = policymanager.policymanager_api.get_iface_default( + module_name="ethtool", + ifname=ifname, + attr="link-lanes", + ) + + user_mode_intent = any( + self._value_is_set(value) + for value in (user_speed, user_autoneg) + ) + default_mode_intent = any( + self._value_is_set(value) + for value in (default_speed, default_autoneg) + ) + if not ( + default_mode_intent + if down + else user_mode_intent or default_mode_intent + ): + self.process_speed_lane_duplex_autoneg_settings( + ifaceobj, + down, + ) + return + + ( + (old_speed, old_duplex, old_autoneg, old_lanes), + old_effective_autoneg, + old_effective_speed, + old_effective_duplex, + old_effective_lanes, + old_pending_lane_clear, + ) = ( + self.get_old_link_attrs_from_statemanager(ifname) + ) + historical_speed = ( + old_effective_speed + if old_effective_speed is not None + else old_speed + ) + historical_duplex = ( + old_effective_duplex + if old_effective_duplex is not None + else old_duplex + ) + historical_lanes = ( + old_effective_lanes + if old_effective_lanes is not None + else old_lanes + ) + if old_pending_lane_clear: + previous_effective_autoneg = old_pending_lane_clear.get( + "autoneg", + old_effective_autoneg, + ) + elif old_effective_autoneg is not None: + previous_effective_autoneg = old_effective_autoneg + elif self._value_is_set(old_autoneg): + previous_effective_autoneg = ( + utils.get_boolean_from_string(old_autoneg) + ) + elif self._value_is_set(old_speed): + previous_effective_autoneg = False + else: + previous_effective_autoneg = None + ifaceobj.ethtool_effective_autoneg = previous_effective_autoneg + ifaceobj.ethtool_effective_speed = historical_speed + ifaceobj.ethtool_effective_duplex = historical_duplex + ifaceobj.ethtool_effective_lanes = historical_lanes + ifaceobj.ethtool_pending_lane_clear = ( + dict(old_pending_lane_clear) + if old_pending_lane_clear + else None + ) + + if down: + autoneg_raw = default_autoneg + speed_raw = default_speed + duplex_raw = default_duplex + lanes_to_configure = default_lanes or "" + else: + autoneg_raw = ( + user_autoneg + if self._value_is_set(user_autoneg) + else default_autoneg + ) + speed_raw = ( + user_speed + if self._value_is_set(user_speed) + else default_speed + ) + duplex_raw = ( + user_duplex + if self._value_is_set(user_duplex) + else default_duplex + ) + lanes_to_configure = user_lanes or default_lanes or "" + + speed_valid, speed_to_configure = self.validate_ethtool_speed( + ifaceobj, + ifname, + speed_raw, + ) + if not speed_valid: + return + + autoneg_to_configure, _, duplex_to_configure = ( + self.normalize_ethtool_data( + autoneg_raw, + speed_to_configure, + duplex_raw, + ) + ) + if speed_to_configure is None and not autoneg_to_configure: + self.log_error( + "%s: link-speed is not set while link-autoneg is disabled" + % ifname, + ifaceobj, + ) + return + + advertise_to_configure = self.get_advertise_bitmap( + down, + autoneg_to_configure, + speed_to_configure, + ) + command = [ + utils.ethtool_cmd, + "-s", + ifname, + "autoneg", + "on" if autoneg_to_configure else "off", + ] + if speed_to_configure is not None: + command.extend(("speed", str(speed_to_configure))) + if duplex_to_configure: + command.extend(("duplex", duplex_to_configure)) + if lanes_to_configure: + command.extend(("lanes", str(lanes_to_configure))) + if advertise_to_configure: + command.extend(("advertise", advertise_to_configure)) + + lane_cache_clear_command = self.get_lane_cache_clear_command( + ifname, + historical_speed, + old_autoneg, + historical_lanes, + old_effective_autoneg, + old_pending_lane_clear, + autoneg_to_configure, + lanes_to_configure, + speed_to_configure, + duplex_to_configure, + ) + if old_pending_lane_clear: + pending_lane_clear = dict(old_pending_lane_clear) + elif lane_cache_clear_command: + pending_lane_clear = { + "speed": historical_speed or speed_to_configure, + "duplex": historical_duplex, + "lanes": historical_lanes, + "autoneg": previous_effective_autoneg, + } + else: + pending_lane_clear = None + ifaceobj.ethtool_pending_lane_clear = pending_lane_clear + final_command = " ".join(command) + + prepare_error = None + prepare_applied = False + if lane_cache_clear_command: + try: + utils.exec_command(lane_cache_clear_command) + prepare_applied = True + except Exception as error: + prepare_error = error + + final_error = None + try: + utils.exec_command(final_command) + except Exception as error: + final_error = error + + rollback_error = None + if final_error and prepare_applied: + restore_command = self.get_link_mode_restore_command( + ifname, + historical_speed, + historical_duplex, + old_autoneg, + historical_lanes, + old_effective_autoneg, + old_pending_lane_clear, + ) + if restore_command: + try: + utils.exec_command(restore_command) + except Exception as error: + rollback_error = error + + errors = [ + error for error in ( + prepare_error, + final_error, + rollback_error, + ) + if error is not None + ] + if errors: + error_message = "; ".join(str(error) for error in errors) + if not self.ethtool_ignore_errors: + self.log_error( + "%s: %s" % (ifname, error_message), + ifaceobj, + ) + else: + self.logger.warning("%s: %s" % (ifname, error_message)) + return + + ifaceobj.ethtool_effective_autoneg = autoneg_to_configure + ifaceobj.ethtool_effective_speed = speed_to_configure + ifaceobj.ethtool_effective_duplex = duplex_to_configure or None + ifaceobj.ethtool_effective_lanes = lanes_to_configure or None + ifaceobj.ethtool_pending_lane_clear = None + + def process_speed_lane_duplex_autoneg_settings( + self, ifaceobj, down=False): force_speed_apply = False lanes_config_applied, lane_cmd_str = self.do_lanes_settings(ifaceobj) diff --git a/ifupdown2/ifupdown/iface.py b/ifupdown2/ifupdown/iface.py index 38d748d4..113abfb6 100644 --- a/ifupdown2/ifupdown/iface.py +++ b/ifupdown2/ifupdown/iface.py @@ -453,6 +453,11 @@ def __init__(self, attrsdict={}): # types of dependencies self.dependency_type = ifaceDependencyType.UNKNOWN self.blacklisted = False + self.ethtool_effective_autoneg = None + self.ethtool_effective_speed = None + self.ethtool_effective_duplex = None + self.ethtool_effective_lanes = None + self.ethtool_pending_lane_clear = None def __eq__(self, other): return ( @@ -646,6 +651,38 @@ def compare(self, dstiface): if v != dstiface.config.get(k)): return False return True + def sync_runtime_state(self, srciface): + self.ethtool_effective_autoneg = getattr( + srciface, + "ethtool_effective_autoneg", + None, + ) + self.ethtool_effective_speed = getattr( + srciface, + "ethtool_effective_speed", + None, + ) + self.ethtool_effective_duplex = getattr( + srciface, + "ethtool_effective_duplex", + None, + ) + self.ethtool_effective_lanes = getattr( + srciface, + "ethtool_effective_lanes", + None, + ) + pending_lane_clear = getattr( + srciface, + "ethtool_pending_lane_clear", + None, + ) + self.ethtool_pending_lane_clear = ( + dict(pending_lane_clear) + if isinstance(pending_lane_clear, dict) + else None + ) + def squash(self, newifaceobj): """ This squashes the iface object """ for attrname, attrlist in newifaceobj.config.items(): @@ -705,6 +742,11 @@ def __setstate__(self, dict): self.link_privflags = ifaceLinkPrivFlags.UNKNOWN self.dependency_type = ifaceDependencyType.UNKNOWN self.blacklisted = False + self.ethtool_effective_autoneg = None + self.ethtool_effective_speed = None + self.ethtool_effective_duplex = None + self.ethtool_effective_lanes = None + self.ethtool_pending_lane_clear = None self.__dict__.update(dict) def dump_raw(self): diff --git a/ifupdown2/ifupdown/statemanager.py b/ifupdown2/ifupdown/statemanager.py index 16fa14ba..e50116ce 100644 --- a/ifupdown2/ifupdown/statemanager.py +++ b/ifupdown2/ifupdown/statemanager.py @@ -151,9 +151,12 @@ def ifaceobj_sync(self, ifaceobj, op): if not old_ifaceobjs: self.ifaceobjdict[ifaceobj.name] = [ifaceobj] else: - # If it matches any of the object, return - if any(o.compare(ifaceobj) for o in old_ifaceobjs): - return + # If it matches any object, retain the object identity while + # refreshing state that modules persist outside ENI config. + for old_ifaceobj in old_ifaceobjs: + if old_ifaceobj.compare(ifaceobj): + old_ifaceobj.sync_runtime_state(ifaceobj) + return # If it does not match any of the objects, and if # all objs in the list came from the pickled file, # then reset the list and add this object as a fresh one, diff --git a/tests/eni/ethtool_single_speed_an_l2.eni b/tests/eni/ethtool_single_speed_an_l2.eni new file mode 100644 index 00000000..4d879851 --- /dev/null +++ b/tests/eni/ethtool_single_speed_an_l2.eni @@ -0,0 +1,21 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto swp_AA_ +iface swp_AA_ + link-autoneg on + link-speed 100000 + link-duplex full diff --git a/tests/test_l2.py b/tests/test_l2.py index d6c451f2..75273372 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -1,5 +1,6 @@ import logging import json +import re import pytest @@ -765,3 +766,112 @@ def test_pvrst_phase_cache_l2(ssh, setup): assert ssh.run("ip link show br_pvrst")[3] != 0 for ifname in ("swp_AA_", "swp_BB_", "swp_CC_"): ssh.run_assert_success(f"ip link show {ifname}") + + +def test_ethtool_single_speed_an_l2(ssh): + """Single-speed AN applies one speed and restores the original port state.""" + port = ssh.translate_swp_xx("swp_AA_") + if not re.fullmatch(r"swp\d+", port): + pytest.skip("requires a plain, non-breakout physical port") + + policy_value = ssh.run_assert_success( + "python3 - <<'PY'\n" + "import glob, json\n" + "value = None\n" + "paths = (glob.glob('/var/lib/ifupdown2/policy.d/*.json') + " + "glob.glob('/etc/network/ifupdown2/policy.d/*.json'))\n" + "for path in paths:\n" + " try:\n" + " module = json.load(open(path)).get('ethtool', {})\n" + " except Exception:\n" + " continue\n" + " value = module.get('module_globals', {}).get(\n" + " 'enable-single-speed-autoneg', value)\n" + "print(value)\n" + "PY" + ).strip().lower() + if policy_value not in ("1", "on", "true", "yes"): + pytest.skip("single-speed autoneg policy is not enabled") + + def advertised_modes(output): + section = output.split("Advertised link modes:", 1)[1] + section = section.split("Auto-negotiation enabled", 1)[0] + return tuple( + section.split("Advertised pause frame use:", 1)[0].split() + ) + + def supported_modes(output): + section = output.split("Supported link modes:", 1)[1] + return tuple( + section.split("Supported pause frame use:", 1)[0].split() + ) + + baseline_state = ssh.run_assert_success(f"ethtool {port}") + baseline_modes = advertised_modes(baseline_state) + if not ( + any("100000base" in mode for mode in baseline_modes) + and any("25000base" in mode for mode in baseline_modes) + and baseline_modes == supported_modes(baseline_state) + and "Auto-negotiation: on" in baseline_state + ): + pytest.skip("requires all-speed AN with both 25G and 100G") + baseline_admin_up = "UP" in json.loads( + ssh.run_assert_success(f"ip -j link show {port}") + )[0].get("flags", []) + + backup_dir = ssh.run_assert_success( + "mktemp -d /tmp/ifupdown2-ethtool.XXXXXX" + ).strip() + ssh.run_assert_success( + f"mkdir -p {backup_dir}/interfaces.d && " + f"cp -a {ENI} {backup_dir}/interfaces && " + f"cp -a {ENI_D}/. {backup_dir}/interfaces.d/" + ) + + try: + ssh.ifdown_x_eth0_x_mgmt() + ssh.scp("tests/eni/ethtool_single_speed_an_l2.eni", ENI) + ssh.run_assert_success( + f"find {ENI_D} -mindepth 1 -maxdepth 1 -delete" + ) + ssh.ifup_a() + first_state = ssh.run_assert_success(f"ethtool {port}") + first_modes = advertised_modes(first_state) + assert any("100000base" in mode for mode in first_modes) + assert not any("25000base" in mode for mode in first_modes) + assert "Auto-negotiation: on" in first_state + + ssh.ifup(port) + second_state = ssh.run_assert_success(f"ethtool {port}") + second_modes = advertised_modes(second_state) + assert any("100000base" in mode for mode in second_modes) + assert not any("25000base" in mode for mode in second_modes) + assert "Auto-negotiation: on" in second_state + + ssh.ifdown(port) + reset_state = ssh.run_assert_success(f"ethtool {port}") + assert advertised_modes(reset_state) == baseline_modes + finally: + _, _, _, restore_status = ssh.run( + f"ifdown -a -X eth0 -X mgmt || true; " + f"cp -af {backup_dir}/interfaces {ENI} && " + f"find {ENI_D} -mindepth 1 -maxdepth 1 -delete && " + f"cp -af {backup_dir}/interfaces.d/. {ENI_D}/ && " + "ifreload -a" + ) + assert restore_status == 0 + ssh.run_assert_success( + f"ethtool -s {port} autoneg on " + "advertise 0xFFFFFFFFFFFFFFFF" + ) + ssh.run_assert_success( + f"ip link set dev {port} " + f"{'up' if baseline_admin_up else 'down'}" + ) + restored_state = ssh.run_assert_success(f"ethtool {port}") + assert advertised_modes(restored_state) == baseline_modes + restored_admin_up = "UP" in json.loads( + ssh.run_assert_success(f"ip -j link show {port}") + )[0].get("flags", []) + assert restored_admin_up is baseline_admin_up + ssh.run_assert_success(f"rm -rf {backup_dir}") From 4e818823c2f898f3cbcd6aeb0d4fd21dd4f757d7 Mon Sep 17 00:00:00 2001 From: Nita Kachhadiya Date: Wed, 1 Jan 2025 20:43:45 -0800 Subject: [PATCH 54/69] fix(diff-mode): queue CLAG after anycast changes Compare every saved and current loopback clagd-vxlan-anycast-ip value before building the up queue. Added, changed, or removed values now trigger the existing CLAG-wide fanout, including complete loopback removal. Keep the public current-CLAG detection so changes to other global CLAG attributes continue to schedule every configured CLAG interface. (cherry picked from commit c829f6f0ea3dbe8b176b50184f04b0cec8983d32) Signed-off-by: Julien Fortin --- ifupdown2/ifupdown/ifupdownmain.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/ifupdown2/ifupdown/ifupdownmain.py b/ifupdown2/ifupdown/ifupdownmain.py index bb905af0..4996477c 100644 --- a/ifupdown2/ifupdown/ifupdownmain.py +++ b/ifupdown2/ifupdown/ifupdownmain.py @@ -2446,6 +2446,13 @@ def is_clag_interface(self, ifaceobjs): return True return False + @staticmethod + def get_ifaceobjs_attribute_values(ifaceobjs, attribute): + values = [] + for ifaceobj in ifaceobjs or []: + values.extend(ifaceobj.get_attr_value(attribute) or []) + return tuple(sorted(values)) + def add_diff_interface_with_clag_check(self, diff_list: list, ifname: str, current_clag_interface: bool): diff_list.append(ifname) if current_clag_interface: @@ -2460,7 +2467,16 @@ def get_diff_ifaceobjs(self, ifaceobj_dict, ifacedownlist, down_dependency_graph # any changes in clagd attributes will result in all clagd interfaces # to be added to the run queue - self.clagd_change_detected = False + self.clagd_change_detected = ( + self.get_ifaceobjs_attribute_values( + ifaceobj_dict.get("lo"), + "clagd-vxlan-anycast-ip", + ) + != self.get_ifaceobjs_attribute_values( + statemanager_api.ifaceobjdict.get("lo"), + "clagd-vxlan-anycast-ip", + ) + ) clag_interfaces = [] for ifname, ifaceobjs in ifaceobj_dict.items(): From b022fcd3eef9a4868b5db3b2f10b45e256bcb217 Mon Sep 17 00:00:00 2001 From: Abhishek Agarwal Date: Thu, 12 Jun 2025 04:12:29 -0700 Subject: [PATCH 55/69] fix(diff-mode): queue bond for slave recovery When a saved bond slave is missing or administratively down, diff recovery already schedules the slave. Also schedule its configured bond master so the bond module can restore kernel master ownership. Limit expansion to current BOND_SLAVE interfaces and uppers that are actual configured bond devices; ordinary diffs, bridge/VRF slaves, and stale uppers retain existing behavior. (cherry picked from commit 05d4f64e6ce9b9955513857ed4fe493d0a465246) Signed-off-by: Abhishek Agarwal Signed-off-by: Julien Fortin --- ifupdown2/ifupdown/ifupdownmain.py | 30 ++++++++++++++++++++ tests/eni/bond_slave_diff_recovery_l2.eni | 27 ++++++++++++++++++ tests/test_l2.py | 34 +++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 tests/eni/bond_slave_diff_recovery_l2.eni diff --git a/ifupdown2/ifupdown/ifupdownmain.py b/ifupdown2/ifupdown/ifupdownmain.py index 4996477c..b06280d8 100644 --- a/ifupdown2/ifupdown/ifupdownmain.py +++ b/ifupdown2/ifupdown/ifupdownmain.py @@ -2478,6 +2478,7 @@ def get_diff_ifaceobjs(self, ifaceobj_dict, ifacedownlist, down_dependency_graph ) ) clag_interfaces = [] + forced_recovery_bond_slaves = [] for ifname, ifaceobjs in ifaceobj_dict.items(): current_clagd_interface = False @@ -2500,6 +2501,15 @@ def get_diff_ifaceobjs(self, ifaceobj_dict, ifacedownlist, down_dependency_graph # We also do a quick check to see if the interface is right admin state if not self.netlink.cache.link_exists(ifname) or self.interface_should_be_up(ifname, ifaceobjs): self.add_diff_interface_with_clag_check(diff_ifname, ifname, current_clagd_interface) + if ( + any( + ifaceobj.link_privflags + & ifaceLinkPrivFlags.BOND_SLAVE + for ifaceobj in ifaceobjs + ) + and ifname not in forced_recovery_bond_slaves + ): + forced_recovery_bond_slaves.append(ifname) continue for new, old in itertools.zip_longest(ifaceobjs, old_ifaceobjs): @@ -2519,6 +2529,26 @@ def get_diff_ifaceobjs(self, ifaceobj_dict, ifacedownlist, down_dependency_graph if lower_ifname in ifaceobj_dict and lower_ifname not in diff_ifname: diff_ifname.append(lower_ifname) + for slave_ifname in forced_recovery_bond_slaves: + for slave_ifaceobj in ifaceobj_dict.get(slave_ifname, []): + for upper_ifname in slave_ifaceobj.upperifaces or []: + upper_ifaceobjs = ifaceobj_dict.get(upper_ifname) + if not ( + upper_ifaceobjs + and any( + upper_ifaceobj.link_kind + & ifaceLinkKind.BOND + for upper_ifaceobj in upper_ifaceobjs + ) + ): + continue + if upper_ifname not in diff_ifname: + self.logger.info( + "diff-mode: bond slave %s requires master %s" + % (slave_ifname, upper_ifname) + ) + diff_ifname.append(upper_ifname) + return diff_ifname def reload(self, *args, **kargs): diff --git a/tests/eni/bond_slave_diff_recovery_l2.eni b/tests/eni/bond_slave_diff_recovery_l2.eni new file mode 100644 index 00000000..eb67228d --- /dev/null +++ b/tests/eni/bond_slave_diff_recovery_l2.eni @@ -0,0 +1,27 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto swp_AA_ +iface swp_AA_ + +auto swp_BB_ +iface swp_BB_ + +auto bond_diff +iface bond_diff + bond-slaves swp_AA_ swp_BB_ + bond-mode active-backup + bond-miimon 100 diff --git a/tests/test_l2.py b/tests/test_l2.py index 75273372..4a543505 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -875,3 +875,37 @@ def supported_modes(output): )[0].get("flags", []) assert restored_admin_up is baseline_admin_up ssh.run_assert_success(f"rm -rf {backup_dir}") + + +def test_bond_slave_diff_recovery_l2(ssh, setup): + """Diff recovery of a down bond slave also schedules its bond master.""" + recovery_port = ssh.translate_swp_xx("swp_AA_") + try: + ssh.ifup_a() + for ifname in ("swp_AA_", "swp_BB_"): + ssh.run_assert_success( + f"ip -o link show {ifname} | grep -w 'master bond_diff'" + ) + + ssh.run_assert_success("ip link set dev swp_AA_ nomaster") + ssh.run_assert_success("ip link set dev swp_AA_ down") + assert ssh.run( + "ip -o link show swp_AA_ | grep -w 'master bond_diff'" + )[3] != 0 + + reload_output = ssh.ifreload_av() + assert recovery_port in reload_output + assert "bond_diff" in reload_output + ssh.run_assert_success( + "ip -o link show swp_AA_ | grep -w 'master bond_diff'" + ) + + ssh.ifreload_a() + ssh.run_assert_success( + "ip -o link show swp_AA_ | grep -w 'master bond_diff'" + ) + finally: + ssh.run( + "ifdown bond_diff swp_AA_ swp_BB_ " + "2>/dev/null; true" + ) From 6ff6a2f7e4b3e7ad91495ad1e26362919177a3b7 Mon Sep 17 00:00:00 2001 From: Lohith CS Date: Wed, 17 Dec 2025 21:28:13 -0800 Subject: [PATCH 56/69] fix(diff-mode): propagate bridge MAC changes to SVIs When a queued bridge's configured MAC changes, also schedule its direct VLAN uppers. Inherited SVIs then receive the new bridge MAC, while explicit-MAC SVIs rerun to preserve their own MAC and associated FDB state. Compare normalized MAC values across every bridge stanza, preserve value order, aggregate split SVI dependency metadata, and exclude non-VLAN uppers. (cherry picked from commit f1446a13d6a5ce60b07f75bbff2aea982c3da07e) Signed-off-by: Lohith CS Signed-off-by: Julien Fortin --- ifupdown2/ifupdown/ifupdownmain.py | 70 ++++++++++++++++++++++ tests/eni/bridge_mac_svi_diff_l2.after.eni | 33 ++++++++++ tests/eni/bridge_mac_svi_diff_l2.eni | 33 ++++++++++ tests/test_l2.py | 43 +++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 tests/eni/bridge_mac_svi_diff_l2.after.eni create mode 100644 tests/eni/bridge_mac_svi_diff_l2.eni diff --git a/ifupdown2/ifupdown/ifupdownmain.py b/ifupdown2/ifupdown/ifupdownmain.py index b06280d8..85e23ce6 100644 --- a/ifupdown2/ifupdown/ifupdownmain.py +++ b/ifupdown2/ifupdown/ifupdownmain.py @@ -34,6 +34,7 @@ from ifupdown2.ifupdown.exceptions import * from ifupdown2.ifupdown.networkinterfaces import * from ifupdown2.ifupdown.config import ADDON_MODULES_DIR, ADDONS_CONF_PATH, IFUPDOWN2_ADDON_DROPIN_FOLDER + from ifupdown2.ifupdown.utils import utils except ImportError: import lib.nlcache as nlcache @@ -53,6 +54,7 @@ from ifupdown.exceptions import * from ifupdown.networkinterfaces import * from ifupdown.config import ADDON_MODULES_DIR, ADDONS_CONF_PATH, IFUPDOWN2_ADDON_DROPIN_FOLDER + from ifupdown.utils import utils """ @@ -2453,6 +2455,32 @@ def get_ifaceobjs_attribute_values(ifaceobjs, attribute): values.extend(ifaceobj.get_attr_value(attribute) or []) return tuple(sorted(values)) + @staticmethod + def normalize_configured_hwaddress(hwaddress): + if not hwaddress: + return None + value = utils.strip_hwaddress(str(hwaddress)).replace("-", ":") + try: + octets = value.split(":") + if len(octets) != 6: + return value + octet_values = [int(octet, 16) for octet in octets] + if any(octet > 255 for octet in octet_values): + return value + return ":".join("%02x" % octet for octet in octet_values) + except (TypeError, ValueError): + return value + + @classmethod + def get_ifaceobjs_hwaddress_values(cls, ifaceobjs): + values = [] + for ifaceobj in ifaceobjs or []: + for value in ifaceobj.get_attr_value("hwaddress") or []: + normalized = cls.normalize_configured_hwaddress(value) + if normalized is not None: + values.append(normalized) + return tuple(values) + def add_diff_interface_with_clag_check(self, diff_list: list, ifname: str, current_clag_interface: bool): diff_list.append(ifname) if current_clag_interface: @@ -2549,6 +2577,48 @@ def get_diff_ifaceobjs(self, ifaceobj_dict, ifacedownlist, down_dependency_graph ) diff_ifname.append(upper_ifname) + queued_ifnames = set(diff_ifname) + for bridge_ifname in tuple(diff_ifname): + bridge_ifaceobjs = ifaceobj_dict.get(bridge_ifname, []) + if not any( + ifaceobj.link_kind & ifaceLinkKind.BRIDGE + for ifaceobj in bridge_ifaceobjs): + continue + if self.get_ifaceobjs_hwaddress_values( + bridge_ifaceobjs) == self.get_ifaceobjs_hwaddress_values( + statemanager_api.ifaceobjdict.get(bridge_ifname) + ): + continue + + appended_svis = [] + for bridge_ifaceobj in bridge_ifaceobjs: + for upper_ifname in bridge_ifaceobj.upperifaces or []: + upper_ifaceobjs = ifaceobj_dict.get(upper_ifname) + if not ( + upper_ifaceobjs + and any( + upper_ifaceobj.link_kind + & ifaceLinkKind.VLAN + for upper_ifaceobj in upper_ifaceobjs + ) + and any( + bridge_ifname + in (upper_ifaceobj.lowerifaces or []) + for upper_ifaceobj in upper_ifaceobjs + ) + ): + continue + if upper_ifname not in queued_ifnames: + diff_ifname.append(upper_ifname) + queued_ifnames.add(upper_ifname) + appended_svis.append(upper_ifname) + + if appended_svis: + self.logger.info( + "diff-mode: bridge %s MAC change requires SVIs %s" + % (bridge_ifname, appended_svis) + ) + return diff_ifname def reload(self, *args, **kargs): diff --git a/tests/eni/bridge_mac_svi_diff_l2.after.eni b/tests/eni/bridge_mac_svi_diff_l2.after.eni new file mode 100644 index 00000000..1cea3f5a --- /dev/null +++ b/tests/eni/bridge_mac_svi_diff_l2.after.eni @@ -0,0 +1,33 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto swp_AA_ +iface swp_AA_ + +auto br_mac +iface br_mac + bridge-ports swp_AA_ + bridge-vlan-aware yes + bridge-vids 100 200 + bridge-pvid 100 + hwaddress 02:00:00:00:10:02 + +auto br_mac.100 +iface br_mac.100 + +auto br_mac.200 +iface br_mac.200 + hwaddress 02:00:00:00:20:01 diff --git a/tests/eni/bridge_mac_svi_diff_l2.eni b/tests/eni/bridge_mac_svi_diff_l2.eni new file mode 100644 index 00000000..66f8c162 --- /dev/null +++ b/tests/eni/bridge_mac_svi_diff_l2.eni @@ -0,0 +1,33 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto swp_AA_ +iface swp_AA_ + +auto br_mac +iface br_mac + bridge-ports swp_AA_ + bridge-vlan-aware yes + bridge-vids 100 200 + bridge-pvid 100 + hwaddress 02:00:00:00:10:01 + +auto br_mac.100 +iface br_mac.100 + +auto br_mac.200 +iface br_mac.200 + hwaddress 02:00:00:00:20:01 diff --git a/tests/test_l2.py b/tests/test_l2.py index 4a543505..fafabfd9 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -909,3 +909,46 @@ def test_bond_slave_diff_recovery_l2(ssh, setup): "ifdown bond_diff swp_AA_ swp_BB_ " "2>/dev/null; true" ) + + +def test_bridge_mac_svi_diff_l2(ssh, setup): + """Bridge MAC changes reprocess inherited and explicit-MAC SVIs.""" + inherited_svi = "br_mac.100" + explicit_svi = "br_mac.200" + explicit_mac = "02:00:00:00:20:01" + try: + ssh.ifup_a() + assert ssh.run_assert_success( + "cat /sys/class/net/br_mac/address" + ).strip() == "02:00:00:00:10:01" + assert ssh.run_assert_success( + f"cat /sys/class/net/{inherited_svi}/address" + ).strip() == "02:00:00:00:10:01" + assert ssh.run_assert_success( + f"cat /sys/class/net/{explicit_svi}/address" + ).strip() == explicit_mac + + ssh.scp("tests/eni/bridge_mac_svi_diff_l2.after.eni", ENI) + reload_output = ssh.ifreload_av() + for ifname in ("br_mac", inherited_svi, explicit_svi): + assert ifname in reload_output + + assert ssh.run_assert_success( + "cat /sys/class/net/br_mac/address" + ).strip() == "02:00:00:00:10:02" + assert ssh.run_assert_success( + f"cat /sys/class/net/{inherited_svi}/address" + ).strip() == "02:00:00:00:10:02" + assert ssh.run_assert_success( + f"cat /sys/class/net/{explicit_svi}/address" + ).strip() == explicit_mac + + ssh.ifreload_a() + assert ssh.run_assert_success( + f"cat /sys/class/net/{inherited_svi}/address" + ).strip() == "02:00:00:00:10:02" + finally: + ssh.run( + "ifdown br_mac.100 br_mac.200 br_mac swp_AA_ " + "2>/dev/null; true" + ) From 84bfb333be911e21f79e567b39540d30512e8c61 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 09:04:39 +0200 Subject: [PATCH 57/69] chore(release): prepare 3.10.0 Prepare coherent Python and Debian 3.10.0-1 release metadata and summarize the VXLAN, ethtool, reload, address, parser, netlink, and service changes. Signed-off-by: Julien Fortin --- debian/changelog | 11 +++++++++++ debian/control | 2 +- ifupdown2/__init__.py | 2 +- setup.py | 3 ++- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/debian/changelog b/debian/changelog index ed0d6622..4af69cfd 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,14 @@ +ifupdown2 (3.10.0-1) unstable; urgency=medium + + * New: support IPv6 VXLAN local tunnel addresses and address-family changes. + * New: add policy-gated single-speed auto-negotiation for switch ports. + * Fix: preserve VXLAN, bridge, bond, VRF, and SVI state during reload. + * Fix: harden DHCP transitions, gateway handling, and IPv6 address purging. + * Fix: handle malformed interface input and additional netlink route formats. + * Fix: bound networking service startup and correct shutdown ordering. + + -- Julien Fortin Mon, 17 Aug 2026 09:15:00 +0200 + ifupdown2 (3.9.0) unstable; urgency=medium * New: ifreload: new --diff cli argument: only reload delta between /e/n/i diff --git a/debian/control b/debian/control index 58cfff2e..1250a6c7 100644 --- a/debian/control +++ b/debian/control @@ -10,7 +10,7 @@ Build-Depends: debhelper (>= 10), python3-docutils Standards-Version: 4.5.0.2 Homepage: https://github.com/cumulusnetworks/ifupdown2 -X-Python-Version: >= 3.7 +X-Python3-Version: >= 3.7 Package: ifupdown2 Architecture: all diff --git a/ifupdown2/__init__.py b/ifupdown2/__init__.py index bba25fe3..635db005 100644 --- a/ifupdown2/__init__.py +++ b/ifupdown2/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -__version__ = '3.9.0' +__version__ = '3.10.0' # Copyright (C) 2014,2015,2016,2017,2018,2019,2020 Cumulus Networks, Inc. All rights reserved # diff --git a/setup.py b/setup.py index 97fe7003..6c99083e 100755 --- a/setup.py +++ b/setup.py @@ -64,7 +64,8 @@ def build_deb_package(): name='ifupdown2', packages=find_packages(), url='https://github.com/CumulusNetworks/ifupdown2', - version='3.9.0', + version='3.10.0', + python_requires='>=3.7', data_files=DATA_FILES, setup_requires=['setuptools'], scripts=SCRIPTS, From e10979a37612020414fb696a201adee4d85ffe1d Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 09:53:22 +0200 Subject: [PATCH 58/69] fix(netlink): validate multipath route attributes Validate rtnexthop and nested-attribute lengths, scan the complete nested list, and decode only family-correct RTA_GATEWAY payloads. Malformed or unsupported entries are skipped without fabricating a device nexthop. Signed-off-by: Julien Fortin --- ifupdown2/nlmanager/nlpacket.py | 105 +++++++++++++++++++------------- 1 file changed, 63 insertions(+), 42 deletions(-) diff --git a/ifupdown2/nlmanager/nlpacket.py b/ifupdown2/nlmanager/nlpacket.py index e0dc4251..5841d460 100644 --- a/ifupdown2/nlmanager/nlpacket.py +++ b/ifupdown2/nlmanager/nlpacket.py @@ -2108,58 +2108,79 @@ def decode(self, parent_msg, data): data = self.data[4:] - while data: - # Check if there's enough data for the rtnexthop header - if len(data) < self.RTNH_LEN: + while len(data) >= self.RTNH_LEN: + ( + rtnh_len, + rtnh_flags, + rtnh_hops, + rtnh_ifindex, + ) = unpack(self.RTNH_PACK, data[:self.RTNH_LEN]) + if rtnh_len < self.RTNH_LEN or rtnh_len > len(data): break - (rtnh_len, rtnh_flags, rtnh_hops, rtnh_ifindex) = unpack(self.RTNH_PACK, data[:self.RTNH_LEN]) - - # Ensure rtnh_len is reasonable, at least as large as the header - if rtnh_len < self.RTNH_LEN: + aligned_rtnh_len = padded_length(rtnh_len) + if aligned_rtnh_len > len(data) and rtnh_len != len(data): break - data = data[self.RTNH_LEN:] - - if rtnh_len == self.RTNH_LEN: - # Device-based multipath: just the rtnexthop header, no IP address - self.value.append((None, rtnh_ifindex, rtnh_flags, rtnh_hops)) - elif rtnh_len > self.RTNH_LEN: - # Gateway-based multipath: has attribute header + IP address + entry = data[:rtnh_len] + data = data[min(aligned_rtnh_len, len(data)):] + nested = entry[self.RTNH_LEN:] + if not nested: + self.value.append( + (None, rtnh_ifindex, rtnh_flags, rtnh_hops) + ) + continue - # Check if there's enough data for the attribute header - if len(data) < self.HEADER_LEN: + nexthop = None + valid = True + while nested: + if len(nested) < self.HEADER_LEN: + valid = False + break + attr_length, attr_type = unpack( + self.HEADER_PACK, + nested[:self.HEADER_LEN], + ) + if ( + attr_length < self.HEADER_LEN + or attr_length > len(nested) + ): + valid = False break - (attr_type, attr_length) = unpack(self.HEADER_PACK, data[:self.HEADER_LEN]) - data = data[self.HEADER_LEN:] - - if self.family == AF_INET: - if len(data) < self.IPV4_LEN: - break - nexthop = ipnetwork.IPv4Address(unpack('>L', data[:self.IPV4_LEN])[0]) - self.value.append((nexthop, rtnh_ifindex, rtnh_flags, rtnh_hops)) - data = data[self.IPV4_LEN:] - - elif self.family == AF_INET6: - if len(data) < self.IPV6_LEN: + attr_data = nested[self.HEADER_LEN:attr_length] + if (attr_type & 0x3FFF) == Route.RTA_GATEWAY: + if ( + self.family == AF_INET + and len(attr_data) == self.IPV4_LEN + ): + nexthop = ipnetwork.IPv4Address( + unpack(">L", attr_data)[0] + ) + elif ( + self.family == AF_INET6 + and len(attr_data) == self.IPV6_LEN + ): + data1, data2 = unpack(">QQ", attr_data) + nexthop = ipnetwork.IPv6Address( + data1 << 64 | data2 + ) + else: + valid = False break - (data1, data2) = unpack('>QQ', data[:self.IPV6_LEN]) - nexthop = ipnetwork.IPv6Address(data1 << 64 | data2) - self.value.append((nexthop, rtnh_ifindex, rtnh_flags, rtnh_hops)) - data = data[self.IPV6_LEN:] - - # We've consumed: RTNH_LEN (header) + HEADER_LEN (attr header) + IP_LEN (address) - consumed = self.RTNH_LEN + self.HEADER_LEN + (self.IPV4_LEN if self.family == AF_INET else self.IPV6_LEN) - # If rtnh_len indicates more data than we consumed, skip the remainder - if rtnh_len > consumed: - skip_len = rtnh_len - consumed + aligned_attr_length = padded_length(attr_length) + if aligned_attr_length > len(nested): + if attr_length != len(nested): + valid = False + nested = b"" + else: + nested = nested[aligned_attr_length:] - if skip_len <= len(data): - data = data[skip_len:] - else: - break + if valid and nexthop is not None: + self.value.append( + (nexthop, rtnh_ifindex, rtnh_flags, rtnh_hops) + ) self.value = tuple(self.value) From e5fb4375feecbc6b88ff44af205965fdac95f587 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 09:53:38 +0200 Subject: [PATCH 59/69] fix(address): enforce IPv6 MTU for DHCPv6 Treat inet6 and DHCPv6 interface intent as IPv6 configuration even without a static address. Low explicit or inherited MTUs now mark the interface failed instead of logging and returning success. Signed-off-by: Julien Fortin --- ifupdown2/addons/address.py | 56 +++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/ifupdown2/addons/address.py b/ifupdown2/addons/address.py index a9f4e8d3..6db17154 100644 --- a/ifupdown2/addons/address.py +++ b/ifupdown2/addons/address.py @@ -682,8 +682,20 @@ def process_addresses(self, ifaceobj, mtu, ifaceobj_getfunc=None, force_reapply= return #V6 mtu check - if not self._process_ipv6_mtu_config_valid(user_config_ip_addrs_list, mtu): - self.logger.error(f"{ifname}: ipv6 configuration is not allowed with MTU lower than {self.v6_min_mtu}") + if not self._process_ipv6_mtu_config_valid( + user_config_ip_addrs_list, mtu, ifaceobj_list): + error_message = ( + f"{ifname}: ipv6 configuration is not allowed with " + f"MTU lower than {self.v6_min_mtu}" + ) + if ( + ifupdownflags.flags.FORCE + or ifupdownflags.flags.IGNORE_ERRORS + ): + ifaceobj.set_status(ifaceStatus.ERROR) + self.logger.error(error_message) + else: + self.log_error(error_message, ifaceobj) return if not ifupdownflags.flags.PERFMODE and purge_addresses: @@ -894,18 +906,46 @@ def _propagate_mtu_to_upper_devs(self, ifaceobj, mtu_str, mtu_int, ifaceobj_getf if not running_mtu or running_mtu != mtu_int: self.sysfs.link_set_mtu(u, mtu_str=mtu_str, mtu_int=mtu_int) - def _process_ipv6_mtu_config_valid(self, user_config_ip_addrs_list: list, mtu: int) -> bool: + @staticmethod + def _ifaceobjs_use_ipv6(ifaceobjs): + for ifaceobj in ifaceobjs or []: + if ( + "inet6" in (ifaceobj.addr_family or []) + or ifaceobj.addr_method == "dhcp6" + ): + return True + for address in ifaceobj.get_attr_value("address") or []: + try: + if ipnetwork.IPNetwork(address).version == 6: + return True + except Exception: + continue + return False + + def _process_ipv6_mtu_config_valid( + self, user_config_ip_addrs_list: list, mtu: int, + ifaceobjs=None) -> bool: for ip, _ in user_config_ip_addrs_list or []: if ip.version == 6 and mtu < self.v6_min_mtu: return False + if ( + mtu < self.v6_min_mtu + and self._ifaceobjs_use_ipv6(ifaceobjs) + ): + return False return True def _process_mtu_ipv6_config_valid(self, ifaceobj, mtu: int) -> bool: - if mtu < self.v6_min_mtu: - for addr in ifaceobj.get_attr_value("address") or []: - if ipnetwork.IPNetwork(addr).version == 6: - self.log_error(f"{ifaceobj.name}: the minimum allowed MTU is {self.v6_min_mtu} for ipv6 configuration", ifaceobj) - return False + if ( + mtu < self.v6_min_mtu + and self._ifaceobjs_use_ipv6([ifaceobj]) + ): + self.log_error( + f"{ifaceobj.name}: the minimum allowed MTU is " + f"{self.v6_min_mtu} for ipv6 configuration", + ifaceobj, + ) + return False return True def _process_mtu_config_mtu_valid(self, ifaceobj, ifaceobj_getfunc, mtu_str, mtu_int): From a992c158dc6907f6fbc5fc4af4330ce848ce8fcd Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 09:53:58 +0200 Subject: [PATCH 60/69] fix(vxlan): clear removed L3VXI local endpoints Do not seed current global VXLAN state from saved reload objects. When an L3VXI drops its effective local endpoint, inspect the running link and recreate the device so collect-metadata state cannot retain the stale address. Use the requested or cached address family consistently during recreation. Signed-off-by: Julien Fortin --- ifupdown2/addons/vxlan.py | 118 ++++++++++++++++++++++- tests/eni/l3vxi_local_reset_l3.after.eni | 26 +++++ tests/eni/l3vxi_local_reset_l3.eni | 27 ++++++ tests/test_l3.py | 26 +++++ 4 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 tests/eni/l3vxi_local_reset_l3.after.eni create mode 100644 tests/eni/l3vxi_local_reset_l3.eni diff --git a/ifupdown2/addons/vxlan.py b/ifupdown2/addons/vxlan.py index 828c706f..bb5c8546 100644 --- a/ifupdown2/addons/vxlan.py +++ b/ifupdown2/addons/vxlan.py @@ -225,7 +225,8 @@ def get_dependent_ifacenames(self, ifaceobj, ifaceobjs_all=None, old_ifaceobjs=F if self._is_vxlan_device(ifaceobj): ifaceobj.link_kind |= ifaceLinkKind.VXLAN - self._set_global_local_ip(ifaceobj) + if not old_ifaceobjs: + self._set_global_local_ip(ifaceobj) self.__check_and_tag_l3vxi(ifaceobj) @@ -1089,6 +1090,25 @@ def __vxlan_local_tunnelip_family_changed(user_request_vxlan_info_data, cached_v (user_request_vxlan_info_data.get(Link.IFLA_VXLAN_LOCAL) and cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_LOCAL6)) ) + @staticmethod + def __get_running_l3vxi_local(ifname): + try: + links = json.loads( + utils.exec_command( + "ip -d -j link show dev %s" % ifname + ) or "[]" + ) + if not links: + return None + local = ( + links[0].get("linkinfo", {}) + .get("info_data", {}) + .get("local") + ) + return local if local not in (None, "0.0.0.0", "::") else None + except Exception: + return None + def _up(self, ifaceobj): self.check_and_raise_svd_tvd_errors(ifaceobj) @@ -1100,6 +1120,10 @@ def _up(self, ifaceobj): ifname = ifaceobj.name link_exists = self.cache.link_exists(ifname) + is_l3vxi = bool( + ifaceobj.link_privflags & ifaceLinkPrivFlags.L3VXI + or ifaceobj.get_attr_value_first("vxlan-vni") + ) user_request_vxlan_info_data = {} @@ -1107,6 +1131,26 @@ def _up(self, ifaceobj): cached_vxlan_ifla_info_data = self.cache.get_link_info_data(ifname) if link_exists else {} local = self.__config_vxlan_local_tunnelip(ifname, ifaceobj, link_exists, user_request_vxlan_info_data, cached_vxlan_ifla_info_data) + l3vxi_local_configured = ( + ifaceobj.get_attr_value_first("vxlan-local-tunnelip") + or self._vxlan_local_tunnelip + or policymanager.policymanager_api.get_attr_default( + module_name=self.__class__.__name__, + attr="vxlan-local-tunnelip", + ) + ) + running_l3vxi_local = None + if ( + is_l3vxi + and not l3vxi_local_configured + ): + running_l3vxi_local = ( + cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_LOCAL) + or cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_LOCAL6) + or self.__get_running_l3vxi_local(ifname) + ) + if running_l3vxi_local: + link_exists = True if link_exists: # if link already exists make sure this is a vxlan @@ -1128,6 +1172,49 @@ def _up(self, ifaceobj): self.logger.info(f"{ifname}: vxlan-local-tunnelip address family changed to IPv{local.version} - VxLAN needs to be recreated") self._down(ifaceobj) + if ( + link_exists + and is_l3vxi + and ( + any( + attribute in user_request_vxlan_info_data + and user_request_vxlan_info_data[attribute] is None + for attribute in ( + Link.IFLA_VXLAN_LOCAL, + Link.IFLA_VXLAN_LOCAL6, + ) + ) + or ( + not l3vxi_local_configured + and ( + running_l3vxi_local + or any( + old_ifaceobj.get_attr_value_first( + "vxlan-local-tunnelip" + ) + for old_ifaceobj + in statemanager.get_ifaceobjs(ifname) or [] + ) + ) + ) + ) + ): + self.logger.info( + "%s: recreate L3VXI to remove local tunnel IP" + % ifname + ) + self._down(ifaceobj) + link_exists = False + cached_vxlan_ifla_info_data = {} + user_request_vxlan_info_data = {} + local = self.__config_vxlan_local_tunnelip( + ifname, + ifaceobj, + link_exists, + user_request_vxlan_info_data, + cached_vxlan_ifla_info_data, + ) + if vxlan_id_str: # for single vxlan device we don't have a vxlan-id self.__config_vxlan_id(ifname, ifaceobj, vxlan_id_str, user_request_vxlan_info_data, cached_vxlan_ifla_info_data) @@ -1208,6 +1295,27 @@ def _up(self, ifaceobj): else: group_str = group.ip if group else None + if local: + vxlan_ipversion = local.version + elif group: + vxlan_ipversion = group.version + elif ( + Link.IFLA_VXLAN_LOCAL6 + in user_request_vxlan_info_data + or Link.IFLA_VXLAN_GROUP6 + in user_request_vxlan_info_data + ): + vxlan_ipversion = 6 + elif ( + Link.IFLA_VXLAN_LOCAL + in user_request_vxlan_info_data + or Link.IFLA_VXLAN_GROUP + in user_request_vxlan_info_data + ): + vxlan_ipversion = 4 + else: + vxlan_ipversion = None + if ifaceobj.link_privflags & ifaceLinkPrivFlags.SINGLE_VXLAN: self.iproute2.link_add_single_vxlan( link_exists, @@ -1219,19 +1327,19 @@ def _up(self, ifaceobj): user_request_vxlan_info_data.get(Link.IFLA_VXLAN_AGEING), vxlan_vnifilter, vxlan_ttl, - local.version if local else None + vxlan_ipversion ) elif ifaceobj.link_privflags & ifaceLinkPrivFlags.L3VXI: self.iproute2.link_add_l3vxi( link_exists, ifname, - local.ip if local else None, - group.ip if group else None, + local_str, + group_str, vxlan_physdev, user_request_vxlan_info_data.get(Link.IFLA_VXLAN_PORT), user_request_vxlan_info_data.get(Link.IFLA_VXLAN_AGEING), vxlan_ttl, - local.version if local else None + vxlan_ipversion ) else: try: diff --git a/tests/eni/l3vxi_local_reset_l3.after.eni b/tests/eni/l3vxi_local_reset_l3.after.eni new file mode 100644 index 00000000..31c5c5bb --- /dev/null +++ b/tests/eni/l3vxi_local_reset_l3.after.eni @@ -0,0 +1,26 @@ +auto lo +iface lo inet loopback + address 192.0.2.60/32 + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto vrf_l3_reset +iface vrf_l3_reset + vrf-table 4260 + +auto vx_l3_reset +iface vx_l3_reset + vxlan-vni 6001 + vxlan-learning no + vrf vrf_l3_reset diff --git a/tests/eni/l3vxi_local_reset_l3.eni b/tests/eni/l3vxi_local_reset_l3.eni new file mode 100644 index 00000000..834cd00a --- /dev/null +++ b/tests/eni/l3vxi_local_reset_l3.eni @@ -0,0 +1,27 @@ +auto lo +iface lo inet loopback + address 192.0.2.60/32 + +auto eth0 +iface eth0 inet dhcp + ip-forward off + ip6-forward off + vrf mgmt + +auto mgmt +iface mgmt + address 127.0.0.1/8 + address 127.0.1.1/8 + address ::1/128 + vrf-table auto + +auto vrf_l3_reset +iface vrf_l3_reset + vrf-table 4260 + +auto vx_l3_reset +iface vx_l3_reset + vxlan-vni 6001 + vxlan-local-tunnelip 192.0.2.60 + vxlan-learning no + vrf vrf_l3_reset diff --git a/tests/test_l3.py b/tests/test_l3.py index 38668abc..36af1f47 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -599,3 +599,29 @@ def test_ipv6_primary_purge_l3(ssh, setup): ssh.ifdown("dum_purge") ssh.run_assert_success(f"rm -f {monitor_log} {monitor_pid}") + + +def test_l3vxi_local_reset_l3(ssh, setup): + """Removing an L3VXI local endpoint clears the cached kernel value.""" + try: + ssh.ifup_a() + initial = ssh.run_assert_success( + "ip -d -o link show vx_l3_reset" + ) + assert "local 192.0.2.60" in initial + + ssh.scp("tests/eni/l3vxi_local_reset_l3.after.eni", ENI) + ssh.ifreload_diff = False + reload_output = ssh.ifreload_av() + assert ( + "recreate L3VXI to remove local tunnel IP" in reload_output + ), reload_output + updated = ssh.run_assert_success( + "ip -d -o link show vx_l3_reset" + ) + assert "local 192.0.2.60" not in updated + assert "vxlan external" in updated + finally: + ssh.run( + "ifdown vx_l3_reset vrf_l3_reset 2>/dev/null; true" + ) From d86dbe2e332b6ae278a750a2704f7296210e4280 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 09:54:27 +0200 Subject: [PATCH 61/69] fix(utils): normalize boolean aliases case-insensitively Lowercase string inputs before alias lookup so uppercase distutils-compatible values cannot fall through to an opposing caller default. Signed-off-by: Julien Fortin --- ifupdown2/ifupdown/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ifupdown2/ifupdown/utils.py b/ifupdown2/ifupdown/utils.py index 52e0ef92..7d5118e8 100644 --- a/ifupdown2/ifupdown/utils.py +++ b/ifupdown2/ifupdown/utils.py @@ -204,7 +204,8 @@ def get_onoff_bool(value): @staticmethod def get_boolean_from_string(value, default=False): - return utils._string_values.get(value, default) + normalized = value.lower() if isinstance(value, str) else value + return utils._string_values.get(normalized, default) @staticmethod def get_yesno_boolean(bool): From 7128382fe248e6858dc50517356571fbf542c204 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 09:54:45 +0200 Subject: [PATCH 62/69] fix(packaging): remove obsolete shutdown enablement Remove a legacy shutdown.target.wants symlink during upgrades when it resolves to the package-owned networking service. Leave unrelated or non-symlink paths untouched. Signed-off-by: Julien Fortin --- debian/ifupdown2.postinst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/debian/ifupdown2.postinst b/debian/ifupdown2.postinst index 1ed4be40..5e8a83b8 100644 --- a/debian/ifupdown2.postinst +++ b/debian/ifupdown2.postinst @@ -86,6 +86,20 @@ postinst_remove_diverts() _postinst_remove_diverts "/usr/share/man/man5/interfaces.5.gz" } +remove_obsolete_shutdown_wants() +{ + link=/etc/systemd/system/shutdown.target.wants/networking.service + if [ -L "$link" ]; then + target=$(readlink -f "$link" 2>/dev/null || true) + case "$target" in + /lib/systemd/system/networking.service|\ + /usr/lib/systemd/system/networking.service) + rm -f "$link" + ;; + esac + fi +} + case "$1" in configure) fix_dhclient_file_with_space @@ -93,6 +107,7 @@ case "$1" in process_udev chmod +x /usr/share/ifupdown2/__main__.py postinst_remove_diverts + remove_obsolete_shutdown_wants ;; abort-upgrade|abort-remove|abort-deconfigure) From 7eba5e7919ed6218ebb7788f7711905a8891a509 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 09:55:06 +0200 Subject: [PATCH 63/69] test(integration): require safe physical-port isolation Require an explicit plain-port allowlist, preserve and restore ENI/drop-ins for the session, fail on function cleanup errors, and keep Python 3.7-compatible test syntax. Attempt ethtool hardware restoration even if ENI restoration fails. Signed-off-by: Julien Fortin --- tests/conftest.py | 92 +++++++++++++++++++++++++++++++++++++++++++---- tests/test_l2.py | 8 +++-- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 36e8ff19..5add799c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -265,24 +265,45 @@ def download_coverage(self): with SCPClient(self.get_transport()) as session: session.get(remote_path=remote_path, local_path=local_path) - def load_swps(self): + def load_swps(self, requested_ports): if self.swp_available: return _, stdout, _, _ = self.run( - 'python -c "import os;' + 'python -c "import os, re;' 'print(\',\'.join(sorted([dev for dev in os.listdir(\'/sys/class/net/\') ' - 'if dev.startswith(\'swp\') and \'.\' not in dev])));"' + 'if re.fullmatch(\'swp[0-9]+\', dev)])));"' ) for swp in stdout.read().decode("utf-8").strip("\r\n").split(","): if swp: self.swp_available.append(swp) + requested = [ + port.strip() + for port in requested_ports.split(",") + if port.strip() + ] + invalid = [ + port for port in requested + if not re.fullmatch(r"swp[0-9]+", port) + ] + missing = [ + port for port in requested + if port not in self.swp_available + ] + if invalid or missing: + raise NotEnoughPhysDevException( + "invalid=%s missing=%s available=%s" + % (invalid, missing, self.swp_available) + ) + self.swp_available = requested + @pytest.fixture(scope="session") def ssh(): remote_host = os.environ.get("PYTEST_REMOTE_HOST") remote_user = os.environ.get("PYTEST_REMOTE_USER") remote_pw = os.environ.get("PYTEST_REMOTE_PASSWORD") + requested_swp_ports = os.environ.get("PYTEST_SWP_PORTS") if not remote_host: pytest.fail("Missing required PYTEST_REMOTE_HOST in env") @@ -292,24 +313,83 @@ def ssh(): if not remote_pw: pytest.fail("Missing required PYTEST_REMOTE_PASSWORD in env") + if not requested_swp_ports: + pytest.fail( + "Missing required PYTEST_SWP_PORTS allowlist " + "(comma-separated plain physical ports)" + ) # Setup SSH client using paramiko client = SSH() client.load_system_host_keys() client.connect(remote_host, username=remote_user, password=remote_pw) - client.load_swps() + client.load_swps(requested_swp_ports) client.mkdir_coverage() # todo: install necessary packages (i.e. coverage) yield client client.close() +@pytest.fixture(scope="session") +def preserve_device_config(ssh): + """Restore ENI and drop-ins exactly after the integration session.""" + backup_dir = "/tmp/.ifupdown2_eni_backup_%d" % int(time.time()) + ssh.run_assert_success( + "mkdir -p %s/interfaces.d && " + "cp -a %s %s/interfaces && " + "cp -a %s/. %s/interfaces.d/" + % (backup_dir, ENI, backup_dir, ENI_D, backup_dir) + ) + + yield backup_dir + + _, _, _, restore_status = ssh.run( + "ifdown -a -X eth0 -X mgmt || true; " + "cp -af %s/interfaces %s && " + "find %s -mindepth 1 -maxdepth 1 -delete && " + "cp -af %s/interfaces.d/. %s/ && " + "ifreload -a && " + "cmp -s %s/interfaces %s && " + "diff -qr %s/interfaces.d %s" + % ( + backup_dir, + ENI, + ENI_D, + backup_dir, + ENI_D, + backup_dir, + ENI, + backup_dir, + ENI_D, + ) + ) + if restore_status: + pytest.fail( + "failed to restore integration ENI from %s (rc=%s)" + % (backup_dir, restore_status) + ) + + @pytest.fixture(scope="function") -def setup(request, ssh): +def setup(request, ssh, preserve_device_config): ssh.ifdown_x_eth0_x_mgmt() file_name = request.node.name.replace("test_", "") ssh.scp(os.path.join("tests/eni", f"{file_name}.eni"), ENI) - ssh.run(f"rm -f {ENI_D}/*") + ssh.run_assert_success( + f"find {ENI_D} -mindepth 1 -maxdepth 1 -delete" + ) + try: + yield + finally: + ssh.ifreload_diff = True + _, _, _, cleanup_status = ssh.run( + "ifdown -a -X eth0 -X mgmt" + ) + if cleanup_status: + pytest.fail( + "function cleanup failed for %s (rc=%s)" + % (request.node.name, cleanup_status) + ) @pytest.fixture diff --git a/tests/test_l2.py b/tests/test_l2.py index fafabfd9..70d62a9c 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -859,15 +859,17 @@ def supported_modes(output): f"cp -af {backup_dir}/interfaces.d/. {ENI_D}/ && " "ifreload -a" ) - assert restore_status == 0 - ssh.run_assert_success( + _, _, _, mode_restore_status = ssh.run( f"ethtool -s {port} autoneg on " "advertise 0xFFFFFFFFFFFFFFFF" ) - ssh.run_assert_success( + _, _, _, admin_restore_status = ssh.run( f"ip link set dev {port} " f"{'up' if baseline_admin_up else 'down'}" ) + assert restore_status == 0 + assert mode_restore_status == 0 + assert admin_restore_status == 0 restored_state = ssh.run_assert_success(f"ethtool {port}") assert advertised_modes(restored_state) == baseline_modes restored_admin_up = "UP" in json.loads( From 993dc782ef715d6f516a5d265a234fad07dd08fb Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 10:18:46 +0200 Subject: [PATCH 64/69] test(integration): remove verified ENI backups Delete the session snapshot only after ENI restoration, reload, and exact file verification all succeed. Failed restorations keep the snapshot for recovery. Signed-off-by: Julien Fortin --- tests/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5add799c..fde45569 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -350,7 +350,8 @@ def preserve_device_config(ssh): "cp -af %s/interfaces.d/. %s/ && " "ifreload -a && " "cmp -s %s/interfaces %s && " - "diff -qr %s/interfaces.d %s" + "diff -qr %s/interfaces.d %s && " + "rm -rf %s" % ( backup_dir, ENI, @@ -361,6 +362,7 @@ def preserve_device_config(ssh): ENI, backup_dir, ENI_D, + backup_dir, ) ) if restore_status: From 020780db3e032c094324c72f54062098db204f4b Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 13:17:03 +0200 Subject: [PATCH 65/69] test(integration): verify reserved ports before use Reject duplicate, configured, linked, active, or enslaved physical ports before mutation. Preserve ENI around standalone bridge-move tests and allow key-based SSH authentication. Signed-off-by: Julien Fortin --- tests/conftest.py | 53 +++++++++++++++++++++++++++++++++++++++++------ tests/test_l2.py | 7 +++++-- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index fde45569..ea5ddb31 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -282,6 +282,10 @@ def load_swps(self, requested_ports): for port in requested_ports.split(",") if port.strip() ] + duplicates = sorted( + port for port in set(requested) + if requested.count(port) > 1 + ) invalid = [ port for port in requested if not re.fullmatch(r"swp[0-9]+", port) @@ -290,10 +294,43 @@ def load_swps(self, requested_ports): port for port in requested if port not in self.swp_available ] - if invalid or missing: + if not requested or duplicates or invalid or missing: + raise NotEnoughPhysDevException( + "empty=%s duplicates=%s invalid=%s missing=%s available=%s" + % ( + not requested, + duplicates, + invalid, + missing, + self.swp_available, + ) + ) + + _, stdout, stderr, status = self.run("ifquery -l") + if status: + raise NotEnoughPhysDevException( + "cannot verify configured interfaces: %s" + % stderr.read().decode("utf-8") + ) + configured = set( + stdout.read().decode("utf-8").split() + ) + configured_ports = sorted(configured.intersection(requested)) + unsafe_ports = [] + for port in requested: + _, _, _, status = self.run( + "test \"$(cat /sys/class/net/%s/carrier)\" = 0 && " + "test ! -L /sys/class/net/%s/master && " + "flags=$(cat /sys/class/net/%s/flags) && " + "test $((flags & 1)) -eq 0" + % (port, port, port) + ) + if status: + unsafe_ports.append(port) + if configured_ports or unsafe_ports: raise NotEnoughPhysDevException( - "invalid=%s missing=%s available=%s" - % (invalid, missing, self.swp_available) + "configured=%s active_or_enslaved=%s" + % (configured_ports, unsafe_ports) ) self.swp_available = requested @@ -311,8 +348,6 @@ def ssh(): if not remote_user: pytest.fail("Missing required PYTEST_REMOTE_USER in env") - if not remote_pw: - pytest.fail("Missing required PYTEST_REMOTE_PASSWORD in env") if not requested_swp_ports: pytest.fail( "Missing required PYTEST_SWP_PORTS allowlist " @@ -322,7 +357,13 @@ def ssh(): # Setup SSH client using paramiko client = SSH() client.load_system_host_keys() - client.connect(remote_host, username=remote_user, password=remote_pw) + connect_args = { + "hostname": remote_host, + "username": remote_user, + } + if remote_pw: + connect_args["password"] = remote_pw + client.connect(**connect_args) client.load_swps(requested_swp_ports) client.mkdir_coverage() # todo: install necessary packages (i.e. coverage) diff --git a/tests/test_l2.py b/tests/test_l2.py index 70d62a9c..5b356447 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -165,7 +165,8 @@ def test_bridge8_reserved_vlans(ssh, setup, get_file): assert "reserved vlan 3725 being used (reserved vlan range 3725-3999)" in ssh.ifreload_a(return_stderr=True, expected_status=1) -def test_bridge9_multiple_vlan_aware_bridge(ssh, get_json): +def test_bridge9_multiple_vlan_aware_bridge( + ssh, get_json, preserve_device_config): multiple_bridge_support = ssh.run_assert_success( "sed -n " "'s/^multiple_vlan_aware_bridge_support=//p' " @@ -181,7 +182,9 @@ def test_bridge9_multiple_vlan_aware_bridge(ssh, get_json): # setup fixture would otherwise copy an unsupported config before skip. ssh.ifdown_x_eth0_x_mgmt() ssh.scp("tests/eni/bridge9_multiple_vlan_aware_bridge.eni", ENI) - ssh.run(f"rm -f {ENI_D}/*") + ssh.run_assert_success( + f"find {ENI_D} -mindepth 1 -maxdepth 1 -delete" + ) previous_ifreload_diff = ssh.ifreload_diff try: From 41c3d6a38c00c66c3889ea6fb05c90443020ca65 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 13:22:32 +0200 Subject: [PATCH 66/69] test(integration): restore reserved-port admin state Reject connected or enslaved ports before use, while allowing disconnected ports regardless of initial admin state. Restore each reserved port to that initial state after ENI reconciliation. Signed-off-by: Julien Fortin --- tests/conftest.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index ea5ddb31..b68447a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,6 +70,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.swp_translated_dict = {} self.swp_available = [] + self.swp_reserved = [] self.coverage_enabled = False self.ifreload_diff = True @@ -319,11 +320,11 @@ def load_swps(self, requested_ports): unsafe_ports = [] for port in requested: _, _, _, status = self.run( - "test \"$(cat /sys/class/net/%s/carrier)\" = 0 && " "test ! -L /sys/class/net/%s/master && " - "flags=$(cat /sys/class/net/%s/flags) && " - "test $((flags & 1)) -eq 0" - % (port, port, port) + "link_state=$(ethtool %s 2>/dev/null) && " + "! printf '%%s\\n' \"$link_state\" " + "| grep -q 'Link detected: yes'" + % (port, port) ) if status: unsafe_ports.append(port) @@ -332,7 +333,8 @@ def load_swps(self, requested_ports): "configured=%s active_or_enslaved=%s" % (configured_ports, unsafe_ports) ) - self.swp_available = requested + self.swp_available = list(requested) + self.swp_reserved = list(requested) @pytest.fixture(scope="session") @@ -375,6 +377,17 @@ def ssh(): def preserve_device_config(ssh): """Restore ENI and drop-ins exactly after the integration session.""" backup_dir = "/tmp/.ifupdown2_eni_backup_%d" % int(time.time()) + reserved_admin_up = {} + for port in ssh.swp_reserved: + flags = json.loads( + ssh.run_assert_success(f"ip -j link show dev {port}") + )[0].get("flags", []) + reserved_admin_up[port] = "UP" in flags + restore_reserved_ports = "".join( + "ip link set dev %s %s && " + % (port, "up" if admin_up else "down") + for port, admin_up in reserved_admin_up.items() + ) ssh.run_assert_success( "mkdir -p %s/interfaces.d && " "cp -a %s %s/interfaces && " @@ -390,6 +403,7 @@ def preserve_device_config(ssh): "find %s -mindepth 1 -maxdepth 1 -delete && " "cp -af %s/interfaces.d/. %s/ && " "ifreload -a && " + "%s" "cmp -s %s/interfaces %s && " "diff -qr %s/interfaces.d %s && " "rm -rf %s" @@ -399,6 +413,7 @@ def preserve_device_config(ssh): ENI_D, backup_dir, ENI_D, + restore_reserved_ports, backup_dir, ENI, backup_dir, From 4f4c41e4f7c61e778f086bbc0335f7e1c9249354 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 13:32:30 +0200 Subject: [PATCH 67/69] test(integration): remove remote coverage workspace Delete the per-session remote coverage directory during SSH fixture teardown, and fail the session if cleanup cannot be completed. Signed-off-by: Julien Fortin --- tests/conftest.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b68447a1..01ef8afd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -369,8 +369,21 @@ def ssh(): client.load_swps(requested_swp_ports) client.mkdir_coverage() # todo: install necessary packages (i.e. coverage) - yield client - client.close() + try: + yield client + finally: + coverage_root = os.path.dirname( + client.REMOTE_COVERAGE_DATA_DIR.rstrip("/") + ) + _, _, _, cleanup_status = client.run( + "rm -rf %s" % coverage_root + ) + client.close() + if cleanup_status: + pytest.fail( + "failed to remove remote coverage workspace %s" + % coverage_root + ) @pytest.fixture(scope="session") From a553a7241bc8038c13647fb5ddc09e827e3e55c6 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 13:35:09 +0200 Subject: [PATCH 68/69] test(bridge): track capability-gated fixtures Register the two-bridge fixture set before checking host capability so an intentional skip cannot make valid files appear orphaned. Signed-off-by: Julien Fortin --- tests/test_l2.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/test_l2.py b/tests/test_l2.py index 5b356447..c0303fa8 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -1,10 +1,16 @@ import logging import json import re +from pathlib import Path import pytest -from .conftest import assert_identical_json, ENI, ENI_D +from .conftest import ( + assert_identical_json, + registered_files, + ENI, + ENI_D, +) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -167,6 +173,21 @@ def test_bridge8_reserved_vlans(ssh, setup, get_file): def test_bridge9_multiple_vlan_aware_bridge( ssh, get_json, preserve_device_config): + fixture_files = [ + "tests/eni/bridge9_multiple_vlan_aware_bridge.eni", + "tests/output/bridge9_multiple_vlan_aware_bridge." + "bridge_vlan_swp_AA_1.json", + "tests/output/bridge9_multiple_vlan_aware_bridge." + "bridge_vlan_swp_AA_2.json", + "tests/output/bridge9_multiple_vlan_aware_bridge." + "bridge_vlan_swp_BB_1.json", + "tests/output/bridge9_multiple_vlan_aware_bridge." + "bridge_vlan_swp_BB_2.json", + ] + for path in fixture_files: + assert Path(path).is_file() + registered_files.add(path) + multiple_bridge_support = ssh.run_assert_success( "sed -n " "'s/^multiple_vlan_aware_bridge_support=//p' " From 1287d121fbf78dd8ae79a7cd4414bc4fcefc5021 Mon Sep 17 00:00:00 2001 From: Julien Fortin Date: Mon, 17 Aug 2026 13:52:17 +0200 Subject: [PATCH 69/69] test(integration): restore ports after ENI failures Attempt every reserved-port admin-state restoration even when ENI copy or reload fails. Retain the snapshot and return failure unless ENI, ports, and exact verification all succeed. Signed-off-by: Julien Fortin --- tests/conftest.py | 47 ++++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 01ef8afd..817ea323 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -397,7 +397,7 @@ def preserve_device_config(ssh): )[0].get("flags", []) reserved_admin_up[port] = "UP" in flags restore_reserved_ports = "".join( - "ip link set dev %s %s && " + "ip link set dev %s %s || port_rc=$?; " % (port, "up" if admin_up else "down") for port, admin_up in reserved_admin_up.items() ) @@ -410,30 +410,31 @@ def preserve_device_config(ssh): yield backup_dir - _, _, _, restore_status = ssh.run( + restore_command = ( + "restore_rc=0; " "ifdown -a -X eth0 -X mgmt || true; " - "cp -af %s/interfaces %s && " - "find %s -mindepth 1 -maxdepth 1 -delete && " - "cp -af %s/interfaces.d/. %s/ && " - "ifreload -a && " - "%s" - "cmp -s %s/interfaces %s && " - "diff -qr %s/interfaces.d %s && " - "rm -rf %s" - % ( - backup_dir, - ENI, - ENI_D, - backup_dir, - ENI_D, - restore_reserved_ports, - backup_dir, - ENI, - backup_dir, - ENI_D, - backup_dir, - ) + f"(cp -af {backup_dir}/interfaces {ENI} && " + f"find {ENI_D} -mindepth 1 -maxdepth 1 -delete && " + f"cp -af {backup_dir}/interfaces.d/. {ENI_D}/ && " + "ifreload -a) || restore_rc=$?; " + "port_rc=0; " + f"{restore_reserved_ports}" + "verify_rc=0; " + "if [ \"$restore_rc\" -eq 0 ]; then " + f"(cmp -s {backup_dir}/interfaces {ENI} && " + f"diff -qr {backup_dir}/interfaces.d {ENI_D}) " + "|| verify_rc=$?; " + "fi; " + "status=$restore_rc; " + "if [ \"$status\" -eq 0 ] && [ \"$port_rc\" -ne 0 ]; then " + "status=$port_rc; fi; " + "if [ \"$status\" -eq 0 ] && [ \"$verify_rc\" -ne 0 ]; then " + "status=$verify_rc; fi; " + f"if [ \"$status\" -eq 0 ]; then rm -rf {backup_dir} " + "|| status=$?; fi; " + "exit \"$status\"" ) + _, _, _, restore_status = ssh.run(restore_command) if restore_status: pytest.fail( "failed to restore integration ENI from %s (rc=%s)"