Skip to content

Commit 126cbae

Browse files
c-poclaude
andcommitted
T3871: order bootstrap naming by PCIe distance from the root complex
compute_bootstrap_plan() sorted candidates purely by MAC address, which is fully deterministic but has no relationship to physical topology: a NIC wired directly to the CPU's PCIe root complex (e.g. an onboard LOM) could end up sorted last just because its MAC happens to be numerically higher than an add-in card several PCIe-switch hops away. Add pcie_distance(): counts PCI domain:bus:device.function segments (e.g. 0000:00:1f.6) in the fully-resolved sysfs path of an interface's 'device' symlink. Non-PCI hops in that path (virtioN, usbN, the net/<ifname> tail) simply don't match the pattern and are skipped, so virtio's device->virtioN->real-PCI-parent indirection (confirmed on a real virtio_net host) needs no special-casing, and multi-function siblings at the same slot aren't double-counted. Returns a sort-last sentinel (PCIE_DISTANCE_UNKNOWN) if the device symlink is missing/ unresolvable or the path has no PCI segment at all (e.g. a USB NIC). compute_bootstrap_plan()'s sort key becomes (pcie_distance, mac) instead of mac alone - topology first, MAC only as a tie-break among NICs at the same depth. Both are static hardware properties (slot wiring, permanent address), so this stays a pure, deterministic function of the discovered hardware - the core T3871 guarantee (same hardware, same names, every boot) is unaffected. Chose raw sysfs path parsing over pyudev's find_parent('pci') (both viable; pyudev is already a project dependency used elsewhere) to keep this boot-critical script's existing dependency-light style - stdlib only, matching is_wireless_interface()'s sysfs-symlink-check pattern right next to it. Adds TestPcieDistance (shallow device, cascaded bridges, the virtio indirection case, missing/broken device symlink, no-PCI-segment/USB case) and extends TestComputeBootstrapPlan with distance-vs-MAC ordering cases; existing tests default pcie_distance to a constant via setUp() so they keep exercising pure MAC-rank ordering unmodified. Verified the two new distance-ordering tests actually fail against the prior MAC-only sort before confirming they pass with the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ec04718 commit 126cbae

2 files changed

Lines changed: 160 additions & 12 deletions

File tree

src/system/vyos-net-name-resolve.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,38 @@ def is_wireless_interface(name: str, sys_class_net: str = '/sys/class/net') -> b
197197
return (Path(sys_class_net) / name / 'phy80211').exists()
198198

199199

200+
PCI_BDF_RE = re.compile(r'^[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f]$')
201+
202+
# No real hop-count can reach this - reserves it as a sort-last sentinel
203+
# for interfaces whose bus topology can't be determined (USB NICs, or a
204+
# device symlink that doesn't exist/resolve).
205+
PCIE_DISTANCE_UNKNOWN = 999
206+
207+
208+
def pcie_distance(name: str, sys_class_net: str = '/sys/class/net') -> int:
209+
"""Approximate PCIe bus distance from the root complex - counts PCI
210+
domain:bus:device.function segments (e.g. 0000:00:1f.6) in the fully
211+
resolved sysfs path of the interface's 'device' symlink. Non-PCI hops
212+
(virtioN, usbN, the net/<ifname> tail, ...) simply don't match and are
213+
skipped, so virtio's device->virtioN->real-PCI-parent indirection
214+
needs no special-casing. Multi-function siblings at the same slot
215+
contribute exactly one matching segment each, so they are not
216+
double-counted relative to their shared depth.
217+
218+
Returns PCIE_DISTANCE_UNKNOWN (sorts after every real hop-count) if
219+
the 'device' symlink is missing/unresolvable, or the resolved path
220+
has no PCI BDF segment at all (e.g. a USB NIC).
221+
"""
222+
device_link = Path(sys_class_net) / name / 'device'
223+
try:
224+
resolved = device_link.resolve(strict=True)
225+
except (OSError, RuntimeError):
226+
return PCIE_DISTANCE_UNKNOWN
227+
228+
hops = sum(1 for part in resolved.parts if PCI_BDF_RE.match(part))
229+
return hops if hops > 0 else PCIE_DISTANCE_UNKNOWN
230+
231+
200232
def find_available(names: set, prefix: str) -> str:
201233
"""Find the lowest free index for a given interface name prefix"""
202234
index_list = []
@@ -284,13 +316,18 @@ def compute_rename_plan(configured: dict, current: dict) -> dict:
284316

