@@ -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