diff options
Diffstat (limited to 'src/system')
| -rwxr-xr-x | src/system/vyos-net-name-resolve.py | 786 |
1 files changed, 193 insertions, 593 deletions
diff --git a/src/system/vyos-net-name-resolve.py b/src/system/vyos-net-name-resolve.py index e28f3b3c4..f3e4e62fb 100755 --- a/src/system/vyos-net-name-resolve.py +++ b/src/system/vyos-net-name-resolve.py @@ -14,505 +14,121 @@ # 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. +# Boot-time maintenance of the interface mapping store (T3871). # -# 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. +# The store is rendered into systemd .link files, so udev does the actual +# naming as each device is added. What is left here is what udev cannot do: +# name hardware the store has never seen, record it, and re-render. +# +# Nothing here reads the configuration - a name is a property of the +# hardware, not of what happens to be configured on it. import json import logging import logging.handlers -import re -import tempfile +import sys 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.ifconfig.ifname_store import device_matches +from vyos.ifconfig.ifname_store import discover_devices +from vyos.ifconfig.ifname_store import load_store +from vyos.ifconfig.ifname_store import resolve +from vyos.ifconfig.ifname_store import save_store +from vyos.ifconfig.ifname_store import sync_link_files from vyos.system.image import is_running_as_container -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. +# bounded wait for known hardware to appear 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". +# unknown hardware has no specific device to wait for, so N identical +# snapshots stand in for "probing has finished" 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. - - Also excludes interfaces enslaved to another netdev (master symlink - present), which covers Azure VF datapath interfaces bound under their - synthetic parent. Those are acceleration children, not independent - primary interfaces, and must not be candidates for hw-id naming. +def _snapshot(devices: list) -> set: + return {(d['name'], d['mac']) for d in devices} - 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(): - logger.debug( - f"skipping '{entry.name}': no backing device in sysfs" - ) - continue - if (entry / 'master').exists(): - logger.debug( - f"skipping '{entry.name}': interface is enslaved via master link" - ) - continue - mac = get_permanent_mac(entry.name, sys_class_net) - if mac: - interfaces[entry.name] = mac - return interfaces +def _unmatched(keys: set, devices: list) -> set: + """Recorded slots that no device present this boot occupies.""" + return {key for key in keys + if not any(device_matches(d['properties'], key) for d in devices)} -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""" +def wait_for_hardware(known_keys: set, timeout: float = HARDWARE_WAIT_TIMEOUT, + poll: float = HARDWARE_WAIT_POLL) -> tuple: + """Poll until every slot the store knows is filled, or until timeout.""" deadline = time.monotonic() + timeout - current = discover_physical_interfaces() + devices = discover_devices() while True: - missing = configured_macs - set(current.values()) + missing = _unmatched(known_keys, devices) if not missing or time.monotonic() >= deadline: - return current, missing + return devices, missing time.sleep(poll) - current = discover_physical_interfaces() + devices = discover_devices() -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. +def wait_for_settle(initial: list, timeout: float = HARDWARE_SETTLE_TIMEOUT, + poll: float = HARDWARE_WAIT_POLL, + stable_polls: int = HARDWARE_SETTLE_STABLE_POLLS) -> list: + """Poll until the set of interfaces stops changing, or until timeout. + Used before naming hardware the store has never seen, where there is no + specific MAC to wait for. """ deadline = time.monotonic() + timeout - current = initial - stable = 1 if current else 0 + devices = initial + stable = 1 if devices 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() - + seen = discover_devices() + stable = stable + 1 if _snapshot(seen) == _snapshot(devices) else 1 + devices = seen + return devices -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 get_ifindex(name: str) -> str: + try: + return Path(f'/sys/class/net/{name}/ifindex').read_text().strip() + except OSError: + return name -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. +def interface_in_use(name: str, sys_class_net: str = '/sys/class/net') -> bool: + """Is the running system relying on this name? Renaming it then leaves + the configuration pointing at a name nothing answers to. - 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). + At boot this is false for everything - the pass runs before the + configuration is applied, so the ports are still down. Afterwards, which + is the only time the hot-plug unit runs, it is true for every port the + configuration touched and for a card which just arrived it is not. """ - 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}' + flags = int((Path(sys_class_net) / name / 'flags').read_text(), 16) + except (OSError, ValueError): + return False + IFF_UP = 0x1 + return (bool(flags & IFF_UP) + or (Path(sys_class_net) / name / 'master').exists()) -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 +def refuse_renames_in_use(plan: dict, + sys_class_net: str = '/sys/class/net') -> tuple: + """Split a rename plan into what may be applied and what must not.""" + allowed, refused = {}, {} + for old, target in plan.items(): + if interface_in_use(old, sys_class_net): + refused[old] = 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 + allowed[old] = target + return allowed, refused def rename_interface(old: str, new: str) -> bool: @@ -526,12 +142,10 @@ def rename_interface(old: str, new: str) -> bool: 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. + """Stage every interface under a scratch name derived from its ifindex + before assigning the final ones, so the batch cannot collide on a + permutation or a cycle. Only new hardware reaches this - udev has already + named everything the store knows. """ if not plan: return {} @@ -548,64 +162,44 @@ def safe_bulk_rename(plan: dict) -> dict: 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 + # still under the scratch name - do not report it as renamed 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()) +def write_status(store: dict, devices: list, applied: dict, report: dict, + persisted: bool, refused: dict = None) -> None: + """Publish what this pass decided, to warn about and to inspect later.""" + absent = _unmatched(set(store['interfaces'].values()), devices) 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}, + 'interfaces': dict(sorted(store['interfaces'].items())), + 'missing': { + name: key for name, key in sorted(store['interfaces'].items()) + if key in absent + }, + 'renamed': applied, + # in use, so left alone until the next boot + 'rename_refused': refused or {}, + # everything recognised from the store, however it was recognised + 'matched': { + **report.get('matched', {}), + **report.get('reassigned', {}), + **report.get('moved', {}), + **report.get('replaced', {}), + **report.get('adopted', {}), + }, + # the name changed hands between cards the store already knows + 'reassigned': report.get('reassigned', {}), + # the card was found in a different slot - certain, it is the same card + 'moved': report.get('moved', {}), + # a different card took the slot - the one case that needs verifying + 'matched_by_path': report.get('replaced', {}), + # a recorded Hyper-V NIC was gone and its one replacement took its name + 'adopted': report.get('adopted', {}), + 'new_hardware': report.get('bootstrapped', {}), + 'persisted': persisted, } try: status_file.write_text(json.dumps(status, indent=2)) @@ -613,99 +207,105 @@ def write_status(configured: dict, found: dict, missing: set, plan: dict, logger.error(f'could not write status file: {e}') -def main(): +def boot_adoptions(store: dict) -> dict: + """The adoptions the boot pass reported that still hold. A pass for + hot-plugged hardware adopts nothing, and must not wipe the record of + what this boot did.""" + try: + adopted = json.loads(status_file.read_text()).get('adopted') or {} + except (OSError, ValueError, AttributeError): + return {} + hardware = store.get('hardware', {}) + return {name: mac for name, mac in adopted.items() + if hardware.get(name) == mac} + + +def hotplug_requested(argv: list) -> bool: + """--hotplug: started for hardware added to a running system""" + return '--hotplug' in argv + + +def main(hotplug: bool = False): if is_running_as_container(): - # A container has no NIC of its own - its interfaces are veth pairs - # created by the container runtime. They have no backing bus device in - # sysfs and their MAC is assigned by the host and regenerated on every - # start, so there is nothing to wait for and no hw-id worth binding. - logger.info('running inside a container - skipping hw-id naming pass') + # a container owns no NIC - its veth pairs have no backing bus device + logger.info('running inside a container - skipping interface naming') return - configured = get_configfile_interfaces() - pending = get_pending_hwid_nodes() + store = load_store() + known = set(store['interfaces'].values()) - if configured: - current, missing = wait_for_hardware(set(configured)) - for mac in sorted(missing): + devices, _missing = wait_for_hardware(known) + + if not store['interfaces'] or any( + not any(device_matches(d['properties'], k) for k in known) + for d in devices): + # unknown hardware present - let slower drivers finish registering + # so a merely late NIC is not treated as new + devices = wait_for_settle(devices) + + absent = _unmatched(known, devices) + for name, key in sorted(store['interfaces'].items()): + if key in absent: 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' + f"'{name}' ({key}) did not appear within " + f'{HARDWARE_WAIT_TIMEOUT}s - hardware may be missing, still ' + 'initializing, or its driver failed to load. Its name stays ' + 'reserved.' ) - # 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())): + if not hotplug and 'hyperv' not in store and any( + key.startswith('ID_NET_NAME_MAC=') and key in absent + for key in store['interfaces'].values()): + # a name is vacant, and the map was written by code that did not + # record which NICs were Hyper-V ones: the name is not handed over. + # Each pass records the Hyper-V NICs it finds, but never a vacant name + logger.info('the interface map does not say which interfaces were ' + 'Hyper-V NICs - no vacant name is given to a new NIC ' + 'this boot') + plan, store, report = resolve(devices, store, adopt=not hotplug) + + for name, mac in sorted(report.get('bootstrapped', {}).items()): + logger.info(f"new hardware {mac} named '{name}'") + + # A card which took over a slot also took over the addresses configured + # under that name. vyos-router says so for a boot, but a card swapped on + # a running system is only ever seen here. + for name, mac in sorted(report.get('replaced', {}).items()): logger.warning( - f"pending node '{name}' still has no hw-id after this boot's " - 'naming pass' + f"'{name}' was matched to new hardware {mac} by slot - verify it " + 'is the port you expect before relying on its configuration' + ) + for name, mac in sorted(report.get('adopted', {}).items()): + logger.warning( + f"'{name}' had no NIC this boot and was given to the new Hyper-V " + f'NIC {mac}, which took its place - verify it is the port you ' + 'expect before relying on its configuration' + ) + plan, refused = refuse_renames_in_use(plan) + for old, target in sorted(refused.items()): + logger.warning( + f"'{old}' is in use and was not renamed to '{target}' - the map " + 'and the running system disagree until the next boot' ) - 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) + persisted = save_store(store) + if persisted: + # render so udev applies the names on the next boot + try: + sync_link_files(store, devices=devices) + except OSError as e: + logger.error(f'could not write .link files: {e}') + else: + logger.warning( + 'the configuration volume is not mounted - interface names were ' + 'resolved for this boot but could not be persisted' + ) + + if hotplug: + # reported once, by the boot pass; kept on record until the next boot + report = {**report, 'adopted': boot_adoptions(store)} + write_status(store, devices, applied, report, persisted, refused) if __name__ == '__main__': @@ -716,4 +316,4 @@ if __name__ == '__main__': logger.addHandler(syslog_handler) logger.setLevel(logging.DEBUG) - main() + main(hotplug=hotplug_requested(sys.argv[1:])) |
