diff options
32 files changed, 3200 insertions, 2520 deletions
diff --git a/.github/config/smoketest-branches.json b/.github/config/smoketest-branches.json index 1f296fb2c..9c4253b0f 100644 --- a/.github/config/smoketest-branches.json +++ b/.github/config/smoketest-branches.json @@ -12,6 +12,7 @@ "test_tpm_tests": true, "test_vpp": true, "test_vpp_runner": ["ubuntu-24.04"], + "test_ifname": true, "mirror_enabled": true }, "scutum": { @@ -42,6 +43,7 @@ "test_vpp": true, "test_vpp_runner": ["self-hosted", "vpp"], "smoketests_runner": ["self-hosted", "smoketests"], + "test_ifname": false, "mirror_enabled": false }, "sagitta": { @@ -56,6 +58,7 @@ "test_cli_command": "test-no-interfaces", "test_tpm_tests": false, "test_vpp": false, + "test_ifname": false, "mirror_enabled": false } } diff --git a/.github/workflows/package-smoketest.yml b/.github/workflows/package-smoketest.yml index 4a2d2212e..19c9bc18a 100644 --- a/.github/workflows/package-smoketest.yml +++ b/.github/workflows/package-smoketest.yml @@ -47,6 +47,7 @@ jobs: test_tpm_tests: ${{ steps.config.outputs.test_tpm_tests }} test_vpp: ${{ steps.config.outputs.test_vpp }} test_vpp_runner: ${{ steps.config.outputs.test_vpp_runner }} + test_ifname: ${{ steps.config.outputs.test_ifname }} install_build_deps: ${{ steps.config.outputs.install_build_deps }} mirror_enabled: ${{ steps.config.outputs.mirror_enabled }} vyos_build_ref: ${{ steps.determine-ref.outputs.vyos_build_ref }} @@ -81,6 +82,7 @@ jobs: echo "test_tpm_tests=$(echo $CONFIG | jq -r .test_tpm_tests)" >> $GITHUB_OUTPUT echo "test_vpp=$(echo $CONFIG | jq -r .test_vpp)" >> $GITHUB_OUTPUT echo "test_vpp_runner=$(echo $CONFIG | jq -c .test_vpp_runner)" >> $GITHUB_OUTPUT + echo "test_ifname=$(echo $CONFIG | jq -r .test_ifname)" >> $GITHUB_OUTPUT echo "install_build_deps=$(echo $CONFIG | jq -r .install_build_deps)" >> $GITHUB_OUTPUT echo "mirror_enabled=$(echo $CONFIG | jq -r .mirror_enabled)" >> $GITHUB_OUTPUT @@ -318,6 +320,74 @@ jobs: exit 1 fi + test_interface_naming: + needs: [build_iso, set_config] + if: needs.set_config.outputs.test_ifname == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + container: + image: ${{ needs.set_config.outputs.container_image }} + options: --sysctl net.ipv6.conf.lo.disable_ipv6=0 --privileged + outputs: + exit_code: ${{ steps.test.outputs.exit_code }} + steps: + - name: Clone vyos-build source code + uses: actions/checkout@v6 + with: + repository: ${{ needs.set_config.outputs.vyos_build_repo }} + token: ${{ needs.set_config.outputs.use_pat == 'true' && secrets.PAT || secrets.GITHUB_TOKEN }} + ref: ${{ needs.set_config.outputs.vyos_build_ref }} + + - uses: actions/download-artifact@v8 + with: + name: vyos-${{ needs.build_iso.outputs.build_version }} + path: build + + - name: VyOS interface naming tests + id: test + shell: bash + run: | + if sudo make test-interface-naming; then + echo "exit_code=success" >> $GITHUB_OUTPUT + else + echo "exit_code=fail" >> $GITHUB_OUTPUT + exit 1 + fi + + test_interface_naming_vmware: + needs: [build_iso, set_config] + if: needs.set_config.outputs.test_ifname == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + container: + image: ${{ needs.set_config.outputs.container_image }} + options: --sysctl net.ipv6.conf.lo.disable_ipv6=0 --privileged + outputs: + exit_code: ${{ steps.test.outputs.exit_code }} + steps: + - name: Clone vyos-build source code + uses: actions/checkout@v6 + with: + repository: ${{ needs.set_config.outputs.vyos_build_repo }} + token: ${{ needs.set_config.outputs.use_pat == 'true' && secrets.PAT || secrets.GITHUB_TOKEN }} + ref: ${{ needs.set_config.outputs.vyos_build_ref }} + + - uses: actions/download-artifact@v8 + with: + name: vyos-${{ needs.build_iso.outputs.build_version }} + path: build + + - name: VyOS interface naming tests (VMware firmware) + id: test + shell: bash + run: | + if sudo make test-interface-naming-vmware; then + echo "exit_code=success" >> $GITHUB_OUTPUT + else + echo "exit_code=fail" >> $GITHUB_OUTPUT + exit 1 + fi + test_config_load: needs: [build_iso, set_config] runs-on: ubuntu-24.04 @@ -474,6 +544,8 @@ jobs: - test_smoketest_cli - test_smoketest_cli_vpp - test_interfaces_cli + - test_interface_naming + - test_interface_naming_vmware - test_config_load - test_config_load_vpp - test_raid1_install @@ -489,7 +561,7 @@ jobs: echo "${{ github.event.pull_request.number }}" > pr-report/pr_number.txt cat > pr-report/message.md << 'EOF' - CI integration ${{ (needs.test_smoketest_cli.outputs.exit_code == 'success' && needs.test_interfaces_cli.outputs.exit_code == 'success' && needs.test_config_load.outputs.exit_code == 'success' && needs.test_raid1_install.outputs.exit_code == 'success' && (needs.test_smoketest_cli_vpp.outputs.exit_code != 'fail') && (needs.test_config_load_vpp.outputs.exit_code != 'fail') && (needs.test_encrypted_config_tpm.outputs.exit_code != 'fail')) && '👍 passed!' || '❌ failed!' }} + CI integration ${{ (needs.test_smoketest_cli.outputs.exit_code == 'success' && needs.test_interfaces_cli.outputs.exit_code == 'success' && needs.test_config_load.outputs.exit_code == 'success' && needs.test_raid1_install.outputs.exit_code == 'success' && (needs.test_smoketest_cli_vpp.outputs.exit_code != 'fail') && (needs.test_config_load_vpp.outputs.exit_code != 'fail') && (needs.test_encrypted_config_tpm.outputs.exit_code != 'fail') && (needs.test_interface_naming.outputs.exit_code != 'fail') && (needs.test_interface_naming_vmware.outputs.exit_code != 'fail')) && '👍 passed!' || '❌ failed!' }} ### Details @@ -502,6 +574,8 @@ jobs: * CLI Smoketests VPP ${{ needs.test_smoketest_cli_vpp.outputs.exit_code == 'success' && '👍 passed' || needs.test_smoketest_cli_vpp.outputs.exit_code == 'fail' && '❌ failed' || '⏭️ skipped' }} * Config tests VPP ${{ needs.test_config_load_vpp.outputs.exit_code == 'success' && '👍 passed' || needs.test_config_load_vpp.outputs.exit_code == 'fail' && '❌ failed' || '⏭️ skipped' }} * TPM tests ${{ needs.test_encrypted_config_tpm.outputs.exit_code == 'success' && '👍 passed' || needs.test_encrypted_config_tpm.outputs.exit_code == 'fail' && '❌ failed' || '⏭️ skipped' }} + * Interface naming tests ${{ needs.test_interface_naming.outputs.exit_code == 'success' && '👍 passed' || needs.test_interface_naming.outputs.exit_code == 'fail' && '❌ failed' || '⏭️ skipped' }} + * Interface naming tests (VMware) ${{ needs.test_interface_naming_vmware.outputs.exit_code == 'success' && '👍 passed' || needs.test_interface_naming_vmware.outputs.exit_code == 'fail' && '❌ failed' || '⏭️ skipped' }} EOF - uses: actions/upload-artifact@v7 diff --git a/data/config-sync-exclude.json b/data/config-sync-exclude.json index dc4cb88e8..19364be93 100644 --- a/data/config-sync-exclude.json +++ b/data/config-sync-exclude.json @@ -2,11 +2,6 @@ [ "interfaces", "ethernet", - "hw-id" - ], - [ - "interfaces", - "ethernet", "address" ], [ diff --git a/debian/control b/debian/control index 0c7a6f505..43ebe43e8 100644 --- a/debian/control +++ b/debian/control @@ -68,7 +68,6 @@ Depends: at, rsync, vyatta-bash, - vyatta-biosdevname, vyatta-cfg, vyos-http-api-tools, ## End of Fundamentals diff --git a/interface-definitions/include/interface/hw-id.xml.i b/interface-definitions/include/interface/hw-id.xml.i deleted file mode 100644 index a3a1eec7c..000000000 --- a/interface-definitions/include/interface/hw-id.xml.i +++ /dev/null @@ -1,14 +0,0 @@ -<!-- include start from interface/hw-id.xml.i --> -<leafNode name="hw-id"> - <properties> - <help>Associate Ethernet Interface with given Media Access Control (MAC) address</help> - <valueHelp> - <format>macaddr</format> - <description>Hardware (MAC) address</description> - </valueHelp> - <constraint> - <validator name="mac-address"/> - </constraint> - </properties> -</leafNode> -<!-- include end --> diff --git a/interface-definitions/include/version/interfaces-version.xml.i b/interface-definitions/include/version/interfaces-version.xml.i index 28564b3d7..4ec3a6aff 100644 --- a/interface-definitions/include/version/interfaces-version.xml.i +++ b/interface-definitions/include/version/interfaces-version.xml.i @@ -1,3 +1,3 @@ <!-- include start from include/version/interfaces-version.xml.i --> -<syntaxVersion component='interfaces' version='34'></syntaxVersion> +<syntaxVersion component='interfaces' version='35'></syntaxVersion> <!-- include end --> diff --git a/interface-definitions/interfaces_ethernet.xml.in b/interface-definitions/interfaces_ethernet.xml.in index 466c0c8e4..507c3997b 100644 --- a/interface-definitions/interfaces_ethernet.xml.in +++ b/interface-definitions/interfaces_ethernet.xml.in @@ -72,7 +72,6 @@ #include <include/interface/evpn-mh-uplink.xml.i> </children> </node> - #include <include/interface/hw-id.xml.i> #include <include/interface/ipv4-options.xml.i> #include <include/interface/ipv6-options.xml.i> #include <include/interface/ipv6-options-with-nd.xml.i> diff --git a/interface-definitions/interfaces_wireless.xml.in b/interface-definitions/interfaces_wireless.xml.in index ec1817585..fa51709e9 100644 --- a/interface-definitions/interfaces_wireless.xml.in +++ b/interface-definitions/interfaces_wireless.xml.in @@ -628,7 +628,6 @@ #include <include/interface/ipv4-options.xml.i> #include <include/interface/ipv6-options.xml.i> #include <include/interface/ipv6-options-with-nd.xml.i> - #include <include/interface/hw-id.xml.i> <leafNode name="isolate-stations"> <properties> <help>Isolate stations on the AP so they cannot see each other</help> diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 84244d5c1..20d13df7b 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -32,7 +32,6 @@ directories = { 'api_schema': f'{base_dir}/services/api/graphql/graphql/schema/', 'api_client_op': f'{base_dir}/services/api/graphql/graphql/client_op/', 'api_templates': f'{base_dir}/services/api/graphql/session/templates/', - 'vyos_udev_dir' : '/run/udev/vyos', 'isc_dhclient_dir' : '/run/dhclient', 'dhcp6_client_dir' : '/run/dhcp6c', 'vyos_configdir' : '/opt/vyatta/config', diff --git a/python/vyos/ifconfig/ethernet.py b/python/vyos/ifconfig/ethernet.py index ab5c8a0b8..60a3ce7a3 100644 --- a/python/vyos/ifconfig/ethernet.py +++ b/python/vyos/ifconfig/ethernet.py @@ -19,9 +19,12 @@ from glob import glob from vyos.base import Warning from vyos.ethtool import Ethtool +from vyos.ifconfig.ifname_store import permanent_mac +from vyos.ifconfig.ifname_store import recorded_mac from vyos.ifconfig.interface import Interface from vyos.utils.dict import dict_search from vyos.utils.file import read_file +from vyos.utils.network import get_interface_config from vyos.utils.process import run from vyos.utils.assertion import assert_list @@ -83,6 +86,9 @@ class EthernetIf(Interface): }, } + # T5862: clamped against the adapter maximum wherever it is used + DEFAULT_MTU = '1500' + @staticmethod def get_bond_member_allowed_options() -> list: """ @@ -114,7 +120,6 @@ class EthernetIf(Interface): 'ring_buffer.rx', 'ring_buffer.tx', 'speed', - 'hw_id', ] return bond_allowed_sections @@ -123,14 +128,22 @@ class EthernetIf(Interface): self.ethtool = Ethtool(ifname) def _create(self): + # we cannot create this interface as it is managed by the Kernel pass def remove(self): """ - Remove interface from config. Removing the interface deconfigures all - assigned IP addresses. + Return the interface to the state the Kernel gave it. + + An ethernet interface is owned by the Kernel and - like 'lo' - can not + be removed, so deleting its configuration means resetting the port + rather than tearing it down: addresses flushed, MTU and hardware + address back to the Kernel default and the link left up. Leaving it + admin-down stranded a port with nothing left to bring it back, a + released bond member included. + Example: - >>> from vyos.ifconfig import WWANIf + >>> from vyos.ifconfig import EthernetIf >>> i = EthernetIf('eth0') >>> i.remove() """ @@ -141,10 +154,26 @@ class EthernetIf(Interface): # and thus survives this call. super().remove() - if self.exists(self.ifname): - # interface is placed in A/D state when removed from config! It - # will remain visible for the operating system. - self.set_admin_state('down') + if not self.exists(self.ifname): + return + + # undo what the configuration changed about the hardware itself, but + # not while the port is still enslaved: a bond owns its members' + # address and MTU, so resetting them here would leave the member out + # of step with the bond it is still in. Releasing it runs this again. + tmp = get_interface_config(self.ifname) + if dict_search('linkinfo.info_slave_kind', tmp) != 'bond': + # what naming recorded first: a driver which cannot report a + # permanent address reports the current one instead, which is the + # custom address still on the port at this point + mac = recorded_mac(self.ifname) or permanent_mac(self.ifname) + if mac: + self.set_mac(mac) + + mtu = min(int(EthernetIf.DEFAULT_MTU), int(self.get_max_mtu())) + self.set_mtu(str(mtu)) + + self.set_admin_state('up') def set_flow_control(self, enable, warn=True): """ diff --git a/python/vyos/ifconfig/ifname_store.py b/python/vyos/ifconfig/ifname_store.py new file mode 100644 index 000000000..b527c7348 --- /dev/null +++ b/python/vyos/ifconfig/ifname_store.py @@ -0,0 +1,701 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. +# +# Persistent interface name store. +# +# The kernel command line carries net.ifnames=0, so VyOS owns the +# 'ethN'/'wlanN' namespace and has to make its choice survive a reboot. A name +# is mapped to the slot the hardware sits in rather than to a MAC in +# config.boot: the slot is the identity, so a replacement card in that slot is +# simply that interface again. +# +# The key is a single udev property picked from a fallback chain, most stable +# first. udev still computes these under net.ifnames=0 - only the renaming +# policy is disabled. + +import json +import os +import re +import tempfile +from pathlib import Path + +from vyos.defaults import directories +from vyos.utils.process import rc_cmd +from vyos.utils.permission import get_cfg_group_id + +STORE_VERSION = 1 + +# The real path, not '/config': naming runs before the bind mount is up. +store_path = Path(directories['config']) / 'interface-mapping.json' + +# Most stable first: a firmware slot index survives PCI renumbering, a path +# does not. The MAC-derived entry is a last resort for paravirtual buses. +KEY_PROPERTIES = ( + 'ID_NET_NAME_ONBOARD', + 'ID_NET_NAME_SLOT', + 'ID_NET_NAME_PATH', + 'ID_PATH', + 'ID_NET_NAME_MAC', +) + +_BUS_RANK = {'pci': 0, 'vmbus': 1, 'xen': 2, 'usb': 3} +_BUS_RANK_UNKNOWN = 90 + +_PCI_BDF = re.compile(r'([0-9a-f]{4}):([0-9a-f]{2}):([0-9a-f]{2})\.([0-9a-f]+)', re.I) +# ESXi labels its adapters Ethernet0..EthernetN, in "Network adapter N" order +_PORT_LABEL = re.compile(r'^Ethernet(\d+)$') +# sorts after every real label, and must stay an int - canonical_sort_key() +# compares tuples element-wise, so a None here would raise against an int +_LABEL_UNKNOWN = 1 << 30 +_VMWARE_PCI_VENDOR = '0x15ad' +# a Hyper-V NIC carries no slot, path or label of its own - only its MAC +_HYPERV_DRIVER = 'hv_netvsc' +_DMI_SYS_VENDOR = Path('/sys/class/dmi/id/sys_vendor') +_MAC_RE = re.compile(r'([0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5})') + + +def ambiguous_keys(devices: list) -> set: + """Property values carried by more than one device.""" + seen = {} + for properties in devices: + for prop in KEY_PROPERTIES: + value = properties.get(prop, '') + if value: + tmp = f'{prop}={value}' + seen[tmp] = seen.get(tmp, 0) + 1 + return {key for key, count in seen.items() if count > 1} + + +def hardware_key(properties: dict, ambiguous: set = frozenset()) -> str: + """The most stable identifier this device offers, as 'PROPERTY=value'. + + A value more than one device carries identifies neither of them, so it is + passed over for the next one. + """ + for prop in KEY_PROPERTIES: + value = properties.get(prop, '') + if value and f'{prop}={value}' not in ambiguous: + return f'{prop}={value}' + return '' + + +def device_matches(properties: dict, key: str) -> bool: + """Does this device carry the property a stored key names?""" + if not key or '=' not in key: + return False + prop, _, value = key.partition('=') + return properties.get(prop, '') == value + + +def canonical_sort_key(device: dict) -> tuple: + """Total order by bus class, the hypervisor's adapter order where it says + so, then physical position and MAC, so it never depends on probe order. + Only used for hardware not yet in the store. + """ + properties = device.get('properties') or {} + path = properties.get('ID_PATH', '') + rank = _BUS_RANK.get(properties.get('ID_BUS', ''), _BUS_RANK_UNKNOWN) + + # a labelled adapter outranks the PCI address, which on ESXi does not + # follow the order the adapters were added in. Unlabelled hardware sorts + # after it: a passthrough NIC is not part of the hypervisor's own set and + # must not take the first name from it. + label = device.get('label_index', _LABEL_UNKNOWN) + + # numeric, so 0000:09:00.0 sorts before 0000:10:00.0 + segments = [tuple(int(part, 16) for part in m.groups()) + for m in _PCI_BDF.finditer(path)] + + return (rank, label, segments, path, device.get('mac') or '') + + +def is_hyperv_nic(device: dict) -> bool: + properties = device.get('properties') or {} + return properties.get('ID_NET_DRIVER') == _HYPERV_DRIVER + + +def _sysfs(path: Path) -> str: + try: + return path.read_text().strip() + except OSError: + return '' + + +def is_vmware_nic(ifname: str, sys_class_net: str = '/sys/class/net') -> bool: + """Whether a NIC is one ESXi presents - vmxnet3 carries VMware's PCI + vendor, an emulated e1000 carries it as the subsystem vendor. + + The DMI fallback is not redundant: smbios.reflectHost makes a guest report + the physical host's vendor, and a NIC model whose IDs are not listed here + would otherwise go unrecognised. + """ + device = Path(sys_class_net) / ifname / 'device' + if _VMWARE_PCI_VENDOR in (_sysfs(device / 'vendor'), + _sysfs(device / 'subsystem_vendor')): + return True + return _sysfs(_DMI_SYS_VENDOR) == 'VMware, Inc.' + + +def port_label_index(ifname: str, sys_class_net: str = '/sys/class/net') -> int: + """The hypervisor's adapter number, from the firmware port label. + + ESXi does not lay its adapters out in PCI address order - with four or more + it places the fourth on a root port which sorts ahead of the first three - + so the label is the only thing that gives their intended order. + + Returns _LABEL_UNKNOWN when there is no such label. Physical servers label + ports too, e.g. "NIC1" or "Embedded LOM 1 Port 1", and must not be reordered + by this. + """ + m = _PORT_LABEL.match(_sysfs(Path(sys_class_net) / ifname / 'device' / 'label')) + return int(m.group(1)) if m else _LABEL_UNKNOWN + + +def permanent_mac(ifname: str, sys_class_net: str = '/sys/class/net') -> str: + """Permanent hardware address, falling back to the current one when the + driver cannot report it separately. + """ + code, out = rc_cmd(f'ethtool -P {ifname}') + if code == 0: + m = _MAC_RE.search(out) + if m and m.group(1) != '00:00:00:00:00:00': + return m.group(1).lower() + + try: + return Path(f'{sys_class_net}/{ifname}/address').read_text().strip().lower() + except OSError: + return '' + + +def recorded_mac(ifname: str) -> str: + """The address naming recorded for this name. It outlives a dataplane + taking the port over, which leaves nothing in sysfs to read it from. + """ + return load_store().get('hardware', {}).get(ifname, '') + + +def is_wireless(ifname: str, sys_class_net: str = '/sys/class/net') -> bool: + """Checked via sysfs, not via the - possibly still probe-order - name.""" + return (Path(sys_class_net) / ifname / 'phy80211').exists() + + +def is_wwan(ifname: str, sys_class_net: str = '/sys/class/net') -> bool: + """A modem, which the CLI addresses as wwanN. An ethernet port carries no + DEVTYPE at all, so this is exact - and it is the same property systemd + matches on in a .link file. + """ + try: + uevent = (Path(sys_class_net) / ifname / 'uevent').read_text() + except OSError: + return False + return 'DEVTYPE=wwan' in uevent.split() + + +def read_properties(ifname: str) -> dict: + """udev properties for an interface.""" + try: + import pyudev + + context = pyudev.Context() + device = pyudev.Devices.from_path(context, f'/sys/class/net/{ifname}') + return dict(device.properties) + except Exception: + return {} + + +def discover_devices(sys_class_net: str = '/sys/class/net') -> list: + """Every interface backed by independently-addressable hardware. + + Skips virtual interfaces, SR-IOV virtual functions and hypervisor VF + datapaths - the latter share another interface's MAC. + + Wireless is skipped too, and not because it is uninteresting: a radio's + interfaces are created from the configuration on a given phy, and every + one of them carries that phy's address. They are indistinguishable here, + so a name recorded against one would be applied to whichever appeared + next - renaming an interface the operator had just asked for by name. + Wireless names come from the configuration; nothing to anchor. + """ + devices = [] + net_dir = Path(sys_class_net) + if not net_dir.is_dir(): + return devices + + for entry in sorted(net_dir.iterdir()): + if not (entry / 'device').exists(): + continue + # a VF datapath is enslaved to the interface it accelerates, which is + # a device in its own right. A bridge, bond or VRF is not, and its + # members stay real hardware while they are in it. + master = entry / 'master' + if master.exists() and (master / 'device').exists(): + continue + if (entry / 'device' / 'physfn').exists(): + continue + if entry.name.startswith('vf_'): + continue + if is_wireless(entry.name, sys_class_net): + continue + + # a modem is given a random address at each probe, so it says nothing + # about which modem this is - there is nothing here worth recording + wwan = is_wwan(entry.name, sys_class_net) + mac = '' if wwan else permanent_mac(entry.name, sys_class_net) + if not mac and not wwan: + continue + + devices.append({ + 'name': entry.name, + 'mac': mac, + 'wireless': is_wireless(entry.name, sys_class_net), + 'wwan': wwan, + 'label_index': (port_label_index(entry.name, sys_class_net) + if is_vmware_nic(entry.name, sys_class_net) + else _LABEL_UNKNOWN), + 'properties': read_properties(entry.name), + }) + + ambiguous = ambiguous_keys([device['properties'] for device in devices]) + for device in devices: + device['key'] = hardware_key(device['properties'], ambiguous) + + return devices + + +def empty_store() -> dict: + return {'version': STORE_VERSION, 'interfaces': {}, 'hardware': {}} + + +def load_store(path: Path = None) -> dict: + """A missing, unreadable or outdated store yields an empty one, so + everything is named from scratch. + """ + path = path or store_path + try: + data = json.loads(path.read_text()) + except (FileNotFoundError, OSError, ValueError): + return empty_store() + + if not isinstance(data, dict) or data.get('version') != STORE_VERSION: + return empty_store() + if not isinstance(data.get('interfaces'), dict): + return empty_store() + + interfaces = {name: key for name, key in data['interfaces'].items() + if isinstance(key, str) and key + and not name.startswith('wlan')} + + # the card each name last sat on. Absent from a store written before this + # was recorded, which just means the first pass has nothing to compare + # against and reports no replacement - not that nothing changed. + seen = data.get('hardware') + hardware = {name: mac for name, mac in seen.items() + if isinstance(mac, str) and mac and name in interfaces + } if isinstance(seen, dict) else {} + + store = {'version': STORE_VERSION, 'interfaces': interfaces, 'hardware': hardware} + + # names whose hardware was missing at the last pass - kept only while + # there are any, so a store without them reads as it always did + absent = data.get('absent') + if isinstance(absent, list): + absent = sorted({n for n in absent if n in interfaces}) + if absent: + store['absent'] = absent + + # names whose NIC was a Hyper-V one when last seen - the only names a new + # Hyper-V NIC may take over. A store without them (written before they + # were recorded) has none. + hyperv = data.get('hyperv') + if isinstance(hyperv, list): + hyperv = sorted({n for n in hyperv if n in interfaces}) + if hyperv: + store['hyperv'] = hyperv + return store + + +def encrypted_config_volume() -> bool: + """Does this image keep its configuration on a LUKS volume? + + Mirrors mount_encrypted_config() in src/init/vyos-router - a container + named after the running image, below the persistence path. Anything that + cannot be determined (no persistence, no version file, not a VyOS system + at all) means there is no volume waiting to be mounted over the config + directory. + """ + try: + from vyos.system.image import get_running_image + + code, out = rc_cmd('/opt/vyatta/sbin/vyos-persistpath') + if code != 0 or not out.strip(): + return False + image = get_running_image() + return bool(image) and (Path(out.strip()) / 'luks' / image).is_file() + except Exception: + return False + + +def config_dir_is_mounted(path: Path = None) -> bool: + """Will a store written here still be there on the next boot? + + Only an encrypted configuration has a volume which may still be locked: + a failed TPM/LUKS unlock does not stop the boot, and writing to the + directory underneath would be masked once the real volume mounts, + leaving two stores which silently diverge. Everywhere else the directory + is already the persistent one. + + Deliberately not keyed on '.vyatta_config'. That marker is an + install-time hint, written by an activation script which runs *after* + interface naming - so on the first boot of a pre-built image it does not + exist yet, no store is ever written, and the next boot names the + hardware from scratch. Removing a card then shifts every name below it + onto the wrong port (T3871). + """ + path = path or store_path + if not encrypted_config_volume(): + return True + return os.path.ismount(path.parent) + + +def save_store(store: dict, path: Path = None) -> bool: + """Write atomically and durably, so a boot cut short leaves either the + previous names or the new ones, never a half-written file and never a + rename the disk has not seen. + + Returns False without writing on a degraded boot: names are still + resolved for this boot, they just must not be persisted. + """ + path = path or store_path + if not config_dir_is_mounted(path): + return False + + path.parent.mkdir(parents=True, exist_ok=True) + + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f'.{path.name}.') + try: + with os.fdopen(fd, 'w') as f: + json.dump(store, f, indent=2, sort_keys=True) + f.write('\n') + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp, 0o664) + try: + os.chown(tmp, -1, get_cfg_group_id()) + except (OSError, KeyError): + # not fatal, the store is read by root at boot + pass + os.replace(tmp, path) + # the rename itself has to reach the disk, or a boot cut short here + # comes back to the name it had before + dir_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + except Exception: + Path(tmp).unlink(missing_ok=True) + raise + + return True + + +def _prefix_for(device: dict) -> str: + if device.get('wwan'): + return 'wwan' + return 'wlan' if device.get('wireless') else 'eth' + + +def _name_fits(name: str, device: dict) -> bool: + """A name belongs to the namespace the CLI reads it under, so a modem + can not take an ethernet name nor an ethernet card a modem's. + """ + return name.startswith(_prefix_for(device)) + + +def _next_free(taken: set, prefix: str) -> str: + index = 0 + while f'{prefix}{index}' in taken: + index += 1 + return f'{prefix}{index}' + + +def resolve(devices: list, store: dict, adopt: bool = True) -> tuple: + """Name every physical interface present this boot. + + Returns (plan, new_store, report); plan is {current: new} for the + interfaces which have to be renamed. The slot decides the name - nothing + here looks at the configuration, so there is nothing to guess. + + adopt=False keeps a vacant Hyper-V name from going to a new NIC, for a + pass on a running system (see the adoption block below). + """ + entries = store.get('interfaces', {}) + seen = store.get('hardware', {}) + + # a modem an earlier version filed under an ethernet name. The entry still + # matches it by path, so it would be captured again and stay invisible to + #'interfaces wwan' - drop it and let it be named as what it is. The name + # cannot be recognised on its own, and neither can the key: this one reads + # ID_NET_NAME_PATH=wwp..., but a device falling back to ID_PATH reads + # usb-... like anything else on that bus. + misfiled = {name for name, key in entries.items() + if not name.startswith('wwan') + and any(d.get('wwan') and device_matches(d['properties'], key) + for d in devices)} + if misfiled: + entries = {n: k for n, k in entries.items() if n not in misfiled} + seen = {n: m for n, m in seen.items() if n not in misfiled} + + assigned = {} # final name -> device + report = { + 'matched': {}, + 'reassigned': {}, + 'moved': {}, + 'replaced': {}, + 'adopted': {}, + 'bootstrapped': {}, + } + + # A recorded name stays off-limits even while its slot is empty, so new + # hardware cannot inherit an absent interface's name and configuration. + reserved = set(entries) + remaining = list(devices) + # what the last pass recorded: names already missing then, and names + # whose NIC was a Hyper-V one + missing_before = set(store.get('absent', [])) + hyperv_before = set(store.get('hyperv', [])) + + def claim(device, name, bucket): + assigned[name] = device + report[bucket][name] = device['mac'] + remaining.remove(device) + + # the recorded card in its recorded slot. A name with no card on record + # counts too - a store written before addresses were kept has nothing to + # disagree with, and this is as certain as it gets. + for name, key in entries.items(): + for device in remaining: + if (device_matches(device['properties'], key) + and (not device['mac'] + or seen.get(name, device['mac']) == device['mac']) + and _name_fits(name, device)): + claim(device, name, 'matched') + break + + # a card the store already knows about, sitting in this name's slot. The + # cards were rearranged among themselves, or the map was edited by hand - + # either way the map is what decides names, so the slot wins and the + # address on record simply follows. The name changes hands, so it is + # reported apart from a card which simply stayed where it was. + known = {mac for mac in seen.values() if mac} + for name, key in entries.items(): + if name in assigned: + continue + for device in remaining: + if (device_matches(device['properties'], key) + and device['mac'] in known + and _name_fits(name, device)): + claim(device, name, 'reassigned') + break + + # the card moved, and nothing the store knows took its place. Its address + # identifies it, so the name follows the card rather than being handed to + # a stranger that happens to have landed in the slot it left. + for name in entries: + if name in assigned or not seen.get(name): + continue + for device in remaining: + if device['mac'] == seen[name] and _name_fits(name, device): + claim(device, name, 'moved') + break + + # a different card in the recorded slot: an in-place replacement, or an + # unrelated card that happened to land there once the original was pulled. + # Nothing on the device tells those apart, so the slot decides and it is + # reported and warned about rather than taken silently. + for name, key in entries.items(): + if name in assigned: + continue + for device in remaining: + if (device_matches(device['properties'], key) + and _name_fits(name, device)): + claim(device, name, 'replaced') + break + + # a Hyper-V NIC is known by its MAC alone, and one deleted and created + # again comes back with a new MAC, so nothing on it could match. When one + # recorded NIC is gone and one new NIC took its place the pairing is not in + # doubt, and the name goes to it. With more of either it is: the order the + # NICs come up in here does not say which replaced which, so nothing is + # handed over and the names stay reserved. + # Only on the pass the NIC first went missing: a replacement is one change + # to the VM. A name already missing at the last pass belongs to a NIC that + # was removed, and a NIC added later is not its replacement. + # Only at boot: on a running system the configuration under the name is + # not applied to the NIC that takes it, so the name would point at a port + # that is down and unaddressed until the next boot. The new NIC gets a new + # name there, and the vacant one is recorded as missing, so the next boot + # does not hand it over either. + # Only a name whose own NIC was a Hyper-V one: a MAC-keyed name can + # belong to other hardware too, and a store that does not say (written + # before this was recorded) hands nothing over. + vacant = [ + name + for name, key in entries.items() + if name not in assigned + and name not in missing_before + and name in hyperv_before + and key.startswith('ID_NET_NAME_MAC=') + ] + adoptable = [device for device in remaining if is_hyperv_nic(device)] + if (adopt and len(vacant) == 1 and len(adoptable) == 1 + and _name_fits(vacant[0], adoptable[0])): + claim(adoptable[0], vacant[0], 'adopted') + + # unknown hardware: lowest free name of its type, in hardware order + for device in sorted(remaining, key=canonical_sort_key): + name = _next_free(reserved | set(assigned), _prefix_for(device)) + claim(device, name, 'bootstrapped') + + # keep entries for empty slots - an unplugged card keeps its name + new_interfaces = dict(entries) + for name, device in assigned.items(): + if device['key']: + new_interfaces[name] = device['key'] + + # remember which card each name sat on, keeping the last record for an + # absent one so its eventual replacement is still noticed + new_hardware = dict(seen) + for name, device in assigned.items(): + if device['mac']: + new_hardware[name] = device['mac'] + else: + new_hardware.pop(name, None) + + plan = {device['name']: name for name, device in assigned.items() + if device['name'] != name} + + new_store = { + 'version': STORE_VERSION, + 'interfaces': new_interfaces, + 'hardware': new_hardware, + } + absent = sorted(name for name in new_interfaces if name not in assigned) + if absent: + new_store['absent'] = absent + # what each name's NIC was, kept for an absent one like its address + hyperv = sorted( + {name for name, device in assigned.items() if is_hyperv_nic(device)} + | (hyperv_before & set(absent)) + ) + if hyperv: + new_store['hyperv'] = hyperv + + return plan, new_store, report + + +LINK_DIR = Path('/etc/systemd/network') +LINK_PREFIX = '10-vyos-' + + +def _link_type_for(name: str) -> str: + for prefix in ('wlan', 'wwan'): + if name.startswith(prefix): + return prefix + return 'ether' + + +def render_link(name: str, key: str, driver: str = '') -> str: + """Render a systemd .link file body, matching on the stored property. + + The device type has to match as well, and a radio is 'wlan' - a .link + naming a type the device is not simply never applies, leaving the + interface on its probe-order name. resolve() already names by type (see + _prefix_for()), so the recorded name is what says which. + + A MAC is not unique to one interface: an accelerated-networking VF + carries its NIC's MAC, and matched the NIC's file - udev then tried to + give the VF the NIC's name. The driver tells them apart. + + Only the first matching file is applied, so this one shadows the address + policy the distribution ships. Spell it out again, or a device with no + permanent address - a modem - is left on the random one its driver gave + it, which is a different address on every boot. + """ + match_driver = f'Driver={driver}\n' if driver else '' + return ( + '# Generated by VyOS - do not edit.\n' + "# Interface names are managed via 'interface-mapping.json'.\n" + '[Match]\n' + f'Type={_link_type_for(name)}\n' + f'Property={key}\n' + f'{match_driver}' + '\n' + '[Link]\n' + f'Name={name}\n' + 'MACAddressPolicy=persistent\n' + ) + + +def _pinned_driver(path: Path, key: str) -> str: + """The driver an existing file already pins for this key, if any""" + try: + body = path.read_text() + except OSError: + return '' + if f'Property={key}\n' not in body: + return '' + m = re.search(r'^Driver=(\S+)$', body, re.M) + return m.group(1) if m else '' + + +def sync_link_files(store: dict, link_dir: Path = None, devices: list = None) -> list: + """One .link file per recorded name, so udev applies it as the device is + added - before anything can observe a probe-order name. + """ + link_dir = link_dir or LINK_DIR + link_dir.mkdir(parents=True, exist_ok=True) + + # a Hyper-V NIC shares its MAC with its accelerated-networking VF, so + # its file names the driver too - also when the NIC is keyed on the VMBus + # path, as a lone one is. A NIC absent this boot cannot say which, and + # keeps the pin its file already carries. + drivers = {} + for device in devices or []: + key = device.get('key') or '' + if key and is_hyperv_nic(device): + drivers[key] = _HYPERV_DRIVER + + written = {} + for name, key in store.get('interfaces', {}).items(): + # never for a radio: the key is the phy's address, which every + # interface created on that phy shares, so the file would rename + # whichever of them udev saw next + if key and not name.startswith('wlan'): + path = link_dir / f'{LINK_PREFIX}{name}.link' + driver = drivers.get(key) or _pinned_driver(path, key) + written[path] = render_link(name, key, driver) + + for path, body in written.items(): + if path.exists() and path.read_text() == body: + continue + path.write_text(body) + + # drop stale files, but only our own generated ones + for existing in link_dir.glob(f'{LINK_PREFIX}*.link'): + if existing not in written: + existing.unlink(missing_ok=True) + + return sorted(written) diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 692875491..b00bbc18a 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -30,6 +30,8 @@ from vyos.configdict import list_diff from vyos.configdict import dict_merge from vyos.configdict import get_vlan_ids from vyos.defaults import directories +from vyos.ifconfig.ifname_store import permanent_mac +from vyos.ifconfig.ifname_store import recorded_mac from vyos.template import is_ipv4 from vyos.template import is_ipv6 from vyos.template import render @@ -1823,9 +1825,8 @@ class Interface(Control): from vyos.pki import load_certificate from vyos.pki import wrap_private_key - # The default is a fallback to hw_id which is not present for any interface - # other then an ethernet interface. Thus we emulate hw_id by reading back the - # Kernel assigned MAC address + # T3871: the template keys the identity off a MAC, which is no + # longer a configuration node - read it off the device if 'hw_id' not in self.config: self.config['hw_id'] = read_file(f'/sys/class/net/{self.ifname}/address') render(wpa_supplicant_conf, 'ethernet/wpa_supplicant.conf.j2', self.config) @@ -1884,12 +1885,16 @@ class Interface(Control): # method to apply()? self.config = config - # Change interface MAC address - re-set to real hardware address (hw-id) - # if custom mac is removed. Skip if bond member. + # A custom MAC wins; removing it restores the hardware address, which + # T3871 takes from what naming recorded rather than from a + # configuration node. A dataplane which has taken the port over leaves + # no device to read it from, so the record is what is left. Neither is + # there for a bridge or a tunnel, which is what keeps this a no-op for + # them. Skip if bond member. if 'is_bond_member' not in config: - mac = config.get('hw_id') - if 'mac' in config: - mac = config.get('mac') + mac = config.get('mac') or recorded_mac(self.ifname) + if not mac and os.path.exists(f'/sys/class/net/{self.ifname}/device'): + mac = permanent_mac(self.ifname) if mac: self.set_mac(mac) diff --git a/python/vyos/vpp/config_filter.py b/python/vyos/vpp/config_filter.py index f6b4aaff2..71c85cce1 100644 --- a/python/vyos/vpp/config_filter.py +++ b/python/vyos/vpp/config_filter.py @@ -33,7 +33,6 @@ def iface_filter_eth(config: Config, iface: str) -> None: 'dhcpv6-options', 'disable', 'eapol', - 'hw-id', 'ip', 'ipv6', 'mac', diff --git a/smoketest/scripts/cli/test_interfaces_bonding.py b/smoketest/scripts/cli/test_interfaces_bonding.py index 3a3b1e5cf..683fcc947 100755 --- a/smoketest/scripts/cli/test_interfaces_bonding.py +++ b/smoketest/scripts/cli/test_interfaces_bonding.py @@ -24,6 +24,7 @@ from vyos.ifconfig import Section from vyos.ifconfig.interface import Interface from vyos.configsession import ConfigSessionError from vyos.utils.network import get_interface_config +from vyos.utils.network import interface_exists from vyos.utils.file import read_file class BondingInterfaceTest(BasicInterfaceTest.TestCase): @@ -134,6 +135,95 @@ class BondingInterfaceTest(BasicInterfaceTest.TestCase): state = Interface(remove_member).get_admin_state() self.assertEqual('up', state) + def test_bonding_bare_member_node_released(self): + # T3871: a member whose ethernet node exists but carries nothing at + # all - a plain "set interfaces ethernet eth1" and no more - is still + # a configured interface, not an empty one. When it is released from + # the bond it must be brought back up, not torn down as though the + # operator had deleted it. + member = self._members[0] + self.cli_set(['interfaces', 'ethernet', member]) + + for interface in self._interfaces: + for option in self._options.get(interface, []): + self.cli_set(self._base_path + [interface] + option.split()) + self.cli_commit() + + for interface in self._interfaces: + self.cli_delete( + self._base_path + [interface, 'member', 'interface', member]) + self.cli_commit() + + self.assertEqual(Interface(member).get_admin_state(), 'up') + + def test_bonding_delete_and_readd_member(self): + # T3871: deleting a member's whole ethernet node while it is enslaved, + # then re-adding it, must leave the port present and usable - the + # hardware never went anywhere. + # + # A bond also owns its members' address and MTU + # (BondIf.get_inherit_bond_options()), and it goes on using this one + # after the node is gone. Putting the Kernel defaults back here left + # the member out of step with the bond it was still an active slave + # of: on a jumbo bond every frame the bond was sized for was silently + # discarded, and with the address reset the replies addressed to the + # bond were dropped by the port. Both have to wait until the bond has + # released it - which is asserted at the end. + if len(self._members) < 2: + self.skipTest('need a second member - the bond adopts the first ' + "member's address, so that one cannot show an " + 'address reset at all') + + bond = self._interfaces[0] + # deliberately not _members[0]: the bond takes its address from that + # one, so it could not tell a reset apart from a no-op + member = self._members[-1] + own_mac = Interface(member).get_mac() + # any MTU the bond does not share with the Kernel default will do. + # Staying below it keeps this runnable on ports which cannot carry + # jumbo frames, which is every port the smoketests run on. + bond_mtu = '1400' + + for interface in self._interfaces: + for option in self._options.get(interface, []): + self.cli_set(self._base_path + [interface] + option.split()) + self.cli_set(self._base_path + [interface, 'mtu', bond_mtu]) + self.cli_commit() + + bond_mac = Interface(bond).get_mac() + self.assertNotEqual(own_mac, bond_mac, + f'{member} was picked as the address donor - it ' + 'cannot show an address reset') + self.assertEqual(Interface(member).get_mac(), bond_mac) + + self.cli_delete(['interfaces', 'ethernet', member]) + self.cli_commit() + + self.assertTrue(interface_exists(member), + f'{member} disappeared after its configuration was deleted') + self.assertEqual(read_file(f'/sys/class/net/{member}/mtu'), bond_mtu, + f'{member} left out of step with the bond it is still in') + self.assertEqual(Interface(member).get_mac(), bond_mac, + f'{member} no longer carries the address of the bond ' + 'it is still a member of') + + # released: now, and only now, the port goes back to what the Kernel + # gave it - which is what makes the skip above a deferral and not a + # permanent exemption + self.cli_delete(self._base_path + [bond, 'member', 'interface', member]) + self.cli_commit() + + self.assertEqual(Interface(member).get_mac(), own_mac, + f'{member} did not get its own address back once the ' + 'bond released it') + self.assertEqual(read_file(f'/sys/class/net/{member}/mtu'), '1500', + f'{member} did not get the default MTU back once the ' + 'bond released it') + + self.cli_set(['interfaces', 'ethernet', member, 'description', 'readded']) + self.cli_commit() + self.assertEqual(read_file(f'/sys/class/net/{member}/ifalias'), 'readded') + def test_bonding_append_member(self): # T9269: appending a member to a bond that already has one must not # disturb the existing member. leaf_node_changed() used to report the diff --git a/smoketest/scripts/cli/test_interfaces_ethernet.py b/smoketest/scripts/cli/test_interfaces_ethernet.py index 0d29d291a..473af051b 100755 --- a/smoketest/scripts/cli/test_interfaces_ethernet.py +++ b/smoketest/scripts/cli/test_interfaces_ethernet.py @@ -32,7 +32,10 @@ from vyos.ethtool import Ethtool from vyos.netlink import coalesce from vyos.frrender import mgmt_daemon from vyos.ifconfig import Section +from vyos.ifconfig.interface import Interface from vyos.utils.file import read_file +from vyos.utils.network import get_interface_config +from vyos.utils.network import interface_exists from vyos.utils.network import is_intf_addr_assigned from vyos.utils.network import is_ipv6_link_local from vyos.utils.process import cmdl @@ -52,10 +55,6 @@ class EthernetInterfaceTest(BasicInterfaceTest.TestCase): for tmp in Section.interfaces('ethernet', vlan=False): cls._interfaces.append(tmp) - cls._macs = {} - for interface in cls._interfaces: - cls._macs[interface] = read_file(f'/sys/class/net/{interface}/address') - # call base-classes classmethod super(EthernetInterfaceTest, cls).setUpClass() @@ -67,7 +66,6 @@ class EthernetInterfaceTest(BasicInterfaceTest.TestCase): self.cli_delete(self._base_path + [interface]) self.cli_set(self._base_path + [interface, 'duplex', 'auto']) self.cli_set(self._base_path + [interface, 'speed', 'auto']) - self.cli_set(self._base_path + [interface, 'hw-id', self._macs[interface]]) self.cli_commit() @@ -168,6 +166,43 @@ class EthernetInterfaceTest(BasicInterfaceTest.TestCase): # manually, else tearDown() will have problem in commit() self.cli_delete(self._base_path + [unknonw_interface]) + def test_delete_and_readd_interface(self): + # T3871: deleting an ethernet interface removes its configuration, but + # the port itself is "eternal" - it must still be present afterwards, + # keep its name, and be configurable again. An unconfigured port is + # not a deleted one. + for interface in self._interfaces: + self.cli_set(self._base_path + [interface, 'description', 'before']) + self.cli_commit() + + for interface in self._interfaces: + self.cli_delete(self._base_path + [interface]) + self.cli_commit() + + for interface in self._interfaces: + # an ethernet port belongs to the kernel - deleting its + # configuration returns it to the state the kernel gave it, it + # does not take the port away or leave it stranded admin-down + self.assertTrue(interface_exists(interface), + f'{interface} disappeared after its configuration ' + 'was deleted') + self.assertEqual(Interface(interface).get_admin_state(), 'up', + f'{interface} was left admin-down after its ' + 'configuration was deleted') + tmp = get_interface_config(interface) + self.assertEqual(tmp['mtu'], 1500, + f'{interface} kept a stale MTU after its ' + 'configuration was deleted') + + # re-adding must work and take effect + for interface in self._interfaces: + self.cli_set(self._base_path + [interface, 'description', 'after']) + self.cli_commit() + + for interface in self._interfaces: + self.assertEqual( + read_file(f'/sys/class/net/{interface}/ifalias'), 'after') + def test_speed_duplex_verify(self): for interface in self._interfaces: self.cli_set(self._base_path + [interface, 'speed', '1000']) diff --git a/smoketest/scripts/cli/test_interfaces_wireless_rescan.py b/smoketest/scripts/cli/test_interfaces_wireless_rescan.py new file mode 100755 index 000000000..82dc146c9 --- /dev/null +++ b/smoketest/scripts/cli/test_interfaces_wireless_rescan.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import shutil +import tempfile +import unittest +from glob import glob + +from base_vyostest_shim import VyOSUnitTestSHIM + +from vyos.configtree import ConfigTree +from vyos.utils.file import read_file +from vyos.utils.kernel import check_kmod +from vyos.utils.network import interface_exists +from vyos.utils.process import call +from vyos.utils.process import rc_cmd + +rescan = '/usr/libexec/vyos/vyos-interface-rescan.py' +rescan_log = '/var/log/vyatta/vyos-interface-rescan' + +class WirelessRescanTest(VyOSUnitTestSHIM.TestCase): + """T3871: a radio is not in the interface mapping file - every interface on + a phy carries that phy's address, so there is nothing there to anchor a + name to. The rescan has to find radios on the system instead. While it took + its whole list from the map, no radio was ever given a node and 'interfaces + wireless' stayed empty on a box which had one. + """ + + ifname = 'wlan99' + + def setUp(self): + phys = sorted(os.path.basename(p) + for p in glob('/sys/class/ieee80211/*')) + if not phys: + self.skipTest('no wireless phy on this system') + self.phy = phys[0] + + # an interface of our own, so the test does not depend on which radios + # another testcase left behind + if interface_exists(self.ifname): + call(f'sudo iw dev {self.ifname} del') + if call(f'sudo iw phy {self.phy} interface add {self.ifname} ' + 'type managed') != 0: + self.skipTest(f'could not create an interface on {self.phy}') + self.addCleanup(call, f'sudo iw dev {self.ifname} del') + + def test_a_radio_is_given_its_physical_device(self): + # a directory of our own, not /tmp itself - the rescan runs as root + # and fs.protected_regular refuses it an O_CREAT on a file it does not + # own in a world writable sticky directory + tmp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp_dir) + tmp = os.path.join(tmp_dir, 'config.boot') + shutil.copy('/config/config.boot', tmp) + + config = ConfigTree(read_file(tmp)) + self.assertFalse(config.exists(['interfaces', 'wireless', self.ifname])) + + rc, out = rc_cmd(f'sudo {rescan} {tmp}') + if rc != 0: + # it reports a failure to its own log rather than to stderr, so + # without this a failure here is just an exit code + _, log = rc_cmd(f'sudo tail -n 20 {rescan_log}') + self.fail(f'rescan exited {rc}: {out}\n{log}') + + config = ConfigTree(read_file(tmp)) + self.assertTrue(config.exists(['interfaces', 'wireless', self.ifname])) + self.assertEqual( + config.return_value(['interfaces', 'wireless', self.ifname, + 'physical-device']), self.phy) + +if __name__ == '__main__': + check_kmod('mac80211_hwsim') + unittest.main(verbosity=2, failfast=VyOSUnitTestSHIM.TestCase.debug_on()) diff --git a/smoketest/scripts/cli/test_vpp.py b/smoketest/scripts/cli/test_vpp.py index 9eb446615..87b901ffd 100755 --- a/smoketest/scripts/cli/test_vpp.py +++ b/smoketest/scripts/cli/test_vpp.py @@ -141,7 +141,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): cls.cli_delete(cls, base_path) cls.cli_delete(cls, interfaces_path) # drop any pre-existing custom MAC so the MAC test baseline is the - # interface hardware address (hw-id) + # interface's own hardware address cls.cli_delete(cls, ['interfaces', 'ethernet', interface, 'mac']) # drop any pre-existing MTU so tests start from the default MTU cls.cli_delete(cls, ['interfaces', 'ethernet', interface, 'mtu']) @@ -280,7 +280,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.cli_discard() # A custom MAC address must reach the VPP dataplane, and removing it must - # restore the hardware address (hw-id). Rejection of drivers that cannot + # restore the hardware address. Rejection of drivers that cannot # change the MAC (e.g. vmxnet3) is not covered here, as the CI dataplane # NIC uses a supported driver. hw_mac = VPPControl().get_mac(interface) @@ -289,7 +289,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.cli_commit() self.assertEqual(VPPControl().get_mac(interface), mac) - # removing the custom MAC reverts to the hardware address (hw-id) + # removing the custom MAC reverts to the hardware address self.cli_delete(['interfaces', 'ethernet', interface, 'mac']) self.cli_commit() self.assertEqual(VPPControl().get_mac(interface), hw_mac) diff --git a/src/etc/udev/rules.d/60-vyos-net-hotplug.rules b/src/etc/udev/rules.d/60-vyos-net-hotplug.rules new file mode 100644 index 000000000..3dcc6a1b5 --- /dev/null +++ b/src/etc/udev/rules.d/60-vyos-net-hotplug.rules @@ -0,0 +1,19 @@ +# Hardware appearing after boot needs naming too. Interfaces already in the +# mapping store are renamed by udev from the generated .link files, but a NIC +# this system has never seen has to be assigned a name and recorded. + +ACTION!="add", GOTO="vyos_hotplug_end" +SUBSYSTEM!="net", GOTO="vyos_hotplug_end" + +# hypervisor VF datapaths are not interfaces in their own right +KERNEL=="vf_*", GOTO="vyos_hotplug_end" + +# Only hardware: virtual interfaces (bridges, bonds, VLANs, veth, tunnels) are +# created by configuration, not discovered, and are named by whoever created +# them. This has to be the positive form. DRIVERS is matched against the whole +# parent chain one device at a time, and a net device never carries a driver +# itself - it sits below the one that does - so DRIVERS!="?*" is true for every +# interface and would skip the very hardware this rule exists for. +DRIVERS=="?*", ENV{SYSTEMD_WANTS}+="vyos-net-name-resolve-hotplug.service" + +LABEL="vyos_hotplug_end" diff --git a/src/etc/udev/rules.d/62-temporary-interface-rename.rules b/src/etc/udev/rules.d/62-temporary-interface-rename.rules deleted file mode 100644 index 4a579dcab..000000000 --- a/src/etc/udev/rules.d/62-temporary-interface-rename.rules +++ /dev/null @@ -1 +0,0 @@ -SUBSYSTEM=="net", ACTION=="add", KERNEL=="eth*", DRIVERS=="?*", NAME="e$env{IFINDEX}" diff --git a/src/etc/udev/rules.d/63-hyperv-vf-net.rules b/src/etc/udev/rules.d/63-hyperv-vf-net.rules index e5ecb4ede..224af12ab 100644 --- a/src/etc/udev/rules.d/63-hyperv-vf-net.rules +++ b/src/etc/udev/rules.d/63-hyperv-vf-net.rules @@ -1,3 +1,12 @@ +# Azure/Hyper-V VF interfaces must be kept out of the ethN namespace that +# VyOS manages. A VF is the acceleration datapath of an existing synthetic +# NIC, not an interface in its own right, and it shares that NIC's MAC - so +# left alone it would be taken for a second, independent interface. +# +# Renaming it to vf_ethN here, at device-add, is what keeps it out of the +# way; vyos-net-name-resolve.py then skips anything named vf_* (as well as +# anything with an SR-IOV physfn link, which is the general case). + ATTR{[dmi/id]sys_vendor}!="Microsoft Corporation", GOTO="end_hyperv_nic" # Primary rename path for Azure VF. @@ -6,4 +15,11 @@ ATTR{[dmi/id]sys_vendor}!="Microsoft Corporation", GOTO="end_hyperv_nic" # the interfaces already renamed to vf_ethN ACTION=="add", SUBSYSTEM=="net", DRIVERS=="hv_pci", KERNEL=="eth*", PROGRAM="vyos_vf_name %k", NAME="%c" +# Fallback path: a VF that still appears as a plain ethN, which happens +# when it is seen in an intermediate state before the hv_pci match above +# applies. Scoped to Azure on purpose - bare-metal Mellanox NICs are +# ordinary interfaces and must not be caught here. +ACTION=="add", SUBSYSTEM=="net", DRIVERS=="mlx*_core", KERNEL=="eth*", PROGRAM="vyos_vf_name %k", NAME="%c" +ACTION=="add", SUBSYSTEM=="net", DRIVERS=="mana", KERNEL=="eth*", PROGRAM="vyos_vf_name %k", NAME="%c" + LABEL="end_hyperv_nic" diff --git a/src/etc/udev/rules.d/64-vyos-vmware-net.rules b/src/etc/udev/rules.d/64-vyos-vmware-net.rules deleted file mode 100644 index 66a4a069b..000000000 --- a/src/etc/udev/rules.d/64-vyos-vmware-net.rules +++ /dev/null @@ -1,14 +0,0 @@ -ATTR{[dmi/id]sys_vendor}!="VMware, Inc.", GOTO="end_vmware_nic" - -ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet0", ENV{VYOS_IFNAME}="eth0" -ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet1", ENV{VYOS_IFNAME}="eth1" -ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet2", ENV{VYOS_IFNAME}="eth2" -ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet3", ENV{VYOS_IFNAME}="eth3" -ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet4", ENV{VYOS_IFNAME}="eth4" -ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet5", ENV{VYOS_IFNAME}="eth5" -ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet6", ENV{VYOS_IFNAME}="eth6" -ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet7", ENV{VYOS_IFNAME}="eth7" -ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet8", ENV{VYOS_IFNAME}="eth8" -ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet9", ENV{VYOS_IFNAME}="eth9" - -LABEL="end_vmware_nic" diff --git a/src/etc/udev/rules.d/65-vyos-net.rules b/src/etc/udev/rules.d/65-vyos-net.rules deleted file mode 100644 index 0e7567cf8..000000000 --- a/src/etc/udev/rules.d/65-vyos-net.rules +++ /dev/null @@ -1,41 +0,0 @@ -# These rules use vyos_net_name to persistently name network interfaces -# per "hwid" association in the VyOS configuration file. - -ACTION!="add", GOTO="vyos_net_end" -SUBSYSTEM!="net", GOTO="vyos_net_end" - -# Fallback path for Azure VF interfaces: if a VF still appears as plain ethN, -# rename it to vf_ethX before persistent synthetic interface naming runs to -# prevent it from incorrectly being considered as a synthetic interface. -ATTR{[dmi/id]sys_vendor}=="Microsoft Corporation", DRIVERS=="mlx*_core", KERNEL=="eth*", PROGRAM="vyos_vf_name %k", NAME="%c", GOTO="vyos_net_end" -ATTR{[dmi/id]sys_vendor}=="Microsoft Corporation", DRIVERS=="mana", KERNEL=="eth*", PROGRAM="vyos_vf_name %k", NAME="%c", GOTO="vyos_net_end" - -# VF interfaces are not handled by vyos_net_name, so skip them. -# KERNEL=="vf_*" catches already-renamed Mellanox and MANA VFs. -# DRIVERS=="mlx*_core" and DRIVERS=="mana" on Azure catch VF interfaces -# in an unexpected intermediate state (still named ethN/eN) that must not -# be remapped by vyos_net_name. -# This guard is intentionally scoped to Microsoft Azure to -# avoid excluding bare-metal Mellanox NICs on physical hardware, which do -# need persistent naming via vyos_net_name. -KERNEL=="vf_*", GOTO="vyos_net_end" -ATTR{[dmi/id]sys_vendor}=="Microsoft Corporation", DRIVERS=="mlx*_core", GOTO="vyos_net_end" -ATTR{[dmi/id]sys_vendor}=="Microsoft Corporation", DRIVERS=="mana", GOTO="vyos_net_end" - -# Do name change for ethernet and wireless devices only -KERNEL!="eth*|wlan*|e*", GOTO="vyos_net_end" - -# ignore "secondary" monitor interfaces of mac80211 drivers -KERNEL=="wlan*", ATTRS{type}=="803", GOTO="vyos_net_end" - -# If using VyOS predefined names -ENV{VYOS_IFNAME}!="eth*", GOTO="end_vyos_predef_names" - -DRIVERS=="?*", PROGRAM="vyos_net_name %k $attr{address} $env{VYOS_IFNAME}", NAME="%c", GOTO="vyos_net_end" - -LABEL="end_vyos_predef_names" - -# ignore interfaces without a driver link like bridges and VLANs -DRIVERS=="?*", PROGRAM="vyos_net_name %k $attr{address}", NAME="%c" - -LABEL="vyos_net_end" diff --git a/src/helpers/vyos-interface-rescan.py b/src/helpers/vyos-interface-rescan.py index 0bc3ac7d0..040d48e1c 100755 --- a/src/helpers/vyos-interface-rescan.py +++ b/src/helpers/vyos-interface-rescan.py @@ -14,21 +14,25 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. # +# Make discovered interfaces visible in the configuration, so a newly +# installed NIC shows up without the operator adding it by hand. # +# T3871: the node is created bare. Writing a 'hw-id' back into config.boot is +# what used to make a bad guess unrecoverable - naming belongs to the store. -import os -import stat import argparse import logging -import netaddr +import os +import stat +from pathlib import Path from vyos.configtree import ConfigTree from vyos.defaults import directories +from vyos.ifconfig.ifname_store import load_store from vyos.utils.permission import get_cfg_group_id debug = False -vyos_udev_dir = directories['vyos_udev_dir'] vyos_log_dir = directories['log'] log_file = os.path.splitext(os.path.basename(__file__))[0] vyos_log_file = os.path.join(vyos_log_dir, log_file) @@ -39,140 +43,80 @@ formatter = logging.Formatter('%(levelname)s: %(message)s') handler.setFormatter(formatter) logger.addHandler(handler) -passlist = { - '02:07:01' : 'Interlan', - '02:60:60' : '3Com', - '02:60:8c' : '3Com', - '02:a0:c9' : 'Intel', - '02:aa:3c' : 'Olivetti', - '02:cf:1f' : 'CMC', - '02:e0:3b' : 'Prominet', - '02:e6:d3' : 'BTI', - '52:54:00' : 'Realtek', - '52:54:4c' : 'Novell 2000', - '52:54:ab' : 'Realtec', - 'e2:0c:0f' : 'Kingston Technologies' -} - -def is_multicast(addr: netaddr.eui.EUI) -> bool: - return bool(addr.words[0] & 0b1) - -def is_locally_administered(addr: netaddr.eui.EUI) -> bool: - return bool(addr.words[0] & 0b10) - -def is_on_passlist(hwid: str) -> bool: - top = hwid.rsplit(':', 3)[0] - if top in list(passlist): - return True - return False - -def is_persistent(hwid: str) -> bool: - addr = netaddr.EUI(hwid) - if is_multicast(addr): - return False - if is_locally_administered(addr) and not is_on_passlist(hwid): - return False - return True + +def get_interface_type(intf: str) -> str: + if intf.startswith('eth'): + return 'ethernet' + if intf.startswith('wlan'): + return 'wireless' + logger.critical(f"Unrecognized interface type for '{intf}'") + return '' + def get_wireless_physical_device(intf: str) -> str: - if 'wlan' not in intf: - return '' + """The phy a wireless interface belongs to.""" try: - tmp = os.readlink(f'/sys/class/net/{intf}/phy80211') + return os.path.basename( + os.readlink(f'/sys/class/net/{intf}/phy80211')) except OSError: - logger.critical(f"Failed to read '/sys/class/net/{intf}/phy80211'") return '' - phy = os.path.basename(tmp) - logger.info(f"wireless phy is {phy}") - return phy - -def get_interface_type(intf: str) -> str: - if 'eth' in intf: - intf_type = 'ethernet' - elif 'wlan' in intf: - intf_type = 'wireless' - else: - logger.critical('Unrecognized interface type!') - intf_type = '' - return intf_type - -def get_new_interfaces() -> dict: - """ Read any new interface data left in /run/udev/vyos by - vyos-net-name-resolve.py for interfaces without a configured hw-id - """ - interfaces = {} - - for intf in os.listdir(vyos_udev_dir): - path = os.path.join(vyos_udev_dir, intf) - try: - with open(path) as f: - hwid = f.read().rstrip() - except OSError as e: - logger.error(f"OSError {e}") - continue - interfaces[intf] = hwid - # reverse sort to simplify insertion in config - interfaces = {key: value for key, value in sorted(interfaces.items(), - reverse=True)} - return interfaces - -def filter_interfaces(intfs: dict) -> dict: - """ Ignore no longer existing interfaces or non-persistent mac addresses - """ - filtered = {} - - for intf, hwid in intfs.items(): - if not os.path.isdir(os.path.join('/sys/class/net', intf)): - continue - if not is_persistent(hwid): - continue - filtered[intf] = hwid - - return filtered def interface_rescan(config_path: str): - """ Read new data and update config file - """ - interfaces = get_new_interfaces() + """Add a node for every named interface which does not have one yet.""" + store = load_store() - logger.debug(f"interfaces from udev: {interfaces}") + # a reserved name whose hardware is absent has nothing to create a node for + names = [name for name in sorted(store.get('interfaces', {})) + if Path(f'/sys/class/net/{name}').is_dir()] - interfaces = filter_interfaces(interfaces) + # radios are not in the map - every interface on a phy carries that phy's + # address, so there is nothing there to anchor a name to. Take them from + # the system instead, or a new one never gets a node at all. + names += sorted(entry.name for entry in Path('/sys/class/net').iterdir() + if (entry / 'phy80211').exists() + and entry.name not in names) - logger.debug(f"filtered interfaces: {interfaces}") + logger.debug(f'named interfaces present: {names}') try: - with open(config_path) as f: - config_file = f.read() + config_file = Path(config_path).read_text() except OSError as e: - logger.critical(f"OSError {e}") + logger.critical(f'OSError {e}') exit(1) config = ConfigTree(config_file) + changed = False - for intf, hwid in interfaces.items(): - logger.info(f"Writing '{intf}' '{hwid}' to config file") + for intf in names: intf_type = get_interface_type(intf) if not intf_type: continue + if config.exists(['interfaces', intf_type, intf]): + continue + + logger.info(f"Adding '{intf}' to the configuration") if not config.exists(['interfaces', intf_type]): config.set(['interfaces', intf_type]) config.set_tag(['interfaces', intf_type]) - config.set(['interfaces', intf_type, intf, 'hw-id'], value=hwid) + config.set(['interfaces', intf_type, intf]) + changed = True if intf_type == 'wireless': phy = get_wireless_physical_device(intf) - if not phy: - continue - config.set(['interfaces', intf_type, intf, 'physical-device'], - value=phy) + if phy: + config.set(['interfaces', intf_type, intf, 'physical-device'], + value=phy) + + if not changed: + return try: - with open(config_path, 'w') as f: - f.write(config.to_string()) + Path(config_path).write_text(config.to_string()) except OSError as e: - logger.critical(f"OSError {e}") + logger.critical(f'OSError {e}') + exit(1) + def main(): global debug @@ -203,5 +147,6 @@ def main(): interface_rescan(configfile) + if __name__ == '__main__': main() diff --git a/src/init/vyos-router b/src/init/vyos-router index f109b95da..8978d4157 100755 --- a/src/init/vyos-router +++ b/src/init/vyos-router @@ -357,7 +357,9 @@ clear_or_override_config_files () update_interface_config () { - if [ -d /run/udev/vyos ]; then + # add a configuration node for any interface the name store knows about + # that does not have one yet + if [ -f /opt/vyatta/etc/config/interface-mapping.json ]; then $vyos_libexec_dir/vyos-interface-rescan.py $BOOTFILE fi } @@ -369,17 +371,29 @@ warn_missing_interface_hardware () local status_file="/run/vyos-net-name-resolve.json" [ -f "${status_file}" ] || return 0 - jq -r '.missing // {} | to_entries[] | "\(.value) \(.key)"' "${status_file}" \ + jq -r '.missing // {} | to_entries[] | "\(.key) \(.value)"' "${status_file}" \ 2>/dev/null | while read -r intf hwid; do [ -n "${intf}" ] || continue - log_failure_msg "hw-id ${hwid} configured as '${intf}' was not found during boot - check cabling, hardware and driver/firmware" + log_failure_msg "interface '${intf}' (${hwid}) did not appear during boot - check cabling, hardware and driver/firmware. Its name stays reserved." done - jq -r '.pending_unresolved // [] | .[]' "${status_file}" 2>/dev/null | \ - while read -r intf; do + # A replacement NIC inheriting a slot's name also inherits that + # interface's addresses, so say so rather than doing it silently. + jq -r '.matched_by_path // {} | to_entries[] | "\(.key) \(.value)"' "${status_file}" \ + 2>/dev/null | while read -r intf hwid; do [ -n "${intf}" ] || continue - log_failure_msg "interface '${intf}' still has no hw-id configured after this boot's naming pass - bind it manually, e.g. 'set interfaces ethernet ${intf} hw-id <mac>', or remove the node if it is no longer needed" + log_warning_msg "interface '${intf}' was matched to new hardware ${hwid} by slot - verify it is the port you expect before relying on its configuration" done + + jq -r '.adopted // {} | to_entries[] | "\(.key) \(.value)"' "${status_file}" \ + 2>/dev/null | while read -r intf hwid; do + [ -n "${intf}" ] || continue + log_warning_msg "interface '${intf}' had no NIC this boot and was given to the new Hyper-V NIC ${hwid} - verify it is the port you expect before relying on its configuration" + done + + if [ "$(jq -r '.persisted // true' "${status_file}" 2>/dev/null)" = "false" ]; then + log_failure_msg "interface names could not be persisted - the configuration volume is not mounted" + fi } cleanup_post_commit_hooks () { diff --git a/src/migration-scripts/interfaces/34-to-35 b/src/migration-scripts/interfaces/34-to-35 new file mode 100755 index 000000000..a09dc8cd8 --- /dev/null +++ b/src/migration-scripts/interfaces/34-to-35 @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# T3871: interface names are no longer derived from 'hw-id'. Carry the existing +# bindings over to the mapping store - anchored to the slot the MAC sits in, so +# nothing is renamed - then drop the node. Hardware which is not present is +# skipped: the configuration may well originate from another machine. + +from vyos.configtree import ConfigTree +from vyos.ifconfig.ifname_store import discover_devices +from vyos.ifconfig.ifname_store import load_store +from vyos.ifconfig.ifname_store import save_store +from vyos.ifconfig.ifname_store import sync_link_files + +bases = (['interfaces', 'ethernet'], ['interfaces', 'wireless']) + + +def migrate(config: ConfigTree) -> None: + bindings = {} + for base in bases: + if not config.exists(base): + continue + for intf in config.list_nodes(base): + path = base + [intf, 'hw-id'] + if config.exists(path): + bindings[intf] = config.return_value(path).lower() + config.delete(path) + + if not bindings: + return + + devices = {device['mac']: device for device in discover_devices()} + store = load_store() + entries = store['interfaces'] + added = False + + taken = set(entries.values()) + + for name, hwid in bindings.items(): + device = devices.get(hwid) + if device is None or not device['key']: + continue + # an entry already in the store is authoritative, and a slot already + # spoken for must not gain a second name - only one of them could + # ever be filled + if name in entries or device['key'] in taken: + continue + entries[name] = device['key'] + taken.add(device['key']) + added = True + + if added and save_store(store): + try: + sync_link_files(store, devices=list(devices.values())) + except OSError: + pass diff --git a/src/system/vyos-net-name-resolve.py b/src/system/vyos-net-name-resolve.py index e28f3b3c4..f3e4e62fb 100755 --- a/src/system/vyos-net-name-resolve.py +++ b/src/system/vyos-net-name-resolve.py @@ -14,505 +14,121 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. # -# Single authoritative pass that enforces "interfaces/{ethernet,wireless}/ -# */hw-id" from config.boot onto the live system. It runs once, after udev -# has settled, and decides names from a fully-formed view of the hardware -# instead of reacting to individual "add" uevents. +# Boot-time maintenance of the interface mapping store (T3871). # -# The old udev-time approach (src/udev/vyos_net_name, invoked straight from -# a udev rule) decided the final, hw-id based name from a single sysfs -# snapshot taken at the moment one interface's "add" event was evaluated. -# On systems with several different NIC vendors/drivers that snapshot is -# unreliable: the DRIVERS attribute walk-up and the interface's permanent -# MAC address are not guaranteed to be populated yet, and neither gets a -# second chance since each uevent is only evaluated once. This script -# sidesteps that by re-reading live state after hardware has had time to -# settle, and by verifying/repairing the outcome rather than guessing once. +# The store is rendered into systemd .link files, so udev does the actual +# naming as each device is added. What is left here is what udev cannot do: +# name hardware the store has never seen, record it, and re-render. +# +# Nothing here reads the configuration - a name is a property of the +# hardware, not of what happens to be configured on it. import json import logging import logging.handlers -import re -import tempfile +import sys import time from pathlib import Path -from sys import exit -from vyos.configtree import ConfigTree -from vyos.defaults import directories -from vyos.migrate import ConfigMigrate +from vyos.ifconfig.ifname_store import device_matches +from vyos.ifconfig.ifname_store import discover_devices +from vyos.ifconfig.ifname_store import load_store +from vyos.ifconfig.ifname_store import resolve +from vyos.ifconfig.ifname_store import save_store +from vyos.ifconfig.ifname_store import sync_link_files from vyos.system.image import is_running_as_container -from vyos.utils.process import rc_cmd from vyos.utils.process import run -# Bounded wait for all configured hw-id hardware to appear. NICs needing a -# longer firmware/link-training window are rare enough that this has not -# been exposed as a CLI knob yet. +# bounded wait for known hardware to appear HARDWARE_WAIT_TIMEOUT = 10 HARDWARE_WAIT_POLL = 0.25 -# Bounded wait for hardware without a configured hw-id to settle before -# bootstrap-naming it. There is no specific MAC to wait for here, so -# "N consecutive identical snapshots" stands in for "hardware has stopped -# registering/renaming interfaces". +# unknown hardware has no specific device to wait for, so N identical +# snapshots stand in for "probing has finished" HARDWARE_SETTLE_TIMEOUT = 10 HARDWARE_SETTLE_STABLE_POLLS = 3 -config_path = '/opt/vyatta/etc/config/config.boot' -vyos_udev_dir = directories['vyos_udev_dir'] status_file = Path('/run/vyos-net-name-resolve.json') logger = logging.getLogger() -def _load_config_boot() -> ConfigTree: - """Parse config.boot into a ConfigTree, migrating stale syntax if - needed. Returns None if there is no persisted config yet (livecd/ISO). - """ - if not Path(config_path).is_file(): - return None - - try: - config_file = Path(config_path).read_text() - except OSError as e: - logger.critical(f'OSError {e}') - exit(1) - - try: - return ConfigTree(config_file) - except Exception: - try: - logger.debug('updating component version string syntax') - # this will update the component version string syntax, - # required for updates 1.2 --> 1.3/1.4 - with tempfile.NamedTemporaryFile() as fp: - Path(fp.name).write_text(config_file) - config_migrate = ConfigMigrate(fp.name) - if config_migrate.syntax_update_needed(): - config_migrate.update_syntax() - config_migrate.write_config() - config_file = Path(fp.name).read_text() - - return ConfigTree(config_file) - except Exception as e: - logger.critical(f'ConfigTree error: {e}') - exit(1) - - -def get_configfile_interfaces() -> dict: - """Read hw-id -> name mapping for ethernet/wireless from config.boot""" - interfaces: dict = {} - - config = _load_config_boot() - if config is None: - return interfaces - - for base in (['interfaces', 'ethernet'], ['interfaces', 'wireless']): - if not config.exists(base): - continue - for intf in config.list_nodes(base): - path = base + [intf, 'hw-id'] - if not config.exists(path): - logger.warning(f"no 'hw-id' entry for {intf}") - continue - hwid = config.return_value(path).lower() - if hwid in interfaces: - logger.warning( - f'multiple entries for {hwid}: {interfaces[hwid]}, {intf}' - ) - continue - interfaces[hwid] = intf - - logger.debug(f'config file entries: {interfaces}') - return interfaces - - -def get_pending_hwid_nodes() -> dict: - """Interface nodes that exist under interfaces/{ethernet,wireless} in - config.boot but have no 'hw-id' leaf - e.g. the documented "NIC - replaced" remediation (`delete interfaces ethernet eth1 hw-id`) - intentionally leaves the node (and its other settings, like address) - in place, expecting the SAME node to receive a fresh hw-id. These - names have no known MAC yet - that is the point - so they cannot be - folded into get_configfile_interfaces()'s MAC-keyed dict; they are - surfaced here, grouped by type, so main() can try to match them to - this boot's unconfigured hardware (see match_pending_nodes()). - """ - pending = {'ethernet': set(), 'wireless': set()} - - config = _load_config_boot() - if config is None: - return pending - - for intf_type, base in (('ethernet', ['interfaces', 'ethernet']), - ('wireless', ['interfaces', 'wireless'])): - if not config.exists(base): - continue - for intf in config.list_nodes(base): - if not config.exists(base + [intf, 'hw-id']): - pending[intf_type].add(intf) - - return pending - - -def get_permanent_mac(ifname: str, sys_class_net: str = '/sys/class/net') -> str: - """Best-effort permanent hardware address of an interface. - - Falls back to the interface's current address if the driver does not - support reporting a permanent address separately (ethtool -P). - - sys_class_net is overridable for testing against a fake sysfs tree. - """ - code, out = rc_cmd(f'ethtool -P {ifname}') - if code == 0: - m = re.search(r'([0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5})', out) - if m and m.group(1) != '00:00:00:00:00:00': - return m.group(1).lower() - - try: - return Path(f'{sys_class_net}/{ifname}/address').read_text().strip().lower() - except OSError: - return '' - - -def discover_physical_interfaces(sys_class_net: str = '/sys/class/net') -> dict: - """Return {kernel_name: mac} for every interface backed by a real bus - device - excludes lo, bridges, bonds, VLANs, veth, tunnels, etc. - - Also excludes interfaces enslaved to another netdev (master symlink - present), which covers Azure VF datapath interfaces bound under their - synthetic parent. Those are acceleration children, not independent - primary interfaces, and must not be candidates for hw-id naming. +def _snapshot(devices: list) -> set: + return {(d['name'], d['mac']) for d in devices} - sys_class_net is overridable for testing against a fake sysfs tree. - """ - interfaces = {} - net_dir = Path(sys_class_net) - if not net_dir.is_dir(): - return interfaces - - for entry in net_dir.iterdir(): - if not (entry / 'device').exists(): - logger.debug( - f"skipping '{entry.name}': no backing device in sysfs" - ) - continue - if (entry / 'master').exists(): - logger.debug( - f"skipping '{entry.name}': interface is enslaved via master link" - ) - continue - mac = get_permanent_mac(entry.name, sys_class_net) - if mac: - interfaces[entry.name] = mac - return interfaces +def _unmatched(keys: set, devices: list) -> set: + """Recorded slots that no device present this boot occupies.""" + return {key for key in keys + if not any(device_matches(d['properties'], key) for d in devices)} -def wait_for_hardware(configured_macs: set, timeout: float = HARDWARE_WAIT_TIMEOUT, - poll: float = HARDWARE_WAIT_POLL) -> tuple: - """Poll until every configured hw-id has shown up, or until timeout""" +def wait_for_hardware(known_keys: set, timeout: float = HARDWARE_WAIT_TIMEOUT, + poll: float = HARDWARE_WAIT_POLL) -> tuple: + """Poll until every slot the store knows is filled, or until timeout.""" deadline = time.monotonic() + timeout - current = discover_physical_interfaces() + devices = discover_devices() while True: - missing = configured_macs - set(current.values()) + missing = _unmatched(known_keys, devices) if not missing or time.monotonic() >= deadline: - return current, missing + return devices, missing time.sleep(poll) - current = discover_physical_interfaces() + devices = discover_devices() -def wait_for_settle(initial: dict, timeout: float = HARDWARE_SETTLE_TIMEOUT, - poll: float = HARDWARE_WAIT_POLL, - stable_polls: int = HARDWARE_SETTLE_STABLE_POLLS) -> dict: - """Poll until discover_physical_interfaces() returns the same snapshot - stable_polls times in a row, or until timeout. Used before bootstrap- - naming hardware that has no configured hw-id to wait for by MAC. +def wait_for_settle(initial: list, timeout: float = HARDWARE_SETTLE_TIMEOUT, + poll: float = HARDWARE_WAIT_POLL, + stable_polls: int = HARDWARE_SETTLE_STABLE_POLLS) -> list: + """Poll until the set of interfaces stops changing, or until timeout. + Used before naming hardware the store has never seen, where there is no + specific MAC to wait for. """ deadline = time.monotonic() + timeout - current = initial - stable = 1 if current else 0 + devices = initial + stable = 1 if devices else 0 while stable < stable_polls and time.monotonic() < deadline: time.sleep(poll) - seen = discover_physical_interfaces() - stable = stable + 1 if seen == current else 1 - current = seen - return current - - -def is_wireless_interface(name: str, sys_class_net: str = '/sys/class/net') -> bool: - """Race-free wireless check: existence of the phy80211 symlink, not the - interface's current (possibly still cosmetic/pre-bootstrap) name. - """ - return (Path(sys_class_net) / name / 'phy80211').exists() - + seen = discover_devices() + stable = stable + 1 if _snapshot(seen) == _snapshot(devices) else 1 + devices = seen + return devices -PCI_BDF_RE = re.compile(r'^[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f]$') -# No real hop-count can reach this - reserves it as a sort-last sentinel -# for interfaces whose bus topology can't be determined (USB NICs, or a -# device symlink that doesn't exist/resolve). -PCIE_DISTANCE_UNKNOWN = 999 +def get_ifindex(name: str) -> str: + try: + return Path(f'/sys/class/net/{name}/ifindex').read_text().strip() + except OSError: + return name -def pcie_distance(name: str, sys_class_net: str = '/sys/class/net') -> int: - """Approximate PCIe bus distance from the root complex - counts PCI - domain:bus:device.function segments (e.g. 0000:00:1f.6) in the fully - resolved sysfs path of the interface's 'device' symlink. Non-PCI hops - (virtioN, usbN, the net/<ifname> tail, ...) simply don't match and are - skipped, so virtio's device->virtioN->real-PCI-parent indirection - needs no special-casing. Multi-function siblings at the same slot - contribute exactly one matching segment each, so they are not - double-counted relative to their shared depth. +def interface_in_use(name: str, sys_class_net: str = '/sys/class/net') -> bool: + """Is the running system relying on this name? Renaming it then leaves + the configuration pointing at a name nothing answers to. - Returns PCIE_DISTANCE_UNKNOWN (sorts after every real hop-count) if - the 'device' symlink is missing/unresolvable, or the resolved path - has no PCI BDF segment at all (e.g. a USB NIC). + At boot this is false for everything - the pass runs before the + configuration is applied, so the ports are still down. Afterwards, which + is the only time the hot-plug unit runs, it is true for every port the + configuration touched and for a card which just arrived it is not. """ - device_link = Path(sys_class_net) / name / 'device' try: - resolved = device_link.resolve(strict=True) - except (OSError, RuntimeError): - return PCIE_DISTANCE_UNKNOWN - - hops = sum(1 for part in resolved.parts if PCI_BDF_RE.match(part)) - return hops if hops > 0 else PCIE_DISTANCE_UNKNOWN - - -def find_available(names: set, prefix: str) -> str: - """Find the lowest free index for a given interface name prefix""" - index_list = [] - for name in names: - if not name.startswith(prefix): - continue - suffix = name[len(prefix):] - if suffix.isdigit(): - index_list.append(int(suffix)) - - if not index_list: - return f'{prefix}0' - - index_list.sort() - # find 'holes' in list, if any - missing = sorted(set(range(index_list[0], index_list[-1])) - set(index_list)) - if missing: - return f'{prefix}{missing[0]}' - - return f'{prefix}{index_list[-1] + 1}' - - -def find_next_available(names: set, prefix: str, floor: int = 0) -> str: - """Find the lowest free index for a prefix, scanning up from floor. - - Unlike find_available(), this always starts the scan at floor (0 by - default) rather than at the lowest index already present in `names` - - used for bootstrap naming, where a gap must be backfillable even when - it sits below every other index currently in `names` (e.g. `names` - contains only 'eth5' and 'eth9', but 'eth0'..'eth4' and 'eth6'..'eth8' - are genuinely free). A slot only stays unavailable here because it is - explicitly present in `names` - the caller decides what belongs there - (configured hw-id targets, pending nodes, ...), not this function. - """ - n = floor - while f'{prefix}{n}' in names: - n += 1 - return f'{prefix}{n}' + flags = int((Path(sys_class_net) / name / 'flags').read_text(), 16) + except (OSError, ValueError): + return False + IFF_UP = 0x1 + return (bool(flags & IFF_UP) + or (Path(sys_class_net) / name / 'master').exists()) -def compute_rename_plan(configured: dict, current: dict, pending: dict = None) -> dict: - """Build {from_name: to_name} for every interface that needs to move to - its CLI hw-id name. Also relocates any interface that currently squats - on a name owned by a different hw-id, so the rightful owner can take it. - """ - plan = {} - current_by_mac = {mac: name for name, mac in current.items()} - target_owner_mac = {name: mac for mac, name in configured.items()} - - for mac, target in configured.items(): - source = current_by_mac.get(mac) - if source and source != target: - plan[source] = target - - unchanged = set(current) - set(plan) - # every configured target is reserved, even one whose hw-id hasn't - # shown up yet this boot (missing/faulty/slow driver) - a squatter must - # never be relocated onto a name that hardware still owns, or it would - # just need relocating again the moment that hardware actually appears. - # A pending (hw-id-less but still-configured) node name is reserved the - # same way: it may be about to be reclaimed by the exact NIC that just - # vacated it (see match_pending_nodes()), and even when it isn't, its - # other settings (address, description, ...) are still live in - # config.boot and must not be handed to an unrelated squatter. - reserved = set(configured.values()) - if pending: - reserved |= pending.get('ethernet', set()) | pending.get('wireless', set()) - - for name, mac in current.items(): - if name in plan: - continue - owner_mac = target_owner_mac.get(name) - if owner_mac is not None and owner_mac != mac: - prefix = re.sub(r'\d+$', '', name) or name - taken = unchanged | set(plan.values()) | set(plan.keys()) | reserved - new_name = find_available(taken, prefix) - plan[name] = new_name - unchanged.discard(name) - - return plan - - -def unmatched_candidates(configured: dict, current: dict, existing_plan: dict) -> list: - """Physical interfaces this boot with no configured hw-id - the pool - both ordinary bootstrap naming and pending-node reclaim matching draw - from. This deliberately INCLUDES squatters compute_rename_plan() is - already evicting from a configured target name: excluding them here - let their mac skip match_pending_nodes() entirely, so an unconfigured - NIC that happened to be squatting on someone else's hw-id slot could - silently get a permanent, possibly-ambiguous hw-id via ordinary - bootstrap naming instead of being reclaimed or held back like any - other unconfigured candidate. Their eviction destination from - existing_plan still stands as the fallback - match_pending_nodes() - (via main()'s reclaim loop) or compute_bootstrap_plan() only override - it, they never leave a squatter un-evicted. - """ - return [(mac, name) for name, mac in current.items() - if mac not in configured] - - -def match_pending_nodes(pending: dict, candidates: list) -> dict: - """Match this boot's unconfigured candidates to pending (hw-id-less) - config nodes, one type (ethernet/wireless) at a time. Conservative by - design: only matches when there is EXACTLY ONE pending node and - EXACTLY ONE candidate of that type this boot - any other cardinality - is left alone rather than guessed. - - A pending node still carries its OTHER settings (address, - description, ...) - a wrong guess would silently apply one physical - port's configuration to a DIFFERENT port. Confirmed in the field: on - a real, already-provisioned box (whose hw-id came from historical - probe-order rescan, not from any PCIe/MAC sort), a second, completely - unrelated candidate freed in the same boot - e.g. a different - interface's config being fully deleted - is enough for a naive - ascending-sort fill to swap the two, binding a configured node's - address to the wrong wire. There is no way to tell, from MAC and - PCIe position alone, which candidate is genuinely the pending node's - own hardware once its hw-id is gone; leaving the node unresolved and - reported is safer than guessing. A candidate that isn't reclaimed - here is not otherwise held back - it still proceeds to ordinary - bootstrap naming (see compute_bootstrap_plan()) and gets its own - fresh, settings-free name instead. - - Returns {mac: node_name} for every unambiguous match this boot. - """ - grouped = {'ethernet': [], 'wireless': []} - for mac, name in candidates: - group = 'wireless' if is_wireless_interface(name) else 'ethernet' - grouped[group].append((mac, name)) - - matched = {} - for intf_type, nodes in pending.items(): - if not nodes: - continue - cands = grouped.get(intf_type, []) - if len(nodes) == 1 and len(cands) == 1: - (mac, _name) = cands[0] - (target,) = nodes - matched[mac] = target +def refuse_renames_in_use(plan: dict, + sys_class_net: str = '/sys/class/net') -> tuple: + """Split a rename plan into what may be applied and what must not.""" + allowed, refused = {}, {} + for old, target in plan.items(): + if interface_in_use(old, sys_class_net): + refused[old] = target else: - cand_desc = ', '.join(f"'{name}' ({mac})" for mac, name in cands) or 'none' - logger.warning( - f'{len(nodes)} pending {intf_type} node(s) ' - f"({', '.join(sorted(nodes))}) and {len(cands)} unconfigured " - f'{intf_type} candidate(s) this boot ({cand_desc}) - not ' - 'unambiguous, leaving pending rather than guessing' - ) - - return matched - - -def compute_bootstrap_plan(configured: dict, current: dict, existing_plan: dict, - pending: dict = None, - reclaimed_macs: frozenset = frozenset()) -> dict: - """Build {from_name: to_name} for physical interfaces that have no - configured hw-id at all, assigning them a canonical name within their - type group (ethernet/wireless) ordered by PCIe distance from the root - complex first and MAC address as a tie-break, instead of leaving them - at whatever name the racy udev-time fast path produced. Ordering by - topology rather than raw MAC magnitude means an onboard/directly - CPU-attached NIC isn't sorted after add-in cards just because its MAC - happens to be numerically higher. This is what makes a box's very - first boot - before any hw-id exists - just as deterministic as every - boot after hw-id is written (PCIe wiring and MAC are both static - hardware properties), since the name assigned here gets frozen into - config.boot by vyos-interface-rescan.py the same way a real hw-id - match would. - - existing_plan is the hw-id based plan already computed by - compute_rename_plan(): its RIGHTFUL-OWNER targets (an interface moving - to its own configured hw-id name) are reserved so a bootstrap name can - never collide with a configured one. A squatter compute_rename_plan() - is evicting from a configured target IS still bootstrap candidacy here - (see unmatched_candidates()) - its OWN eviction destination is only a - provisional fallback that this function is about to recompute for it - from scratch, so that value must not also count as "taken" (it would - needlessly block a lower slot this function might otherwise give it, - or another candidate, once real). - - pending node names are reserved the same way a real hw-id target is - - a candidate main() could not unambiguously match to one via - match_pending_nodes() must never squat there instead, since that name - still carries the node's other settings (address, description, ...). - reclaimed_macs are candidates main() already matched to a pending - node - excluded here so this function never reassigns them elsewhere. - - A numeric slot is only ever off-limits here because something still - occupies or reserves it - a real hw-id target or a pending node. A gap - left by fully deleting an interface's config (hw-id and node both - gone, nothing in `pending` either) carries no such reservation and is - freely backfilled, exactly as bootstrap naming would treat it on a - system that never had any config for it at all - there is no - remaining signal in config.boot to tell those two cases apart, and - deleting the whole node is the admin's explicit way of saying so. - """ - plan = {} - candidates = unmatched_candidates(configured, current, existing_plan) - candidates = [(mac, name) for mac, name in candidates - if mac not in reclaimed_macs] - if not candidates: - return plan - - candidate_names = {name for _, name in candidates} - # a plan entry whose source IS one of this function's own candidates - # is a squatter's provisional eviction fallback, about to be - # recomputed below - only a rightful-owner move's target is a real, - # authoritative reservation - rightful_movers = {src: target for src, target in existing_plan.items() - if src not in candidate_names} - # a rightful mover's CURRENT (source) name looks occupied right now, - # but safe_bulk_rename()'s two-phase scratch-name staging vacates it - # before any target name is actually claimed - so it must not count - # as taken here either. - reserved_pending = set() - if pending: - reserved_pending = pending.get('ethernet', set()) | pending.get('wireless', set()) - taken = (set(current) - candidate_names - set(rightful_movers)) \ - | set(rightful_movers.values()) | reserved_pending - - for mac, name in sorted(candidates, key=lambda c: (pcie_distance(c[1]), c[0])): - prefix = 'wlan' if is_wireless_interface(name) else 'eth' - new_name = find_next_available(taken, prefix) - taken.add(new_name) - if new_name != name: - plan[name] = new_name - - return plan - - -def get_ifindex(name: str) -> str: - try: - return Path(f'/sys/class/net/{name}/ifindex').read_text().strip() - except OSError: - return name + allowed[old] = target + return allowed, refused def rename_interface(old: str, new: str) -> bool: @@ -526,12 +142,10 @@ def rename_interface(old: str, new: str) -> bool: def safe_bulk_rename(plan: dict) -> dict: - """Two-phase rename: stage every interface via a unique scratch name - (derived from its ifindex, which is always unique) before assigning - final names. This makes the whole batch collision-proof regardless of - permutations/cycles between current and target names - a straight - from->to rename can fail if the target name is still held by another - interface earlier/later in the same plan. + """Stage every interface under a scratch name derived from its ifindex + before assigning the final ones, so the batch cannot collide on a + permutation or a cycle. Only new hardware reaches this - udev has already + named everything the store knows. """ if not plan: return {} @@ -548,64 +162,44 @@ def safe_bulk_rename(plan: dict) -> dict: if rename_interface(tmp, target): run(f'ip link set dev {target} up') else: - # still sitting under the scratch name, not the target - don't - # report it as renamed, and don't leave it down indefinitely + # still under the scratch name - do not report it as renamed del applied[old] run(f'ip link set dev {tmp} up') return applied -def sync_rescan_hints(current_state: dict, configured: dict, - stale_names: set = frozenset()) -> None: - """Leave a hint under vyos_udev_dir for every interface that has no - hw-id configured yet, so vyos-interface-rescan.py can auto-populate - config.boot. Remove stale/no-longer-hw-id-needed hints. - """ - try: - Path(vyos_udev_dir).mkdir(parents=True, exist_ok=True) - except OSError as e: - logger.critical(f'error creating rescan hint directory: {e}') - return - - for name, mac in current_state.items(): - hint = Path(vyos_udev_dir) / name - if mac in configured: - hint.unlink(missing_ok=True) - else: - try: - hint.write_text(mac) - except OSError as e: - logger.error(f'could not write rescan hint for {name}: {e}') - - for old_name in stale_names: - if old_name not in current_state: - (Path(vyos_udev_dir) / old_name).unlink(missing_ok=True) - - -def write_status(configured: dict, found: dict, missing: set, plan: dict, - pending: dict = None, reclaimed: dict = None, - candidates: list = None) -> None: - """candidates is the full unmatched_candidates() list evaluated this - boot (before reclaim/bootstrap assignment) - surfaced here so a - pending node left unresolved for lack of hardware is self-diagnosable - from this one file: 'pending_unresolved' names what needed a match, - 'unconfigured_candidates' names every physical interface that was in - the running for one, without needing a separate `ip -br link show` or - `show configuration commands` to reconstruct the same picture by hand. - """ - pending = pending or {} - reclaimed = reclaimed or {} - candidates = candidates or [] - all_pending = pending.get('ethernet', set()) | pending.get('wireless', set()) +def write_status(store: dict, devices: list, applied: dict, report: dict, + persisted: bool, refused: dict = None) -> None: + """Publish what this pass decided, to warn about and to inspect later.""" + absent = _unmatched(set(store['interfaces'].values()), devices) status = { - 'configured': configured, - 'found': sorted(found.values()), - 'missing': {mac: configured[mac] for mac in sorted(missing)}, - 'renamed': plan, - 'pending_unresolved': sorted(all_pending - set(reclaimed.values())), - 'reclaimed': reclaimed, - 'unconfigured_candidates': {name: mac for mac, name in candidates}, + 'interfaces': dict(sorted(store['interfaces'].items())), + 'missing': { + name: key for name, key in sorted(store['interfaces'].items()) + if key in absent + }, + 'renamed': applied, + # in use, so left alone until the next boot + 'rename_refused': refused or {}, + # everything recognised from the store, however it was recognised + 'matched': { + **report.get('matched', {}), + **report.get('reassigned', {}), + **report.get('moved', {}), + **report.get('replaced', {}), + **report.get('adopted', {}), + }, + # the name changed hands between cards the store already knows + 'reassigned': report.get('reassigned', {}), + # the card was found in a different slot - certain, it is the same card + 'moved': report.get('moved', {}), + # a different card took the slot - the one case that needs verifying + 'matched_by_path': report.get('replaced', {}), + # a recorded Hyper-V NIC was gone and its one replacement took its name + 'adopted': report.get('adopted', {}), + 'new_hardware': report.get('bootstrapped', {}), + 'persisted': persisted, } try: status_file.write_text(json.dumps(status, indent=2)) @@ -613,99 +207,105 @@ def write_status(configured: dict, found: dict, missing: set, plan: dict, logger.error(f'could not write status file: {e}') -def main(): +def boot_adoptions(store: dict) -> dict: + """The adoptions the boot pass reported that still hold. A pass for + hot-plugged hardware adopts nothing, and must not wipe the record of + what this boot did.""" + try: + adopted = json.loads(status_file.read_text()).get('adopted') or {} + except (OSError, ValueError, AttributeError): + return {} + hardware = store.get('hardware', {}) + return {name: mac for name, mac in adopted.items() + if hardware.get(name) == mac} + + +def hotplug_requested(argv: list) -> bool: + """--hotplug: started for hardware added to a running system""" + return '--hotplug' in argv + + +def main(hotplug: bool = False): if is_running_as_container(): - # A container has no NIC of its own - its interfaces are veth pairs - # created by the container runtime. They have no backing bus device in - # sysfs and their MAC is assigned by the host and regenerated on every - # start, so there is nothing to wait for and no hw-id worth binding. - logger.info('running inside a container - skipping hw-id naming pass') + # a container owns no NIC - its veth pairs have no backing bus device + logger.info('running inside a container - skipping interface naming') return - configured = get_configfile_interfaces() - pending = get_pending_hwid_nodes() + store = load_store() + known = set(store['interfaces'].values()) - if configured: - current, missing = wait_for_hardware(set(configured)) - for mac in sorted(missing): + devices, _missing = wait_for_hardware(known) + + if not store['interfaces'] or any( + not any(device_matches(d['properties'], k) for k in known) + for d in devices): + # unknown hardware present - let slower drivers finish registering + # so a merely late NIC is not treated as new + devices = wait_for_settle(devices) + + absent = _unmatched(known, devices) + for name, key in sorted(store['interfaces'].items()): + if key in absent: logger.warning( - f"hw-id '{mac}' configured as '{configured[mac]}' was not found " - f'within {HARDWARE_WAIT_TIMEOUT}s - hardware may be missing, ' - 'still initializing, or its driver failed to load' - ) - plan = compute_rename_plan(configured, current, pending) - else: - logger.info('no hw-id configured yet') - current, missing, plan = discover_physical_interfaces(), set(), {} - - all_pending = pending.get('ethernet', set()) | pending.get('wireless', set()) - - reclaimed = {} - candidates = [] - if all_pending or any(mac not in configured for mac in current.values()): - # The `all_pending` half of this condition matters even when - # `current` (from wait_for_hardware() above, bounded only on - # already-CONFIGURED macs) shows nothing unconfigured yet: on a - # system with several different NIC vendors/drivers, the exact - # hardware a pending node needs can simply not have finished - # probing at this snapshot. Without this, wait_for_settle() below - # would never even run, giving that slower hardware zero extra - # time to appear before this boot gives up on the pending node. - current = wait_for_settle(current) - if configured: - # a configured hw-id whose driver was too slow to show up - # within wait_for_hardware()'s timeout can still appear during - # the extra time wait_for_settle() just spent waiting for - # unconfigured hardware to stabilize - recompute against the - # settled snapshot so it still gets renamed (and drops out of - # `missing`) this boot instead of being silently skipped. - missing = set(configured) - set(current.values()) - plan = compute_rename_plan(configured, current, pending) - - # let a NIC that just lost its hw-id (the documented "delete - # hw-id to force regeneration" remediation) reclaim the exact - # node its other settings (address, description, ...) still live - # under, rather than bootstrap-naming it to a new bare node and - # orphaning that config - only when unambiguous, see - # match_pending_nodes() - candidates = unmatched_candidates(configured, current, plan) - reclaimed = match_pending_nodes(pending, candidates) - candidate_by_mac = dict(candidates) - for mac, target in reclaimed.items(): - name = candidate_by_mac[mac] - if name != target: - plan[name] = target - logger.info( - f"reclaiming pending node '{target}' for hw-id '{mac}' " - 'this boot' + f"'{name}' ({key}) did not appear within " + f'{HARDWARE_WAIT_TIMEOUT}s - hardware may be missing, still ' + 'initializing, or its driver failed to load. Its name stays ' + 'reserved.' ) - # bootstrap-name whatever is left, deterministically by PCIe - # distance and MAC, instead of leaving it at its racy cosmetic - # udev-time name - this is what vyos-interface-rescan.py will - # freeze into config.boot. Pending node names stay reserved here - # (see compute_bootstrap_plan()) so an ambiguous leftover - # candidate can never squat on one and inherit its settings. - plan.update(compute_bootstrap_plan( - configured, current, plan, pending=pending, - reclaimed_macs=set(reclaimed))) - - for name in sorted(all_pending - set(reclaimed.values())): + if not hotplug and 'hyperv' not in store and any( + key.startswith('ID_NET_NAME_MAC=') and key in absent + for key in store['interfaces'].values()): + # a name is vacant, and the map was written by code that did not + # record which NICs were Hyper-V ones: the name is not handed over. + # Each pass records the Hyper-V NICs it finds, but never a vacant name + logger.info('the interface map does not say which interfaces were ' + 'Hyper-V NICs - no vacant name is given to a new NIC ' + 'this boot') + plan, store, report = resolve(devices, store, adopt=not hotplug) + + for name, mac in sorted(report.get('bootstrapped', {}).items()): + logger.info(f"new hardware {mac} named '{name}'") + + # A card which took over a slot also took over the addresses configured + # under that name. vyos-router says so for a boot, but a card swapped on + # a running system is only ever seen here. + for name, mac in sorted(report.get('replaced', {}).items()): logger.warning( - f"pending node '{name}' still has no hw-id after this boot's " - 'naming pass' + f"'{name}' was matched to new hardware {mac} by slot - verify it " + 'is the port you expect before relying on its configuration' + ) + for name, mac in sorted(report.get('adopted', {}).items()): + logger.warning( + f"'{name}' had no NIC this boot and was given to the new Hyper-V " + f'NIC {mac}, which took its place - verify it is the port you ' + 'expect before relying on its configuration' + ) + plan, refused = refuse_renames_in_use(plan) + for old, target in sorted(refused.items()): + logger.warning( + f"'{old}' is in use and was not renamed to '{target}' - the map " + 'and the running system disagree until the next boot' ) - applied = safe_bulk_rename(plan) - final_current = discover_physical_interfaces() - # configured is passed unmutated (not merged with `reclaimed`): the - # reclaimed mac still has no real hw-id in config.boot yet, so it must - # still get a rescan hint under its (now reclaimed) name, letting - # vyos-interface-rescan.py write the hw-id into the existing node. - sync_rescan_hints(final_current, configured, set(applied.keys())) - write_status(configured, current, missing, applied, - pending=pending, reclaimed=reclaimed, candidates=candidates) + persisted = save_store(store) + if persisted: + # render so udev applies the names on the next boot + try: + sync_link_files(store, devices=devices) + except OSError as e: + logger.error(f'could not write .link files: {e}') + else: + logger.warning( + 'the configuration volume is not mounted - interface names were ' + 'resolved for this boot but could not be persisted' + ) + + if hotplug: + # reported once, by the boot pass; kept on record until the next boot + report = {**report, 'adopted': boot_adoptions(store)} + write_status(store, devices, applied, report, persisted, refused) if __name__ == '__main__': @@ -716,4 +316,4 @@ if __name__ == '__main__': logger.addHandler(syslog_handler) logger.setLevel(logging.DEBUG) - main() + main(hotplug=hotplug_requested(sys.argv[1:])) diff --git a/src/systemd/vyos-net-name-resolve-hotplug.service b/src/systemd/vyos-net-name-resolve-hotplug.service new file mode 100644 index 000000000..d52fc6061 --- /dev/null +++ b/src/systemd/vyos-net-name-resolve-hotplug.service @@ -0,0 +1,13 @@ +[Unit] +Description=VyOS persistent interface naming for hot-plugged hardware + +# Pulled in by 60-vyos-net-hotplug.rules when a network device appears. +# Held off until the boot-time configuration has been applied, otherwise the +# boot storm of "add" events would each queue a redundant run. +ConditionPathExists=/tmp/vyos-config-status + +[Service] +Type=oneshot +ExecStart=/usr/libexec/vyos/system/vyos-net-name-resolve.py --hotplug +TimeoutStartSec=90 +StandardOutput=journal diff --git a/src/systemd/vyos-net-name-resolve.service b/src/systemd/vyos-net-name-resolve.service index ec7c45636..8ad1c3418 100644 --- a/src/systemd/vyos-net-name-resolve.service +++ b/src/systemd/vyos-net-name-resolve.service @@ -1,17 +1,15 @@ [Unit] -Description=VyOS deterministic hw-id based interface naming +Description=VyOS persistent interface naming Documentation=man:vyos-net-name-resolve.py(8) -# Started explicitly by vyos-router (like frr.service/vyos-netlinkd.service) -# once /opt/vyatta/etc/config is mounted and config.boot exists - which, for -# an encrypted config volume, only happens after vyos-router has unlocked -# and mounted it. It cannot be ordered independently before vyos-router: -# there is no config.boot to read before that point. +# Started by vyos-router once the config directory is mounted - for an +# encrypted volume that is only after it has been unlocked - so this can not +# be ordered independently. This is the boot pass; hot-plugged hardware is +# named by vyos-net-name-resolve-hotplug.service, which passes --hotplug. DefaultDependencies=no [Service] Type=oneshot -RemainAfterExit=yes ExecStart=/usr/libexec/vyos/system/vyos-net-name-resolve.py TimeoutStartSec=90 StandardOutput=journal+console diff --git a/src/tests/test_ifname_store.py b/src/tests/test_ifname_store.py new file mode 100644 index 000000000..40a975ba5 --- /dev/null +++ b/src/tests/test_ifname_store.py @@ -0,0 +1,1317 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# Coverage for T3871: slot-anchored persistent interface naming. +# +# The regression classes at the bottom replay, verbatim, the field +# reproductions from the review of vyos-1x PR #5417 - the cases where the +# config-tree-driven resolver bound a configured address to the wrong +# physical wire. Under the store-based design none of them have a code path +# to travel, and these tests exist to keep it that way. + +import json +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from vyos.ifconfig.ifname_store import STORE_VERSION +from vyos.ifconfig.ifname_store import canonical_sort_key +from vyos.ifconfig.ifname_store import config_dir_is_mounted +from vyos.ifconfig.ifname_store import device_matches +from vyos.ifconfig.ifname_store import ambiguous_keys +from vyos.ifconfig.ifname_store import hardware_key +from vyos.ifconfig.ifname_store import discover_devices +from vyos.ifconfig.ifname_store import empty_store +from vyos.ifconfig.ifname_store import load_store +from vyos.ifconfig.ifname_store import port_label_index +from vyos.ifconfig.ifname_store import is_vmware_nic +from vyos.ifconfig.ifname_store import _LABEL_UNKNOWN +from vyos.ifconfig.ifname_store import permanent_mac +from vyos.ifconfig.ifname_store import render_link +from vyos.ifconfig.ifname_store import resolve +from vyos.ifconfig.ifname_store import sync_link_files +from vyos.ifconfig.ifname_store import save_store + + +def dev(name, path, mac, wireless=False, bus='pci', label=None): + """A device sitting at 'path'. Its key is what udev would report for it - + here the raw ID_PATH, which is what a plain virtio or e1000e NIC with no + firmware slot index ends up keyed on.""" + properties = {'ID_PATH': path, 'ID_BUS': bus} if path else {} + device = {'name': name, 'mac': mac, 'wireless': wireless, + 'properties': properties, + 'key': hardware_key(properties)} + if label is not None: + device['label_index'] = label + return device + + +def store_of(mapping, hardware=None): + """mapping: {name: path} - the slot each name is recorded against""" + return { + 'version': STORE_VERSION, + 'interfaces': {n: f'ID_PATH={p}' for n, p in mapping.items()}, + 'hardware': dict(hardware or {}), + } + + +class TestCanonicalOrder(unittest.TestCase): + def test_pci_sorted_numerically_not_lexically(self): + # 0000:09:00.0 must precede 0000:10:00.0 - string order gets this wrong + a = dev('x', 'pci-0000:00:09.0', 'aa:00:00:00:00:01') + b = dev('y', 'pci-0000:00:10.0', 'aa:00:00:00:00:02') + self.assertLess(canonical_sort_key(a), canonical_sort_key(b)) + + def test_pci_before_usb(self): + pci = dev('x', 'pci-0000:00:1f.6', 'aa:00:00:00:00:01') + usb = dev('y', 'usb-0:1:1.0', 'aa:00:00:00:00:00', bus='usb') + self.assertLess(canonical_sort_key(pci), canonical_sort_key(usb)) + + def test_order_is_independent_of_mac(self): + # the whole point: a numerically higher MAC in a lower slot still wins + low_slot = dev('x', 'pci-0000:00:02.0', 'ff:ff:ff:ff:ff:ff') + high_slot = dev('y', 'pci-0000:00:05.0', '00:00:00:00:00:01') + self.assertLess(canonical_sort_key(low_slot), canonical_sort_key(high_slot)) + + +class TestHypervisorAdapterOrder(unittest.TestCase): + """ESXi does not lay its adapters out in PCI address order. With four or + more it puts the fourth on a root port which sorts ahead of the first + three, so naming by PCI address alone made Network adapter 4 eth0. On the + OVA that moves the WAN: cloud-init takes eth0 from the OVF properties. + Reported against ESXi 8.0.3, vmx-21. + """ + + # the two interfaces from the report, verbatim + ADAPTER_4 = ('pci-0000:04:00.0', '00:0c:29:60:0e:5c', 3) # Ethernet3 + ADAPTER_1 = ('pci-0000:0b:00.0', '00:0c:29:60:0e:3e', 0) # Ethernet0 + + def test_the_first_adapter_sorts_first_despite_its_pci_address(self): + a4 = dev('eth0', self.ADAPTER_4[0], self.ADAPTER_4[1], label=self.ADAPTER_4[2]) + a1 = dev('eth1', self.ADAPTER_1[0], self.ADAPTER_1[1], label=self.ADAPTER_1[2]) + self.assertEqual(sorted([a4, a1], key=canonical_sort_key)[0]['mac'], + self.ADAPTER_1[1]) + + def test_four_adapters_are_named_in_adapter_order(self): + devices = [dev('?', 'pci-0000:04:00.0', 'aa:03', label=3), + dev('?', 'pci-0000:0b:00.0', 'aa:00', label=0), + dev('?', 'pci-0000:0c:00.0', 'aa:01', label=1), + dev('?', 'pci-0000:0d:00.0', 'aa:02', label=2)] + _, store, report = resolve(devices, empty_store()) + # assert on the hardware, not just that four names exist + self.assertEqual(report['bootstrapped'], + {'eth0': 'aa:00', 'eth1': 'aa:01', + 'eth2': 'aa:02', 'eth3': 'aa:03'}) + self.assertEqual(store['interfaces']['eth3'], 'ID_PATH=pci-0000:04:00.0') + + def test_a_labelled_and_an_unlabelled_device_can_be_compared(self): + # the sentinel has to be an int - sort keys are tuples compared + # element-wise, and None against int raises + labelled = dev('eth0', 'pci-0000:0b:00.0', 'aa:00', label=0) + plain = dev('eth1', 'pci-0000:03:00.0', 'bb:00') + self.assertEqual(sorted([plain, labelled], key=canonical_sort_key)[0]['mac'], + 'aa:00') + + def test_unlabelled_hardware_never_takes_the_first_name(self): + # a passthrough NIC is not in the OVF network mapping + devices = [dev('?', 'pci-0000:03:00.0', 'bb:00'), + dev('?', 'pci-0000:0b:00.0', 'aa:00', label=0)] + _, _, report = resolve(devices, empty_store()) + self.assertEqual(report['bootstrapped']['eth0'], 'aa:00') + + def test_adding_unlabelled_hardware_does_not_reshuffle_the_adapters(self): + adapters = [dev('?', 'pci-0000:0b:00.0', 'aa:00', label=0), + dev('?', 'pci-0000:04:00.0', 'aa:01', label=1)] + plain = dev('?', 'pci-0000:03:00.0', 'bb:00') + before = [d['mac'] for d in sorted(adapters, key=canonical_sort_key)] + after = [d['mac'] for d in sorted(adapters + [plain], key=canonical_sort_key)] + self.assertEqual(after[:len(before)], before) + + def test_the_order_survives_into_the_store(self): + # the names close the gap: two adapters labelled 0 and 3 become + # eth0 and eth1, the lowest free names in adapter order + devices = [dev('enp4s0', 'pci-0000:04:00.0', 'aa:03', label=3), + dev('enp11s0', 'pci-0000:0b:00.0', 'aa:00', label=0)] + plan, store, _ = resolve(devices, empty_store()) + self.assertEqual(plan, {'enp11s0': 'eth0', 'enp4s0': 'eth1'}) + + # the next boot sees them under the names it just gave them + for device in devices: + device['name'] = plan[device['name']] + + plan, _, report = resolve(devices, store) + self.assertEqual(plan, {}) + self.assertEqual(report['bootstrapped'], {}) + + def test_hardware_added_later_takes_the_next_name(self): + devices = [dev('?', 'pci-0000:0b:00.0', 'aa:00', label=0)] + _, store, _ = resolve(devices, empty_store()) + # a second adapter, labelled lower, must not displace the first + devices.append(dev('?', 'pci-0000:04:00.0', 'aa:09', label=9)) + _, store, report = resolve(devices, store) + self.assertEqual(report['bootstrapped'], {'eth1': 'aa:09'}) + + def test_the_label_is_ordering_only_never_identity(self): + # it must not reach hardware_key(), or a relabelled adapter would read + # as different hardware + properties = {'ID_PATH': 'pci-0000:0b:00.0', 'ID_BUS': 'pci'} + self.assertNotIn('Ethernet', hardware_key(properties)) + self.assertNotIn('label', hardware_key(properties)) + + +def hv(name, mac): + """A Hyper-V NIC as udev reports it on Azure: every one shares the ACPI + VMBus node as its ID_PATH and has no slot, so its MAC is its key.""" + return { + 'name': name, + 'mac': mac, + 'wireless': False, + 'properties': { + 'ID_PATH': 'acpi-MSFT1000:00', + 'ID_NET_NAME_MAC': 'enx' + mac.replace(':', ''), + 'ID_NET_DRIVER': 'hv_netvsc', + }, + } + + +def keyed(devices): + """Keys as discover_devices() computes them, shared ID_PATH passed over""" + ambiguous = ambiguous_keys([d['properties'] for d in devices]) + for device in devices: + device['key'] = hardware_key(device['properties'], ambiguous) + return devices + + +class TestHyperV(unittest.TestCase): + """Azure, four NICs, measured on a fresh deployment of this series: every + Hyper-V NIC shares one ID_PATH and has no slot, so its MAC is its key. + The order Azure lists the NICs in is cloud-init's to record; what is left + here is a NIC deleted and created again on the same VM, where cloud-init + does not run again. + """ + + AZURE = [ + ('eth0', '00:0d:3a:47:38:1d'), # WAN, the primary NIC + ('eth1', '00:0d:3a:47:3b:29'), + ('eth2', '00:0d:3a:47:33:ad'), + ('eth3', '00:0d:3a:47:3b:f8'), + ] + + def nics(self): + return keyed([hv(name, mac) for name, mac in self.AZURE]) + + def stored(self): + """the map as cloud-init records it on Azure: the names it gave""" + store = empty_store() + for name, mac in self.AZURE: + store['interfaces'][name] = 'ID_NET_NAME_MAC=enx' + mac.replace(':', '') + store['hardware'][name] = mac + plan, store, report = resolve(self.nics(), store) + assert plan == {} and len(report['matched']) == 4 + return store + + def test_each_nic_is_keyed_on_its_own_mac(self): + store = self.stored() + self.assertEqual( + sorted(store['interfaces'].values()), + sorted( + 'ID_NET_NAME_MAC=enx' + mac.replace(':', '') for _, mac in self.AZURE + ), + ) + + def test_a_recreated_nic_takes_the_name_of_the_one_it_replaced(self): + # LAN3's NIC deleted and created again: new MAC. It came up as eth4, + # eth3 stayed reserved and the boot commit failed on it every boot. + store = self.stored() + devices = self.nics() + devices[3] = keyed([hv('eth3', '00:0d:3a:47:aa:01')] + devices[:3])[0] + plan, store, report = resolve(devices, store) + self.assertEqual(report.get('adopted', {}), {'eth3': '00:0d:3a:47:aa:01'}) + self.assertEqual(report['bootstrapped'], {}) + self.assertEqual(plan, {}) + self.assertEqual(store['interfaces']['eth3'], 'ID_NET_NAME_MAC=enx000d3a47aa01') + + def test_two_recreated_nics_are_not_paired_by_guess(self): + # nothing here says which new NIC replaced which, and pairing them by + # the order they came up in can swap two subnets' configuration + store = self.stored() + devices = self.nics()[:2] + keyed( + [hv('eth3', '00:0d:3a:99:00:11'), hv('eth2', '00:0d:3a:99:00:33')] + ) + _, store, report = resolve(devices, store) + self.assertEqual(report.get('adopted', {}), {}) + self.assertEqual(set(report['bootstrapped']), {'eth4', 'eth5'}) + self.assertIn('eth2', store['interfaces']) + self.assertIn('eth3', store['interfaces']) + + def test_two_vacancies_and_one_new_nic_are_not_paired(self): + # which of two gone NICs the one new NIC replaced is not known + store = self.stored() + devices = self.nics()[:2] + keyed( + [hv('eth3', '00:0d:3a:99:00:11')] + self.nics()[:2])[:1] + _, store, report = resolve(devices, store) + self.assertEqual(report.get('adopted', {}), {}) + self.assertEqual(report['bootstrapped'], {'eth4': '00:0d:3a:99:00:11'}) + self.assertEqual(store.get('absent'), ['eth2', 'eth3']) + + def test_a_new_nic_with_no_vacancy_takes_a_new_name(self): + store = self.stored() + devices = self.nics() + keyed([hv('eth4', '00:0d:3a:48:00:09')]) + _, _, report = resolve(devices, store) + self.assertEqual(report.get('adopted', {}), {}) + self.assertEqual(report['bootstrapped'], {'eth4': '00:0d:3a:48:00:09'}) + + def test_one_vacancy_and_two_new_nics_are_not_paired(self): + store = self.stored() + devices = self.nics()[:3] + keyed( + [hv('eth3', '00:0d:3a:48:00:01'), hv('eth4', '00:0d:3a:48:00:02')] + ) + _, _, report = resolve(devices, store) + self.assertEqual(report.get('adopted', {}), {}) + + def test_a_disk_on_all_new_nics_is_not_handed_over_here(self): + # every recorded NIC gone at once: the platform's order is cloud-init's + # to supply (it runs again on a new instance); guessing it here could + # put the WAN's configuration on a LAN + store = self.stored() + new = keyed([hv(f'eth{i}', f'00:0d:3a:48:00:0{i}') for i in range(4)]) + _, _, report = resolve(new, store) + self.assertEqual(report.get('adopted', {}), {}) + + def test_a_slot_keyed_name_is_never_adopted(self): + # a PCI card has a slot to come back to; its name stays reserved + devices = [dev('eth0', 'pci-0000:00:03.0', 'aa:00')] + _, store, _ = resolve(devices, empty_store()) + other = keyed([hv('eth1', '00:0d:3a:48:00:09')]) + _, store, report = resolve(other, store) + self.assertEqual(report.get('adopted', {}), {}) + self.assertEqual(report['bootstrapped'], {'eth1': '00:0d:3a:48:00:09'}) + + def test_a_nic_added_after_one_was_removed_is_not_its_replacement(self): + # P8, measured on Azure: LAN3's NIC deleted for good (one boot), a NIC + # on another subnet added later (the next boot). Taking the vacant + # name put LAN3's configuration on the other subnet. + store = self.stored() + devices = self.nics()[:3] + _, store, report = resolve(devices, store) + self.assertEqual(report.get('adopted', {}), {}) + later = devices + keyed([hv('eth4', '38:33:c5:cc:a3:3e')]) + _, store, report = resolve(later, store) + self.assertEqual(report.get('adopted', {}), {}) + self.assertEqual(report['bootstrapped'], {'eth4': '38:33:c5:cc:a3:3e'}) + self.assertEqual(store.get('absent'), ['eth3']) + + def test_a_nic_replaced_on_a_running_system_is_not_adopted(self): + # measured on Hyper-V: a NIC hot-added after another was hot-removed + # took its name at once, while the configuration under that name + # stayed unapplied until the next boot + store = self.stored() + devices = self.nics() + devices[3] = keyed([hv('eth3', '00:0d:3a:47:aa:01')] + devices[:3])[0] + _, store, report = resolve(devices, store, adopt=False) + self.assertEqual(report.get('adopted', {}), {}) + self.assertEqual(report['bootstrapped'], {'eth4': '00:0d:3a:47:aa:01'}) + self.assertEqual(store.get('absent'), ['eth3']) + # nor at the next boot: the name was already missing at the last pass + _, store, report = resolve(devices, store) + self.assertEqual(report.get('adopted', {}), {}) + self.assertEqual(report['matched'].get('eth4'), '00:0d:3a:47:aa:01') + + def test_a_name_whose_nic_was_not_hyperv_is_not_handed_over(self): + # a MAC-keyed name can belong to other hardware sharing the VMBus + # path; only a Hyper-V NIC's name goes to a Hyper-V replacement + other = hv('eth1', '00:0d:3a:47:3b:29') + other['properties']['ID_NET_DRIVER'] = 'mlx5_core' + devices = keyed([hv('eth0', '00:0d:3a:47:38:1d'), other]) + _, store, _ = resolve(devices, empty_store()) + self.assertEqual(store.get('hyperv'), ['eth0']) + later = keyed([hv('eth0', '00:0d:3a:47:38:1d'), + hv('eth1', '00:0d:3a:48:00:09')]) + _, _, report = resolve(later, store) + self.assertEqual(report.get('adopted', {}), {}) + self.assertEqual(report['bootstrapped'], {'eth2': '00:0d:3a:48:00:09'}) + + def test_only_a_hyperv_nic_takes_a_vacant_name(self): + store = self.stored() + new = hv('eth3', '00:0d:3a:48:00:09') + new['properties']['ID_NET_DRIVER'] = 'virtio_net' + devices = self.nics()[:3] + keyed([new] + self.nics()[:3])[:1] + _, _, report = resolve(devices, store) + self.assertEqual(report.get('adopted', {}), {}) + self.assertEqual(report['bootstrapped'], {'eth4': '00:0d:3a:48:00:09'}) + + def test_a_new_nic_takes_no_name_of_another_namespace(self): + store = self.stored() + new = keyed([hv('wlan0', '00:0d:3a:48:00:09')] + self.nics()[:3])[0] + new['wireless'] = True + _, _, report = resolve(self.nics()[:3] + [new], store) + self.assertEqual(report.get('adopted', {}), {}) + self.assertEqual(report['bootstrapped'], {'wlan0': '00:0d:3a:48:00:09'}) + + def test_a_store_that_does_not_say_which_nics_were_hyperv_hands_nothing_over(self): + # written by code that did not record it: whether the vacant name + # was a Hyper-V NIC's, or how long it has been vacant, is unknown + store = self.stored() + store.pop('hyperv') + devices = self.nics() + devices[3] = keyed([hv('eth3', '00:0d:3a:47:aa:01')] + devices[:3])[0] + _, store, report = resolve(devices, store) + self.assertEqual(report.get('adopted', {}), {}) + self.assertEqual(report['bootstrapped'], {'eth4': '00:0d:3a:47:aa:01'}) + self.assertEqual(store.get('absent'), ['eth3']) + + def test_the_hyperv_record_survives_a_load_and_follows_the_nics(self): + store = self.stored() + self.assertEqual(store['hyperv'], ['eth0', 'eth1', 'eth2', 'eth3']) + with tempfile.TemporaryDirectory() as d: + path = Path(d) / 'map.json' + with mock.patch('vyos.ifconfig.ifname_store.config_dir_is_mounted', + return_value=True), \ + mock.patch('vyos.ifconfig.ifname_store.get_cfg_group_id', + return_value=0): + save_store(store, path) + self.assertEqual(load_store(path)['hyperv'], store['hyperv']) + # an absent NIC keeps its record, so its replacement is recognised + _, store, _ = resolve(self.nics()[:3], store) + self.assertEqual(store['hyperv'], ['eth0', 'eth1', 'eth2', 'eth3']) + + def test_a_lone_hyperv_nic_keyed_on_its_path_still_pins_its_driver(self): + # one NIC has the VMBus path to itself, so it is keyed on it; its VF + # must not match its file any more than with a MAC key + devices = keyed([hv('eth0', '00:0d:3a:47:38:1d')]) + _, store, _ = resolve(devices, empty_store()) + self.assertTrue(store['interfaces']['eth0'].startswith('ID_PATH=')) + with tempfile.TemporaryDirectory() as d: + sync_link_files(store, link_dir=Path(d), devices=devices) + body = (Path(d) / '10-vyos-eth0.link').read_text() + self.assertIn('Driver=hv_netvsc\n', body) + + def test_a_store_without_the_record_still_adopts(self): + # written before names missing at a pass were recorded: nothing was + # missing then, as far as it can say, so a one-step replacement holds + store = self.stored() + store.pop('absent', None) + devices = self.nics() + devices[3] = keyed([hv('eth3', '00:0d:3a:47:aa:01')] + devices[:3])[0] + _, _, report = resolve(devices, store) + self.assertEqual(report.get('adopted', {}), {'eth3': '00:0d:3a:47:aa:01'}) + + def test_the_record_clears_when_the_nic_is_back(self): + store = self.stored() + _, store, _ = resolve(self.nics()[:3], store) + self.assertEqual(store.get('absent'), ['eth3']) + _, store, report = resolve(self.nics(), store) + self.assertNotIn('absent', store) + self.assertEqual(report['matched']['eth3'], self.AZURE[3][1]) + + def test_the_record_survives_a_load(self): + tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, tmp) + path = tmp / 'interface-mapping.json' + store = self.stored() + _, store, _ = resolve(self.nics()[:3], store) + path.write_text(json.dumps(store)) + self.assertEqual(load_store(path).get('absent'), ['eth3']) + # a name the map no longer has is dropped from it + store['absent'] = ['eth3', 'eth9'] + path.write_text(json.dumps(store)) + self.assertEqual(load_store(path).get('absent'), ['eth3']) + + def test_link_file_pins_the_driver_for_a_mac_key(self): + # the accelerated-networking VF carries its NIC's MAC + body = render_link('eth0', 'ID_NET_NAME_MAC=enx000d3a47381d', 'hv_netvsc') + self.assertIn( + 'Property=ID_NET_NAME_MAC=enx000d3a47381d\nDriver=hv_netvsc\n', body + ) + + def test_link_file_without_a_driver_is_unchanged(self): + self.assertNotIn('Driver=', render_link('eth0', 'ID_PATH=pci-0000:00:03.0')) + + def link_dir(self): + tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, tmp) + return tmp + + def test_sync_pins_the_driver_for_hyperv_only(self): + devices = self.nics() + usb = { + 'name': 'eth4', + 'mac': 'aa:00:00:00:00:04', + 'wireless': False, + 'properties': { + 'ID_NET_NAME_MAC': 'enxaa0000000004', + 'ID_NET_DRIVER': 'r8152', + }, + } + devices += keyed([usb]) + _, store, _ = resolve(devices, empty_store()) + tmp = self.link_dir() + sync_link_files(store, link_dir=tmp, devices=devices) + name_of = {key: name for name, key in store['interfaces'].items()} + wan = name_of['ID_NET_NAME_MAC=enx000d3a47381d'] + other = name_of['ID_NET_NAME_MAC=enxaa0000000004'] + self.assertIn('Driver=hv_netvsc', (tmp / f'10-vyos-{wan}.link').read_text()) + self.assertNotIn('Driver=', (tmp / f'10-vyos-{other}.link').read_text()) + + def test_an_absent_nic_keeps_its_driver_pin(self): + # a boot without the NIC used to rewrite its file without the pin, and + # on its return its VF matched the file again + devices = self.nics() + _, store, _ = resolve(devices, empty_store()) + tmp = self.link_dir() + sync_link_files(store, link_dir=tmp, devices=devices) + sync_link_files(store, link_dir=tmp, devices=devices[:3]) + self.assertIn('Driver=hv_netvsc', (tmp / '10-vyos-eth3.link').read_text()) + + +class TestReplacedHardware(unittest.TestCase): + """A slot keeps its name when its card is swapped - which hands that + interface's addresses to different hardware. resolve() reports the change + so vyos-router can warn instead of letting it happen silently. + """ + + PATH = 'pci-0000:00:12.0' + + def _device(self, mac): + return dev('eth0', self.PATH, mac) + + def test_same_card_reports_nothing(self): + store = store_of({'eth0': self.PATH}, {'eth0': 'aa:aa:aa:aa:aa:01'}) + _, _, report = resolve([self._device('aa:aa:aa:aa:aa:01')], store) + self.assertEqual(report['replaced'], {}) + + def test_swapped_card_is_reported_with_its_new_mac(self): + store = store_of({'eth0': self.PATH}, {'eth0': 'aa:aa:aa:aa:aa:01'}) + _, new_store, report = resolve([self._device('bb:bb:bb:bb:bb:02')], store) + self.assertEqual(report['replaced'], {'eth0': 'bb:bb:bb:bb:bb:02'}) + # the name is still the slot's, and the new card is now what is on record + self.assertEqual(new_store['interfaces']['eth0'], f'ID_PATH={self.PATH}') + self.assertEqual(new_store['hardware']['eth0'], 'bb:bb:bb:bb:bb:02') + + def test_reported_once_not_on_every_later_boot(self): + store = store_of({'eth0': self.PATH}, {'eth0': 'aa:aa:aa:aa:aa:01'}) + _, store, _ = resolve([self._device('bb:bb:bb:bb:bb:02')], store) + _, _, report = resolve([self._device('bb:bb:bb:bb:bb:02')], store) + self.assertEqual(report['replaced'], {}) + + def test_absent_card_keeps_its_record_so_a_later_swap_is_caught(self): + store = store_of({'eth0': self.PATH}, {'eth0': 'aa:aa:aa:aa:aa:01'}) + _, store, _ = resolve([], store) + self.assertEqual(store['hardware']['eth0'], 'aa:aa:aa:aa:aa:01') + _, _, report = resolve([self._device('bb:bb:bb:bb:bb:02')], store) + self.assertEqual(report['replaced'], {'eth0': 'bb:bb:bb:bb:bb:02'}) + + def test_new_hardware_is_not_a_replacement(self): + _, _, report = resolve([self._device('aa:aa:aa:aa:aa:01')], empty_store()) + self.assertEqual(report['replaced'], {}) + self.assertIn('eth0', report['bootstrapped']) + + def test_store_written_before_this_field_reports_nothing(self): + # nothing to compare against yet - and no interface may be renamed + legacy = {'version': STORE_VERSION, + 'interfaces': {'eth0': f'ID_PATH={self.PATH}'}} + plan, new_store, report = resolve([self._device('aa:aa:aa:aa:aa:01')], + legacy) + self.assertEqual(report['replaced'], {}) + self.assertEqual(plan, {}) + self.assertEqual(new_store['interfaces']['eth0'], f'ID_PATH={self.PATH}') + + +class TestMovedHardware(unittest.TestCase): + """A card carries its name with it. Matching on the slot alone would give + the name - and the address configured under it - to whatever took the old + position, which is how a card added for another purpose ended up on + another segment's address. + """ + + A = 'aa:aa:aa:aa:aa:01' + B = 'bb:bb:bb:bb:bb:02' + C = 'cc:cc:cc:cc:cc:03' + + def setUp(self): + self.store = store_of({'eth0': 'pci-0000:00:02.0', + 'eth1': 'pci-0000:00:03.0'}, + {'eth0': self.A, 'eth1': self.B}) + + @staticmethod + def _at(slot, mac): + return dev('?', f'pci-0000:00:{slot:02x}.0', mac) + + def test_card_moved_to_another_slot_keeps_its_name(self): + _, store, report = resolve([self._at(4, self.A), self._at(3, self.B)], + self.store) + self.assertEqual(report['moved'], {'eth0': self.A}) + self.assertEqual(report['replaced'], {}) + self.assertEqual(store['interfaces']['eth0'], 'ID_PATH=pci-0000:00:04.0') + + def test_a_new_card_in_the_vacated_slot_does_not_inherit_the_name(self): + # the shape reported from the field, with the original card still in + # the box: it keeps eth0, and the newcomer is named as new hardware + _, store, report = resolve( + [self._at(4, self.A), self._at(3, self.B), self._at(2, self.C)], + self.store) + self.assertEqual(report['moved'], {'eth0': self.A}) + self.assertEqual(report['bootstrapped'], {'eth2': self.C}) + self.assertEqual(report['replaced'], {}) + self.assertEqual(store['interfaces']['eth0'], 'ID_PATH=pci-0000:00:04.0') + + def test_two_known_cards_swapping_slots_stay_with_their_slots(self): + # Both cards are on record, so this is a rearrangement within the set + # the store already describes - and the store is what decides names. + # The same path covers an operator swapping two entries by hand, which + # has to take effect or the map would not be authoritative at all. + _, store, report = resolve([self._at(3, self.A), self._at(2, self.B)], + self.store) + self.assertEqual(report['moved'], {}) + self.assertEqual(report['matched'], {}) + self.assertEqual(report['reassigned'], {'eth0': self.B, 'eth1': self.A}) + self.assertEqual(store['interfaces']['eth0'], 'ID_PATH=pci-0000:00:02.0') + self.assertEqual(store['hardware']['eth0'], self.B) + + def test_editing_the_slot_map_by_hand_takes_effect(self): + # what `make test-interface-naming` does: swap the two entries in 'interfaces' + # and leave 'hardware' alone. Following the recorded address here + # would quietly undo the edit. + store = store_of({'eth0': 'pci-0000:00:03.0', + 'eth1': 'pci-0000:00:02.0'}, + {'eth0': self.A, 'eth1': self.B}) + _, _, report = resolve([self._at(2, self.A), self._at(3, self.B)], + store) + self.assertEqual(report['matched'], {}) + self.assertEqual(report['reassigned'], {'eth0': self.B, 'eth1': self.A}) + self.assertEqual(report['moved'], {}) + + def test_identity_wins_over_the_slot(self): + # both rules could fire: the card that owns the name sits elsewhere + # while another card occupies its recorded slot. The card wins. + _, _, report = resolve( + [self._at(4, self.A), self._at(2, self.C), self._at(3, self.B)], + self.store) + self.assertIn('eth0', report['moved']) + self.assertNotIn('eth0', report['replaced']) + + def test_gone_card_still_falls_back_to_the_slot_and_is_reported(self): + # the original is nowhere to be found, so nothing distinguishes an + # in-place replacement from an unrelated card - the slot decides, loudly + _, _, report = resolve([self._at(2, self.C), self._at(3, self.B)], + self.store) + self.assertEqual(report['replaced'], {'eth0': self.C}) + self.assertEqual(report['moved'], {}) + + +class TestWwanNaming(unittest.TestCase): + """A modem is addressed as wwanN by the CLI - 'interfaces wwan' looks at + nothing else. Naming it ethN like any other port left an HP lt4132 bound, + working and completely invisible on a DUT. + """ + + WWP = 'wwp0s20f0u2u4c3' + MODEM_MAC = 'b6:53:dd:66:c2:98' + + def _modem(self, name='eth6'): + # no address: discovery keeps none for a modem + properties = {'ID_NET_NAME_PATH': self.WWP, 'ID_BUS': 'usb'} + return {'name': name, 'mac': '', 'wireless': False, + 'wwan': True, 'properties': properties, + 'key': f'ID_NET_NAME_PATH={self.WWP}'} + + def _nic(self, name='eth0', path='enp3s0', mac='00:f0:cb:fe:ba:99'): + properties = {'ID_NET_NAME_PATH': path, 'ID_BUS': 'pci'} + return {'name': name, 'mac': mac, 'wireless': False, 'wwan': False, + 'properties': properties, 'key': f'ID_NET_NAME_PATH={path}'} + + def test_a_modem_is_named_in_its_own_namespace(self): + _, store, _ = resolve([self._nic(), self._modem()], empty_store()) + self.assertEqual(store['interfaces']['wwan0'], + f'ID_NET_NAME_PATH={self.WWP}') + # and it must not have eaten an ethernet number on the way + self.assertEqual(store['interfaces']['eth0'], + 'ID_NET_NAME_PATH=enp3s0') + self.assertNotIn('eth1', store['interfaces']) + + def test_the_link_file_names_the_type_the_device_is(self): + # a .link naming a type the device is not simply never applies + body = render_link('wwan0', f'ID_NET_NAME_PATH={self.WWP}') + self.assertIn('Type=wwan', body) + self.assertNotIn('Type=ether', body) + + def test_a_modem_filed_under_an_ethernet_name_is_reclaimed(self): + # what an earlier version left on the DUT: the entry still matches the + # modem by path, so without dropping it the modem is captured again and + # stays invisible to 'interfaces wwan' + store = {'version': STORE_VERSION, + 'interfaces': {'eth0': 'ID_NET_NAME_PATH=enp3s0', + 'eth6': f'ID_NET_NAME_PATH={self.WWP}'}, + 'hardware': {'eth0': '00:f0:cb:fe:ba:99', + 'eth6': self.MODEM_MAC}} + plan, new_store, _ = resolve([self._nic(), self._modem()], store) + + self.assertNotIn('eth6', new_store['interfaces']) + self.assertNotIn('eth6', new_store['hardware']) + self.assertEqual(new_store['interfaces']['wwan0'], + f'ID_NET_NAME_PATH={self.WWP}') + self.assertEqual(plan, {'eth6': 'wwan0'}) + + def test_the_stale_link_file_goes_with_it(self): + store = {'version': STORE_VERSION, + 'interfaces': {'eth6': f'ID_NET_NAME_PATH={self.WWP}'}, + 'hardware': {'eth6': self.MODEM_MAC}} + _, new_store, _ = resolve([self._modem()], store) + with tempfile.TemporaryDirectory() as d: + (Path(d) / '10-vyos-eth6.link').write_text('left over') + sync_link_files(new_store, Path(d)) + names = sorted(p.name for p in Path(d).iterdir()) + self.assertEqual(names, ['10-vyos-wwan0.link']) + + def test_a_nic_in_the_modem_s_socket_does_not_take_its_name(self): + # the modem is gone and an ordinary USB NIC is in the port it used. + # The slot matches, but wwan0 is a name 'interfaces wwan' reads - give + # it to a NIC and the modem's configuration is applied to a card that + # cannot carry it. Leave the name reserved and name the NIC as a NIC. + store = {'version': STORE_VERSION, + 'interfaces': {'wwan0': f'ID_NET_NAME_PATH={self.WWP}'}, + 'hardware': {}} + nic = self._nic('eth0', self.WWP, 'aa:bb:cc:dd:ee:07') + _, new_store, report = resolve([nic], store) + + self.assertEqual(report['replaced'], {}) + self.assertIn('eth0', new_store['interfaces']) + self.assertEqual(new_store['interfaces']['wwan0'], + f'ID_NET_NAME_PATH={self.WWP}') + + def test_a_modem_is_not_reported_as_replaced(self): + # its address changes at every probe, so comparing it reported the + # modem as a different card in the same socket on every boot + store = {'version': STORE_VERSION, + 'interfaces': {'wwan0': f'ID_NET_NAME_PATH={self.WWP}'}, + 'hardware': {'wwan0': self.MODEM_MAC}} + plan, new_store, report = resolve([self._modem('wwan0')], store) + + self.assertEqual(plan, {}) + self.assertEqual(report['replaced'], {}) + self.assertIn('wwan0', report['matched']) + # and an address an earlier version recorded is dropped, so a store + # already carrying one settles on the next pass + self.assertNotIn('wwan0', new_store['hardware']) + + def test_a_card_whose_address_changed_is_still_replaced(self): + # the modem must not have weakened this: a card in a recorded slot + # with a different address is the case worth warning about + store = {'version': STORE_VERSION, + 'interfaces': {'eth0': 'ID_NET_NAME_PATH=enp3s0'}, + 'hardware': {'eth0': '00:f0:cb:fe:ba:99'}} + nic = self._nic('eth0', 'enp3s0', 'aa:bb:cc:dd:ee:07') + _, _, report = resolve([nic], store) + + self.assertIn('eth0', report['replaced']) + + def test_an_ordinary_port_on_the_usb_bus_is_not_a_modem(self): + # the key alone cannot tell them apart - a device falling back to + # ID_PATH reads usb-... whether it is a modem or not + usb_nic = self._nic('eth1', 'enp0s20f0u3', 'aa:bb:cc:dd:ee:01') + usb_nic['properties'] = {'ID_PATH': 'usb-0:2:1.0', 'ID_BUS': 'usb'} + usb_nic['key'] = 'ID_PATH=usb-0:2:1.0' + _, store, _ = resolve([usb_nic], empty_store()) + self.assertIn('eth0', store['interfaces']) + self.assertNotIn('wwan0', store['interfaces']) + + +class TestStoreIO(unittest.TestCase): + @staticmethod + def _config_dir(d): + """An ordinary, unencrypted config directory - the common case.""" + return Path(d) / 'interface-mapping.json' + + def test_roundtrip(self): + with tempfile.TemporaryDirectory() as d, \ + mock.patch('vyos.ifconfig.ifname_store.encrypted_config_volume', + return_value=False): + p = self._config_dir(d) + s = store_of({'eth0': 'pci-0000:00:12.0'}) + self.assertTrue(save_store(s, p)) + self.assertEqual(load_store(p), s) + + def test_refuses_to_write_when_the_encrypted_volume_is_not_mounted(self): + # TPM/LUKS unlock failed: vyos-router continues, but persisting here + # would write a shadow store that the real volume masks later + with tempfile.TemporaryDirectory() as d, \ + mock.patch('vyos.ifconfig.ifname_store.encrypted_config_volume', + return_value=True): + p = Path(d) / 'interface-mapping.json' # a plain dir, not a mount + self.assertFalse(save_store(store_of({'eth0': 'x'}), p)) + self.assertFalse(p.exists()) + + def test_writes_on_the_first_boot_of_a_prebuilt_image(self): + # T3871: the check used to key on '.vyatta_config', which an + # activation script writes only AFTER interface naming has run. On a + # qcow2 that marker is absent on the first boot, so no store was ever + # written and the next boot named the hardware from scratch - pull a + # card and every name below it moved onto the wrong port. + with tempfile.TemporaryDirectory() as d, \ + mock.patch('vyos.ifconfig.ifname_store.encrypted_config_volume', + return_value=False): + p = Path(d) / 'interface-mapping.json' + self.assertFalse((Path(d) / '.vyatta_config').exists()) + self.assertTrue(save_store(store_of({'eth0': 'pci-0000:00:12.0'}), p)) + self.assertTrue(p.exists()) + + def test_unencrypted_config_needs_no_mount_point(self): + # the config directory of an ordinary install is not a mount of its + # own - only an encrypted one is + with tempfile.TemporaryDirectory() as d, \ + mock.patch('vyos.ifconfig.ifname_store.encrypted_config_volume', + return_value=False): + self.assertTrue(config_dir_is_mounted( + Path(d) / 'interface-mapping.json')) + + def test_missing_file_is_empty_store(self): + with tempfile.TemporaryDirectory() as d: + self.assertEqual(load_store(Path(d) / 'nope.json'), empty_store()) + + def test_corrupt_file_is_empty_store(self): + with tempfile.TemporaryDirectory() as d: + p = self._config_dir(d) + p.write_text('{ this is not json') + self.assertEqual(load_store(p), empty_store()) + + def test_write_is_atomic_no_leftovers(self): + with tempfile.TemporaryDirectory() as d, \ + mock.patch('vyos.ifconfig.ifname_store.encrypted_config_volume', + return_value=False): + p = self._config_dir(d) + save_store(store_of({'eth0': 'pci-0000:00:12.0'}), p) + self.assertEqual(sorted(f.name for f in Path(d).iterdir()), + ['interface-mapping.json']) + + +def final_names(report): + """Which name each MAC ended up with, read back off the resolve report.""" + out = {} + for bucket in report.values(): + for name, mac in bucket.items(): + out[mac] = name + return out + + +class TestDeviceMatches(unittest.TestCase): + """A stored key names the property it was taken from, so matching a + device is comparing that one property - never a guess about which + identifier was meant.""" + + def test_matches_the_named_property(self): + self.assertTrue(device_matches({'ID_NET_NAME_SLOT': 'ens18'}, + 'ID_NET_NAME_SLOT=ens18')) + + def test_same_value_under_a_different_property_does_not_match(self): + self.assertFalse(device_matches({'ID_NET_NAME_PATH': 'ens18'}, + 'ID_NET_NAME_SLOT=ens18')) + + def test_device_without_the_property_does_not_match(self): + self.assertFalse(device_matches({'ID_PATH': 'pci-0000:00:12.0'}, + 'ID_NET_NAME_SLOT=ens18')) + + def test_empty_or_malformed_key_never_matches(self): + self.assertFalse(device_matches({'ID_NET_NAME_SLOT': 'ens18'}, '')) + self.assertFalse(device_matches({'ID_NET_NAME_SLOT': 'ens18'}, 'ens18')) + + +class TestResolveBasics(unittest.TestCase): + def test_first_boot_bootstraps_in_slot_order(self): + devices = [ + dev('eth0', 'pci-0000:00:15.0', '00:00:5e:00:53:03'), + dev('eth1', 'pci-0000:00:12.0', '00:00:5e:00:53:00'), + dev('eth2', 'pci-0000:00:13.0', '00:00:5e:00:53:01'), + ] + _, new_store, _ = resolve(devices, empty_store()) + self.assertEqual(new_store['interfaces']['eth0'], 'ID_PATH=pci-0000:00:12.0') + self.assertEqual(new_store['interfaces']['eth1'], 'ID_PATH=pci-0000:00:13.0') + self.assertEqual(new_store['interfaces']['eth2'], 'ID_PATH=pci-0000:00:15.0') + + def test_established_names_are_stable_regardless_of_probe_order(self): + store = store_of({'eth0': 'pci-0000:00:12.0', 'eth1': 'pci-0000:00:13.0'}) + # drivers finished in the opposite order this boot + devices = [ + dev('eth0', 'pci-0000:00:13.0', '00:00:5e:00:53:01'), + dev('eth1', 'pci-0000:00:12.0', '00:00:5e:00:53:00'), + ] + plan, _, _ = resolve(devices, store) + self.assertEqual(plan, {'eth0': 'eth1', 'eth1': 'eth0'}) + + def test_replacement_nic_in_same_slot_inherits_name(self): + # the slot is the identity, so this needs no special rule: whatever + # card is in eth1's slot is eth1, along with its configuration + store = store_of({'eth1': 'pci-0000:00:13.0'}) + devices = [dev('eth0', 'pci-0000:00:13.0', 'bb:bb:bb:bb:bb:bb')] + plan, new_store, report = resolve(devices, store) + self.assertEqual(plan, {'eth0': 'eth1'}) + self.assertEqual(report['matched'], {'eth1': 'bb:bb:bb:bb:bb:bb'}) + self.assertEqual(new_store['interfaces']['eth1'], 'ID_PATH=pci-0000:00:13.0') + + def test_a_card_moved_to_another_slot_takes_that_slot_s_name(self): + # the consequence of slot identity, stated plainly: move a card and it + # becomes whatever the new slot is called, not what it used to be + store = store_of({'eth1': 'pci-0000:00:13.0', 'eth2': 'pci-0000:00:14.0'}) + devices = [dev('eth9', 'pci-0000:00:14.0', 'aa:aa:aa:aa:aa:aa')] + plan, _, report = resolve(devices, store) + self.assertEqual(plan, {'eth9': 'eth2'}) + self.assertEqual(final_names(report)['aa:aa:aa:aa:aa:aa'], 'eth2') + + def test_absent_hardware_keeps_its_name_reserved(self): + store = store_of({'eth0': 'pci-0000:00:12.0', 'eth1': 'pci-0000:00:13.0'}) + # eth0's slot is empty; a brand new card appeared elsewhere + devices = [ + dev('eth0', 'pci-0000:00:13.0', '00:00:5e:00:53:01'), + dev('eth1', 'pci-0000:00:1c.0', 'cc:cc:cc:cc:cc:cc'), + ] + _, new_store, report = resolve(devices, store) + # the new card must not inherit eth0's name, and with it eth0's config + self.assertNotEqual(final_names(report)['cc:cc:cc:cc:cc:cc'], 'eth0') + self.assertEqual(new_store['interfaces']['eth0'], 'ID_PATH=pci-0000:00:12.0') + + def test_hardware_with_no_usable_path_falls_back_to_its_mac(self): + # some paravirtual buses give udev nothing to build a path from; the + # MAC-derived name is the last step of the key chain so the interface + # is still stable instead of being renamed every boot + properties = {'ID_NET_NAME_MAC': 'enxaabbccddeeff'} + device = {'name': 'eth0', 'mac': 'aa:bb:cc:dd:ee:ff', 'wireless': False, + 'properties': properties, 'key': hardware_key(properties)} + _, new_store, _ = resolve([device], empty_store()) + self.assertEqual(new_store['interfaces']['eth0'], + 'ID_NET_NAME_MAC=enxaabbccddeeff') + + def test_a_path_two_devices_share_is_not_used_to_identify_either(self): + # a modem can put several interfaces on one parent device, and they + # then report the same path. Keying both on it would give them one + # name between them, so fall through to something which tells them + # apart. + first = {'ID_PATH': 'pci-0000:00:12.0', + 'ID_NET_NAME_MAC': 'enxaabbccddee01'} + second = {'ID_PATH': 'pci-0000:00:12.0', + 'ID_NET_NAME_MAC': 'enxaabbccddee02'} + ambiguous = ambiguous_keys([first, second]) + self.assertEqual(hardware_key(first, ambiguous), + 'ID_NET_NAME_MAC=enxaabbccddee01') + self.assertEqual(hardware_key(second, ambiguous), + 'ID_NET_NAME_MAC=enxaabbccddee02') + + def test_a_path_only_one_device_has_is_still_used(self): + first = {'ID_PATH': 'pci-0000:00:12.0'} + second = {'ID_PATH': 'pci-0000:00:13.0'} + ambiguous = ambiguous_keys([first, second]) + self.assertEqual(hardware_key(first, ambiguous), + 'ID_PATH=pci-0000:00:12.0') + + def test_firmware_slot_is_preferred_over_a_bare_pci_path(self): + # a firmware slot index survives PCI renumbering, a bus address does not + properties = {'ID_NET_NAME_SLOT': 'ens18', + 'ID_NET_NAME_PATH': 'enp0s18', + 'ID_PATH': 'pci-0000:00:12.0'} + self.assertEqual(hardware_key(properties), 'ID_NET_NAME_SLOT=ens18') + + def test_a_radio_recorded_by_an_older_version_is_dropped(self): + # Wireless is no longer named here: every interface on a phy carries + # that phy's address, so an entry recorded against one of them would + # be applied to whichever appeared next - renaming an interface the + # operator had just created by name. Drop what an older version wrote + # so the entry, and the .link file made from it, go away on upgrade. + with tempfile.TemporaryDirectory() as d: + p = Path(d) / 'interface-mapping.json' + p.write_text(json.dumps({ + 'version': STORE_VERSION, + 'interfaces': {'eth0': 'ID_PATH=pci-0000:00:12.0', + 'wlan0': 'ID_NET_NAME_MAC=wlx020000000000'}, + 'hardware': {'eth0': 'aa:aa:aa:aa:aa:aa', + 'wlan0': '02:00:00:00:00:00'}})) + store = load_store(p) + self.assertEqual(list(store['interfaces']), ['eth0']) + self.assertNotIn('wlan0', store['hardware']) + + +class TestPR5417Regressions(unittest.TestCase): + """Each test replays a reproduction from the review of PR #5417, where the + config-tree-driven resolver put a configured address on the wrong wire. + The assertions are about physical correctness - which MAC ends up behind + which name - not about log output. + """ + + def test_multi_nic_cloud_init_first_boot(self): + """2026-08-25 #1: three NICs, cloud-init synthesized a hw-id for eth0 + only. eth1/eth2 were pushed to eth3/eth4 and both data addresses were + unreachable, persisting across reboot. + """ + devices = [ + dev('eth0', 'pci-0000:00:03.0', '52:54:00:00:00:10'), + dev('eth1', 'pci-0000:00:04.0', '52:54:00:ff:00:21'), + dev('eth2', 'pci-0000:00:05.0', '52:54:00:00:00:22'), + ] + _, new_store, report = resolve(devices, empty_store()) + names = final_names(report) + self.assertEqual(names['52:54:00:00:00:10'], 'eth0') + self.assertEqual(names['52:54:00:ff:00:21'], 'eth1') + self.assertEqual(names['52:54:00:00:00:22'], 'eth2') + self.assertEqual(set(new_store['interfaces']), {'eth0', 'eth1', 'eth2'}) + + def test_names_follow_slots_not_mac_magnitude(self): + """2026-08-25 #2: the resolver swapped eth1/eth2 by MAC order, so each + address landed on the other's physical network, and the swap was + persisted with no warning. eth1 sits in the lower slot but has the + numerically HIGHER MAC. + """ + devices = [ + dev('eth1', 'pci-0000:00:04.0', '52:54:00:ff:00:21'), + dev('eth2', 'pci-0000:00:05.0', '52:54:00:00:00:22'), + ] + _, _, report = resolve(devices, empty_store()) + names = final_names(report) + self.assertEqual(names['52:54:00:ff:00:21'], 'eth0') + self.assertEqual(names['52:54:00:00:00:22'], 'eth1') + + def test_box_named_out_of_slot_order_is_not_reordered(self): + """2026-09-02 / 2026-09-09: a box first named by an earlier release in + MAC order - eth1 on the slot-5 NIC, eth4 on the slot-2 NIC. Deleting + hw-id entries made the resolver re-derive names in PCI slot order and + put eth1 and eth4 on each other's cable, silently and permanently. + + The store records the slot each name was actually on, so nothing is + ever re-derived and the box keeps the names it had. + """ + store = store_of({ + 'eth0': 'pci-0000:00:01.0', + 'eth1': 'pci-0000:00:05.0', + 'eth2': 'pci-0000:00:03.0', + 'eth3': 'pci-0000:00:04.0', + 'eth4': 'pci-0000:00:02.0', + }) + devices = [ + dev('eth0', 'pci-0000:00:01.0', '00:00:5e:00:54:01'), + dev('eth1', 'pci-0000:00:05.0', '00:00:5e:00:54:12'), + dev('eth2', 'pci-0000:00:03.0', '00:00:5e:00:54:13'), + dev('eth3', 'pci-0000:00:04.0', '00:00:5e:00:54:14'), + dev('eth4', 'pci-0000:00:02.0', '00:00:5e:00:54:19'), + ] + plan, _, report = resolve(devices, store) + self.assertEqual(plan, {}, 'nothing may be renamed') + names = final_names(report) + self.assertEqual(names['00:00:5e:00:54:12'], 'eth1') + self.assertEqual(names['00:00:5e:00:54:19'], 'eth4') + + def test_spare_nic_does_not_displace_configured_interfaces(self): + """2026-09-02 #2: a fourth, unconfigured NIC was enough to make the + three configured interfaces fail to get their hardware - eth1/eth2 + stayed bare while eth3/eth4/eth5 appeared holding the NICs. + """ + devices = [ + dev('eth0', 'pci-0000:00:03.0', '00:00:5e:00:54:31'), + dev('eth1', 'pci-0000:00:04.0', '00:00:5e:00:54:39'), + dev('eth2', 'pci-0000:00:05.0', '00:00:5e:00:54:32'), + dev('eth3', 'pci-0000:00:06.0', '00:00:5e:00:54:3c'), + ] + _, new_store, report = resolve(devices, empty_store()) + self.assertEqual(set(new_store['interfaces']), + {'eth0', 'eth1', 'eth2', 'eth3'}) + names = final_names(report) + self.assertEqual(names['00:00:5e:00:54:31'], 'eth0') + self.assertEqual(names['00:00:5e:00:54:39'], 'eth1') + self.assertEqual(names['00:00:5e:00:54:32'], 'eth2') + + def test_config_node_without_hardware_does_not_block_the_others(self): + """2026-09-09: eth0/eth1/eth2 configured with no hw-id on a two-NIC + VM. Every interface was left pending, both NICs parked on eth3/eth4, + and the box was unreachable from the network. + """ + devices = [ + dev('eth0', 'pci-0000:00:03.0', '00:00:5e:00:54:a1'), + dev('eth1', 'pci-0000:00:04.0', '00:00:5e:00:54:a9'), + ] + plan, new_store, report = resolve(devices, empty_store()) + # the two NICs present take eth0 and eth1; a configured eth2 node + # simply has no hardware, which is a reporting matter not a naming one + self.assertEqual(plan, {}) + names = final_names(report) + self.assertEqual(names['00:00:5e:00:54:a1'], 'eth0') + self.assertEqual(names['00:00:5e:00:54:a9'], 'eth1') + self.assertNotIn('eth3', new_store['interfaces']) + + +class TestLinkFiles(unittest.TestCase): + """The .link layer is what removes the rename window: udev applies the + name as the device is added, so no probe-order name is ever observable. + """ + + def test_matches_the_same_property_the_store_keyed_on(self): + # udev must apply exactly the mapping recorded in the store - if the + # .link matched on anything else the two could disagree about what + # identifies an interface + body = render_link('eth1', 'ID_NET_NAME_SLOT=ens18') + self.assertIn('Property=ID_NET_NAME_SLOT=ens18', body) + self.assertIn('Name=eth1', body) + self.assertIn('Type=ether', body) + + def test_the_address_policy_is_spelled_out(self): + # only the first matching file is applied, so this one shadows the + # policy the distribution ships. Without it a modem keeps the random + # address its driver gives it, fresh on every boot. + for name in ('eth1', 'wwan0'): + body = render_link(name, 'ID_NET_NAME_PATH=wwp0s20f0u2u4c3') + self.assertIn('MACAddressPolicy=persistent', body) + + def test_a_file_without_the_policy_is_rewritten(self): + # the upgrade path: a box already carrying files from before only + # picks this up if an existing one is replaced + store = {'version': STORE_VERSION, + 'interfaces': {'wwan0': 'ID_NET_NAME_PATH=wwp0s20f0u2u4c3'}, + 'hardware': {}} + with tempfile.TemporaryDirectory() as d: + path = Path(d) / '10-vyos-wwan0.link' + path.write_text('[Match]\nType=wwan\n\n[Link]\nName=wwan0\n') + sync_link_files(store, Path(d)) + self.assertIn('MACAddressPolicy=persistent', path.read_text()) + + def test_no_link_file_is_written_for_a_radio(self): + # a .link keyed on a phy's address matches every interface created on + # that phy, so writing one renamed the interface the operator had just + # asked for. Wireless is named from the configuration instead. + store = {'version': STORE_VERSION, + 'interfaces': {'eth0': 'ID_PATH=pci-0000:00:12.0', + 'wlan0': 'ID_NET_NAME_MAC=wlx020000000000'}, + 'hardware': {}} + with tempfile.TemporaryDirectory() as d: + written = sync_link_files(store, Path(d)) + names = [p.name for p in written] + self.assertEqual(names, ['10-vyos-eth0.link']) + + def test_one_file_per_interface(self): + with tempfile.TemporaryDirectory() as d: + written = sync_link_files( + store_of({'eth1': 'pci-0000:00:13.0'}), Path(d)) + self.assertEqual([p.name for p in written], ['10-vyos-eth1.link']) + + def test_stale_files_are_removed(self): + with tempfile.TemporaryDirectory() as d: + link_dir = Path(d) + sync_link_files(store_of({'eth1': 'p1', + 'eth2': 'p2'}), link_dir) + sync_link_files(store_of({'eth1': 'p1'}), link_dir) + remaining = sorted(p.name for p in link_dir.glob('*.link')) + self.assertEqual(remaining, ['10-vyos-eth1.link']) + + def test_operator_files_are_left_alone(self): + with tempfile.TemporaryDirectory() as d: + link_dir = Path(d) + custom = link_dir / '99-my-tuning.link' + custom.write_text('[Match]\nType=ether\n\n[Link]\nMTUBytes=9000\n') + sync_link_files(store_of({'eth1': 'p1'}), link_dir) + self.assertTrue(custom.exists()) + + +class TestPortLabelGate(unittest.TestCase): + """The adapter label only orders hardware the hypervisor presented. + Physical servers label their ports too - "NIC1", "Embedded LOM 1 Port 1" - + and reordering those would be the same bug on bare metal. + """ + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.dmi = os.path.join(self.tmp, 'sys_vendor') + patch = mock.patch('vyos.ifconfig.ifname_store._DMI_SYS_VENDOR', + Path(self.dmi)) + patch.start() + self.addCleanup(patch.stop) + self._dmi('Dell Inc.') + + def _dmi(self, vendor): + with open(self.dmi, 'w') as f: + f.write(vendor + '\n') + + def _make(self, name, label=None, vendor=None, subsystem_vendor=None): + device = os.path.join(self.tmp, name, 'device') + os.makedirs(device) + for attr, value in (('label', label), ('vendor', vendor), + ('subsystem_vendor', subsystem_vendor)): + if value is not None: + with open(os.path.join(device, attr), 'w') as f: + f.write(value + '\n') + return name + + def test_label_index_is_parsed(self): + self._make('eth0', label='Ethernet0') + self._make('eth1', label='Ethernet10') + self.assertEqual(port_label_index('eth0', self.tmp), 0) + # not lexical - Ethernet10 must sort after Ethernet9 + self.assertEqual(port_label_index('eth1', self.tmp), 10) + + def test_labels_physical_servers_use_are_not_adapter_numbers(self): + for i, label in enumerate(['NIC1', 'Integrated NIC 1', 'LAN1', + 'Embedded LOM 1 Port 1', + 'PCIe Slot 1 Port 1', 'Ethernet', + 'Ethernet0 Port 2', '']): + name = self._make(f'eth{i}', label=label) + self.assertEqual(port_label_index(name, self.tmp), _LABEL_UNKNOWN, + f'{label!r} was taken for an adapter number') + + def test_no_label_at_all(self): + self._make('eth0') + self.assertEqual(port_label_index('eth0', self.tmp), _LABEL_UNKNOWN) + + def test_a_vmware_nic_is_recognised_by_its_pci_vendor_alone(self): + # DMI says Dell - smbios.reflectHost does exactly this, and a gate on + # DMI alone would switch itself off + self._make('eth0', vendor='0x15ad') # vmxnet3 + self._make('eth1', subsystem_vendor='0x15ad') # emulated e1000 + self.assertTrue(is_vmware_nic('eth0', self.tmp)) + self.assertTrue(is_vmware_nic('eth1', self.tmp)) + + def test_dmi_still_catches_a_model_whose_pci_ids_are_unknown(self): + self._make('eth0', vendor='0x1af4') + self._dmi('VMware, Inc.') + self.assertTrue(is_vmware_nic('eth0', self.tmp)) + + def test_a_physical_nic_is_left_alone(self): + self._make('eth0', label='Ethernet0', + vendor='0x8086', subsystem_vendor='0x8086') + self.assertFalse(is_vmware_nic('eth0', self.tmp)) + + +class TestPermanentMac(unittest.TestCase): + """Reading the hardware's own address matters wherever a custom MAC may + be in effect: the current address is then the custom one. This used to + come from the hw-id node, which could also simply be stale. + """ + + def test_ethtool_permanent_address(self): + with mock.patch('vyos.ifconfig.ifname_store.rc_cmd', + return_value=(0, 'Permanent address: aa:bb:cc:dd:ee:ff\n')): + self.assertEqual(permanent_mac('eth0'), 'aa:bb:cc:dd:ee:ff') + + def test_zero_address_falls_back_to_sysfs(self): + # some drivers report an all-zero "unsupported" permanent address + # instead of failing outright + with mock.patch('vyos.ifconfig.ifname_store.rc_cmd', + return_value=(0, 'Permanent address: 00:00:00:00:00:00\n')), \ + mock.patch('pathlib.Path.read_text', return_value='11:22:33:44:55:66\n'): + self.assertEqual(permanent_mac('eth0'), '11:22:33:44:55:66') + + def test_ethtool_unsupported_falls_back_to_sysfs(self): + with mock.patch('vyos.ifconfig.ifname_store.rc_cmd', + return_value=(1, 'Operation not supported')), \ + mock.patch('pathlib.Path.read_text', return_value='11:22:33:44:55:66\n'): + self.assertEqual(permanent_mac('eth0'), '11:22:33:44:55:66') + + def test_no_mac_available_returns_empty(self): + with mock.patch('vyos.ifconfig.ifname_store.rc_cmd', return_value=(1, '')), \ + mock.patch('pathlib.Path.read_text', side_effect=OSError): + self.assertEqual(permanent_mac('eth0'), '') + + +class TestDiscoverDevices(unittest.TestCase): + """Only real, independently-addressable hardware is a naming candidate. + Virtual interfaces have no bus device; SR-IOV VFs and hypervisor VF + datapaths are acceleration children sharing another interface's MAC, so + naming them would invent a second interface for one physical port. Being + a member of a bridge, bond or VRF says nothing about the hardware. + """ + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + + for patch in (mock.patch('vyos.ifconfig.ifname_store.read_properties', + return_value={}), + mock.patch('vyos.ifconfig.ifname_store.rc_cmd', + return_value=(1, ''))): + patch.start() + self.addCleanup(patch.stop) + + def make(name, mac=None, has_device=True, enslaved=False, is_vf=False, + master_is_device=False, wwan=False): + path = os.path.join(self.tmp, name) + os.mkdir(path) + if has_device: + os.mkdir(os.path.join(path, 'device')) + if is_vf: + pf = os.path.join(self.tmp, f'{name}_pf') + os.mkdir(pf) + os.symlink(pf, os.path.join(path, 'device', 'physfn')) + if enslaved: + master = os.path.join(self.tmp, f'{name}_master') + os.mkdir(master) + if master_is_device: + os.mkdir(os.path.join(master, 'device')) + os.symlink(master, os.path.join(path, 'master')) + if mac: + with open(os.path.join(path, 'address'), 'w') as f: + f.write(mac + '\n') + if wwan: + with open(os.path.join(path, 'uevent'), 'w') as f: + f.write('DEVTYPE=wwan\n') + + self.make = make + make('eth0', mac='aa:aa:aa:aa:aa:00') + make('eth1', mac='aa:aa:aa:aa:aa:01') + make('br0', mac='aa:aa:aa:aa:aa:99', has_device=False) + make('lo', has_device=False) + make('eth8', mac='aa:aa:aa:aa:aa:08', enslaved=True) + make('eth7', mac='aa:aa:aa:aa:aa:07', enslaved=True, + master_is_device=True) + make('eth9', mac='aa:aa:aa:aa:aa:09', is_vf=True) + make('vf_eth0', mac='aa:aa:aa:aa:aa:00') + + def _names(self): + return {d['name'] for d in discover_devices(self.tmp)} + + def test_only_physical_independent_interfaces(self): + self.assertEqual(self._names(), {'eth0', 'eth1', 'eth8'}) + + def test_a_modem_is_discovered_without_an_address(self): + # a modem is given a random address at each probe, so recording it + # would make the same modem look like different hardware every boot + self.make('wwan0', mac='b6:53:dd:66:c2:98', wwan=True) + devices = {d['name']: d for d in discover_devices(self.tmp)} + self.assertIn('wwan0', devices) + self.assertEqual(devices['wwan0']['mac'], '') + self.assertTrue(devices['wwan0']['wwan']) + + def test_a_card_with_no_address_is_still_skipped(self): + # only a modem is kept without one + self.make('eth5') + self.assertNotIn('eth5', self._names()) + + def test_virtual_interfaces_excluded(self): + self.assertNotIn('br0', self._names()) + self.assertNotIn('lo', self._names()) + + def test_bridge_or_bond_member_still_discovered(self): + # a member is still its own card, in its own slot. Dropping it here + # would report the hardware as missing for as long as it is in use. + self.assertIn('eth8', self._names()) + + def test_vf_datapath_enslaved_to_its_parent_excluded(self): + # the accelerated path is enslaved to the interface it belongs to, + # which - unlike a bridge or a bond - is a device itself + self.assertNotIn('eth7', self._names()) + + def test_sriov_virtual_function_excluded(self): + # the general marker, independent of hypervisor - the previous + # exclusion relied on udev rules scoped to a Microsoft DMI vendor + # string and would miss a bare-metal Mellanox VF + self.assertNotIn('eth9', self._names()) + + def test_hypervisor_vf_datapath_excluded(self): + # parked as vf_* by 63-hyperv-vf-net.rules; shares its synthetic + # parent's MAC and must not be taken for a second interface + self.assertNotIn('vf_eth0', self._names()) diff --git a/src/tests/test_migration_interfaces_34_to_35.py b/src/tests/test_migration_interfaces_34_to_35.py new file mode 100644 index 000000000..ad83de424 --- /dev/null +++ b/src/tests/test_migration_interfaces_34_to_35.py @@ -0,0 +1,196 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# Coverage for the interfaces 34-to-35 migration (T3871): hw-id leaves the +# CLI, and its bindings are carried into the interface mapping store so that +# no interface changes name across the upgrade. + +import os +import unittest +from unittest import mock + +from helper import prepare_module +from vyos.configtree import ConfigTree +from vyos.ifconfig.ifname_store import STORE_VERSION + +_here = os.path.dirname(__file__) + +migration = prepare_module( + os.path.join(_here, '../migration-scripts/interfaces/34-to-35'), + 'interfaces_34_to_35') + + +def device(name, mac, path): + properties = {'ID_PATH': path, 'ID_BUS': 'pci'} + return {'name': name, 'mac': mac, 'wireless': False, + 'properties': properties, 'key': f'ID_PATH={path}'} + + +CONFIG = """interfaces { + ethernet eth0 { + address "192.0.2.1/24" + hw-id "00:00:5e:00:53:01" + } + ethernet eth1 { + description "uplink" + hw-id "00:00:5e:00:53:02" + } +} +""" + + +class TestMigration34to35(unittest.TestCase): + def setUp(self): + self.config = ConfigTree(CONFIG) + self.saved = [] + + patches = [ + mock.patch.object(migration, 'load_store', + return_value={'version': STORE_VERSION, + 'interfaces': {}}), + mock.patch.object(migration, 'save_store', + side_effect=lambda s: self.saved.append(s) or True), + mock.patch.object(migration, 'sync_link_files'), + ] + for p in patches: + p.start() + self.addCleanup(p.stop) + + def _devices(self, devices): + return mock.patch.object(migration, 'discover_devices', + return_value=devices) + + def test_hwid_nodes_are_removed(self): + with self._devices([]): + migration.migrate(self.config) + self.assertFalse(self.config.exists( + ['interfaces', 'ethernet', 'eth0', 'hw-id'])) + self.assertFalse(self.config.exists( + ['interfaces', 'ethernet', 'eth1', 'hw-id'])) + + def test_other_settings_are_untouched(self): + with self._devices([]): + migration.migrate(self.config) + self.assertEqual( + self.config.return_value(['interfaces', 'ethernet', 'eth0', 'address']), + '192.0.2.1/24') + self.assertEqual( + self.config.return_value(['interfaces', 'ethernet', 'eth1', 'description']), + 'uplink') + + def test_matching_hardware_is_recorded_with_its_slot(self): + devices = [ + device('eth0', '00:00:5e:00:53:01', 'pci-0000:00:04.0'), + device('eth1', '00:00:5e:00:53:02', 'pci-0000:00:05.0'), + ] + with self._devices(devices): + migration.migrate(self.config) + + self.assertEqual(len(self.saved), 1) + entries = self.saved[0]['interfaces'] + self.assertEqual(entries['eth0'], 'ID_PATH=pci-0000:00:04.0') + self.assertEqual(entries['eth1'], 'ID_PATH=pci-0000:00:05.0') + + def test_the_link_files_are_written_from_the_hardware_present(self): + # the files pin a Hyper-V NIC's driver, which only the device says + devices = [ + device('eth0', '00:00:5e:00:53:01', 'pci-0000:00:04.0'), + device('eth1', '00:00:5e:00:53:02', 'pci-0000:00:05.0'), + ] + with self._devices(devices): + migration.migrate(self.config) + migration.sync_link_files.assert_called_once() + self.assertEqual(migration.sync_link_files.call_args.kwargs['devices'], + devices) + + def test_names_are_preserved_even_out_of_slot_order(self): + # a box named by an earlier release in MAC order: eth0 sits in the + # HIGHER slot. The migration must record what is, not what slot + # order would suggest, or the upgrade renames its interfaces. + devices = [ + device('eth0', '00:00:5e:00:53:01', 'pci-0000:00:09.0'), + device('eth1', '00:00:5e:00:53:02', 'pci-0000:00:03.0'), + ] + with self._devices(devices): + migration.migrate(self.config) + entries = self.saved[0]['interfaces'] + self.assertEqual(entries['eth0'], 'ID_PATH=pci-0000:00:09.0') + self.assertEqual(entries['eth1'], 'ID_PATH=pci-0000:00:03.0') + + def test_foreign_config_writes_nothing(self): + # migrations also run against configuration files from other + # machines (config tests, a config copied off another box). Nothing + # matches, so nothing may be written - otherwise this would reserve + # names for hardware that is not here. + with self._devices([device('eth0', 'ff:ff:ff:ff:ff:fe', 'pci-0000:00:04.0')]): + migration.migrate(self.config) + self.assertEqual(self.saved, []) + + def test_existing_store_entry_is_never_overwritten(self): + existing = {'version': STORE_VERSION, + 'interfaces': {'eth0': 'ID_PATH=pci-0000:00:11.0'}} + devices = [ + device('eth0', '00:00:5e:00:53:01', 'pci-0000:00:04.0'), + device('eth1', '00:00:5e:00:53:02', 'pci-0000:00:05.0'), + ] + with mock.patch.object(migration, 'load_store', return_value=existing), \ + self._devices(devices): + migration.migrate(self.config) + entries = self.saved[0]['interfaces'] + self.assertEqual(entries['eth0'], 'ID_PATH=pci-0000:00:11.0') + self.assertEqual(entries['eth1'], 'ID_PATH=pci-0000:00:05.0') + + def test_slot_already_named_does_not_gain_a_second_name(self): + # the store already names this slot. Recording the config's name for + # it too would leave two names on one slot: resolve() gives the slot + # to the first, so the second reserves a name nothing can ever fill, + # and sync_link_files() would point two .link files at one device. + existing = {'version': STORE_VERSION, + 'interfaces': {'eth0': 'ID_PATH=pci-0000:00:04.0'}} + config = ConfigTree('interfaces {\n ethernet eth9 {\n' + ' hw-id "00:00:5e:00:53:01"\n }\n}\n') + with mock.patch.object(migration, 'load_store', return_value=existing), \ + self._devices([device('eth0', '00:00:5e:00:53:01', + 'pci-0000:00:04.0')]): + migration.migrate(config) + + # nothing new to record, so nothing is written + self.assertEqual(self.saved, []) + self.assertNotIn('eth9', existing['interfaces']) + + def test_one_hwid_on_two_nodes_records_only_the_first(self): + # a malformed config binding the same hw-id twice must not put that + # slot in the store under both names. + config = ConfigTree('interfaces {\n' + ' ethernet eth0 {\n' + ' hw-id "00:00:5e:00:53:01"\n }\n' + ' ethernet eth1 {\n' + ' hw-id "00:00:5e:00:53:01"\n }\n}\n') + with self._devices([device('eth0', '00:00:5e:00:53:01', + 'pci-0000:00:04.0')]): + migration.migrate(config) + + entries = self.saved[0]['interfaces'] + self.assertEqual(entries, {'eth0': 'ID_PATH=pci-0000:00:04.0'}) + self.assertEqual(len(set(entries.values())), len(entries)) + + def test_config_without_hwid_is_a_no_op(self): + config = ConfigTree('interfaces {\n ethernet eth0 {\n }\n}\n') + with self._devices([device('eth0', '00:00:5e:00:53:01', 'p')]): + migration.migrate(config) + self.assertEqual(self.saved, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/tests/test_net_name_resolve.py b/src/tests/test_net_name_resolve.py index e1530eba7..b529c7164 100644 --- a/src/tests/test_net_name_resolve.py +++ b/src/tests/test_net_name_resolve.py @@ -12,19 +12,18 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. # -# Coverage for T3871 (boot race on hw-id based interface naming): the -# MAC-driven, collision-safe rename plan that replaces the old per-uevent -# udev decision, the find_available() crash it used to hit, the -# ethtool/sysfs permanent-MAC fallback, and the config.boot-availability -# ordering corner case (vyos-net-name-resolve must run from inside -# vyos-router, after the config directory is mounted/decrypted, not as an -# independently-scheduled early-boot unit). +# Coverage for T3871: the boot-time half of persistent interface naming - +# settling, renaming and boot ordering. +# +# The naming decision and hardware discovery both live in +# vyos.ifconfig.ifname_store and are covered by test_ifname_store.py. import json import os import shutil import tempfile import unittest +from pathlib import Path from unittest import mock from helper import prepare_module @@ -34,9 +33,6 @@ _here = os.path.dirname(__file__) resolver = prepare_module( os.path.join(_here, '../system/vyos-net-name-resolve.py'), 'vyos_net_name_resolve') -vyos_net_name = prepare_module( - os.path.join(_here, '../udev/vyos_net_name'), - 'vyos_net_name') _container_patcher = None @@ -56,704 +52,63 @@ def tearDownModule(): _container_patcher.stop() -class TestGetPendingHwidNodes(unittest.TestCase): - """A node that exists under interfaces/{ethernet,wireless} but has no - hw-id (e.g. after `delete interfaces ethernet eth1 hw-id`, the - documented "NIC replaced" remediation) must be surfaced separately - from the MAC-keyed configured dict, since it has no MAC to key by yet - - this is what lets main() try to reclaim it for the exact NIC that - vacated it, instead of silently orphaning its other settings. - """ - - def setUp(self): - self.tmp = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) - self.config_path = os.path.join(self.tmp, 'config.boot') - self._orig_config_path = resolver.config_path - resolver.config_path = self.config_path - self.addCleanup(setattr, resolver, 'config_path', self._orig_config_path) - - def _write_config(self, text): - with open(self.config_path, 'w') as f: - f.write(text) - - def test_node_with_hwid_not_pending(self): - self._write_config( - 'interfaces {\n' - ' ethernet eth0 {\n' - ' hw-id 00:11:22:33:44:55\n' - ' }\n' - '}\n' - ) - self.assertEqual(resolver.get_pending_hwid_nodes(), - {'ethernet': set(), 'wireless': set()}) - - def test_node_without_hwid_is_pending(self): - self._write_config( - 'interfaces {\n' - ' ethernet eth1 {\n' - ' address 10.99.1.1/24\n' - ' }\n' - '}\n' - ) - self.assertEqual(resolver.get_pending_hwid_nodes(), - {'ethernet': {'eth1'}, 'wireless': set()}) - - def test_mixed_ethernet_and_wireless(self): - self._write_config( - 'interfaces {\n' - ' ethernet eth0 {\n' - ' hw-id 00:11:22:33:44:55\n' - ' }\n' - ' ethernet eth1 {\n' - ' address 10.99.1.1/24\n' - ' }\n' - ' wireless wlan0 {\n' - ' ssid example\n' - ' }\n' - '}\n' - ) - self.assertEqual(resolver.get_pending_hwid_nodes(), - {'ethernet': {'eth1'}, 'wireless': {'wlan0'}}) - - def test_no_interfaces_section(self): - self._write_config('system {\n host-name vyos\n}\n') - self.assertEqual(resolver.get_pending_hwid_nodes(), - {'ethernet': set(), 'wireless': set()}) - - def test_no_config_boot_at_all(self): - # livecd/ISO case - config_path doesn't exist - self.assertEqual(resolver.get_pending_hwid_nodes(), - {'ethernet': set(), 'wireless': set()}) - - -class TestFindAvailableCrashFix(unittest.TestCase): - """find_available() used to raise IndexError on an empty candidate set - (index_list[0]) and ValueError on a name with no trailing digit - (int('')) - both are realistic inputs on a box's first boot, or when a - biosdevname/predefined name has no numeric suffix. - """ - - def test_resolver_empty_set(self): - self.assertEqual(resolver.find_available(set(), 'eth'), 'eth0') - - def test_resolver_no_prefix_match(self): - self.assertEqual(resolver.find_available({'wlan3'}, 'eth'), 'eth0') - - def test_resolver_contiguous(self): - names = {'eth0', 'eth1', 'eth2'} - self.assertEqual(resolver.find_available(names, 'eth'), 'eth3') - - def test_resolver_fills_hole(self): - self.assertEqual(resolver.find_available({'eth2', 'eth5'}, 'eth'), 'eth3') - - def test_resolver_no_hole_appends(self): - self.assertEqual(resolver.find_available({'eth2', 'eth3'}, 'eth'), 'eth4') - - def test_vyos_net_name_empty_dict(self): - self.assertEqual(vyos_net_name.find_available({}, 'eth'), 'eth0') - - def test_vyos_net_name_no_digit_suffix(self): - self.assertEqual( - vyos_net_name.find_available({'m': 'wlan3'}, 'eth'), 'eth0') - - def test_vyos_net_name_contiguous(self): - intfs = {'a': 'eth0', 'b': 'eth1'} - self.assertEqual(vyos_net_name.find_available(intfs, 'eth'), 'eth2') - - def test_vyos_net_name_is_available(self): - self.assertTrue(vyos_net_name.is_available({'a': 'eth0'}, 'eth1')) - self.assertFalse(vyos_net_name.is_available({'a': 'eth0'}, 'eth0')) - - def test_vyos_net_name_mod_ifname(self): - self.assertEqual(vyos_net_name.mod_ifname('e5'), 'eth3') - self.assertEqual(vyos_net_name.mod_ifname('e2'), 'eth0') - self.assertEqual(vyos_net_name.mod_ifname('e1'), 'eth1') - self.assertEqual(vyos_net_name.mod_ifname('wlan0'), 'wlan0') - - -class TestUnmatchedCandidates(unittest.TestCase): - """Field report: an unconfigured NIC that happens to be squatting on a - DIFFERENT interface's configured hw-id slot this boot (e.g. due to - probe-order scrambling) was excluded here just because - compute_rename_plan() already scheduled it for eviction. That skipped - it past match_pending_nodes() entirely, so a squatter that was really - the exact NIC a pending node should reclaim - or one that made a - reclaim genuinely ambiguous - fell through to ordinary bootstrap - naming and got a permanent hw-id with no ambiguity check at all. - Squatters must stay in this pool; only their eviction destination - (still computed by compute_rename_plan()) is a fallback, not a reason - to hide them from reclaim/ambiguity handling. - """ - - def test_squatter_is_still_a_candidate(self): - configured = {'m0': 'eth1'} - current = {'eth1': 'squatter-mac', 'eth9': 'm0'} - existing_plan = {'eth1': 'eth3', 'eth9': 'eth1'} - candidates = resolver.unmatched_candidates(configured, current, existing_plan) - self.assertEqual(candidates, [('squatter-mac', 'eth1')]) - - def test_rightful_owner_still_excluded(self): - configured = {'m0': 'eth0'} - current = {'eth0': 'm0'} - candidates = resolver.unmatched_candidates(configured, current, {}) - self.assertEqual(candidates, []) - - -class TestMatchPendingNodes(unittest.TestCase): - """A pending node's hw-id is unknown by construction - there's no MAC - to check a candidate against - so matching must only ever happen in - the unambiguous exactly-one-pending/exactly-one-candidate case per - type. Confirmed in the field: a naive deterministic fill (sorting - pending nodes and candidates together like numeric gaps) silently - bound a configured node's address to a different physical NIC than - its own, because an already-provisioned box's existing names have no - relationship to PCIe/MAC sort order. Guessing in any other - cardinality risks the same mistake; leaving the node unresolved and - reported is safer. - """ - - def setUp(self): - patcher = mock.patch.object(resolver, 'is_wireless_interface', - return_value=False) - self.is_wireless = patcher.start() - self.addCleanup(patcher.stop) - - def test_unambiguous_single_pending_single_candidate_matches(self): - pending = {'ethernet': {'eth1'}, 'wireless': set()} - candidates = [('m1', 'eth9')] - matched = resolver.match_pending_nodes(pending, candidates) - self.assertEqual(matched, {'m1': 'eth1'}) - - def test_two_pending_one_candidate_no_match(self): - # can't tell which of the two pending nodes this one candidate - # belongs to - neither is matched. - pending = {'ethernet': {'eth1', 'eth4'}, 'wireless': set()} - candidates = [('m1', 'eth9')] - matched = resolver.match_pending_nodes(pending, candidates) - self.assertEqual(matched, {}) - - def test_one_pending_two_candidates_no_match(self): - # the field-reported shape: an unrelated second candidate (e.g. - # freed by a different interface's config being fully deleted in - # the same boot) is enough to make this genuinely ambiguous, even - # though it isn't itself pending anything. - pending = {'ethernet': {'eth1'}, 'wireless': set()} - candidates = [('m1', 'eth9'), ('m2', 'eth8')] - matched = resolver.match_pending_nodes(pending, candidates) - self.assertEqual(matched, {}) - - def test_ethernet_and_wireless_matched_independently(self): - self.is_wireless.side_effect = lambda name: name == 'radio0' - pending = {'ethernet': {'eth1'}, 'wireless': {'wlan2'}} - candidates = [('m1', 'ifaceB'), ('m2', 'radio0')] - matched = resolver.match_pending_nodes(pending, candidates) - self.assertEqual(matched, {'m1': 'eth1', 'm2': 'wlan2'}) - - def test_no_pending_returns_empty(self): - pending = {'ethernet': set(), 'wireless': set()} - candidates = [('m1', 'eth9')] - matched = resolver.match_pending_nodes(pending, candidates) - self.assertEqual(matched, {}) - - -class TestComputeRenamePlan(unittest.TestCase): - """The authoritative rename plan is what fixes the multi-vendor NIC - boot race: it is driven purely by MAC address, independent of - ifindex/probe order, and must never produce a colliding assignment. - """ - - def test_noop_when_already_correct(self): - configured = {'m0': 'eth0', 'm1': 'eth1'} - current = {'eth0': 'm0', 'eth1': 'm1'} - self.assertEqual(resolver.compute_rename_plan(configured, current), {}) - - def test_rename_independent_of_probe_order(self): - # m0/m1 landed on unrelated names this boot (e.g. a different - # vendor driver won the probe race) - the plan must still point - # them at their configured targets regardless. - configured = {'m0': 'eth0', 'm1': 'eth1'} - current = {'eth5': 'm0', 'eth3': 'm1'} - plan = resolver.compute_rename_plan(configured, current) - self.assertEqual(plan, {'eth5': 'eth0', 'eth3': 'eth1'}) - - def test_full_swap_cycle(self): - # eth0 and eth1 need to trade names - a naive from->to rename - # would collide; the two-phase scratch-name staging must not. - configured = {'m0': 'eth1', 'm1': 'eth0'} - current = {'eth0': 'm0', 'eth1': 'm1'} - plan = resolver.compute_rename_plan(configured, current) - self.assertEqual(plan, {'eth0': 'eth1', 'eth1': 'eth0'}) - - def test_squatter_relocated(self): - # an unconfigured interface currently sits on a name a configured - # hw-id needs - it must be moved out of the way, not left there. - configured = {'m0': 'eth0'} - current = {'eth0': 'unconfigured-mac', 'eth5': 'm0'} - plan = resolver.compute_rename_plan(configured, current) - self.assertEqual(plan['eth5'], 'eth0') - self.assertNotEqual(plan['eth0'], 'eth0') - self.assertTrue(plan['eth0'].startswith('eth')) - - def test_squatter_never_relocated_onto_a_missing_hwid_target(self): - # macMissing's hardware hasn't shown up this boot (removed/faulty/ - # slow driver), so its reserved target 'eth1' never appears in - # `current` and the main hw-id loop skips it entirely (source is - # None) - the squatter being relocated off of 'eth0' must still not - # be handed 'eth1', or it would need relocating again the moment - # that hardware actually appears. - configured = {'macA': 'eth0', 'macMissing': 'eth1'} - current = {'eth0': 'squatterMac', 'eth2': 'macA'} - plan = resolver.compute_rename_plan(configured, current) - self.assertEqual(plan.get('eth2'), 'eth0') - self.assertNotEqual(plan.get('eth0'), 'eth1') - - def test_squatter_never_relocated_onto_a_pending_hwid_less_node(self): - # 'eth1' has no hw-id at all (e.g. `delete interfaces ethernet - # eth1 hw-id`, the documented remediation for a replaced NIC) but - # its config node - and settings like address - still exist. A - # squatter being relocated off of 'eth0' must not be handed 'eth1' - # either, or it would permanently steal the exact slot the - # original NIC needs to reclaim. - configured = {'macA': 'eth0'} - pending = {'ethernet': {'eth1'}, 'wireless': set()} - current = {'eth0': 'squatterMac', 'eth2': 'macA'} - plan = resolver.compute_rename_plan(configured, current, pending) - self.assertEqual(plan.get('eth2'), 'eth0') - self.assertNotEqual(plan.get('eth0'), 'eth1') - - def test_missing_hardware_no_crash_no_entry(self): - # hw-id configured but the NIC never showed up (missing/faulty) - - # must be skipped, not crash or incorrectly assign another interface. - configured = {'m0': 'eth0', 'missing-mac': 'eth9'} - current = {'eth5': 'm0'} - plan = resolver.compute_rename_plan(configured, current) - self.assertEqual(plan, {'eth5': 'eth0'}) - - def test_rename_introducing_a_gap(self): - # user edits the CLI so m2's node is renamed eth2 -> eth10 while - # keeping the same hw-id, leaving eth5..eth9 unused. - configured = { - 'm0': 'eth0', 'm1': 'eth1', 'm2': 'eth10', - 'm3': 'eth3', 'm4': 'eth4', - } - current = { - 'eth0': 'm0', 'eth1': 'm1', 'eth2': 'm2', - 'eth3': 'm3', 'eth4': 'm4', - } - plan = resolver.compute_rename_plan(configured, current) - self.assertEqual(plan, {'eth2': 'eth10'}) - - def test_rename_introducing_a_gap_with_scrambled_fastpath(self): - # same as above, but this boot's cosmetic udev fast-path lands m2 - # on an unrelated name and coincidentally puts a different NIC on - # the now-meaningless old 'eth2'. - configured = { - 'm0': 'eth0', 'm1': 'eth1', 'm2': 'eth10', - 'm3': 'eth3', 'm4': 'eth4', - } - current = { - 'eth0': 'm0', 'eth1': 'm1', 'eth7': 'm2', - 'eth2': 'm3', 'eth4': 'm4', - } - plan = resolver.compute_rename_plan(configured, current) - self.assertEqual(plan.get('eth7'), 'eth10') - self.assertEqual(plan.get('eth2'), 'eth3') - self.assertNotIn('eth2', configured.values()) +def device(name, mac, path='', wireless=False): + return {'name': name, 'mac': mac, 'path': path, 'wireless': wireless, + 'properties': {}} class TestWaitForSettle(unittest.TestCase): - """There is no specific MAC to wait for when bootstrap-naming hardware - without a configured hw-id, so settling is detected as N consecutive - identical discover_physical_interfaces() snapshots instead. + """There is no specific MAC to wait for when naming hardware the store + has never seen, so settling is detected as N consecutive identical + discover_physical_interfaces() snapshots instead. """ def test_returns_after_n_consecutive_stable_polls(self): - snapshots = [ - {'eth0': 'm0'}, # still changing - {'eth0': 'm0', 'eth1': 'm1'}, # still changing - {'eth0': 'm0', 'eth1': 'm1'}, # 1st stable repeat - {'eth0': 'm0', 'eth1': 'm1'}, # 2nd stable repeat -> settled - ] - with mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=snapshots): - result = resolver.wait_for_settle( - {}, timeout=5, poll=0, stable_polls=3) - self.assertEqual(result, {'eth0': 'm0', 'eth1': 'm1'}) + one = [device('eth0', 'm0')] + two = [device('eth0', 'm0'), device('eth1', 'm1')] + snapshots = [one, two, two, two] + with mock.patch.object(resolver, 'discover_devices', + side_effect=snapshots): + result = resolver.wait_for_settle([], timeout=5, poll=0, + stable_polls=3) + self.assertEqual(resolver._snapshot(result), + {('eth0', 'm0'), ('eth1', 'm1')}) def test_bounded_by_timeout_when_never_stable(self): counter = iter(range(1000)) def ever_changing(*_a, **_kw): - return {'eth0': f'm{next(counter)}'} + return [device('eth0', f'm{next(counter)}')] clock = iter([0, 0.1, 20, 20]) - with mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=ever_changing), \ + with mock.patch.object(resolver, 'discover_devices', + side_effect=ever_changing), \ mock.patch('time.monotonic', side_effect=lambda: next(clock)): - result = resolver.wait_for_settle( - {}, timeout=10, poll=0, stable_polls=3) + result = resolver.wait_for_settle([], timeout=10, poll=0, + stable_polls=3) # must return SOMETHING (the last snapshot seen), not hang or crash - self.assertIn('eth0', result) + self.assertEqual(len(result), 1) def test_stable_polls_of_one_returns_on_initial_snapshot(self): - with mock.patch.object(resolver, 'discover_physical_interfaces') as m: - result = resolver.wait_for_settle( - {'eth0': 'm0'}, timeout=5, poll=0, stable_polls=1) + initial = [device('eth0', 'm0')] + with mock.patch.object(resolver, 'discover_devices') as m: + result = resolver.wait_for_settle(initial, timeout=5, poll=0, + stable_polls=1) m.assert_not_called() - self.assertEqual(result, {'eth0': 'm0'}) - - -class TestIsWirelessInterface(unittest.TestCase): - """Wireless detection must be race-free - it has to be correct even - when the interface's current name is a leftover from the cosmetic - udev-time fast path and doesn't look like a wlan name at all. - """ - - def setUp(self): - self.tmp = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) - # phy80211 is always a symlink to a real sysfs node in practice - - # exists() follows symlinks, so the test target must actually exist - self.phy_target = os.path.join(self.tmp, 'phy0') - os.mkdir(self.phy_target) - - def test_phy80211_present_is_wireless(self): - path = os.path.join(self.tmp, 'wlan0') - os.mkdir(path) - os.symlink(self.phy_target, os.path.join(path, 'phy80211')) - self.assertTrue(resolver.is_wireless_interface('wlan0', self.tmp)) - - def test_no_phy80211_is_not_wireless(self): - os.mkdir(os.path.join(self.tmp, 'eth0')) - self.assertFalse(resolver.is_wireless_interface('eth0', self.tmp)) - - def test_detection_independent_of_misleading_name(self): - # named like a leftover cosmetic ethernet guess, but it's a real - # wifi card - must still be classified wireless - path = os.path.join(self.tmp, 'eth7') - os.mkdir(path) - os.symlink(self.phy_target, os.path.join(path, 'phy80211')) - self.assertTrue(resolver.is_wireless_interface('eth7', self.tmp)) - - -class TestPcieDistance(unittest.TestCase): - """PCIe hop-depth is what lets bootstrap naming reflect physical slot - position instead of raw MAC magnitude - a NIC wired straight to the - root complex must not sort after an add-in card just because its MAC - happens to be numerically higher. - """ - - def setUp(self): - self.tmp = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) - # fake /sys/devices tree, separate from the fake /sys/class/net - # entries, so symlink targets can point at realistic PCI-bus-shaped - # paths the way a real /sys/class/net/<if>/device symlink does - self.devices_root = os.path.join(self.tmp, 'devices') - os.makedirs(self.devices_root) - - def _make_iface_pointing_at(self, name, *path_segments): - """Create <tmp>/<name>/device as a symlink to a fake sysfs device - node at devices_root/<path_segments...>. - """ - target = os.path.join(self.devices_root, *path_segments) - os.makedirs(target, exist_ok=True) - iface_path = os.path.join(self.tmp, name) - os.mkdir(iface_path) - os.symlink(target, os.path.join(iface_path, 'device')) - - def test_shallow_device_one_pci_segment(self): - # e.g. a NIC directly under the root complex: pci0000:00/0000:00:1f.6 - self._make_iface_pointing_at('eth0', 'pci0000:00', '0000:00:1f.6') - self.assertEqual(resolver.pcie_distance('eth0', self.tmp), 1) - - def test_deep_device_behind_bridges(self): - # e.g. an add-in card behind two cascaded PCIe bridges - self._make_iface_pointing_at( - 'eth1', 'pci0000:00', '0000:00:1c.0', - '0000:01:00.0', '0000:02:04.0') - self.assertEqual(resolver.pcie_distance('eth1', self.tmp), 3) - - def test_virtio_indirection_not_miscounted(self): - # verified-real virtio_net layout: device -> virtioN node, whose - # OWN parent is the true PCI BDF - the virtioN segment itself must - # be skipped, not counted as a hop. - self._make_iface_pointing_at( - 'eth2', 'pci0000:00', '0000:00:12.0', 'virtio2', 'net') - self.assertEqual(resolver.pcie_distance('eth2', self.tmp), 1) - - def test_missing_device_symlink_returns_sentinel(self): - os.mkdir(os.path.join(self.tmp, 'eth3')) # no 'device' entry at all - self.assertEqual(resolver.pcie_distance('eth3', self.tmp), - resolver.PCIE_DISTANCE_UNKNOWN) - - def test_broken_device_symlink_returns_sentinel(self): - iface_path = os.path.join(self.tmp, 'eth4') - os.mkdir(iface_path) - os.symlink(os.path.join(self.devices_root, 'does-not-exist'), - os.path.join(iface_path, 'device')) - self.assertEqual(resolver.pcie_distance('eth4', self.tmp), - resolver.PCIE_DISTANCE_UNKNOWN) - - def test_no_pci_segment_at_all_returns_sentinel(self): - # simulated USB NIC: resolved path has no PCI BDF component - self._make_iface_pointing_at('eth5', 'usb1', '1-1', '1-1:1.0') - self.assertEqual(resolver.pcie_distance('eth5', self.tmp), - resolver.PCIE_DISTANCE_UNKNOWN) - - -class TestComputeBootstrapPlan(unittest.TestCase): - """Bootstrap naming is what makes a box's very first boot (before any - hw-id exists) deterministic, the same way hw-id makes every boot after - that deterministic - it must depend only on PCIe topology and MAC - rank, never on whatever name the racy cosmetic fast-path happened to - assign. pcie_distance is held constant by default in these tests, so - they exercise pure MAC-rank ordering (see TestPcieDistance for the - topology-ordering cases specifically). - """ - - def setUp(self): - patcher = mock.patch.object(resolver, 'is_wireless_interface', - return_value=False) - self.is_wireless = patcher.start() - self.addCleanup(patcher.stop) - - distance_patcher = mock.patch.object(resolver, 'pcie_distance', - return_value=0) - self.pcie_distance = distance_patcher.start() - self.addCleanup(distance_patcher.stop) - - def test_sorted_by_mac_independent_of_current_names(self): - # names are in the OPPOSITE order of their MACs - current = {'eth5': 'bb', 'eth2': 'aa'} - plan = resolver.compute_bootstrap_plan({}, current, {}) - self.assertEqual(plan, {'eth2': 'eth0', 'eth5': 'eth1'}) - - def test_noop_when_already_in_canonical_rank_order(self): - current = {'eth0': 'aa', 'eth1': 'bb'} - plan = resolver.compute_bootstrap_plan({}, current, {}) - self.assertEqual(plan, {}) - - def test_configured_targets_are_never_overwritten(self): - configured = {'m0': 'eth0'} - current = {'eth0': 'm0', 'eth9': 'm2', 'eth8': 'm1'} - plan = resolver.compute_bootstrap_plan(configured, current, {}) - self.assertNotIn('eth0', plan.values()) - self.assertEqual(plan.get('eth8'), 'eth1') - self.assertEqual(plan.get('eth9'), 'eth2') - - def test_new_interface_backfills_a_gap_with_no_pending_node(self): - # eth1 is a gap between two hw-id'd interfaces, but nothing in - # config.boot reserves it (no hw-id, no pending node either - the - # interface was fully deleted at some point, or never existed) - - # a new, unrelated NIC discovered this boot is free to take it, - # the same as it would on a system with no config for it at all. - configured = {'m0': 'eth0', 'm2': 'eth2'} - current = {'eth0': 'm0', 'eth2': 'm2', 'eth9': 'new-mac'} - plan = resolver.compute_bootstrap_plan(configured, current, {}) - self.assertEqual(plan, {'eth9': 'eth1'}) - - def test_multiple_new_interfaces_backfill_gaps_in_order(self): - configured = {'m0': 'eth0', 'm2': 'eth2'} - current = { - 'eth0': 'm0', 'eth2': 'm2', - 'ethB': 'bb', 'ethA': 'aa', - } - plan = resolver.compute_bootstrap_plan(configured, current, {}) - # lower mac 'aa' takes the gap at eth1 first, 'bb' continues to - # the next free slot, eth3 - not "skip past the gap to eth3/eth4" - self.assertEqual(plan, {'ethA': 'eth1', 'ethB': 'eth3'}) - - def test_ethernet_and_wireless_numbered_independently(self): - self.is_wireless.side_effect = lambda name: name == 'radio0' - current = {'ifaceB': 'bb', 'radio0': 'aa'} - plan = resolver.compute_bootstrap_plan({}, current, {}) - self.assertEqual(plan.get('radio0'), 'wlan0') - self.assertEqual(plan.get('ifaceB'), 'eth0') - - def test_squatter_already_in_existing_plan_excluded(self): - # 'eth0' is a squatter the hw-id plan already decided to move away - # (existing_plan key) - it IS still bootstrap candidacy (see - # unmatched_candidates()), but must never be handed a second, - # conflicting assignment on top of its existing_plan eviction. - existing_plan = {'eth0': 'eth3'} - current = {'eth0': 'unconfigured-mac', 'eth7': 'm0'} - plan = resolver.compute_bootstrap_plan({'m0': 'eth1'}, current, - existing_plan) - self.assertNotIn('eth0', plan) - - def test_bootstrap_targets_never_collide_with_existing_plan_values(self): - # existing_plan says an interface currently named 'oldname' is - # moving to 'eth0' - nothing else in `current` is named 'eth0' or - # 'eth*' at all, so if that target isn't reserved, find_available() - # would freely hand 'eth0' to the bootstrap candidate too. - existing_plan = {'oldname': 'eth0'} - current = {'oldname': 'm0', 'newnic': 'unconfigured-mac'} - plan = resolver.compute_bootstrap_plan({'m0': 'eth0'}, current, - existing_plan) - self.assertNotEqual(plan.get('newnic'), 'eth0') - - def test_smaller_pcie_distance_wins_over_higher_mac(self): - # 'bb' has the numerically higher MAC but sits closer to the root - # complex - it must be named first despite losing on MAC alone. - current = {'eth5': 'bb', 'eth2': 'aa'} - self.pcie_distance.side_effect = lambda name: {'eth5': 0, 'eth2': 3}[name] - plan = resolver.compute_bootstrap_plan({}, current, {}) - self.assertEqual(plan, {'eth5': 'eth0', 'eth2': 'eth1'}) - - def test_same_pcie_distance_falls_back_to_mac_order(self): - current = {'eth5': 'bb', 'eth2': 'aa'} - self.pcie_distance.side_effect = lambda name: 2 # tie for both - plan = resolver.compute_bootstrap_plan({}, current, {}) - self.assertEqual(plan, {'eth2': 'eth0', 'eth5': 'eth1'}) - - def test_unknown_pcie_distance_sorts_last(self): - # 'aa' has the numerically lowest MAC but an undeterminable bus - # position (e.g. a USB NIC) - it must not jump the queue. - current = {'eth9': 'cc', 'eth5': 'bb', 'ethX': 'aa'} - self.pcie_distance.side_effect = lambda name: { - 'eth9': 1, 'eth5': 2, 'ethX': resolver.PCIE_DISTANCE_UNKNOWN, - }[name] - plan = resolver.compute_bootstrap_plan({}, current, {}) - self.assertEqual(plan, {'eth9': 'eth0', 'eth5': 'eth1', 'ethX': 'eth2'}) - - def test_pcie_distance_ordering_independent_of_ethernet_wireless_split(self): - # distance-based ordering applies within each type group separately, - # same as MAC does today - a wlan candidate's distance must not - # affect eth numbering or vice versa. - self.is_wireless.side_effect = lambda name: name == 'radio0' - current = {'ifaceB': 'bb', 'radio0': 'aa'} - self.pcie_distance.side_effect = lambda name: { - 'ifaceB': 5, 'radio0': 0, - }[name] - plan = resolver.compute_bootstrap_plan({}, current, {}) - self.assertEqual(plan.get('radio0'), 'wlan0') - self.assertEqual(plan.get('ifaceB'), 'eth0') - - -class TestComputeBootstrapPlanGapFill(unittest.TestCase): - """compute_bootstrap_plan() fills ordinary numeric gaps (no config - trace at all) deterministically by PCIe distance then MAC. A pending - node (hw-id deleted, node kept - see get_pending_hwid_nodes()) is a - separate concern here: passing `pending` reserves its name so an - unrelated candidate can never squat on it and inherit its settings - - only match_pending_nodes() may fill it, and only when unambiguous. - """ - - def setUp(self): - patcher = mock.patch.object(resolver, 'is_wireless_interface', - return_value=False) - self.is_wireless = patcher.start() - self.addCleanup(patcher.stop) - - distance_patcher = mock.patch.object(resolver, 'pcie_distance', - return_value=0) - self.pcie_distance = distance_patcher.start() - self.addCleanup(distance_patcher.stop) - - def test_open_name_filled_like_an_ordinary_gap(self): - # 'eth1' has nothing configured for it and no pending reservation - # - it is simply the lowest open name. - configured = {'m0': 'eth0'} - current = {'eth0': 'm0', 'eth9': 'new-mac'} - plan = resolver.compute_bootstrap_plan(configured, current, {}) - self.assertEqual(plan.get('eth9'), 'eth1') - - def test_ascending_fill_has_no_preference_between_open_names(self): - # two open names (eth1, eth4 - the reason either is open makes no - # difference) and two candidates - the lower-sorted candidate - # takes the lower-numbered open name, unconditionally. - configured = {'m0': 'eth0', 'm2': 'eth2', 'm3': 'eth3'} - current = {'eth0': 'm0', 'eth2': 'm2', 'eth3': 'm3', - 'eth9': 'bb', 'eth8': 'aa'} - plan = resolver.compute_bootstrap_plan(configured, current, {}) - self.assertEqual(plan.get('eth8'), 'eth1') - self.assertEqual(plan.get('eth9'), 'eth4') - - def test_two_plain_gaps_fill_in_ascending_order(self): - # two fully-deleted interfaces (no hw-id, no pending node either) - # - both ordinary gaps, filled by the same ascending PCIe/MAC - # sort as any other unconfigured hardware. - configured = { - '00:00:5e:00:53:00': 'eth0', '00:00:5e:00:53:01': 'eth1', - '00:00:5e:00:53:03': 'eth3', '00:00:5e:00:53:05': 'eth5', - '00:00:5e:00:53:06': 'eth6', '00:00:5e:00:53:07': 'eth7', - } - current = {name: mac for mac, name in configured.items()} - current.update({'eth9': '00:00:5e:00:53:02', - 'eth8': '00:00:5e:00:53:04'}) - plan = resolver.compute_bootstrap_plan(configured, current, {}) - self.assertEqual(plan.get('eth9'), 'eth2') - self.assertEqual(plan.get('eth8'), 'eth4') - - def test_open_names_currently_squatted_by_a_rightful_mover_are_not_taken(self): - # real boot reproduction (via the resolver's own status file, - # captured mid-failure): the cosmetic fast-path this boot had - # every configured mac sitting on some OTHER configured mac's - # target name (a full rotation), including the two plain gaps - # (eth1, eth6) both currently squatted by macs that are about to - # move elsewhere via a rightful-owner move. existing_plan's - # SOURCE names looked "taken" at the exact moment this function - # ran, but safe_bulk_rename()'s two-phase staging vacates every - # source before any target is claimed - so they must not count - # as taken, or the two real candidates get needlessly pushed to - # fresh eth8/eth9-style slots instead of their own open names. - configured = { - '00:00:5e:00:53:00': 'eth0', '00:00:5e:00:53:02': 'eth2', - '00:00:5e:00:53:03': 'eth3', '00:00:5e:00:53:04': 'eth4', - '00:00:5e:00:53:05': 'eth5', '00:00:5e:00:53:07': 'eth7', - } - current = { - 'eth4': '00:00:5e:00:53:00', 'eth6': '00:00:5e:00:53:02', - 'eth7': '00:00:5e:00:53:03', 'eth2': '00:00:5e:00:53:04', - 'eth3': '00:00:5e:00:53:05', 'eth1': '00:00:5e:00:53:07', - 'eth0': '00:00:5e:00:53:06', # unconfigured - squats on eth0 - 'eth5': '00:00:5e:00:53:01', # unconfigured - squats on eth5 - } - existing_plan = resolver.compute_rename_plan(configured, current) - plan = resolver.compute_bootstrap_plan(configured, current, - existing_plan) - self.assertEqual(plan.get('eth5'), 'eth1') - self.assertEqual(plan.get('eth0'), 'eth6') - - def test_pending_name_reserved_against_unrelated_bootstrap_candidate(self): - # 'eth1' is pending (no hw-id) - two unrelated new NICs discovered - # this boot must not land on it just because sequential numbering - # would otherwise reach it as the second assignment. - configured = {} - pending = {'ethernet': {'eth1'}, 'wireless': set()} - current = {'eth9': 'aa', 'eth8': 'bb'} - plan = resolver.compute_bootstrap_plan(configured, current, {}, - pending=pending) - self.assertEqual(plan.get('eth9'), 'eth0') - self.assertEqual(plan.get('eth8'), 'eth2') - self.assertNotEqual(plan.get('eth9'), 'eth1') - self.assertNotEqual(plan.get('eth8'), 'eth1') - - def test_reclaimed_candidate_excluded_from_ordinary_bootstrap(self): - # main() already matched this candidate to a pending node via - # match_pending_nodes() - it must not also be assigned a fresh - # bootstrap name here, even if its current name differs from the - # reclaimed target (main() handles the actual rename itself). - configured = {} - current = {'eth9': 'm1', 'eth8': 'm2'} - plan = resolver.compute_bootstrap_plan(configured, current, {}, - reclaimed_macs={'m1'}) - self.assertNotIn('eth9', plan) - self.assertIn('eth8', plan) + self.assertEqual(result, initial) class TestSafeBulkRename(unittest.TestCase): - """The two-phase rename must accurately report what actually happened - - a phase-2 (scratch -> final target) failure must not be reported as a + """Only hardware the store has never seen should reach this on a settled + system, but when it does the two-phase rename must report accurately: a + phase-2 (scratch -> final target) failure must not be reported as a successful rename, and must not leave the interface down indefinitely under a name nothing else knows about. """ def setUp(self): patcher = mock.patch.object(resolver, 'get_ifindex', - side_effect=lambda name: name) + side_effect=lambda name: name) patcher.start() self.addCleanup(patcher.stop) @@ -763,8 +118,6 @@ class TestSafeBulkRename(unittest.TestCase): self.assertEqual(applied, {'eth5': 'eth0'}) def test_phase_two_failure_is_not_reported_as_applied(self): - # 'ip link set dev vyetheth5 name eth0' fails (exit 1); everything - # else (down, the phase-1 rename to scratch) succeeds def fake_run(command, *_a, **_kw): return 1 if command == 'ip link set dev vyetheth5 name eth0' else 0 @@ -772,7 +125,6 @@ class TestSafeBulkRename(unittest.TestCase): applied = resolver.safe_bulk_rename({'eth5': 'eth0'}) self.assertNotIn('eth5', applied) - self.assertNotEqual(applied.get('eth5'), 'eth0') def test_phase_two_failure_brings_scratch_name_back_up(self): calls = [] @@ -792,850 +144,255 @@ class TestSafeBulkRename(unittest.TestCase): return 1 if command == 'ip link set dev vyetheth5 name eth0' else 0 with mock.patch.object(resolver, 'run', side_effect=fake_run): - applied = resolver.safe_bulk_rename({'eth5': 'eth0', 'eth9': 'eth1'}) + applied = resolver.safe_bulk_rename({'eth5': 'eth0', + 'eth9': 'eth1'}) self.assertNotIn('eth5', applied) self.assertEqual(applied.get('eth9'), 'eth1') -class TestGetPermanentMac(unittest.TestCase): - """Part of the original race is reading a MAC before the driver has - programmed the permanent address. Verify the ethtool-first, - sysfs-fallback behaviour, including drivers that report an all-zero - 'unsupported' permanent address instead of failing outright. - """ - - def test_ethtool_permanent_address(self): - with mock.patch.object( - resolver, 'rc_cmd', - return_value=(0, 'Permanent address: aa:bb:cc:dd:ee:ff\n')): - self.assertEqual(resolver.get_permanent_mac('eth0'), - 'aa:bb:cc:dd:ee:ff') - - def test_ethtool_zero_address_falls_back_to_sysfs(self): - with mock.patch.object( - resolver, 'rc_cmd', - return_value=(0, 'Permanent address: 00:00:00:00:00:00\n')), \ - mock.patch('pathlib.Path.read_text', - return_value='11:22:33:44:55:66\n'): - self.assertEqual(resolver.get_permanent_mac('eth0'), - '11:22:33:44:55:66') - - def test_ethtool_unsupported_falls_back_to_sysfs(self): - with mock.patch.object( - resolver, 'rc_cmd', - return_value=(1, 'Operation not supported')), \ - mock.patch('pathlib.Path.read_text', - return_value='11:22:33:44:55:66\n'): - self.assertEqual(resolver.get_permanent_mac('eth0'), - '11:22:33:44:55:66') - - def test_no_mac_available_returns_empty(self): - with mock.patch.object(resolver, 'rc_cmd', return_value=(1, '')), \ - mock.patch('pathlib.Path.read_text', side_effect=OSError): - self.assertEqual(resolver.get_permanent_mac('eth0'), '') - - -class TestDiscoverPhysicalInterfaces(unittest.TestCase): - """Only interfaces with a real bus 'device' link are candidates - - bridges/bonds/VLANs/veth must be excluded regardless of name, since - this replaces the racy udev DRIVERS=="?*" check with a stable, - post-settle sysfs read. +class TestRefuseRenamesInUse(unittest.TestCase): + """A name the running system answers to must not move. The map is only + free to change a name while nothing is using it, which at boot is every + interface and afterwards is only a card which has just arrived. """ def setUp(self): self.tmp = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) - def make_iface(name, mac=None, has_device=True): - path = os.path.join(self.tmp, name) - os.mkdir(path) - if has_device: - os.mkdir(os.path.join(path, 'device')) - if mac: - with open(os.path.join(path, 'address'), 'w') as f: - f.write(mac + '\n') - - make_iface('eth0', mac='aa:aa:aa:aa:aa:00') - make_iface('eth1', mac='aa:aa:aa:aa:aa:01') - make_iface('br0', mac='aa:aa:aa:aa:aa:99', has_device=False) - make_iface('lo', has_device=False) - - def test_only_physical_interfaces_with_mac(self): - with mock.patch.object(resolver, 'rc_cmd', return_value=(1, '')): - found = resolver.discover_physical_interfaces(self.tmp) - self.assertEqual(found, { - 'eth0': 'aa:aa:aa:aa:aa:00', - 'eth1': 'aa:aa:aa:aa:aa:01', - }) - self.assertNotIn('br0', found) - self.assertNotIn('lo', found) - - -class TestSyncRescanHints(unittest.TestCase): - """New/unconfigured hardware must still be surfaced to - vyos-interface-rescan.py, already-configured interfaces must not, and - hints left under a pre-rename name must not linger once the resolver - has moved that interface to its hw-id target. - """ - - def setUp(self): - self.tmp = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) - self._orig_dir = resolver.vyos_udev_dir - resolver.vyos_udev_dir = self.tmp - self.addCleanup(setattr, resolver, 'vyos_udev_dir', self._orig_dir) - - def test_writes_hint_for_unconfigured_interface(self): - resolver.sync_rescan_hints({'eth5': 'new-mac'}, configured={}) - hint = os.path.join(self.tmp, 'eth5') - self.assertTrue(os.path.isfile(hint)) - with open(hint) as f: - self.assertEqual(f.read(), 'new-mac') - - def test_no_hint_for_configured_interface(self): - resolver.sync_rescan_hints({'eth0': 'm0'}, configured={'m0': 'eth0'}) - self.assertFalse(os.path.exists(os.path.join(self.tmp, 'eth0'))) - - def test_stale_hint_removed_after_rename(self): - # udev's cosmetic fast-path wrote a hint under the pre-rename name; - # the resolver then moved that interface to its hw-id target. - stale = os.path.join(self.tmp, 'eth7') - with open(stale, 'w') as f: - f.write('m2') - resolver.sync_rescan_hints({'eth10': 'm2'}, configured={'m2': 'eth10'}, - stale_names={'eth7'}) - self.assertFalse(os.path.exists(stale)) - - - -class TestMainFirstBootBootstrap(unittest.TestCase): - """Direct regression guard for the gap this feature closes: main() - used to leave a fully-unconfigured system's interfaces at whatever - racy cosmetic name they already had, with no settle wait at all, and - freeze that into the rescan hints vyos-interface-rescan.py persists. + def _make(self, name, up=False, enslaved=False): + path = os.path.join(self.tmp, name) + os.mkdir(path) + # IFF_UP is bit 0; 0x1002 is a down interface with IFF_BROADCAST + with open(os.path.join(path, 'flags'), 'w') as f: + f.write('0x1003\n' if up else '0x1002\n') + if enslaved: + master = os.path.join(self.tmp, f'{name}_master') + os.mkdir(master) + os.symlink(master, os.path.join(path, 'master')) + + def _split(self, plan): + return resolver.refuse_renames_in_use(plan, self.tmp) + + def test_a_card_which_just_arrived_is_renamed(self): + self._make('eth8') + allowed, refused = self._split({'eth8': 'eth2'}) + self.assertEqual(allowed, {'eth8': 'eth2'}) + self.assertEqual(refused, {}) + + def test_an_interface_carrying_traffic_is_left_alone(self): + # what an edited map plus an unrelated hot-plug used to do: rename a + # running port out from under the configuration still naming it + self._make('eth1', up=True) + allowed, refused = self._split({'eth1': 'eth23'}) + self.assertEqual(allowed, {}) + self.assertEqual(refused, {'eth1': 'eth23'}) + + def test_a_bridge_member_is_left_alone(self): + self._make('eth1', enslaved=True) + allowed, refused = self._split({'eth1': 'eth23'}) + self.assertEqual(refused, {'eth1': 'eth23'}) + + def test_one_refusal_does_not_hold_up_the_rest(self): + self._make('eth1', up=True) + self._make('eth8') + allowed, refused = self._split({'eth1': 'eth23', 'eth8': 'eth2'}) + self.assertEqual(allowed, {'eth8': 'eth2'}) + self.assertEqual(refused, {'eth1': 'eth23'}) + + +class TestStatusAdopted(unittest.TestCase): + """A Hyper-V NIC re-created with a new MAC takes the vacant name. That is + neither a card found in its recorded slot nor anything the slot says, so + it is reported under its own key - vyos-router warns about it from there. """ - def setUp(self): - self.udev_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, self.udev_dir, ignore_errors=True) - self._orig_udev_dir = resolver.vyos_udev_dir - resolver.vyos_udev_dir = self.udev_dir - self.addCleanup(setattr, resolver, 'vyos_udev_dir', self._orig_udev_dir) - - status_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, status_dir, ignore_errors=True) - self._orig_status_file = resolver.status_file - resolver.status_file = resolver.Path(status_dir) / 'status.json' - self.addCleanup(setattr, resolver, 'status_file', self._orig_status_file) - - def test_empty_configfile_bootstrap_renames_and_hints_new_names(self): - # a tiny fake kernel: name -> mac, mutated by simulated 'ip link' - # calls so discover_physical_interfaces() reflects renames for real - state = {'eth5': 'bb', 'eth2': 'aa'} # cosmetic fast-path leftover - - def fake_discover(*_a, **_kw): - return dict(state) - - def fake_run(command, *_a, **_kw): - parts = command.split() - if 'name' in parts: - old = parts[parts.index('dev') + 1] - new = parts[parts.index('name') + 1] - if old in state: - state[new] = state.pop(old) - return 0 - - with mock.patch.object(resolver, 'get_configfile_interfaces', - return_value={}), \ - mock.patch.object(resolver, 'get_pending_hwid_nodes', - return_value={'ethernet': set(), 'wireless': set()}), \ - mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=fake_discover), \ - mock.patch.object(resolver, 'is_wireless_interface', - return_value=False), \ - mock.patch.object(resolver, 'run', side_effect=fake_run), \ - mock.patch('time.sleep'): - resolver.main() - - hints = set(os.listdir(self.udev_dir)) - # renamed into MAC-rank order: lower mac 'aa' -> eth0, higher 'bb' -> eth1 - self.assertEqual(hints, {'eth0', 'eth1'}) - with open(os.path.join(self.udev_dir, 'eth0')) as f: - self.assertEqual(f.read(), 'aa') - with open(os.path.join(self.udev_dir, 'eth1')) as f: - self.assertEqual(f.read(), 'bb') - - def test_pending_node_still_settles_when_its_hardware_is_slow_to_appear(self): - # field report: a pending node ended up permanently bound to a - # fresh eth8/eth9-style name despite its own hardware genuinely - # being present - because that hardware (a slower driver, exactly - # the multi-vendor-NIC race this whole feature addresses) simply - # had not shown up in the FIRST, unsettled discover_physical_ - # interfaces() snapshot wait_for_hardware() returns as soon as all - # already-CONFIGURED macs are found. Since that snapshot showed - # nothing unconfigured yet, main()'s gate into wait_for_settle() - # never even ran, giving the slow hardware zero extra time to - # appear before the boot gave up. A pending node in play must - # force wait_for_settle() to run regardless of what the early - # snapshot shows, so slower hardware still gets its chance. - configured = {'m0': 'eth0'} - pending = {'ethernet': {'eth1'}, 'wireless': set()} - - calls = {'n': 0} - - def fake_discover(*_a, **_kw): - calls['n'] += 1 - if calls['n'] <= 2: - return {'eth0': 'm0'} # m1's hardware not visible yet - return {'eth0': 'm0', 'eth9': 'm1'} # now it has appeared - - state = {'eth0': 'm0', 'eth9': 'm1'} - - def fake_run(command, *_a, **_kw): - parts = command.split() - if 'name' in parts: - old = parts[parts.index('dev') + 1] - new = parts[parts.index('name') + 1] - if old in state: - state[new] = state.pop(old) - return 0 - - with mock.patch.object(resolver, 'get_configfile_interfaces', - return_value=configured), \ - mock.patch.object(resolver, 'get_pending_hwid_nodes', - return_value=pending), \ - mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=fake_discover), \ - mock.patch.object(resolver, 'is_wireless_interface', - return_value=False), \ - mock.patch.object(resolver, 'pcie_distance', return_value=0), \ - mock.patch.object(resolver, 'run', side_effect=fake_run), \ - mock.patch('time.sleep'): - resolver.main() - - self.assertEqual(state.get('eth1'), 'm1') - - status = json.loads(resolver.status_file.read_text()) - self.assertEqual(status['reclaimed'], {'m1': 'eth1'}) - self.assertEqual(status['pending_unresolved'], []) - - def test_reported_reproduction_end_to_end(self): - # exact reported scenario: eth0/eth2 keep their hw-id, eth1's hw-id - # was deleted (the documented "NIC replaced" remediation) but its - # node - and an address setting - stay in config.boot. The same - # physical NIC (mac 'm1') must come back as 'eth1', not bootstrap - # past the floor to 'eth3', and must still get a rescan hint under - # 'eth1' so vyos-interface-rescan.py can refill its hw-id. - configured = {'m0': 'eth0', 'm2': 'eth2'} - pending = {'ethernet': {'eth1'}, 'wireless': set()} - # cosmetic fast-path already placed everything at its final - # hw-id'd name except the replaced NIC, which landed on a racy name - state = {'eth0': 'm0', 'eth2': 'm2', 'eth9': 'm1'} - - def fake_discover(*_a, **_kw): - return dict(state) - - def fake_run(command, *_a, **_kw): - parts = command.split() - if 'name' in parts: - old = parts[parts.index('dev') + 1] - new = parts[parts.index('name') + 1] - if old in state: - state[new] = state.pop(old) - return 0 - - with mock.patch.object(resolver, 'get_configfile_interfaces', - return_value=configured), \ - mock.patch.object(resolver, 'get_pending_hwid_nodes', - return_value=pending), \ - mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=fake_discover), \ - mock.patch.object(resolver, 'is_wireless_interface', - return_value=False), \ - mock.patch.object(resolver, 'run', side_effect=fake_run), \ - mock.patch('time.sleep'): - resolver.main() - - # the NIC is back at 'eth1', not bootstrapped past the floor to 'eth3' - self.assertEqual(state.get('eth1'), 'm1') - self.assertNotIn('eth3', state) - - # still gets a rescan hint under its reclaimed name, so - # vyos-interface-rescan.py can write the real hw-id into the - # existing 'eth1' node (preserving its other settings, e.g. address) - hints = set(os.listdir(self.udev_dir)) - self.assertEqual(hints, {'eth1'}) - with open(os.path.join(self.udev_dir, 'eth1')) as f: - self.assertEqual(f.read(), 'm1') - - status = json.loads(resolver.status_file.read_text()) - self.assertEqual(status['reclaimed'], {'m1': 'eth1'}) - self.assertEqual(status['pending_unresolved'], []) - - def test_fully_deleted_interfaces_backfill_their_gaps_end_to_end(self): - # reported scenario: eth3, eth6 and eth7 were fully deleted (not - # just their hw-id - the whole node, so get_pending_hwid_nodes() - # has nothing for them either) while eth0,1,2,4,5,8,9 stay - # configured. The three now-unconfigured NICs must backfill the - # gaps at 3/6/7 in MAC order, not bootstrap past the highest - # configured index (9) to 10/11/12. - configured = { - 'm0': 'eth0', 'm1': 'eth1', 'm2': 'eth2', 'm4': 'eth4', - 'm5': 'eth5', 'm8': 'eth8', 'm9': 'eth9', - } - pending = {'ethernet': set(), 'wireless': set()} - state = {name: mac for mac, name in configured.items()} - # realistic macs, deliberately NOT alphabetically matching their - # old slot, so the test can't pass by coincidence - state.update({'ethX': 'ff:ff:ff:ff:ff:07', - 'ethY': 'aa:aa:aa:aa:aa:03', - 'ethZ': 'cc:cc:cc:cc:cc:06'}) - - def fake_discover(*_a, **_kw): - return dict(state) - - def fake_run(command, *_a, **_kw): - parts = command.split() - if 'name' in parts: - old = parts[parts.index('dev') + 1] - new = parts[parts.index('name') + 1] - if old in state: - state[new] = state.pop(old) - return 0 - - with mock.patch.object(resolver, 'get_configfile_interfaces', - return_value=configured), \ - mock.patch.object(resolver, 'get_pending_hwid_nodes', - return_value=pending), \ - mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=fake_discover), \ - mock.patch.object(resolver, 'is_wireless_interface', - return_value=False), \ - mock.patch.object(resolver, 'pcie_distance', return_value=0), \ - mock.patch.object(resolver, 'run', side_effect=fake_run), \ - mock.patch('time.sleep'): - resolver.main() - - # the three gaps got backfilled in ascending MAC order, not pushed - # past the highest configured index to eth10/eth11/eth12 - self.assertEqual(state.get('eth3'), 'aa:aa:aa:aa:aa:03') - self.assertEqual(state.get('eth6'), 'cc:cc:cc:cc:cc:06') - self.assertEqual(state.get('eth7'), 'ff:ff:ff:ff:ff:07') - self.assertNotIn('eth10', state) - self.assertNotIn('eth11', state) - self.assertNotIn('eth12', state) - - def test_stray_leftover_interface_makes_reclaim_ambiguous(self): - # field report: deleting only eth7's hw-id and rebooting produced - # "could not be safely auto-matched" instead of a clean reclaim, - # because a stray interface left over from an earlier, unrelated - # boot (e.g. a scratch vyethN name stuck after a failed rename) - # was also present, making this a 1 pending/2 candidate case. - # There is no way to tell which candidate was "really" eth7's own - # past hardware once its hw-id is gone, so eth7 stays unresolved; - # both candidates still get a real, settings-free hw-id via - # ordinary bootstrap naming instead of being lost. - configured = { - 'm0': 'eth0', 'm1': 'eth1', 'm2': 'eth2', 'm4': 'eth4', - 'm5': 'eth5', 'm6': 'eth6', - } - pending = {'ethernet': {'eth7'}, 'wireless': set()} - state = {name: mac for mac, name in configured.items()} - state.update({'eth9': 'aa:bb:cc:dd:ee:07', # eth7's own hardware - 'vyeth13': 'ff:ff:ff:ff:ff:99'}) # unrelated leftover - - def fake_discover(*_a, **_kw): - return dict(state) - - def fake_run(command, *_a, **_kw): - parts = command.split() - if 'name' in parts: - old = parts[parts.index('dev') + 1] - new = parts[parts.index('name') + 1] - if old in state: - state[new] = state.pop(old) - return 0 - - with mock.patch.object(resolver, 'get_configfile_interfaces', - return_value=configured), \ - mock.patch.object(resolver, 'get_pending_hwid_nodes', - return_value=pending), \ - mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=fake_discover), \ - mock.patch.object(resolver, 'is_wireless_interface', - return_value=False), \ - mock.patch.object(resolver, 'pcie_distance', return_value=0), \ - mock.patch.object(resolver, 'run', side_effect=fake_run), \ - mock.patch('time.sleep'): - resolver.main() - - # 'eth7' stays pending rather than being guessed at; both - # candidates still land on real, settings-free names (the lone - # ordinary gap, then the next fresh slot after that) - self.assertNotIn('eth7', state) - self.assertEqual(state.get('eth3'), 'aa:bb:cc:dd:ee:07') - self.assertEqual(state.get('eth8'), 'ff:ff:ff:ff:ff:99') - - hints = set(os.listdir(self.udev_dir)) - self.assertEqual(hints, {'eth3', 'eth8'}) - - status = json.loads(resolver.status_file.read_text()) - self.assertEqual(status['pending_unresolved'], ['eth7']) - self.assertEqual(status['reclaimed'], {}) - - def test_second_pending_node_makes_reclaim_ambiguous(self): - # two pending nodes (eth1 and eth7), but only one candidate showed - # up this boot - which one it belongs to can't be told, so - # neither is matched; the candidate still gets a real, - # settings-free name via ordinary bootstrap naming. - configured = {'m0': 'eth0'} - pending = {'ethernet': {'eth1', 'eth7'}, 'wireless': set()} - state = {'eth0': 'm0', 'eth9': 'aa:bb:cc:dd:ee:07'} - - def fake_discover(*_a, **_kw): - return dict(state) - - def fake_run(command, *_a, **_kw): - parts = command.split() - if 'name' in parts: - old = parts[parts.index('dev') + 1] - new = parts[parts.index('name') + 1] - if old in state: - state[new] = state.pop(old) - return 0 - - with mock.patch.object(resolver, 'get_configfile_interfaces', - return_value=configured), \ - mock.patch.object(resolver, 'get_pending_hwid_nodes', - return_value=pending), \ - mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=fake_discover), \ - mock.patch.object(resolver, 'is_wireless_interface', - return_value=False), \ - mock.patch.object(resolver, 'pcie_distance', return_value=0), \ - mock.patch.object(resolver, 'run', side_effect=fake_run), \ - mock.patch('time.sleep'): - resolver.main() - - self.assertNotIn('eth1', state) - self.assertNotIn('eth7', state) - self.assertEqual(state.get('eth2'), 'aa:bb:cc:dd:ee:07') - - status = json.loads(resolver.status_file.read_text()) - self.assertEqual(status['pending_unresolved'], ['eth1', 'eth7']) - self.assertEqual(status['reclaimed'], {}) - - def test_ambiguous_candidates_never_get_a_permanent_wrong_binding(self): - # field report: deleting eth2's hw-id (leaving its node in place) - # produced "0 unconfigured candidates" on a later boot, because an - # EARLIER ambiguous boot had already permanently bound the wrong - # candidate elsewhere. One pending node with two candidates this - # boot must not be guessed at either way - it stays pending, and - # both candidates still get a real, settings-free hw-id via - # ordinary bootstrap naming (the one open gap, then a fresh slot). - configured = {'m0': 'eth0'} - pending = {'ethernet': {'eth2'}, 'wireless': set()} - state = { - 'eth0': 'm0', - 'racyA': 'aa:bb:cc:dd:ee:02', # eth2's own hardware - 'racyB': 'ff:ff:ff:ff:ff:99', # unrelated leftover candidate - } - - def fake_discover(*_a, **_kw): - return dict(state) - - def fake_run(command, *_a, **_kw): - parts = command.split() - if 'name' in parts: - old = parts[parts.index('dev') + 1] - new = parts[parts.index('name') + 1] - if old in state: - state[new] = state.pop(old) - return 0 - - with mock.patch.object(resolver, 'get_configfile_interfaces', - return_value=configured), \ - mock.patch.object(resolver, 'get_pending_hwid_nodes', - return_value=pending), \ - mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=fake_discover), \ - mock.patch.object(resolver, 'is_wireless_interface', - return_value=False), \ - mock.patch.object(resolver, 'pcie_distance', return_value=0), \ - mock.patch.object(resolver, 'run', side_effect=fake_run), \ - mock.patch('time.sleep'): - resolver.main() - - # 'eth2' stays pending; both candidates land on real, settings- - # free names instead (the one open gap, then a fresh slot) - self.assertNotIn('eth2', state) - self.assertEqual(state.get('eth1'), 'aa:bb:cc:dd:ee:02') - self.assertEqual(state.get('eth3'), 'ff:ff:ff:ff:ff:99') - - hints = set(os.listdir(self.udev_dir)) - self.assertEqual(hints, {'eth1', 'eth3'}) - - status = json.loads(resolver.status_file.read_text()) - self.assertEqual(status['pending_unresolved'], ['eth2']) - self.assertEqual(status['reclaimed'], {}) - - def test_simultaneous_ambiguity_holds_back_every_candidate(self): - # two NICs' hw-id were deleted before the same reboot (eth0 and - # eth2, nodes left in place), and both came back racily named. - # Each candidate's MAC happens to obviously "belong" to one - # specific pending node (racyA's mac ends in :00, matching what - # eth0 used to be; racyB's mac ends in :02, matching eth2) - but - # there is no way to actually verify that pairing, so with 2 - # pending nodes and 2 candidates present at once, neither may be - # auto-matched: guessing the "obvious" pairing risks silently - # binding a configured node's settings to the wrong physical NIC - # if the guess is ever wrong. Both stay pending and reported; - # both candidates still get a real, settings-free hw-id via - # ordinary bootstrap naming. - configured = { - 'm1': 'eth1', 'm3': 'eth3', 'm4': 'eth4', - 'm5': 'eth5', 'm6': 'eth6', 'm7': 'eth7', + def test_an_adoption_is_reported_apart_from_slot_matches(self): + tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp) + status = os.path.join(tmp, 'status.json') + store = { + 'version': 1, + 'hardware': {}, + 'interfaces': {'eth3': 'ID_NET_NAME_MAC=enx000d3a47aa01'}, } - pending = {'ethernet': {'eth0', 'eth2'}, 'wireless': set()} - state = {name: mac for mac, name in configured.items()} - state.update({ - 'racyA': 'aa:bb:cc:dd:ee:00', # looks like eth0's old hardware - 'racyB': 'aa:bb:cc:dd:ee:02', # looks like eth2's old hardware - }) - - def fake_discover(*_a, **_kw): - return dict(state) - - def fake_run(command, *_a, **_kw): - parts = command.split() - if 'name' in parts: - old = parts[parts.index('dev') + 1] - new = parts[parts.index('name') + 1] - if old in state: - state[new] = state.pop(old) - return 0 - - with mock.patch.object(resolver, 'get_configfile_interfaces', - return_value=configured), \ - mock.patch.object(resolver, 'get_pending_hwid_nodes', - return_value=pending), \ - mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=fake_discover), \ - mock.patch.object(resolver, 'is_wireless_interface', - return_value=False), \ - mock.patch.object(resolver, 'pcie_distance', return_value=0), \ - mock.patch.object(resolver, 'run', side_effect=fake_run), \ - mock.patch('time.sleep'): - resolver.main() - - # neither pending node is guessed at - both candidates land on - # fresh, settings-free bootstrap names instead (no ordinary gaps - # exist here, so both go beyond the highest configured index) - self.assertNotIn('eth0', state) - self.assertNotIn('eth2', state) - self.assertEqual(state.get('eth8'), 'aa:bb:cc:dd:ee:00') - self.assertEqual(state.get('eth9'), 'aa:bb:cc:dd:ee:02') - - hints = set(os.listdir(self.udev_dir)) - self.assertEqual(hints, {'eth8', 'eth9'}) - - status = json.loads(resolver.status_file.read_text()) - self.assertEqual(status['pending_unresolved'], ['eth0', 'eth2']) - self.assertEqual(status['reclaimed'], {}) - - def test_squatting_pending_candidate_still_reclaims_correctly(self): - # field report: deleting eth7's hw-id (node left in place) produced - # a "0 unconfigured candidates" boot even though eth7's real - # hardware was present, and it later turned up permanently bound - # to a fresh eth8/eth9-style name. Root cause: eth7's hardware - # (mac07) happened to be squatting on eth2's configured hw-id slot - # this boot (probe-order scrambling) - compute_rename_plan() - # already scheduled it for eviction, which used to make - # unmatched_candidates() skip it entirely, so it never reached the - # ascending fill and fell through to a stale, unrelated fallback - # destination instead. A squatting candidate must still reclaim - # its pending node exactly like a non-squatting one would, when it - # is the only open name and the only candidate this boot. - configured = { - 'm0': 'eth0', 'm1': 'eth1', 'm2': 'eth2', 'm3': 'eth3', - 'm4': 'eth4', 'm5': 'eth5', 'm6': 'eth6', - } - pending = {'ethernet': {'eth7'}, 'wireless': set()} - state = {name: mac for mac, name in configured.items()} - state.update({ - 'eth2': 'aa:bb:cc:dd:ee:07', # eth7's hardware squats on eth2 - 'eth9': 'm2', # m2's rightful NIC, racy this boot - }) - - def fake_discover(*_a, **_kw): - return dict(state) - - def fake_run(command, *_a, **_kw): - parts = command.split() - if 'name' in parts: - old = parts[parts.index('dev') + 1] - new = parts[parts.index('name') + 1] - if old in state: - state[new] = state.pop(old) - return 0 - - with mock.patch.object(resolver, 'get_configfile_interfaces', - return_value=configured), \ - mock.patch.object(resolver, 'get_pending_hwid_nodes', - return_value=pending), \ - mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=fake_discover), \ - mock.patch.object(resolver, 'is_wireless_interface', - return_value=False), \ - mock.patch.object(resolver, 'pcie_distance', return_value=0), \ - mock.patch.object(resolver, 'run', side_effect=fake_run), \ - mock.patch('time.sleep'): - resolver.main() - - # m2 still lands on its own configured slot, and the squatter - # reclaims 'eth7' - not some unrelated fresh bootstrap slot - self.assertEqual(state.get('eth2'), 'm2') - self.assertEqual(state.get('eth7'), 'aa:bb:cc:dd:ee:07') - self.assertNotIn('eth8', state) - self.assertNotIn('eth9', state) - - hints = set(os.listdir(self.udev_dir)) - self.assertEqual(hints, {'eth7'}) - - status = json.loads(resolver.status_file.read_text()) - self.assertEqual(status['reclaimed'], {'aa:bb:cc:dd:ee:07': 'eth7'}) - self.assertEqual(status['pending_unresolved'], []) - - def test_squatting_candidate_and_extra_candidate_makes_reclaim_ambiguous(self): - # same squatter-eviction shape as the test above, but with a - # second, unrelated candidate also present this boot - genuinely - # ambiguous, exactly like a non-squatting extra candidate would - # be. The pending node stays unresolved; the squatter still gets - # evicted off of the configured slot it's sitting on (so the - # rightful owner isn't blocked) and, like the stray, lands on a - # real, settings-free bootstrap name instead of the pending one. - configured = { - 'm0': 'eth0', 'm1': 'eth1', 'm2': 'eth2', - 'm3': 'eth3', 'm4': 'eth4', 'm5': 'eth5', - } - pending = {'ethernet': {'eth7'}, 'wireless': set()} - state = {name: mac for mac, name in configured.items()} - state.update({ - 'eth3': 'aa:bb:cc:dd:ee:07', # squats on m3's configured slot - 'eth9': 'm3', # m3's rightful NIC, racy this boot - 'racyC': 'ff:ff:ff:ff:ff:99', # unrelated leftover candidate - }) - - def fake_discover(*_a, **_kw): - return dict(state) - - def fake_run(command, *_a, **_kw): - parts = command.split() - if 'name' in parts: - old = parts[parts.index('dev') + 1] - new = parts[parts.index('name') + 1] - if old in state: - state[new] = state.pop(old) - return 0 - - with mock.patch.object(resolver, 'get_configfile_interfaces', - return_value=configured), \ - mock.patch.object(resolver, 'get_pending_hwid_nodes', - return_value=pending), \ - mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=fake_discover), \ - mock.patch.object(resolver, 'is_wireless_interface', - return_value=False), \ - mock.patch.object(resolver, 'pcie_distance', return_value=0), \ - mock.patch.object(resolver, 'run', side_effect=fake_run), \ - mock.patch('time.sleep'): - resolver.main() - - # m3 still lands on its own configured slot; 'eth7' stays pending - # rather than being guessed at; the squatter and the stray both - # land on real, settings-free names instead - self.assertEqual(state.get('eth3'), 'm3') - self.assertNotIn('eth7', state) - self.assertEqual(state.get('eth6'), 'aa:bb:cc:dd:ee:07') - self.assertEqual(state.get('eth8'), 'ff:ff:ff:ff:ff:99') - - hints = set(os.listdir(self.udev_dir)) - self.assertEqual(hints, {'eth6', 'eth8'}) - - status = json.loads(resolver.status_file.read_text()) - self.assertEqual(status['pending_unresolved'], ['eth7']) - self.assertEqual(status['reclaimed'], {}) - - def test_gap_backfill_and_pending_node_coexisting_stays_ambiguous(self): - # the exact vyos-build check-qemu-install --ifnametest shape: one - # interface's whole config node is fully deleted (a free numeric - # gap) while a DIFFERENT interface's hw-id alone is deleted (a - # pending node) in the very same reboot - # (del_idx, hwid_idx = random.sample(range(8), 2)). This is - # genuinely ambiguous for the pending node (eth2): its own - # hardware and the gap's freed hardware are two indistinguishable - # candidates of the same type, and guessing risks silently - # binding eth2's settings to the wrong physical NIC - confirmed - # in the field. eth2 stays pending; both candidates still get a - # real, settings-free hw-id via ordinary bootstrap naming (the - # actual gap, then a fresh slot) - nothing is lost, just not - # auto-attributed to the right name. - configured = { - 'm0': 'eth0', 'm1': 'eth1', 'm3': 'eth3', 'm4': 'eth4', - 'm6': 'eth6', 'm8': 'eth8', 'm9': 'eth9', - } - pending = {'ethernet': {'eth2'}, 'wireless': set()} - state = {name: mac for mac, name in configured.items()} - state.update({ - 'eth7': 'aa:bb:cc:dd:ee:02', # eth2's real hardware, racy this boot - 'eth5': 'ff:ff:ff:ff:ff:05', # eth5 fully deleted, its own hardware freed - }) - - def fake_discover(*_a, **_kw): - return dict(state) - - def fake_run(command, *_a, **_kw): - parts = command.split() - if 'name' in parts: - old = parts[parts.index('dev') + 1] - new = parts[parts.index('name') + 1] - if old in state: - state[new] = state.pop(old) - return 0 - - with mock.patch.object(resolver, 'get_configfile_interfaces', - return_value=configured), \ - mock.patch.object(resolver, 'get_pending_hwid_nodes', - return_value=pending), \ - mock.patch.object(resolver, 'discover_physical_interfaces', - side_effect=fake_discover), \ - mock.patch.object(resolver, 'is_wireless_interface', - return_value=False), \ - mock.patch.object(resolver, 'pcie_distance', return_value=0), \ - mock.patch.object(resolver, 'run', side_effect=fake_run), \ - mock.patch('time.sleep'): - resolver.main() - - self.assertNotIn('eth2', state) - self.assertEqual(state.get('eth5'), 'aa:bb:cc:dd:ee:02') - self.assertEqual(state.get('eth7'), 'ff:ff:ff:ff:ff:05') - - status = json.loads(resolver.status_file.read_text()) - self.assertEqual(status['pending_unresolved'], ['eth2']) - self.assertEqual(status['reclaimed'], {}) + report = {'adopted': {'eth3': '00:0d:3a:47:aa:01'}} + devices = [device('eth3', '00:0d:3a:47:aa:01')] + devices[0]['properties'] = {'ID_NET_NAME_MAC': 'enx000d3a47aa01'} + with mock.patch.object(resolver, 'status_file', Path(status)): + resolver.write_status(store, devices, {}, report, True) + with open(status) as f: + data = json.load(f) + self.assertEqual(data['adopted'], {'eth3': '00:0d:3a:47:aa:01'}) + self.assertEqual(data['matched_by_path'], {}) + self.assertIn('eth3', data['matched']) class TestMainInContainer(unittest.TestCase): - """A container owns no NIC: its interfaces are runtime-created veth - pairs with no backing bus device in sysfs and a host-assigned MAC that - changes on every start. The naming pass can therefore never resolve a - pending node there - it only spent both bounded hardware waits and then - told the user to bind an hw-id that would not survive a restart. + """A container owns no NIC: its interfaces are runtime-created veth pairs + with no backing bus device in sysfs and a host-assigned MAC that changes + on every start. There is nothing to name and nothing worth persisting. """ def setUp(self): - self.udev_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, self.udev_dir, ignore_errors=True) - self._orig_udev_dir = resolver.vyos_udev_dir - resolver.vyos_udev_dir = self.udev_dir - self.addCleanup(setattr, resolver, 'vyos_udev_dir', self._orig_udev_dir) - status_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, status_dir, ignore_errors=True) self._orig_status_file = resolver.status_file resolver.status_file = resolver.Path(status_dir) / 'status.json' - self.addCleanup(setattr, resolver, 'status_file', self._orig_status_file) + self.addCleanup(setattr, resolver, 'status_file', + self._orig_status_file) def test_naming_pass_is_skipped_entirely(self): with mock.patch.object(resolver, 'is_running_as_container', - return_value=True), \ - mock.patch.object(resolver, 'get_configfile_interfaces') as configured, \ - mock.patch.object(resolver, 'get_pending_hwid_nodes') as pending, \ - mock.patch.object(resolver, 'discover_physical_interfaces') as discover, \ + return_value=True), \ + mock.patch.object(resolver, 'load_store') as load, \ + mock.patch.object(resolver, 'discover_devices') as discover, \ + mock.patch.object(resolver, 'save_store') as save, \ mock.patch.object(resolver, 'run') as run: resolver.main() - configured.assert_not_called() - pending.assert_not_called() + load.assert_not_called() discover.assert_not_called() + save.assert_not_called() run.assert_not_called() - # no status file means vyos-router's warn_missing_interface_hardware() - # stays silent instead of warning about an unbindable pending node self.assertFalse(resolver.status_file.exists()) - self.assertEqual(os.listdir(self.udev_dir), []) -class TestWriteStatus(unittest.TestCase): - """A pending node that couldn't be safely auto-matched must be - reported so vyos-router can surface a boot-time warning - it must - never regress into another silent, buried-syslog-only failure like - the one this whole fix closes. +class TestHotplugPass(unittest.TestCase): + """A pass for hardware added to a running system hands no vacant name + over: the configuration under it would not be applied until the next boot. """ - def setUp(self): + def _unit(self, name): + with open(os.path.join(_here, '../systemd', name)) as f: + return f.read() + + def test_only_the_hotplug_unit_says_so(self): + self.assertIn('vyos-net-name-resolve.py --hotplug\n', + self._unit('vyos-net-name-resolve-hotplug.service')) + exec_start = [line for line in + self._unit('vyos-net-name-resolve.service').splitlines() + if line.startswith('ExecStart=')] + self.assertEqual(len(exec_start), 1) + self.assertNotIn('--hotplug', exec_start[0]) + + def _main(self, hotplug): status_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, status_dir, ignore_errors=True) - self._orig_status_file = resolver.status_file - resolver.status_file = resolver.Path(status_dir) / 'status.json' - self.addCleanup(setattr, resolver, 'status_file', self._orig_status_file) - - def _read_status(self): - return json.loads(resolver.status_file.read_text()) - - def test_pending_unresolved_reported_when_no_match(self): - pending = {'ethernet': {'eth1', 'eth4'}, 'wireless': set()} - resolver.write_status({}, {}, set(), {}, pending=pending, reclaimed={}) - status = self._read_status() - self.assertEqual(status['pending_unresolved'], ['eth1', 'eth4']) - - def test_reclaimed_not_reported_as_unresolved(self): - pending = {'ethernet': {'eth1'}, 'wireless': set()} - reclaimed = {'m1': 'eth1'} - resolver.write_status({}, {}, set(), {}, pending=pending, - reclaimed=reclaimed) - status = self._read_status() - self.assertEqual(status['pending_unresolved'], []) - self.assertEqual(status['reclaimed'], {'m1': 'eth1'}) - - def test_no_pending_no_crash(self): - resolver.write_status({}, {}, set(), {}) - status = self._read_status() - self.assertEqual(status['pending_unresolved'], []) - self.assertEqual(status['reclaimed'], {}) - - def test_unconfigured_candidates_reported_for_diagnosis(self): - # the full unmatched_candidates() list from main(), so an - # ambiguous reclaim (zero or multiple candidates for a pending - # node) is diagnosable from this one file alone - which physical - # interfaces were actually competing - without needing a separate - # `ip -br link show` or `show configuration commands` to - # reconstruct the same picture by hand. - pending = {'ethernet': {'eth7'}, 'wireless': set()} - candidates = [('aa:bb:cc:dd:ee:07', 'eth9'), ('aa:bb:cc:dd:ee:99', 'vyeth13')] - resolver.write_status({}, {}, set(), {}, pending=pending, - reclaimed={}, candidates=candidates) - status = self._read_status() - self.assertEqual(status['pending_unresolved'], ['eth7']) - self.assertEqual(status['unconfigured_candidates'], { - 'eth9': 'aa:bb:cc:dd:ee:07', - 'vyeth13': 'aa:bb:cc:dd:ee:99', - }) - - def test_unconfigured_candidates_defaults_empty(self): - resolver.write_status({}, {}, set(), {}) - status = self._read_status() - self.assertEqual(status['unconfigured_candidates'], {}) + store = {'interfaces': {}, 'hardware': {}} + with mock.patch.object(resolver, 'is_running_as_container', + return_value=False), \ + mock.patch.object(resolver, 'status_file', + resolver.Path(status_dir) / 'status.json'), \ + mock.patch.object(resolver, 'load_store', return_value=store), \ + mock.patch.object(resolver, 'wait_for_hardware', + return_value=([], set())), \ + mock.patch.object(resolver, 'wait_for_settle', return_value=[]), \ + mock.patch.object(resolver, 'resolve', + return_value=({}, store, {})) as resolve, \ + mock.patch.object(resolver, 'save_store', return_value=False): + resolver.main(hotplug=hotplug) + return resolve.call_args + + def test_the_flag_is_read_from_the_command_line(self): + self.assertTrue(resolver.hotplug_requested(['--hotplug'])) + self.assertFalse(resolver.hotplug_requested([])) + + def test_a_hotplug_pass_keeps_the_boot_adoption_on_record(self): + # the boot pass gave eth2 to a new NIC; a later VF re-add starts a + # hotplug pass, which must neither forget nor repeat that + status_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, status_dir, ignore_errors=True) + status = resolver.Path(status_dir) / 'status.json' + status.write_text(json.dumps({'adopted': {'eth2': '60:45:bd:00:00:01', + 'eth3': '60:45:bd:00:00:02'}})) + store = {'interfaces': {'eth2': 'ID_NET_NAME_MAC=enx6045bd000001', + 'eth3': 'ID_NET_NAME_MAC=enx6045bd000002'}, + 'hardware': {'eth2': '60:45:bd:00:00:01', + 'eth3': '60:45:bd:00:00:09'}} + with mock.patch.object(resolver, 'is_running_as_container', + return_value=False), \ + mock.patch.object(resolver, 'status_file', status), \ + mock.patch.object(resolver, 'load_store', return_value=store), \ + mock.patch.object(resolver, 'wait_for_hardware', + return_value=([], set())), \ + mock.patch.object(resolver, 'wait_for_settle', return_value=[]), \ + mock.patch.object(resolver, 'resolve', + return_value=({}, store, {})), \ + mock.patch.object(resolver, 'save_store', return_value=False), \ + mock.patch.object(resolver.logger, 'warning') as warning: + resolver.main(hotplug=True) + # eth3's NIC changed since: that record no longer holds + self.assertEqual(json.loads(status.read_text())['adopted'], + {'eth2': '60:45:bd:00:00:01'}) + self.assertFalse(any('had no NIC this boot' in str(c) + for c in warning.call_args_list)) + + def test_the_boot_pass_reports_only_its_own_adoptions(self): + # a status left from an earlier pass is not carried by a boot pass + status_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, status_dir, ignore_errors=True) + status = resolver.Path(status_dir) / 'status.json' + status.write_text(json.dumps({'adopted': {'eth2': '60:45:bd:00:00:01'}})) + store = {'interfaces': {'eth2': 'ID_NET_NAME_MAC=enx6045bd000001'}, + 'hardware': {'eth2': '60:45:bd:00:00:01'}, 'hyperv': ['eth2']} + with mock.patch.object(resolver, 'is_running_as_container', + return_value=False), \ + mock.patch.object(resolver, 'status_file', status), \ + mock.patch.object(resolver, 'load_store', return_value=store), \ + mock.patch.object(resolver, 'wait_for_hardware', + return_value=([], set())), \ + mock.patch.object(resolver, 'wait_for_settle', return_value=[]), \ + mock.patch.object(resolver, 'resolve', + return_value=({}, store, {})), \ + mock.patch.object(resolver, 'save_store', return_value=False): + resolver.main(hotplug=False) + self.assertEqual(json.loads(status.read_text())['adopted'], {}) + + def _unrecorded_map_logs(self, present): + store = {'interfaces': {'eth0': 'ID_NET_NAME_MAC=enx000d3a000001'}, + 'hardware': {'eth0': '00:0d:3a:00:00:01'}} + devices = [{'properties': {'ID_NET_NAME_MAC': 'enx000d3a000001'}, + 'mac': '00:0d:3a:00:00:01', 'name': 'eth0'}] if present else [] + status_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, status_dir, ignore_errors=True) + with mock.patch.object(resolver, 'is_running_as_container', + return_value=False), \ + mock.patch.object(resolver, 'status_file', + resolver.Path(status_dir) / 'status.json'), \ + mock.patch.object(resolver, 'load_store', return_value=store), \ + mock.patch.object(resolver, 'wait_for_hardware', + return_value=(devices, set())), \ + mock.patch.object(resolver, 'wait_for_settle', + return_value=devices), \ + mock.patch.object(resolver, 'resolve', + return_value=({}, store, {})), \ + mock.patch.object(resolver, 'save_store', return_value=False), \ + mock.patch.object(resolver.logger, 'info') as info: + resolver.main(hotplug=False) + return any('does not say which interfaces' in str(c) + for c in info.call_args_list) + + def test_an_unrecorded_map_is_mentioned_only_when_a_name_is_vacant(self): + # a fresh Azure deployment: cloud-init wrote the map, every NIC is + # there - nothing is held back, so nothing is said (5479.0.9 G1) + self.assertFalse(self._unrecorded_map_logs(present=True)) + self.assertTrue(self._unrecorded_map_logs(present=False)) + + def test_the_boot_pass_may_adopt(self): + self.assertTrue(self._main(False).kwargs['adopt']) + + def test_the_hotplug_pass_does_not(self): + self.assertFalse(self._main(True).kwargs['adopt']) class TestBootOrdering(unittest.TestCase): - """Regression guard for the config.boot availability corner case: - /opt/vyatta/etc/config/config.boot does not exist until vyos-router - mounts (and, for an encrypted config volume, decrypts) the config - directory itself. vyos-net-name-resolve must be started explicitly - from inside vyos-router after that point, and must not be - reintroduced as an independently-scheduled unit ordered before - vyos-router/network-pre.target/cloud-init. + """The store lives in the config directory, which does not exist until + vyos-router mounts (and, for an encrypted config volume, decrypts) it. So + the pass must be started explicitly from inside vyos-router after that + point, and must not be reintroduced as an independently-scheduled unit + ordered before it. """ def setUp(self): @@ -1654,15 +411,16 @@ class TestBootOrdering(unittest.TestCase): migrate = self._first_index('migrate_bootfile || overall_status=1') resolve = self._first_index( 'systemctl start vyos-net-name-resolve.service') - update_iface = self._first_index('update_interface_config || overall_status=1') + update_iface = self._first_index( + 'update_interface_config || overall_status=1') load_boot = self._first_index('disabled configure || load_bootfile') self.assertLess(migrate, resolve, - 'resolver must run after config.boot is migrated/current') + 'resolver must run after config.boot is current') self.assertLess(resolve, update_iface, - 'resolver must run before new-hardware rescan consumes its hints') + 'resolver must write the store before the rescan reads it') self.assertLess(resolve, load_boot, - 'resolver must run before the CLI config is applied') + 'resolver must run before the CLI config is applied') def test_service_unit_not_independently_ordered_before_vyos_router(self): path = os.path.join(_here, '../systemd/vyos-net-name-resolve.service') diff --git a/src/udev/vyos_net_name b/src/udev/vyos_net_name deleted file mode 100755 index 388cc78be..000000000 --- a/src/udev/vyos_net_name +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright VyOS maintainers and contributors <maintainers@vyos.io> -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import os -import re -import time -import logging -import logging.handlers -from pathlib import Path -from sys import argv - -from vyos.defaults import directories -from vyos.utils.process import cmdl -from vyos.utils.boot import boot_configuration_complete -from vyos.utils.locking import Lock - -# Define variables -vyos_udev_dir = directories['vyos_udev_dir'] - -# NOTE: this script only picks a provisional name for a physical interface -# as it is discovered by udev - it is a best-effort placeholder and does -# NOT look at 'hw-id' in config.boot. The authoritative, hw-id based name -# (if one is configured) is applied afterwards, once, by -# vyos-net-name-resolve.py (src/system/vyos-net-name-resolve.py), which -# runs after all hardware has settled. Deciding hw-id names here, from a -# single per-uevent sysfs snapshot, is what caused interface names to be -# unreliable on systems with multiple NIC vendors/drivers: the DRIVERS -# attribute and the interface's permanent MAC address are not guaranteed -# to be populated yet at the moment a single "add" uevent is evaluated, -# and there is no second chance to re-evaluate it. - - -def is_available(intfs: dict, intf_name: str) -> bool: - """Check if interface name is already assigned""" - if intf_name in list(intfs.values()): - return False - return True - - -def find_available(intfs: dict, prefix: str) -> str: - """Find lowest indexed interface name that is not assigned""" - index_list = [] - for x in intfs.values(): - if not x.startswith(prefix): - continue - suffix = x[len(prefix):] - if suffix.isdigit(): - index_list.append(int(suffix)) - - if not index_list: - return f'{prefix}0' - - index_list.sort() - # find 'holes' in list, if any - missing = sorted(set(range(index_list[0], index_list[-1])) - set(index_list)) - if missing: - return f'{prefix}{missing[0]}' - - return f'{prefix}{index_list[-1] + 1}' - - -def mod_ifname(ifname: str) -> str: - """Check interface with names eX and return ifname on the next format eth{ifindex} - 2""" - if re.match('^e[0-9]+$', ifname): - intf = ifname.split('e') - if intf[1]: - if int(intf[1]) >= 2: - return 'eth' + str(int(intf[1]) - 2) - else: - return 'eth' + str(intf[1]) - - return ifname - - -def get_biosdevname(ifname: str) -> str: - """Use legacy vyatta-biosdevname to query for name - - This is carried over for compatibility only, and will likely be dropped - going forward. - XXX: This throws an error, and likely has for a long time, unnoticed - since vyatta_net_name redirected stderr to /dev/null. - """ - intf = mod_ifname(ifname) - - if 'eth' not in intf: - return intf - if os.path.isdir('/proc/xen'): - return intf - - time.sleep(1) - - try: - biosname = cmdl(['/sbin/biosdevname', '--policy', 'all_ethN', '-i', ifname]) - except Exception as e: - # expected/harmless on most virtualized and some bare-metal - # systems - see the XXX above; the caller already falls back to - # mod_ifname()'s guess, so this isn't worth more than a debug note - logger.debug(f'biosdevname unavailable for {ifname}: {e}') - biosname = '' - - return intf if biosname == '' else biosname - - -def leave_rescan_hint(intf_name: str, hwid: str): - """Write interface information reported by udev - - This script is called while the root mount is still read-only. Leave - information in /run/udev: file name, the interface; contents, the - hardware id. - """ - try: - os.mkdir(vyos_udev_dir) - except FileExistsError: - pass - except Exception as e: - logger.critical(f'Error creating rescan hint directory: {e}') - exit(1) - - try: - with open(os.path.join(vyos_udev_dir, intf_name), 'w') as f: - f.write(hwid) - except OSError as e: - logger.critical(f'OSError {e}') - - -def add_assigned_interfaces(intfs: dict): - """Add interfaces found by previous invocation of udev rule""" - if not os.path.isdir(vyos_udev_dir): - return - - for intf in os.listdir(vyos_udev_dir): - path = os.path.join(vyos_udev_dir, intf) - try: - with open(path) as f: - hwid = f.read().rstrip() - except OSError as e: - logger.error(f'OSError {e}') - continue - intfs[hwid] = intf - - -def on_boot_event(intf_name: str, hwid: str, predefined: str = '') -> str: - """Called on boot by vyos-router: 'coldplug' in vyatta_net_name - - Picks a provisional name only - see the module docstring. hw-id from - config.boot is deliberately NOT consulted here. - """ - interfaces: dict = {} - add_assigned_interfaces(interfaces) - logger.debug(f'{intf_name} ({hwid}): already assigned this boot: {interfaces}') - - if predefined: - newname = predefined - else: - newname = get_biosdevname(intf_name) - - if not is_available(interfaces, newname): - prefix = re.sub(r'\d+$', '', newname) - newname = find_available(interfaces, prefix) - - logger.info(f"{intf_name} ({hwid}) -> '{newname}'") - - leave_rescan_hint(newname, hwid) - - return newname - - -def hotplug_event(): - # Not yet implemented, since interface-rescan will only be run on boot. - pass - - -if __name__ == '__main__': - # Set up logging to syslog - syslog_handler = logging.handlers.SysLogHandler(address='/dev/log') - formatter = logging.Formatter(f'{Path(__file__).name}: %(message)s') - syslog_handler.setFormatter(formatter) - - logger = logging.getLogger() - logger.addHandler(syslog_handler) - logger.setLevel(logging.INFO) - - logger.debug(f'started with arguments: {argv}') - - if len(argv) > 3: - predef_name = argv[3] - else: - predef_name = '' - - lock = Lock('vyos_net_name') - # Wait 60 seconds for other running scripts to finish - lock.acquire(60) - - if not boot_configuration_complete(): - print(on_boot_event(argv[1], argv[2], predefined=predef_name)) - - lock.release() |
