Skip to content

Commit 43b78a8

Browse files
authored
Merge pull request #5405 from c-po/boot-ifname-race-2
T3871: never guess a pending node's hardware when ambiguous
2 parents ad95acb + ce4686e commit 43b78a8

8 files changed

Lines changed: 323 additions & 174 deletions

File tree

smoketest/configs/assert/vpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ set interfaces vpp loopback vpplo12 description 'Loop12'
3939
set interfaces vpp vxlan vppvxlan10 remote '192.0.2.2'
4040
set interfaces vpp vxlan vppvxlan10 source-address '192.0.2.1'
4141
set interfaces vpp vxlan vppvxlan10 vni '10'
42+
set vpp settings allow-unsupported-nics
4243
set vpp settings interface eth1
4344
set vpp settings interface eth2
4445
set vpp settings interface eth3

smoketest/configs/vpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ vpp {
111111
}
112112
}
113113
settings {
114+
allow-unsupported-nics
114115
interface eth1 {
115116
driver "dpdk"
116117
}

smoketest/scripts/cli/base_vyostest_shim.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,16 @@ class TestCase(unittest.TestCase):
4848
# certain failure condition.
4949
debug = False
5050
mgmt_daemon_pid = 0
51+
smoketest_hint_file = '/tmp/vyos.smoketests.hint'
5152

5253
@staticmethod
5354
def debug_on():
5455
return os.path.exists('/tmp/vyos.smoketest.debug')
5556

57+
@classmethod
58+
def running_in_smoketest_harness(cls):
59+
return os.path.exists(cls.smoketest_hint_file)
60+
5661
@classmethod
5762
def setUpClass(cls):
5863
cls._session = ConfigSession(os.getpid())

smoketest/scripts/cli/test_interfaces_bonding.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ def test_bonding_evpn_multihoming(self):
366366
def test_bonding_member_mtu(self):
367367
# This Smoketest only works on our CI platform where we force the NIC
368368
# to virtio and an MTU of only 1500 bytes max
369-
if not os.path.exists('/tmp/vyos.smoketests.hint'):
369+
if not self.running_in_smoketest_harness():
370370
self.skipTest('Not running under VyOS CI/CD QEMU environment!')
371371

372372
for interface in self._interfaces:

smoketest/scripts/cli/test_protocols_static.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ def test_05_dhcp_default_route(self):
596596
# When running via vyos-build under the QEMU environment a local DHCP
597597
# server is available. This test verifies that the default route is set.
598598
# When not running under the VyOS QEMU environment, this test is skipped.
599-
if not os.path.exists('/tmp/vyos.smoketests.hint'):
599+
if not self.running_in_smoketest_harness():
600600
self.skipTest('Not running under VyOS CI/CD QEMU environment!')
601601

602602
interface = 'eth0'
@@ -635,7 +635,7 @@ def test_06_dhcp_default_route_for_vrf(self):
635635
# When running via vyos-build under the QEMU environment a local DHCP
636636
# server is available. This test verifies that the default route is set.
637637
# When not running under the VyOS QEMU environment, this test is skipped.
638-
if not os.path.exists('/tmp/vyos.smoketests.hint'):
638+
if not self.running_in_smoketest_harness():
639639
self.skipTest('Not running under VyOS CI/CD QEMU environment!')
640640

641641
interface = 'eth0'
@@ -683,7 +683,7 @@ def test_07_dhcp_interface_static_routes(self):
683683
# When running via vyos-build under the QEMU environment a local DHCP
684684
# server is available. This test verifies that static routes with
685685
# dhcp-interface are configured correctly.
686-
if not os.path.exists('/tmp/vyos.smoketests.hint'):
686+
if not self.running_in_smoketest_harness():
687687
self.skipTest('Not running under VyOS CI/CD QEMU environment!')
688688

689689
dhcp_interface = 'eth0'

smoketest/scripts/cli/test_system_console.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
# You should have received a copy of the GNU General Public License
1515
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1616

17-
import os
1817
import unittest
1918

2019
from base_vyostest_shim import VyOSUnitTestSHIM
@@ -60,7 +59,7 @@ def test_multiple_kernel_consoles(self):
6059
self.cli_commit()
6160

6261
def test_fbcon_and_serial_con_switch(self):
63-
if not os.path.exists('/tmp/vyos.smoketests.hint'):
62+
if not self.running_in_smoketest_harness():
6463
self.skipTest('Not running under VyOS CI/CD QEMU environment!')
6564

6665
grub_vars = get_grub_vars()

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

Lines changed: 114 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,60 @@ def unmatched_candidates(configured: dict, current: dict, existing_plan: dict) -
365365
if mac not in configured]
366366

