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/debian/ifupdown2.networking.service b/debian/ifupdown2.networking.service index cdb30657..07cf8c14 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 @@ -19,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 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) 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/ifupdown2/addons/address.py b/ifupdown2/addons/address.py index 3ae9b723..6db17154 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__, @@ -584,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: @@ -611,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) @@ -635,7 +655,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 +681,23 @@ 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, 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: # if perfmode is not set and purge addresses is set to True # lets purge addresses not in the config @@ -675,16 +712,16 @@ def process_addresses(self, ifaceobj, ifaceobj_getfunc=None, force_reapply=False 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. - 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) @@ -697,7 +734,7 @@ def process_addresses(self, ifaceobj, ifaceobj_getfunc=None, force_reapply=False 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): """ @@ -769,12 +806,20 @@ 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) 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) @@ -861,17 +906,65 @@ 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) + @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 + 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): if not self._check_mtu_config(ifaceobj, mtu_str, mtu_int, ifaceobj_getfunc): - return + 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) + 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 +977,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 +990,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 +1010,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 +1130,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 +1154,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') @@ -1102,14 +1205,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) @@ -1133,28 +1241,37 @@ 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 - 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, socket.AF_INET) - force_reapply = True - elif dhclientcmd.is_running6(ifaceobj.name): - dhclientcmd.release6(ifaceobj.name) - self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET6) - 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 - self.process_mtu(ifaceobj, ifaceobj_getfunc) + mtu = self.process_mtu(ifaceobj, ifaceobj_getfunc) self.up_ipv6_addrgen(ifaceobj) try: @@ -1163,7 +1280,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) 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/ifupdown2/addons/bond.py b/ifupdown2/addons/bond.py index dcccf5ab..c14a6709 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): @@ -432,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) @@ -446,6 +448,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 +462,30 @@ 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 + if not self.slave_has_no_subinterface(ifaceobj, slave, ifaceobj_getfunc): continue @@ -907,6 +939,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/ifupdown2/addons/bridge.py b/ifupdown2/addons/bridge.py index e70710ee..a8da6126 100644 --- a/ifupdown2/addons/bridge.py +++ b/ifupdown2/addons/bridge.py @@ -1299,6 +1299,22 @@ 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 + + # 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 + 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) @@ -1762,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)): @@ -1787,6 +1811,7 @@ 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) + try: if vids_to_del: if pvid_to_add in vids_to_del: @@ -2003,6 +2028,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 @@ -2015,6 +2046,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: @@ -2879,6 +2916,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, @@ -2888,7 +2926,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: @@ -2906,7 +2944,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) @@ -2921,6 +2959,22 @@ 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: + # 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") + 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) @@ -4080,7 +4134,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/addons/dhcp.py b/ifupdown2/addons/dhcp.py index 9b2f5f9b..a33b82b9 100644 --- a/ifupdown2/addons/dhcp.py +++ b/ifupdown2/addons/dhcp.py @@ -6,7 +6,6 @@ import re import time -import socket import logging try: @@ -140,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 @@ -258,11 +257,19 @@ 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) - self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET6) + 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) - self.cache.force_address_flush_family(ifaceobj.name, socket.AF_INET) + 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): self._dhcp_down(ifaceobj) diff --git a/ifupdown2/addons/ethtool.py b/ifupdown2/addons/ethtool.py index 3aace393..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) @@ -364,6 +889,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/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/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/ifupdown2/addons/vrf.py b/ifupdown2/addons/vrf.py index 712c5fd0..ca91491d 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) @@ -517,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): @@ -954,6 +995,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 +1026,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: diff --git a/ifupdown2/addons/vxlan.py b/ifupdown2/addons/vxlan.py index 4cab0332..bb5c8546 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", @@ -220,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) @@ -356,7 +362,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,12 +370,12 @@ 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: - return int(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 +385,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)) @@ -521,10 +527,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 +552,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 +560,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(cached_ifla_vxlan_local.version)] = None return local @@ -1065,6 +1074,41 @@ 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)) + ) + + @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) @@ -1076,6 +1120,37 @@ 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 = {} + + # 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) + 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 @@ -1089,12 +1164,56 @@ 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 = {} - - user_request_vxlan_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 = {} + + 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 @@ -1106,7 +1225,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) @@ -1162,18 +1280,43 @@ 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: - - 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 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, ifname, @@ -1181,18 +1324,22 @@ 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 + vxlan_ttl, + 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), - vxlan_ttl + user_request_vxlan_info_data.get(Link.IFLA_VXLAN_AGEING), + vxlan_ttl, + vxlan_ipversion ) else: try: @@ -1428,6 +1575,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 @@ -1476,24 +1645,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 @@ -1629,12 +1781,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 @@ -1676,6 +1824,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) @@ -1711,7 +1860,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/ifupdown/iface.py b/ifupdown2/ifupdown/iface.py index 00725159..113abfb6 100644 --- a/ifupdown2/ifupdown/iface.py +++ b/ifupdown2/ifupdown/iface.py @@ -453,14 +453,16 @@ 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 ( 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 +642,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): @@ -648,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(): @@ -707,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/ifupdownmain.py b/ifupdown2/ifupdown/ifupdownmain.py index d02f7810..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 """ @@ -117,6 +119,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" ) @@ -1567,7 +1585,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]: @@ -2430,6 +2448,39 @@ 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)) + + @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: @@ -2444,8 +2495,18 @@ 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 = [] + forced_recovery_bond_slaves = [] for ifname, ifaceobjs in ifaceobj_dict.items(): current_clagd_interface = False @@ -2468,6 +2529,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): @@ -2487,6 +2557,68 @@ 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) + + 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/ifupdown2/ifupdown/networkinterfaces.py b/ifupdown2/ifupdown/networkinterfaces.py index c0c8ad96..f61740eb 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): @@ -490,13 +491,22 @@ 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: - 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) 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/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/ifupdown2/ifupdown/utils.py b/ifupdown2/ifupdown/utils.py index 3a97b725..7d5118e8 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 } @@ -198,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): @@ -265,7 +272,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 +282,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/ifupdownaddons/dhclient.py b/ifupdown2/ifupdownaddons/dhclient.py index f967a8e6..205530f5 100644 --- a/ifupdown2/ifupdownaddons/dhclient.py +++ b/ifupdown2/ifupdownaddons/dhclient.py @@ -19,7 +19,11 @@ class dhclient(utilsBase): """ This class contains helper methods to interact with the dhclient utility """ - def _pid_exists(self, pidfilename): + 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( @@ -29,17 +33,28 @@ 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 + # Cleanup the dhclient process and return False to indicate dhclient + # is not running + if cleanup: + if is_v4: + self.release(ifacename) + else: + self.release6(ifacename) + return False except Exception: return False except Exception: return False return False - def is_running(self, ifacename): - return self._pid_exists('/run/dhclient.%s.pid' %ifacename) + def is_running(self, ifacename, cleanup=False): + return self._pid_exists(ifacename, True, cleanup) - def is_running6(self, ifacename): - return self._pid_exists('/run/dhclient6.%s.pid' %ifacename) + def is_running6(self, ifacename, cleanup=False): + return self._pid_exists(ifacename, False, cleanup) def _run_dhclient_cmd(self, cmd, cmd_prefix=None): if not cmd_prefix: 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/ifupdown2/lib/iproute2.py b/ifupdown2/lib/iproute2.py index 6c9b17cc..2d08a524 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) @@ -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, 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, v # 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. @@ -318,10 +323,19 @@ def link_add_single_vxlan(self, link_exists, ifname, ip, group, physdev, port, v if ttl: cmd.append("ttl %s" % ttl) + if ageing is not None: + 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, ipversion=4): + + cmd = [] + + if ipversion == 6: + cmd.append("-6") + self.logger.info("creating l3vxi device: %s" % ifname) if link_exists: @@ -329,9 +343,9 @@ def link_add_l3vxi(self, link_exists, ifname, ip, group, physdev, port, ttl=None # 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 @@ -351,6 +365,9 @@ def link_add_l3vxi(self, link_exists, ifname, ip, group, physdev, port, ttl=None if ttl: cmd.append("ttl %s" % ttl) + if ageing is not None: + cmd.append("ageing %s" % ageing) + self.__execute_or_batch(utils.ip_cmd, " ".join(cmd)) self.__update_cache_after_link_creation(ifname, "vxlan") @@ -376,7 +393,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/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: 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 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: diff --git a/ifupdown2/nlmanager/nlpacket.py b/ifupdown2/nlmanager/nlpacket.py index bf6c84fa..5841d460 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): @@ -2106,27 +2108,79 @@ def decode(self, parent_msg, data): data = self.data[4:] - while data: - (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:] + 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 + + aligned_rtnh_len = padded_length(rtnh_len) + if aligned_rtnh_len > len(data) and rtnh_len != len(data): + break + + 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 - if self.family == AF_INET: - if len(data) < self.IPV4_LEN: + nexthop = None + valid = True + while nested: + if len(nested) < self.HEADER_LEN: + valid = False break - nexthop = ipnetwork.IPv4Address(unpack('>L', data[:self.IPV4_LEN])[0]) - self.value.append((nexthop, rtnh_ifindex, rtnh_flags, rtnh_hops)) - - elif self.family == AF_INET6: - if len(data) < self.IPV6_LEN: + 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 - (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_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 + + 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 valid and nexthop is not None: + self.value.append( + (nexthop, rtnh_ifindex, rtnh_flags, rtnh_hops) + ) self.value = tuple(self.value) @@ -3735,6 +3789,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 +5578,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 +5624,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/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, diff --git a/tests/conftest.py b/tests/conftest.py index db6d873c..817ea323 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: @@ -69,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 @@ -76,7 +78,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}" @@ -141,6 +144,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, @@ -251,24 +266,83 @@ 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() + ] + 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) + ] + missing = [ + port for port in requested + if port not in self.swp_available + ] + 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 ! -L /sys/class/net/%s/master && " + "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) + if configured_ports or unsafe_ports: + raise NotEnoughPhysDevException( + "configured=%s active_or_enslaved=%s" + % (configured_ports, unsafe_ports) + ) + self.swp_available = list(requested) + self.swp_reserved = list(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") @@ -276,26 +350,118 @@ 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 " + "(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() + 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) - 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") +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_rc=$?; " + % (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 && " + "cp -a %s/. %s/interfaces.d/" + % (backup_dir, ENI, backup_dir, ENI_D, backup_dir) + ) + + yield backup_dir + + restore_command = ( + "restore_rc=0; " + "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) || 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)" + % (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/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/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/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/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/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/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/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 new file mode 100644 index 00000000..e2cebd4b --- /dev/null +++ b/tests/eni/bridge_new_port_admin_l2.eni @@ -0,0 +1,10 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + vrf mgmt + +auto mgmt +iface mgmt + vrf-table auto 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/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/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/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/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/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/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/eni/vlan_bridge_binding_scope_l2.eni b/tests/eni/vlan_bridge_binding_scope_l2.eni new file mode 100644 index 00000000..ba862439 --- /dev/null +++ b/tests/eni/vlan_bridge_binding_scope_l2.eni @@ -0,0 +1,32 @@ +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 vlan100 +iface vlan100 + vlan-raw-device swp_AA_ + vlan-id 100 + vlan-bridge-binding on + +auto swp_BB_ +iface swp_BB_ + +auto br_bind +iface br_bind + bridge-vlan-aware yes + bridge-ports swp_BB_ + bridge-vids 200 + +auto br_bind.200 +iface br_bind.200 + vlan-bridge-binding on 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/eni/vxlan_ipv6_local_tunnelip_l3.eni b/tests/eni/vxlan_ipv6_local_tunnelip_l3.eni new file mode 100644 index 00000000..1f15e9f5 --- /dev/null +++ b/tests/eni/vxlan_ipv6_local_tunnelip_l3.eni @@ -0,0 +1,24 @@ +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 192.0.2.60/24 + 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/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/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/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/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_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}") diff --git a/tests/test_l2.py b/tests/test_l2.py index 95f501fe..c0303fa8 100644 --- a/tests/test_l2.py +++ b/tests/test_l2.py @@ -1,6 +1,16 @@ import logging +import json +import re +from pathlib import Path -from .conftest import assert_identical_json, ENI, ENI_D +import pytest + +from .conftest import ( + assert_identical_json, + registered_files, + ENI, + ENI_D, +) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -9,7 +19,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() @@ -155,6 +171,68 @@ 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, 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' " + "/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_assert_success( + f"find {ENI_D} -mindepth 1 -maxdepth 1 -delete" + ) + + 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): bridge_ifquery_ac_json = get_json("bridge_access.ifquery.ac.json") bridge_vlan_show_json = get_json("bridge_access.vlan.show.json") @@ -390,18 +468,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_up("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) @@ -410,14 +492,489 @@ 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("swp_AA_") + 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") 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_bridge_new_port_admin_l2(ssh, setup): + """A new bridge port is restored up after protected enslavement.""" + 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.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'" + ) + ssh.run_assert_success( + "ip -o link show dummy_new | grep -w UP" + ) + + 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_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_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 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): + """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 + + +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 + + +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 + + +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}") + + +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" + ) + _, _, _, mode_restore_status = ssh.run( + f"ethtool -s {port} autoneg on " + "advertise 0xFFFFFFFFFFFFFFFF" + ) + _, _, _, 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( + 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}") + + +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" + ) + + +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" + ) diff --git a/tests/test_l3.py b/tests/test_l3.py index 9bc242c9..36af1f47 100644 --- a/tests/test_l3.py +++ b/tests/test_l3.py @@ -7,30 +7,50 @@ 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")) -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) +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. - assert_identical_json(ssh.ifquery_ac_json(), get_json("address_gateway.ifquery.ac.json")) + The former behavior errored while adding the unreachable gateway. + """ + # ifup now succeeds; br1 emits an unrelated STP warning. + ssh.ifup_a(ignore_stderr=True) - ssh.run(f"sed -i 's/address .*//' {ENI}") + # 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'") - 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) + # The configured link-down state is actually in effect. + assert ssh.run("ip -o link show swp_AA_ | grep -w UP")[3] != 0 - assert_identical_json(ssh.ifquery_ac_json(), get_json("address_gateway.empty_addrs.ifquery.ac.json")) + # 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): - 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")) @@ -138,3 +158,470 @@ 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_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_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_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}'" + ) + + 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 + + +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_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_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_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_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) + 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_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" + 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}") + + +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 without changing IPv6 configuration. + ssh.run_assert_success( + f"sed -i '/address 192\\.0\\.2\\.1\\/24/d' {ENI}" + ) + 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 + + 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() + + # Confirm the monitor observed the IPv4 transition and neither configured + # IPv6 address flapped. + assert "192.0.2" in monitor_output + 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'" + )[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'" + ) + ssh.run_assert_success( + "ip -6 -o address show dev dum_purge | grep '2001:db8:43::2/64'" + ) + + 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" + )