285317
def compute_bootstrap_plan(configured: dict, current: dict, existing_plan: dict) -> dict:
286318
"""Build {from_name: to_name} for physical interfaces that have no
287-
configured hw-id at all, assigning them a canonical name in ascending
288-
MAC order within their type group (ethernet/wireless) instead of
289-
leaving them at whatever name the racy udev-time fast path produced.
290-
This is what makes a box's very first boot - before any hw-id exists -
291-
just as deterministic as every boot after hw-id is written, since the
292-
name assigned here gets frozen into config.boot by
293-
vyos-interface-rescan.py the same way a real hw-id match would.
319+
configured hw-id at all, assigning them a canonical name within their
320+
type group (ethernet/wireless) ordered by PCIe distance from the root
321+
complex first and MAC address as a tie-break, instead of leaving them
322+
at whatever name the racy udev-time fast path produced. Ordering by
323+
topology rather than raw MAC magnitude means an onboard/directly
324+
CPU-attached NIC isn't sorted after add-in cards just because its MAC
325+
happens to be numerically higher. This is what makes a box's very
326+
first boot - before any hw-id exists - just as deterministic as every
327+
boot after hw-id is written (PCIe wiring and MAC are both static
328+
hardware properties), since the name assigned here gets frozen into
329+
config.boot by vyos-interface-rescan.py the same way a real hw-id
330+
match would.
294331
295332
existing_plan is the hw-id based plan already computed by
296333
compute_rename_plan(): its sources are excluded from bootstrap
@@ -313,7 +350,7 @@ def compute_bootstrap_plan(configured: dict, current: dict, existing_plan: dict)
313350
'wlan': highest_configured_index(configured, 'wlan') + 1,
314351
}
315352

316-
for mac, name in sorted(candidates):
353+
for mac, name in sorted(candidates, key=lambda c: (pcie_distance(c[1]), c[0])):
317354
prefix = 'wlan' if is_wireless_interface(name) else 'eth'
318355
new_name = find_next_available(taken, prefix, floor[prefix])
319356
taken.add(new_name)

src/tests/test_net_name_resolve.py

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,80 @@ def test_detection_independent_of_misleading_name(self):
249249
self.assertTrue(resolver.is_wireless_interface('eth7', self.tmp))
250250

251251

252+
class TestPcieDistance(unittest.TestCase):
253+
"""PCIe hop-depth is what lets bootstrap naming reflect physical slot
254+
position instead of raw MAC magnitude - a NIC wired straight to the
255+
root complex must not sort after an add-in card just because its MAC
256+
happens to be numerically higher.
257+
"""
258+
259+
def setUp(self):
260+
self.tmp = tempfile.mkdtemp()
261+
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
262+
# fake /sys/devices tree, separate from the fake /sys/class/net
263+
# entries, so symlink targets can point at realistic PCI-bus-shaped
264+
# paths the way a real /sys/class/net/<if>/device symlink does
265+
self.devices_root = os.path.join(self.tmp, 'devices')
266+
os.makedirs(self.devices_root)
267+
268+
def _make_iface_pointing_at(self, name, *path_segments):
269+
"""Create <tmp>/<name>/device as a symlink to a fake sysfs device
270+
node at devices_root/<path_segments...>.
271+
"""
272+
target = os.path.join(self.devices_root, *path_segments)
273+
os.makedirs(target, exist_ok=True)
274+
iface_path = os.path.join(self.tmp, name)
275+
os.mkdir(iface_path)
276+
os.symlink(target, os.path.join(iface_path, 'device'))
277+
278+
def test_shallow_device_one_pci_segment(self):
279+
# e.g. a NIC directly under the root complex: pci0000:00/0000:00:1f.6
280+
self._make_iface_pointing_at('eth0', 'pci0000:00', '0000:00:1f.6')
281+
self.assertEqual(resolver.pcie_distance('eth0', self.tmp), 1)
282+
283+
def test_deep_device_behind_bridges(self):
284+
# e.g. an add-in card behind two cascaded PCIe bridges
285+
self._make_iface_pointing_at(
286+
'eth1', 'pci0000:00', '0000:00:1c.0',
287+
'0000:01:00.0', '0000:02:04.0')
288+
self.assertEqual(resolver.pcie_distance('eth1', self.tmp), 3)
289+
290+
def test_virtio_indirection_not_miscounted(self):
291+
# verified-real virtio_net layout: device -> virtioN node, whose
292+
# OWN parent is the true PCI BDF - the virtioN segment itself must
293+
# be skipped, not counted as a hop.
294+
self._make_iface_pointing_at(
295+
'eth2', 'pci0000:00', '0000:00:12.0', 'virtio2', 'net')
296+
self.assertEqual(resolver.pcie_distance('eth2', self.tmp), 1)
297+
298+
def test_missing_device_symlink_returns_sentinel(self):
299+
os.mkdir(os.path.join(self.tmp, 'eth3')) # no 'device' entry at all
300+
self.assertEqual(resolver.pcie_distance('eth3', self.tmp),
301+
resolver.PCIE_DISTANCE_UNKNOWN)
302+
303+
def test_broken_device_symlink_returns_sentinel(self):
304+
iface_path = os.path.join(self.tmp, 'eth4')
305+
os.mkdir(iface_path)
306+
os.symlink(os.path.join(self.devices_root, 'does-not-exist'),
307+
os.path.join(iface_path, 'device'))
308+
self.assertEqual(resolver.pcie_distance('eth4', self.tmp),
309+
resolver.PCIE_DISTANCE_UNKNOWN)
310+
311+
def test_no_pci_segment_at_all_returns_sentinel(self):
312+
# simulated USB NIC: resolved path has no PCI BDF component
313+
self._make_iface_pointing_at('eth5', 'usb1', '1-1', '1-1:1.0')
314+
self.assertEqual(resolver.pcie_distance('eth5', self.tmp),
315+
resolver.PCIE_DISTANCE_UNKNOWN)
316+
317+
252318
class TestComputeBootstrapPlan(unittest.TestCase):
253-
"""MAC-sorted bootstrap naming is what makes a box's very first boot
254-
(before any hw-id exists) deterministic, the same way hw-id makes every
255-
boot after that deterministic - it must depend only on MAC rank, never
256-
on whatever name the racy cosmetic fast-path happened to assign.
319+
"""Bootstrap naming is what makes a box's very first boot (before any
320+
hw-id exists) deterministic, the same way hw-id makes every boot after
321+
that deterministic - it must depend only on PCIe topology and MAC
322+
rank, never on whatever name the racy cosmetic fast-path happened to
323+
assign. pcie_distance is held constant by default in these tests, so
324+
they exercise pure MAC-rank ordering (see TestPcieDistance for the
325+
topology-ordering cases specifically).
257326
"""
258327