367367

368-
def compute_bootstrap_plan(configured: dict, current: dict, existing_plan: dict) -> dict:
368+
def match_pending_nodes(pending: dict, candidates: list) -> dict:
369+
"""Match this boot's unconfigured candidates to pending (hw-id-less)
370+
config nodes, one type (ethernet/wireless) at a time. Conservative by
371+
design: only matches when there is EXACTLY ONE pending node and
372+
EXACTLY ONE candidate of that type this boot - any other cardinality
373+
is left alone rather than guessed.
374+
375+
A pending node still carries its OTHER settings (address,
376+
description, ...) - a wrong guess would silently apply one physical
377+
port's configuration to a DIFFERENT port. Confirmed in the field: on
378+
a real, already-provisioned box (whose hw-id came from historical
379+
probe-order rescan, not from any PCIe/MAC sort), a second, completely
380+
unrelated candidate freed in the same boot - e.g. a different
381+
interface's config being fully deleted - is enough for a naive
382+
ascending-sort fill to swap the two, binding a configured node's
383+
address to the wrong wire. There is no way to tell, from MAC and
384+
PCIe position alone, which candidate is genuinely the pending node's
385+
own hardware once its hw-id is gone; leaving the node unresolved and
386+
reported is safer than guessing. A candidate that isn't reclaimed
387+
here is not otherwise held back - it still proceeds to ordinary
388+
bootstrap naming (see compute_bootstrap_plan()) and gets its own
389+
fresh, settings-free name instead.
390+
391+
Returns {mac: node_name} for every unambiguous match this boot.
392+
"""
393+
grouped = {'ethernet': [], 'wireless': []}
394+
for mac, name in candidates:
395+
group = 'wireless' if is_wireless_interface(name) else 'ethernet'
396+
grouped[group].append((mac, name))
397+
398+
matched = {}
399+
for intf_type, nodes in pending.items():
400+
if not nodes:
401+
continue
402+
cands = grouped.get(intf_type, [])
403+
if len(nodes) == 1 and len(cands) == 1:
404+
(mac, _name) = cands[0]
405+
(target,) = nodes
406+
matched[mac] = target
407+
else:
408+
cand_desc = ', '.join(f"'{name}' ({mac})" for mac, name in cands) or 'none'
409+
logger.warning(
410+
f'{len(nodes)} pending {intf_type} node(s) '
411+
f"({', '.join(sorted(nodes))}) and {len(cands)} unconfigured "
412+
f'{intf_type} candidate(s) this boot ({cand_desc}) - not '
413+
'unambiguous, leaving pending rather than guessing'
414+
)
415+
416+
return matched
417+
418+
419+
def compute_bootstrap_plan(configured: dict, current: dict, existing_plan: dict,
420+
pending: dict = None,
421+
reclaimed_macs: frozenset = frozenset()) -> dict:
369422
"""Build {from_name: to_name} for physical interfaces that have no
370423
configured hw-id at all, assigning them a canonical name within their
371424
type group (ethernet/wireless) ordered by PCIe distance from the root
@@ -380,23 +433,6 @@ def compute_bootstrap_plan(configured: dict, current: dict, existing_plan: dict)
380433
config.boot by vyos-interface-rescan.py the same way a real hw-id
381434
match would.
382435
383-
A pending node's name (hw-id deleted, node kept - see
384-
get_pending_hwid_nodes()) is treated as just another available slot
385-
here, exactly like a numeric gap - not reserved for it specifically.
386-
That is what lets a pending node recover its OWN original hardware
387-
whenever it and some other now-unclaimed NIC become free in the same
388-
boot (e.g. a different interface's config was fully deleted at the
389-
same time): PCIe distance and MAC are static per-NIC properties, so
390-
the relative sort order among any subset of NICs is identical on
391-
every boot. Removing whichever NICs stay configured from that fixed
392-
order leaves the rest in the same relative order they always had -
393-
which, since a box's very first boot assigns names by this exact
394-
sort, is precisely each one's original slot. No pending-node-specific
395-
matching logic is needed for this to hold; it falls out of sorting
396-
the same way every time. main() attributes a resulting name back to
397-
a "reclaim" after the fact by checking it against `pending`'s node
398-
names - see there.
399-
400436
existing_plan is the hw-id based plan already computed by
401437
compute_rename_plan(): its RIGHTFUL-OWNER targets (an interface moving
402438
to its own configured hw-id name) are reserved so a bootstrap name can
@@ -405,19 +441,29 @@ def compute_bootstrap_plan(configured: dict, current: dict, existing_plan: dict)
405441
(see unmatched_candidates()) - its OWN eviction destination is only a
406442
provisional fallback that this function is about to recompute for it
407443
from scratch, so that value must not also count as "taken" (it would
408-
needlessly block a lower slot the unified ascending fill might
409-
otherwise give it, or another candidate, once real).
410-
411-
A numeric slot is only ever off-limits here because a real, currently
412-
configured hw-id target still occupies or reserves it. A gap left by
413-
fully deleting an interface's config, or a pending node whose hw-id
414-
alone was deleted, carry no such reservation and are freely (and
415-
identically) backfilled - there is no remaining signal in config.boot
416-
to treat those two cases differently, and either way the admin's own
417-
action is what freed the slot.
444+
needlessly block a lower slot this function might otherwise give it,
445+
or another candidate, once real).
446+
447+
pending node names are reserved the same way a real hw-id target is -
448+
a candidate main() could not unambiguously match to one via
449+
match_pending_nodes() must never squat there instead, since that name
450+
still carries the node's other settings (address, description, ...).
451+
reclaimed_macs are candidates main() already matched to a pending
452+
node - excluded here so this function never reassigns them elsewhere.
453+
454+
A numeric slot is only ever off-limits here because something still
455+
occupies or reserves it - a real hw-id target or a pending node. A gap
456+
left by fully deleting an interface's config (hw-id and node both
457+
gone, nothing in `pending` either) carries no such reservation and is
458+
freely backfilled, exactly as bootstrap naming would treat it on a
459+
system that never had any config for it at all - there is no
460+
remaining signal in config.boot to tell those two cases apart, and
461+
deleting the whole node is the admin's explicit way of saying so.
418462
"""
419463
plan = {}
420464
candidates = unmatched_candidates(configured, current, existing_plan)
465+
candidates = [(mac, name) for mac, name in candidates
466+
if mac not in reclaimed_macs]
421467
if not candidates:
422468
return plan
423469

