Skip to content

Commit b3be720

Browse files
committed
T3871: never guess a pending node's hardware when ambiguous
Reported against the previous PCIe/MAC-sorted replacement fill: on a real, already-provisioned box (hw-id from historical probe-order rescan, unrelated to any PCIe/MAC sort), a configured node's address ended up silently applied to a different physical NIC. Deleting one interface's hw-id (keeping its settings) while a different, unrelated interface's config was fully removed in the same boot was enough - the two freed candidates' MAC order didn't match their old name order, so the deterministic sort swapped them. Once written back by the rescan helper, the wrong binding became permanent and self-reinforcing on every later boot. There's no way to verify, from MAC and PCIe position alone, which of several unconfigured candidates is genuinely a given node's own hardware once its hw-id is gone. Restore strict matching: a node only recovers its hw-id automatically when it's the sole pending node of its type this boot and exactly one candidate exists - any other count leaves it pending and reported rather than guessed. A candidate that isn't matched this way is not otherwise held back - it still gets an ordinary, settings-free bootstrap name instead of being lost, just never inherits another node's configuration. This necessarily changes what a combined "delete one interface fully, clear a different one's hw-id" reboot can auto-resolve; the accompanying test-harness change acknowledges that trade-off explicitly.
1 parent 84906d5 commit b3be720

2 files changed

Lines changed: 302 additions & 168 deletions

File tree

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

Lines changed: 105 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,35 @@ 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+
634+
# let a NIC that just lost its hw-id (the documented "delete
635+
# hw-id to force regeneration" remediation) reclaim the exact
636+
# node its other settings (address, description, ...) still live
637+
# under, rather than bootstrap-naming it to a new bare node and
638+
# orphaning that config - only when unambiguous, see
639+
# match_pending_nodes()
595640
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
641+
reclaimed = match_pending_nodes(pending, candidates)
642+
candidate_by_mac = dict(candidates)
643+
for mac, target in reclaimed.items():
644+
name = candidate_by_mac[mac]
645+
if name != target:
646+
plan[name] = target
610647
logger.info(
611-
f"reclaiming pending node '{final_name}' for hw-id '{mac}' "
648+
f"reclaiming pending node '{target}' for hw-id '{mac}' "
612649
'this boot'
613650
)
651+
652+
# bootstrap-name whatever is left, deterministically by PCIe
653+
# distance and MAC, instead of leaving it at its racy cosmetic
654+
# udev-time name - this is what vyos-interface-rescan.py will
655+
# freeze into config.boot. Pending node names stay reserved here
656+
# (see compute_bootstrap_plan()) so an ambiguous leftover
657+
# candidate can never squat on one and inherit its settings.
658+
plan.update(compute_bootstrap_plan(
659+
configured, current, plan, pending=pending,
660+
reclaimed_macs=set(reclaimed)))
661+
614662
for name in sorted(all_pending - set(reclaimed.values())):
615663
logger.warning(
616664
f"pending node '{name}' still has no hw-id after this boot's "

0 commit comments

Comments
 (0)