summaryrefslogtreecommitdiff
path: root/src/system
diff options
context:
space:
mode:
Diffstat (limited to 'src/system')
-rwxr-xr-xsrc/system/vyos-net-name-resolve.py786
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:]))