@@ -431,12 +477,12 @@ def compute_bootstrap_plan(configured: dict, current: dict, existing_plan: dict)
431477
# a rightful mover's CURRENT (source) name looks occupied right now,
432478
# but safe_bulk_rename()'s two-phase scratch-name staging vacates it
433479
# before any target name is actually claimed - so it must not count
434-
# as taken here either, or a candidate that belongs there (e.g. a
435-
# pending node whose name happens to be some other configured mac's
436-
# racy cosmetic position this boot) gets pushed to a fresh slot
437-
# instead for no reason.
480+
# as taken here either.
481+
reserved_pending = set()
482+
if pending:
483+
reserved_pending = pending.get('ethernet', set()) | pending.get('wireless', set())
438484
taken = (set(current) - candidate_names - set(rightful_movers)) \
439-
| set(rightful_movers.values())
485+
| set(rightful_movers.values()) | reserved_pending
440486

441487
for mac, name in sorted(candidates, key=lambda c: (pcie_distance(c[1]), c[0])):
442488
prefix = 'wlan' if is_wireless_interface(name) else 'eth'
@@ -572,17 +618,9 @@ def main():
572618

573619
all_pending = pending.get('ethernet', set()) | pending.get('wireless', set())
574620

621+
reclaimed = {}
575622
candidates = []
576623
if all_pending or any(mac not in configured for mac in current.values()):
577-
# bootstrap-name whatever has no hw-id match, deterministically by
578-
# PCIe distance and MAC, instead of leaving it at its racy cosmetic
579-
# udev-time name - this is what vyos-interface-rescan.py will
580-
# freeze into config.boot. A NIC that just lost its hw-id (the
581-
# documented "delete hw-id to force regeneration" remediation)
582-
# competes for this same ascending fill exactly like a numeric
583-
# gap does - see compute_bootstrap_plan() for why that recovers
584-
# its own original hardware rather than an arbitrary one.
585-
#
586624
# The `all_pending` half of this condition matters even when
587625
# `current` (from wait_for_hardware() above, bounded only on
588626
# already-CONFIGURED macs) shows nothing unconfigured yet: on a
@@ -592,25 +630,44 @@ def main():
592630
# would never even run, giving that slower hardware zero extra
593631
# time to appear before this boot gives up on the pending node.
594632
current = wait_for_settle(current)
633+
if configured:
634+
# a configured hw-id whose driver was too slow to show up
635+
# within wait_for_hardware()'s timeout can still appear during
636+
# the extra time wait_for_settle() just spent waiting for
637+
# unconfigured hardware to stabilize - recompute against the
638+
# settled snapshot so it still gets renamed (and drops out of
639+
# `missing`) this boot instead of being silently skipped.
640+
missing = set(configured) - set(current.values())
641+
plan = compute_rename_plan(configured, current, pending)
642+
643+
# let a NIC that just lost its hw-id (the documented "delete
644+
# hw-id to force regeneration" remediation) reclaim the exact
645+
# node its other settings (address, description, ...) still live
646+
# under, rather than bootstrap-naming it to a new bare node and
647+
# orphaning that config - only when unambiguous, see
648+
# match_pending_nodes()
595649
candidates = unmatched_candidates(configured, current, plan)
596-
plan.update(compute_bootstrap_plan(configured, current, plan))
597-
598-
# attribute any candidate that landed on a pending node's name back to
599-
# that node - so its rescan hint lands there and vyos-interface-
600-
# rescan.py can write the real hw-id into the node's existing settings
601-
# (address, description, ...) - and warn about any pending node still
602-
# without a hw-id after this pass (a candidate may have existed and
603-
# landed on a lower-numbered unrelated slot instead - this is not
604-
# necessarily a hardware shortage).
605-
reclaimed = {}
606-
for mac, name in candidates:
607-
final_name = plan.get(name, name)
608-
if final_name in all_pending:
609-
reclaimed[mac] = final_name
650+
reclaimed = match_pending_nodes(pending, candidates)
651+
candidate_by_mac = dict(candidates)
652+
for mac, target in reclaimed.items():
653+
name = candidate_by_mac[mac]
654+
if name != target:
655+
plan[name] = target
610656
logger.info(
611-
f"reclaiming pending node '{final_name}' for hw-id '{mac}' "
657+
f"reclaiming pending node '{target}' for hw-id '{mac}' "
612658
'this boot'
613659
)
660+
661+
# bootstrap-name whatever is left, deterministically by PCIe
662+
# distance and MAC, instead of leaving it at its racy cosmetic
663+
# udev-time name - this is what vyos-interface-rescan.py will
664+
# freeze into config.boot. Pending node names stay reserved here
665+
# (see compute_bootstrap_plan()) so an ambiguous leftover
666+
# candidate can never squat on one and inherit its settings.
667+
plan.update(compute_bootstrap_plan(
668+
configured, current, plan, pending=pending,
669+
reclaimed_macs=set(reclaimed)))
670+
614671
for name in sorted(all_pending - set(reclaimed.values())):
615672
logger.warning(
616673
f"pending node '{name}' still has no hw-id after this boot's "

0 commit comments

Comments
 (0)