forked from vyos/vyos-1x
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvyos-net-name-resolve.py
More file actions
executable file
·697 lines (594 loc) · 29.4 KB
/
Copy pathvyos-net-name-resolve.py
File metadata and controls
executable file
·697 lines (594 loc) · 29.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
#!/usr/bin/env python3
#
# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
#
# 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 <http://www.gnu.org/licenses/>.
#
# Single authoritative pass that enforces "interfaces/{ethernet,wireless}/
# */hw-id" from config.boot onto the live system. It runs once, after udev
# has settled, and decides names from a fully-formed view of the hardware
# instead of reacting to individual "add" uevents.
#
# The old udev-time approach (src/udev/vyos_net_name, invoked straight from
# a udev rule) decided the final, hw-id based name from a single sysfs
# snapshot taken at the moment one interface's "add" event was evaluated.
# On systems with several different NIC vendors/drivers that snapshot is
# unreliable: the DRIVERS attribute walk-up and the interface's permanent
# MAC address are not guaranteed to be populated yet, and neither gets a
# second chance since each uevent is only evaluated once. This script
# sidesteps that by re-reading live state after hardware has had time to
# settle, and by verifying/repairing the outcome rather than guessing once.
import json
import logging
import logging.handlers
import re
import tempfile
import time
from pathlib import Path
from sys import exit
from vyos.configtree import ConfigTree
from vyos.defaults import directories
from vyos.migrate import ConfigMigrate
from vyos.utils.process import rc_cmd
from vyos.utils.process import run
# Bounded wait for all configured hw-id hardware to appear. NICs needing a
# longer firmware/link-training window are rare enough that this has not
# been exposed as a CLI knob yet.
HARDWARE_WAIT_TIMEOUT = 10
HARDWARE_WAIT_POLL = 0.25
# Bounded wait for hardware without a configured hw-id to settle before
# bootstrap-naming it. There is no specific MAC to wait for here, so
# "N consecutive identical snapshots" stands in for "hardware has stopped
# registering/renaming interfaces".
HARDWARE_SETTLE_TIMEOUT = 10
HARDWARE_SETTLE_STABLE_POLLS = 3
config_path = '/opt/vyatta/etc/config/config.boot'
vyos_udev_dir = directories['vyos_udev_dir']
status_file = Path('/run/vyos-net-name-resolve.json')
logger = logging.getLogger()
def _load_config_boot() -> ConfigTree:
"""Parse config.boot into a ConfigTree, migrating stale syntax if
needed. Returns None if there is no persisted config yet (livecd/ISO).
"""
if not Path(config_path).is_file():
return None
try:
config_file = Path(config_path).read_text()
except OSError as e:
logger.critical(f'OSError {e}')
exit(1)
try:
return ConfigTree(config_file)
except Exception:
try:
logger.debug('updating component version string syntax')
# this will update the component version string syntax,
# required for updates 1.2 --> 1.3/1.4
with tempfile.NamedTemporaryFile() as fp:
Path(fp.name).write_text(config_file)
config_migrate = ConfigMigrate(fp.name)
if config_migrate.syntax_update_needed():
config_migrate.update_syntax()
config_migrate.write_config()
config_file = Path(fp.name).read_text()
return ConfigTree(config_file)
except Exception as e:
logger.critical(f'ConfigTree error: {e}')
exit(1)
def get_configfile_interfaces() -> dict:
"""Read hw-id -> name mapping for ethernet/wireless from config.boot"""
interfaces: dict = {}
config = _load_config_boot()
if config is None:
return interfaces
for base in (['interfaces', 'ethernet'], ['interfaces', 'wireless']):
if not config.exists(base):
continue
for intf in config.list_nodes(base):
path = base + [intf, 'hw-id']
if not config.exists(path):
logger.warning(f"no 'hw-id' entry for {intf}")
continue
hwid = config.return_value(path).lower()
if hwid in interfaces:
logger.warning(
f'multiple entries for {hwid}: {interfaces[hwid]}, {intf}'
)
continue
interfaces[hwid] = intf
logger.debug(f'config file entries: {interfaces}')
return interfaces
def get_pending_hwid_nodes() -> dict:
"""Interface nodes that exist under interfaces/{ethernet,wireless} in
config.boot but have no 'hw-id' leaf - e.g. the documented "NIC
replaced" remediation (`delete interfaces ethernet eth1 hw-id`)
intentionally leaves the node (and its other settings, like address)
in place, expecting the SAME node to receive a fresh hw-id. These
names have no known MAC yet - that is the point - so they cannot be
folded into get_configfile_interfaces()'s MAC-keyed dict; they are
surfaced here, grouped by type, so main() can try to match them to
this boot's unconfigured hardware (see match_pending_nodes()).
"""
pending = {'ethernet': set(), 'wireless': set()}
config = _load_config_boot()
if config is None:
return pending
for intf_type, base in (('ethernet', ['interfaces', 'ethernet']),
('wireless', ['interfaces', 'wireless'])):
if not config.exists(base):
continue
for intf in config.list_nodes(base):
if not config.exists(base + [intf, 'hw-id']):
pending[intf_type].add(intf)
return pending
def get_permanent_mac(ifname: str, sys_class_net: str = '/sys/class/net') -> str:
"""Best-effort permanent hardware address of an interface.
Falls back to the interface's current address if the driver does not
support reporting a permanent address separately (ethtool -P).
sys_class_net is overridable for testing against a fake sysfs tree.
"""
code, out = rc_cmd(f'ethtool -P {ifname}')
if code == 0:
m = re.search(r'([0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5})', out)
if m and m.group(1) != '00:00:00:00:00:00':
return m.group(1).lower()
try:
return Path(f'{sys_class_net}/{ifname}/address').read_text().strip().lower()
except OSError:
return ''
def discover_physical_interfaces(sys_class_net: str = '/sys/class/net') -> dict:
"""Return {kernel_name: mac} for every interface backed by a real bus
device - excludes lo, bridges, bonds, VLANs, veth, tunnels, etc.
sys_class_net is overridable for testing against a fake sysfs tree.
"""
interfaces = {}
net_dir = Path(sys_class_net)
if not net_dir.is_dir():
return interfaces
for entry in net_dir.iterdir():
if not (entry / 'device').exists():
continue
mac = get_permanent_mac(entry.name, sys_class_net)
if mac:
interfaces[entry.name] = mac
return interfaces
def wait_for_hardware(configured_macs: set, timeout: float = HARDWARE_WAIT_TIMEOUT,
poll: float = HARDWARE_WAIT_POLL) -> tuple:
"""Poll until every configured hw-id has shown up, or until timeout"""
deadline = time.monotonic() + timeout
current = discover_physical_interfaces()
while True:
missing = configured_macs - set(current.values())
if not missing or time.monotonic() >= deadline:
return current, missing
time.sleep(poll)
current = discover_physical_interfaces()
def wait_for_settle(initial: dict, timeout: float = HARDWARE_SETTLE_TIMEOUT,
poll: float = HARDWARE_WAIT_POLL,
stable_polls: int = HARDWARE_SETTLE_STABLE_POLLS) -> dict:
"""Poll until discover_physical_interfaces() returns the same snapshot
stable_polls times in a row, or until timeout. Used before bootstrap-
naming hardware that has no configured hw-id to wait for by MAC.
"""
deadline = time.monotonic() + timeout
current = initial
stable = 1 if current else 0
while stable < stable_polls and time.monotonic() < deadline:
time.sleep(poll)
seen = discover_physical_interfaces()
stable = stable + 1 if seen == current else 1
current = seen
return current
def is_wireless_interface(name: str, sys_class_net: str = '/sys/class/net') -> bool:
"""Race-free wireless check: existence of the phy80211 symlink, not the
interface's current (possibly still cosmetic/pre-bootstrap) name.
"""
return (Path(sys_class_net) / name / 'phy80211').exists()
PCI_BDF_RE = re.compile(r'^[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f]$')
# No real hop-count can reach this - reserves it as a sort-last sentinel
# for interfaces whose bus topology can't be determined (USB NICs, or a
# device symlink that doesn't exist/resolve).
PCIE_DISTANCE_UNKNOWN = 999
def pcie_distance(name: str, sys_class_net: str = '/sys/class/net') -> int:
"""Approximate PCIe bus distance from the root complex - counts PCI
domain:bus:device.function segments (e.g. 0000:00:1f.6) in the fully
resolved sysfs path of the interface's 'device' symlink. Non-PCI hops
(virtioN, usbN, the net/<ifname> tail, ...) simply don't match and are
skipped, so virtio's device->virtioN->real-PCI-parent indirection
needs no special-casing. Multi-function siblings at the same slot
contribute exactly one matching segment each, so they are not
double-counted relative to their shared depth.
Returns PCIE_DISTANCE_UNKNOWN (sorts after every real hop-count) if
the 'device' symlink is missing/unresolvable, or the resolved path
has no PCI BDF segment at all (e.g. a USB NIC).
"""
device_link = Path(sys_class_net) / name / 'device'
try:
resolved = device_link.resolve(strict=True)
except (OSError, RuntimeError):
return PCIE_DISTANCE_UNKNOWN
hops = sum(1 for part in resolved.parts if PCI_BDF_RE.match(part))
return hops if hops > 0 else PCIE_DISTANCE_UNKNOWN
def find_available(names: set, prefix: str) -> str:
"""Find the lowest free index for a given interface name prefix"""
index_list = []
for name in names:
if not name.startswith(prefix):
continue
suffix = name[len(prefix):]
if suffix.isdigit():
index_list.append(int(suffix))
if not index_list:
return f'{prefix}0'
index_list.sort()
# find 'holes' in list, if any
missing = sorted(set(range(index_list[0], index_list[-1])) - set(index_list))
if missing:
return f'{prefix}{missing[0]}'
return f'{prefix}{index_list[-1] + 1}'
def find_next_available(names: set, prefix: str, floor: int = 0) -> str:
"""Find the lowest free index for a prefix, scanning up from floor.
Unlike find_available(), this always starts the scan at floor (0 by
default) rather than at the lowest index already present in `names` -
used for bootstrap naming, where a gap must be backfillable even when
it sits below every other index currently in `names` (e.g. `names`
contains only 'eth5' and 'eth9', but 'eth0'..'eth4' and 'eth6'..'eth8'
are genuinely free). A slot only stays unavailable here because it is
explicitly present in `names` - the caller decides what belongs there
(configured hw-id targets, pending nodes, ...), not this function.
"""
n = floor
while f'{prefix}{n}' in names:
n += 1
return f'{prefix}{n}'
def compute_rename_plan(configured: dict, current: dict, pending: dict = None) -> dict:
"""Build {from_name: to_name} for every interface that needs to move to
its CLI hw-id name. Also relocates any interface that currently squats
on a name owned by a different hw-id, so the rightful owner can take it.
"""
plan = {}
current_by_mac = {mac: name for name, mac in current.items()}
target_owner_mac = {name: mac for mac, name in configured.items()}
for mac, target in configured.items():
source = current_by_mac.get(mac)
if source and source != target:
plan[source] = target
unchanged = set(current) - set(plan)
# every configured target is reserved, even one whose hw-id hasn't
# shown up yet this boot (missing/faulty/slow driver) - a squatter must
# never be relocated onto a name that hardware still owns, or it would
# just need relocating again the moment that hardware actually appears.
# A pending (hw-id-less but still-configured) node name is reserved the
# same way: it may be about to be reclaimed by the exact NIC that just
# vacated it (see match_pending_nodes()), and even when it isn't, its
# other settings (address, description, ...) are still live in
# config.boot and must not be handed to an unrelated squatter.
reserved = set(configured.values())
if pending:
reserved |= pending.get('ethernet', set()) | pending.get('wireless', set())
for name, mac in current.items():
if name in plan:
continue
owner_mac = target_owner_mac.get(name)
if owner_mac is not None and owner_mac != mac:
prefix = re.sub(r'\d+$', '', name) or name
taken = unchanged | set(plan.values()) | set(plan.keys()) | reserved
new_name = find_available(taken, prefix)
plan[name] = new_name
unchanged.discard(name)
return plan
def unmatched_candidates(configured: dict, current: dict, existing_plan: dict) -> list:
"""Physical interfaces this boot with no configured hw-id - the pool
both ordinary bootstrap naming and pending-node reclaim matching draw
from. This deliberately INCLUDES squatters compute_rename_plan() is
already evicting from a configured target name: excluding them here
let their mac skip match_pending_nodes() entirely, so an unconfigured
NIC that happened to be squatting on someone else's hw-id slot could
silently get a permanent, possibly-ambiguous hw-id via ordinary
bootstrap naming instead of being reclaimed or held back like any
other unconfigured candidate. Their eviction destination from
existing_plan still stands as the fallback - match_pending_nodes()
(via main()'s reclaim loop) or compute_bootstrap_plan() only override
it, they never leave a squatter un-evicted.
"""
return [(mac, name) for name, mac in current.items()
if mac not in configured]
def match_pending_nodes(pending: dict, candidates: list) -> dict:
"""Match this boot's unconfigured candidates to pending (hw-id-less)
config nodes, one type (ethernet/wireless) at a time. Conservative by
design: only matches when there is EXACTLY ONE pending node and
EXACTLY ONE candidate of that type this boot - any other cardinality
is left alone rather than guessed.
A pending node still carries its OTHER settings (address,
description, ...) - a wrong guess would silently apply one physical
port's configuration to a DIFFERENT port. Confirmed in the field: on
a real, already-provisioned box (whose hw-id came from historical
probe-order rescan, not from any PCIe/MAC sort), a second, completely
unrelated candidate freed in the same boot - e.g. a different
interface's config being fully deleted - is enough for a naive
ascending-sort fill to swap the two, binding a configured node's
address to the wrong wire. There is no way to tell, from MAC and
PCIe position alone, which candidate is genuinely the pending node's
own hardware once its hw-id is gone; leaving the node unresolved and
reported is safer than guessing. A candidate that isn't reclaimed
here is not otherwise held back - it still proceeds to ordinary
bootstrap naming (see compute_bootstrap_plan()) and gets its own
fresh, settings-free name instead.
Returns {mac: node_name} for every unambiguous match this boot.
"""
grouped = {'ethernet': [], 'wireless': []}
for mac, name in candidates:
group = 'wireless' if is_wireless_interface(name) else 'ethernet'
grouped[group].append((mac, name))
matched = {}
for intf_type, nodes in pending.items():
if not nodes:
continue
cands = grouped.get(intf_type, [])
if len(nodes) == 1 and len(cands) == 1:
(mac, _name) = cands[0]
(target,) = nodes
matched[mac] = target
else:
cand_desc = ', '.join(f"'{name}' ({mac})" for mac, name in cands) or 'none'
logger.warning(
f'{len(nodes)} pending {intf_type} node(s) '
f"({', '.join(sorted(nodes))}) and {len(cands)} unconfigured "
f'{intf_type} candidate(s) this boot ({cand_desc}) - not '
'unambiguous, leaving pending rather than guessing'
)
return matched
def compute_bootstrap_plan(configured: dict, current: dict, existing_plan: dict,
pending: dict = None,
reclaimed_macs: frozenset = frozenset()) -> dict:
"""Build {from_name: to_name} for physical interfaces that have no
configured hw-id at all, assigning them a canonical name within their
type group (ethernet/wireless) ordered by PCIe distance from the root
complex first and MAC address as a tie-break, instead of leaving them
at whatever name the racy udev-time fast path produced. Ordering by
topology rather than raw MAC magnitude means an onboard/directly
CPU-attached NIC isn't sorted after add-in cards just because its MAC
happens to be numerically higher. This is what makes a box's very
first boot - before any hw-id exists - just as deterministic as every
boot after hw-id is written (PCIe wiring and MAC are both static
hardware properties), since the name assigned here gets frozen into
config.boot by vyos-interface-rescan.py the same way a real hw-id
match would.
existing_plan is the hw-id based plan already computed by
compute_rename_plan(): its RIGHTFUL-OWNER targets (an interface moving
to its own configured hw-id name) are reserved so a bootstrap name can
never collide with a configured one. A squatter compute_rename_plan()
is evicting from a configured target IS still bootstrap candidacy here
(see unmatched_candidates()) - its OWN eviction destination is only a
provisional fallback that this function is about to recompute for it
from scratch, so that value must not also count as "taken" (it would
needlessly block a lower slot this function might otherwise give it,
or another candidate, once real).
pending node names are reserved the same way a real hw-id target is -
a candidate main() could not unambiguously match to one via
match_pending_nodes() must never squat there instead, since that name
still carries the node's other settings (address, description, ...).
reclaimed_macs are candidates main() already matched to a pending
node - excluded here so this function never reassigns them elsewhere.
A numeric slot is only ever off-limits here because something still
occupies or reserves it - a real hw-id target or a pending node. A gap
left by fully deleting an interface's config (hw-id and node both
gone, nothing in `pending` either) carries no such reservation and is
freely backfilled, exactly as bootstrap naming would treat it on a
system that never had any config for it at all - there is no
remaining signal in config.boot to tell those two cases apart, and
deleting the whole node is the admin's explicit way of saying so.
"""
plan = {}
candidates = unmatched_candidates(configured, current, existing_plan)
candidates = [(mac, name) for mac, name in candidates
if mac not in reclaimed_macs]
if not candidates:
return plan
candidate_names = {name for _, name in candidates}
# a plan entry whose source IS one of this function's own candidates
# is a squatter's provisional eviction fallback, about to be
# recomputed below - only a rightful-owner move's target is a real,
# authoritative reservation
rightful_movers = {src: target for src, target in existing_plan.items()
if src not in candidate_names}
# a rightful mover's CURRENT (source) name looks occupied right now,
# but safe_bulk_rename()'s two-phase scratch-name staging vacates it
# before any target name is actually claimed - so it must not count
# as taken here either.
reserved_pending = set()
if pending:
reserved_pending = pending.get('ethernet', set()) | pending.get('wireless', set())
taken = (set(current) - candidate_names - set(rightful_movers)) \
| set(rightful_movers.values()) | reserved_pending
for mac, name in sorted(candidates, key=lambda c: (pcie_distance(c[1]), c[0])):
prefix = 'wlan' if is_wireless_interface(name) else 'eth'
new_name = find_next_available(taken, prefix)
taken.add(new_name)
if new_name != name:
plan[name] = new_name
return plan
def get_ifindex(name: str) -> str:
try:
return Path(f'/sys/class/net/{name}/ifindex').read_text().strip()
except OSError:
return name
def rename_interface(old: str, new: str) -> bool:
run(f'ip link set dev {old} down')
code = run(f'ip link set dev {old} name {new}')
if code != 0:
logger.error(f"failed to rename '{old}' -> '{new}' (exit {code})")
return False
logger.info(f"renamed '{old}' -> '{new}'")
return True
def safe_bulk_rename(plan: dict) -> dict:
"""Two-phase rename: stage every interface via a unique scratch name
(derived from its ifindex, which is always unique) before assigning
final names. This makes the whole batch collision-proof regardless of
permutations/cycles between current and target names - a straight
from->to rename can fail if the target name is still held by another
interface earlier/later in the same plan.
"""
if not plan:
return {}
applied = {}
scratch = {}
for old, target in plan.items():
tmp = f'vyeth{get_ifindex(old)}'
if rename_interface(old, tmp):
scratch[tmp] = (old, target)
applied[old] = target
for tmp, (old, target) in scratch.items():
if rename_interface(tmp, target):
run(f'ip link set dev {target} up')
else:
# still sitting under the scratch name, not the target - don't
# report it as renamed, and don't leave it down indefinitely
del applied[old]
run(f'ip link set dev {tmp} up')
return applied
def sync_rescan_hints(current_state: dict, configured: dict,
stale_names: set = frozenset()) -> None:
"""Leave a hint under vyos_udev_dir for every interface that has no
hw-id configured yet, so vyos-interface-rescan.py can auto-populate
config.boot. Remove stale/no-longer-hw-id-needed hints.
"""
try:
Path(vyos_udev_dir).mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.critical(f'error creating rescan hint directory: {e}')
return
for name, mac in current_state.items():
hint = Path(vyos_udev_dir) / name
if mac in configured:
hint.unlink(missing_ok=True)
else:
try:
hint.write_text(mac)
except OSError as e:
logger.error(f'could not write rescan hint for {name}: {e}')
for old_name in stale_names:
if old_name not in current_state:
(Path(vyos_udev_dir) / old_name).unlink(missing_ok=True)
def write_status(configured: dict, found: dict, missing: set, plan: dict,
pending: dict = None, reclaimed: dict = None,
candidates: list = None) -> None:
"""candidates is the full unmatched_candidates() list evaluated this
boot (before reclaim/bootstrap assignment) - surfaced here so a
pending node left unresolved for lack of hardware is self-diagnosable
from this one file: 'pending_unresolved' names what needed a match,
'unconfigured_candidates' names every physical interface that was in
the running for one, without needing a separate `ip -br link show` or
`show configuration commands` to reconstruct the same picture by hand.
"""
pending = pending or {}
reclaimed = reclaimed or {}
candidates = candidates or []
all_pending = pending.get('ethernet', set()) | pending.get('wireless', set())
status = {
'configured': configured,
'found': sorted(found.values()),
'missing': {mac: configured[mac] for mac in sorted(missing)},
'renamed': plan,
'pending_unresolved': sorted(all_pending - set(reclaimed.values())),
'reclaimed': reclaimed,
'unconfigured_candidates': {name: mac for mac, name in candidates},
}
try:
status_file.write_text(json.dumps(status, indent=2))
except OSError as e:
logger.error(f'could not write status file: {e}')
def main():
configured = get_configfile_interfaces()
pending = get_pending_hwid_nodes()
if configured:
current, missing = wait_for_hardware(set(configured))
for mac in sorted(missing):
logger.warning(
f"hw-id '{mac}' configured as '{configured[mac]}' was not found "
f'within {HARDWARE_WAIT_TIMEOUT}s - hardware may be missing, '
'still initializing, or its driver failed to load'
)
plan = compute_rename_plan(configured, current, pending)
else:
logger.info('no hw-id configured yet')
current, missing, plan = discover_physical_interfaces(), set(), {}
all_pending = pending.get('ethernet', set()) | pending.get('wireless', set())
reclaimed = {}
candidates = []
if all_pending or any(mac not in configured for mac in current.values()):
# The `all_pending` half of this condition matters even when
# `current` (from wait_for_hardware() above, bounded only on
# already-CONFIGURED macs) shows nothing unconfigured yet: on a
# system with several different NIC vendors/drivers, the exact
# hardware a pending node needs can simply not have finished
# probing at this snapshot. Without this, wait_for_settle() below
# would never even run, giving that slower hardware zero extra
# time to appear before this boot gives up on the pending node.
current = wait_for_settle(current)
if configured:
# a configured hw-id whose driver was too slow to show up
# within wait_for_hardware()'s timeout can still appear during
# the extra time wait_for_settle() just spent waiting for
# unconfigured hardware to stabilize - recompute against the
# settled snapshot so it still gets renamed (and drops out of
# `missing`) this boot instead of being silently skipped.
missing = set(configured) - set(current.values())
plan = compute_rename_plan(configured, current, pending)
# let a NIC that just lost its hw-id (the documented "delete
# hw-id to force regeneration" remediation) reclaim the exact
# node its other settings (address, description, ...) still live
# under, rather than bootstrap-naming it to a new bare node and
# orphaning that config - only when unambiguous, see
# match_pending_nodes()
candidates = unmatched_candidates(configured, current, plan)
reclaimed = match_pending_nodes(pending, candidates)
candidate_by_mac = dict(candidates)
for mac, target in reclaimed.items():
name = candidate_by_mac[mac]
if name != target:
plan[name] = target
logger.info(
f"reclaiming pending node '{target}' for hw-id '{mac}' "
'this boot'
)
# bootstrap-name whatever is left, deterministically by PCIe
# distance and MAC, instead of leaving it at its racy cosmetic
# udev-time name - this is what vyos-interface-rescan.py will
# freeze into config.boot. Pending node names stay reserved here
# (see compute_bootstrap_plan()) so an ambiguous leftover
# candidate can never squat on one and inherit its settings.
plan.update(compute_bootstrap_plan(
configured, current, plan, pending=pending,
reclaimed_macs=set(reclaimed)))
for name in sorted(all_pending - set(reclaimed.values())):
logger.warning(
f"pending node '{name}' still has no hw-id after this boot's "
'naming pass'
)
applied = safe_bulk_rename(plan)
final_current = discover_physical_interfaces()
# configured is passed unmutated (not merged with `reclaimed`): the
# reclaimed mac still has no real hw-id in config.boot yet, so it must
# still get a rescan hint under its (now reclaimed) name, letting
# vyos-interface-rescan.py write the hw-id into the existing node.
sync_rescan_hints(final_current, configured, set(applied.keys()))
write_status(configured, current, missing, applied,
pending=pending, reclaimed=reclaimed, candidates=candidates)
if __name__ == '__main__':
syslog_handler = logging.handlers.SysLogHandler(address='/dev/log')
formatter = logging.Formatter(f'{Path(__file__).name}: %(message)s')
syslog_handler.setFormatter(formatter)
logger.addHandler(syslog_handler)
logger.setLevel(logging.DEBUG)
main()