259328
def setUp(self):
@@ -262,6 +331,11 @@ def setUp(self):
262331
self.is_wireless = patcher.start()
263332
self.addCleanup(patcher.stop)
264333

334+
distance_patcher = mock.patch.object(resolver, 'pcie_distance',
335+
return_value=0)
336+
self.pcie_distance = distance_patcher.start()
337+
self.addCleanup(distance_patcher.stop)
338+
265339
def test_sorted_by_mac_independent_of_current_names(self):
266340
# names are in the OPPOSITE order of their MACs
267341
current = {'eth5': 'bb', 'eth2': 'aa'}
@@ -328,6 +402,43 @@ def test_bootstrap_targets_never_collide_with_existing_plan_values(self):
328402
existing_plan)
329403
self.assertNotEqual(plan.get('newnic'), 'eth0')
330404

405+
def test_smaller_pcie_distance_wins_over_higher_mac(self):
406+
# 'bb' has the numerically higher MAC but sits closer to the root
407+
# complex - it must be named first despite losing on MAC alone.
408+
current = {'eth5': 'bb', 'eth2': 'aa'}
409+
self.pcie_distance.side_effect = lambda name: {'eth5': 0, 'eth2': 3}[name]
410+
plan = resolver.compute_bootstrap_plan({}, current, {})
411+
self.assertEqual(plan, {'eth5': 'eth0', 'eth2': 'eth1'})
412+
413+
def test_same_pcie_distance_falls_back_to_mac_order(self):
414+
current = {'eth5': 'bb', 'eth2': 'aa'}
415+
self.pcie_distance.side_effect = lambda name: 2 # tie for both
416+
plan = resolver.compute_bootstrap_plan({}, current, {})
417+
self.assertEqual(plan, {'eth2': 'eth0', 'eth5': 'eth1'})
418+
419+
def test_unknown_pcie_distance_sorts_last(self):
420+
# 'aa' has the numerically lowest MAC but an undeterminable bus
421+
# position (e.g. a USB NIC) - it must not jump the queue.
422+
current = {'eth9': 'cc', 'eth5': 'bb', 'ethX': 'aa'}
423+
self.pcie_distance.side_effect = lambda name: {
424+
'eth9': 1, 'eth5': 2, 'ethX': resolver.PCIE_DISTANCE_UNKNOWN,
425+
}[name]
426+
plan = resolver.compute_bootstrap_plan({}, current, {})
427+
self.assertEqual(plan, {'eth9': 'eth0', 'eth5': 'eth1', 'ethX': 'eth2'})
428+
429+
def test_pcie_distance_ordering_independent_of_ethernet_wireless_split(self):
430+
# distance-based ordering applies within each type group separately,
431+
# same as MAC does today - a wlan candidate's distance must not
432+
# affect eth numbering or vice versa.
433+
self.is_wireless.side_effect = lambda name: name == 'radio0'
434+
current = {'ifaceB': 'bb', 'radio0': 'aa'}
435+
self.pcie_distance.side_effect = lambda name: {
436+
'ifaceB': 5, 'radio0': 0,
437+
}[name]
438+
plan = resolver.compute_bootstrap_plan({}, current, {})
439+
self.assertEqual(plan.get('radio0'), 'wlan0')
440+
self.assertEqual(plan.get('ifaceB'), 'eth0')
441+
331442

332443
class TestSafeBulkRename(unittest.TestCase):
333444
"""The two-phase rename must accurately report what actually happened -

0 commit comments

Comments
 (0)