summaryrefslogtreecommitdiff
path: root/python
diff options
context:
space:
mode:
authorAlex Kudentsov <43482574+alexk37@users.noreply.github.com>2026-10-14 20:46:08 +0700
committerChristian Breunig <christian@breunig.cc>2026-10-31 09:58:29 +0000
commit153dd688b327c922ec4b371db1fdd11b8ee8cf9e (patch)
tree8fcb56faa26c097d86e76f1b7af82bdf0b2d5af2 /python
parent639d575ad258f6f6ba629a0c50a710b736e4911d (diff)
downloadvyos-1x-153dd688b327c922ec4b371db1fdd11b8ee8cf9e.tar.gz
vyos-1x-153dd688b327c922ec4b371db1fdd11b8ee8cf9e.zip
interfaces: T3871: give a re-created Hyper-V NIC the name of the NIC it replaced
A Hyper-V NIC is known by its MAC alone. One deleted and created again on the same VM - an Azure NIC re-created, or a Hyper-V adapter removed and added - comes back with a new MAC, so nothing on it matched the store: its name stayed reserved, the new NIC got a new name, and its configuration was left on a name with no NIC. cloud-init does not run again on the same instance, so nothing else repairs it. At boot, a vacant name now goes to a new NIC only when the pairing is not in doubt: - exactly one recorded name has no NIC, and exactly one new Hyper-V NIC appeared. With more of either, the order the NICs come up in does not say which replaced which, so nothing is handed over and the names stay reserved, as before; - the name went missing at this pass. The store records which names were missing at the last pass, so a NIC removed for good does not hand its name to a NIC added at a later boot; - the name's own NIC was a Hyper-V one. The store records which names sat on a Hyper-V NIC; a MAC-keyed name can belong to other hardware, and a store written before this was recorded hands nothing over (the boot pass logs that when it holds a name back); - only at boot. On a running system the configuration under a name is not applied to the NIC that takes it, so the hotplug unit passes --hotplug, which names the new NIC like any other and records the vacant name as missing, so the next boot does not hand it over either. An adoption is logged as a warning, reported under 'adopted' in the status file (kept there across hotplug passes while it holds), and repeated by vyos-router, as a slot match is: verify the port before relying on its configuration.
Diffstat (limited to 'python')
-rw-r--r--python/vyos/ifconfig/ifname_store.py89
1 files changed, 82 insertions, 7 deletions
diff --git a/python/vyos/ifconfig/ifname_store.py b/python/vyos/ifconfig/ifname_store.py
index 20731aed1..961a81d15 100644
--- a/python/vyos/ifconfig/ifname_store.py
+++ b/python/vyos/ifconfig/ifname_store.py
@@ -302,8 +302,25 @@ def load_store(path: Path = None) -> dict:
if isinstance(mac, str) and mac and name in interfaces
} if isinstance(seen, dict) else {}
- return {'version': STORE_VERSION, 'interfaces': interfaces,
- 'hardware': hardware}
+ store = {'version': STORE_VERSION, 'interfaces': interfaces, 'hardware': hardware}
+
+ # names whose hardware was missing at the last pass - kept only while
+ # there are any, so a store without them reads as it always did
+ absent = data.get('absent')
+ if isinstance(absent, list):
+ absent = sorted({n for n in absent if n in interfaces})
+ if absent:
+ store['absent'] = absent
+
+ # names whose NIC was a Hyper-V one when last seen - the only names a new
+ # Hyper-V NIC may take over. A store without them (written before they
+ # were recorded) has none.
+ hyperv = data.get('hyperv')
+ if isinstance(hyperv, list):
+ hyperv = sorted({n for n in hyperv if n in interfaces})
+ if hyperv:
+ store['hyperv'] = hyperv
+ return store
def encrypted_config_volume() -> bool:
@@ -411,12 +428,15 @@ def _next_free(taken: set, prefix: str) -> str:
return f'{prefix}{index}'
-def resolve(devices: list, store: dict) -> tuple:
+def resolve(devices: list, store: dict, adopt: bool = True) -> tuple:
"""Name every physical interface present this boot.
Returns (plan, new_store, report); plan is {current: new} for the
interfaces which have to be renamed. The slot decides the name - nothing
here looks at the configuration, so there is nothing to guess.
+
+ adopt=False keeps a vacant Hyper-V name from going to a new NIC, for a
+ pass on a running system (see the adoption block below).
"""
entries = store.get('interfaces', {})
seen = store.get('hardware', {})
@@ -436,13 +456,23 @@ def resolve(devices: list, store: dict) -> tuple:
seen = {n: m for n, m in seen.items() if n not in misfiled}
assigned = {} # final name -> device
- report = {'matched': {}, 'reassigned': {}, 'moved': {},
- 'replaced': {}, 'bootstrapped': {}}
+ report = {
+ 'matched': {},
+ 'reassigned': {},
+ 'moved': {},
+ 'replaced': {},
+ 'adopted': {},
+ 'bootstrapped': {},
+ }
# A recorded name stays off-limits even while its slot is empty, so new
# hardware cannot inherit an absent interface's name and configuration.
reserved = set(entries)
remaining = list(devices)
+ # what the last pass recorded: names already missing then, and names
+ # whose NIC was a Hyper-V one
+ missing_before = set(store.get('absent', []))
+ hyperv_before = set(store.get('hyperv', []))
def claim(device, name, bucket):
assigned[name] = device
@@ -500,6 +530,36 @@ def resolve(devices: list, store: dict) -> tuple:
claim(device, name, 'replaced')
break
+ # a Hyper-V NIC is known by its MAC alone, and one deleted and created
+ # again comes back with a new MAC, so nothing on it could match. When one
+ # recorded NIC is gone and one new NIC took its place the pairing is not in
+ # doubt, and the name goes to it. With more of either it is: the order the
+ # NICs come up in here does not say which replaced which, so nothing is
+ # handed over and the names stay reserved.
+ # Only on the pass the NIC first went missing: a replacement is one change
+ # to the VM. A name already missing at the last pass belongs to a NIC that
+ # was removed, and a NIC added later is not its replacement.
+ # Only at boot: on a running system the configuration under the name is
+ # not applied to the NIC that takes it, so the name would point at a port
+ # that is down and unaddressed until the next boot. The new NIC gets a new
+ # name there, and the vacant one is recorded as missing, so the next boot
+ # does not hand it over either.
+ # Only a name whose own NIC was a Hyper-V one: a MAC-keyed name can
+ # belong to other hardware too, and a store that does not say (written
+ # before this was recorded) hands nothing over.
+ vacant = [
+ name
+ for name, key in entries.items()
+ if name not in assigned
+ and name not in missing_before
+ and name in hyperv_before
+ and key.startswith('ID_NET_NAME_MAC=')
+ ]
+ adoptable = [device for device in remaining if is_hyperv_nic(device)]
+ if (adopt and len(vacant) == 1 and len(adoptable) == 1
+ and _name_fits(vacant[0], adoptable[0])):
+ claim(adoptable[0], vacant[0], 'adopted')
+
# unknown hardware: lowest free name of its type, in hardware order
for device in sorted(remaining, key=canonical_sort_key):
name = _next_free(reserved | set(assigned), _prefix_for(device))
@@ -521,8 +581,23 @@ def resolve(devices: list, store: dict) -> tuple:
plan = {device['name']: name for name, device in assigned.items()
if device['name'] != name}
- return plan, {'version': STORE_VERSION, 'interfaces': new_interfaces,
- 'hardware': new_hardware}, report
+ new_store = {
+ 'version': STORE_VERSION,
+ 'interfaces': new_interfaces,
+ 'hardware': new_hardware,
+ }
+ absent = sorted(name for name in new_interfaces if name not in assigned)
+ if absent:
+ new_store['absent'] = absent
+ # what each name's NIC was, kept for an absent one like its address
+ hyperv = sorted(
+ {name for name, device in assigned.items() if is_hyperv_nic(device)}
+ | (hyperv_before & set(absent))
+ )
+ if hyperv:
+ new_store['hyperv'] = hyperv
+
+ return plan, new_store, report
LINK_DIR = Path('/etc/systemd/network')