From e12776075f2dde8026f523e0d86f327bac7d398c Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Wed, 7 Oct 2026 19:56:39 +0200 Subject: interfaces: T3871: remove the hw-id node and migrate its bindings The node decides nothing now that the mapping store owns naming, and keeping it would leave two sources of truth free to disagree. Existing bindings are carried into the store by matching each hw-id against the permanent address of the hardware present, so a box named by an older release keeps exactly the names it had. Entries whose hardware is absent are skipped - the configuration may well originate from another machine. The hardware address of an interface is now read off the device rather than from the configuration. --- src/migration-scripts/interfaces/34-to-35 | 69 +++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100755 src/migration-scripts/interfaces/34-to-35 (limited to 'src/migration-scripts/interfaces') diff --git a/src/migration-scripts/interfaces/34-to-35 b/src/migration-scripts/interfaces/34-to-35 new file mode 100755 index 000000000..9176184f4 --- /dev/null +++ b/src/migration-scripts/interfaces/34-to-35 @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors +# +# 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 . +# +# 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) + except OSError: + pass -- cgit v1.2.3 From 639d575ad258f6f6ba629a0c50a710b736e4911d Mon Sep 17 00:00:00 2001 From: Alex Kudentsov <43482574+alexk37@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:45:45 +0700 Subject: interfaces: T3871: keep an accelerated-networking VF off its NIC's .link file On Azure every Hyper-V NIC shares one ID_PATH and has no slot, so it is keyed on its MAC. Its accelerated-networking VF carries the same MAC and matched the NIC's .link file, so udev tried to give the VF the NIC's name. The .link file of a Hyper-V NIC now also matches Driver=hv_netvsc, so the VF cannot match it. This holds for a lone NIC keyed on the VMBus path too. A NIC absent at a pass cannot say what it is, so its file keeps the pin it already carries. NICs that are not Hyper-V ones get no Driver line, and their files do not change. The 34-to-35 migration passes the hardware it found, so the pin is written on the first boot of an upgrade too. --- python/vyos/ifconfig/ifname_store.py | 43 ++++++++- src/migration-scripts/interfaces/34-to-35 | 2 +- src/system/vyos-net-name-resolve.py | 2 +- src/tests/test_ifname_store.py | 116 ++++++++++++++++++++++++ src/tests/test_migration_interfaces_34_to_35.py | 12 +++ 5 files changed, 170 insertions(+), 5 deletions(-) (limited to 'src/migration-scripts/interfaces') diff --git a/python/vyos/ifconfig/ifname_store.py b/python/vyos/ifconfig/ifname_store.py index e0a6b4cbe..20731aed1 100644 --- a/python/vyos/ifconfig/ifname_store.py +++ b/python/vyos/ifconfig/ifname_store.py @@ -60,6 +60,8 @@ _PORT_LABEL = re.compile(r'^Ethernet(\d+)$') # 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})') @@ -119,6 +121,11 @@ def canonical_sort_key(device: dict) -> tuple: 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() @@ -529,40 +536,70 @@ def _link_type_for(name: str) -> str: return 'ether' -def render_link(name: str, key: str) -> str: +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. """ + 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' ) -def sync_link_files(store: dict, link_dir: Path = None) -> list: +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'): - written[link_dir / f'{LINK_PREFIX}{name}.link'] = render_link(name, key) + 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: diff --git a/src/migration-scripts/interfaces/34-to-35 b/src/migration-scripts/interfaces/34-to-35 index 9176184f4..a09dc8cd8 100755 --- a/src/migration-scripts/interfaces/34-to-35 +++ b/src/migration-scripts/interfaces/34-to-35 @@ -64,6 +64,6 @@ def migrate(config: ConfigTree) -> None: if added and save_store(store): try: - sync_link_files(store) + 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 58966e291..d74663707 100755 --- a/src/system/vyos-net-name-resolve.py +++ b/src/system/vyos-net-name-resolve.py @@ -254,7 +254,7 @@ def main(): if persisted: # render so udev applies the names on the next boot try: - sync_link_files(store) + sync_link_files(store, devices=devices) except OSError as e: logger.error(f'could not write .link files: {e}') else: diff --git a/src/tests/test_ifname_store.py b/src/tests/test_ifname_store.py index b61cd0041..ff0f916ec 100644 --- a/src/tests/test_ifname_store.py +++ b/src/tests/test_ifname_store.py @@ -173,6 +173,122 @@ class TestHypervisorAdapterOrder(unittest.TestCase): 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. + """ + + 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_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_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 diff --git a/src/tests/test_migration_interfaces_34_to_35.py b/src/tests/test_migration_interfaces_34_to_35.py index 4a38d6341..ad83de424 100644 --- a/src/tests/test_migration_interfaces_34_to_35.py +++ b/src/tests/test_migration_interfaces_34_to_35.py @@ -102,6 +102,18 @@ class TestMigration34to35(unittest.TestCase): 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 -- cgit v1.2.3