From d8dcf6bc7c98435c8c01150e6db0d565c88c3849 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Mon, 26 Oct 2026 18:45:56 +0100 Subject: cloud-init: T3871: record the names the cloud gave its NICs Interface names are no longer derived from a MAC held in the configuration, so writing one back was producing a node which no longer exists. A configuration carrying it is rejected on load, and migration does not catch it: the file is brought to the current version before it is modified, so nothing runs over it afterwards. Which NIC the cloud considers the first one is not visible in the hardware and only the datasource knows it, so that order still has to be recorded - now in the mapping store, against the slot each card sits in, before the naming pass reads it. Hardware the datasource describes but which is not present is skipped, as is a name or a slot already spoken for. --- cloudinit/config/cc_vyos.py | 78 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/cloudinit/config/cc_vyos.py b/cloudinit/config/cc_vyos.py index 56014794..1e265c4d 100644 --- a/cloudinit/config/cc_vyos.py +++ b/cloudinit/config/cc_vyos.py @@ -36,6 +36,9 @@ from cloudinit.util import load_file, load_json, get_hostname_fqdn, get_cfg_by_p from cloudinit.sources.DataSourceOVF import get_properties as ovf_get_properties try: from vyos.configtree import ConfigTree + from vyos.ifconfig.ifname_store import discover_devices + from vyos.ifconfig.ifname_store import load_store + from vyos.ifconfig.ifname_store import save_store except ImportError as err: print(f'The module cannot be imported: {err}') @@ -466,12 +469,6 @@ def set_config_interfaces_v1(config, iface_config: dict): if iface_config['type'] == 'physical': iface_name = iface_config['name'] - # configre MAC - if 'mac_address' in iface_config: - logger.debug("Setting MAC for {}: {}".format(iface_name, iface_config['mac_address'])) - config.set(['interfaces', 'ethernet', iface_name, 'hw-id'], value=iface_config['mac_address'], replace=True) - config.set_tag(['interfaces', 'ethernet']) - # configre MTU if 'mtu' in iface_config and iface_config['mtu'] is not None: set_ether_mtu(config, iface_name, iface_config['mtu']) @@ -955,15 +952,6 @@ def config_net_v2_ethernet(config, iface_name: str, iface_config: dict) -> None: config_net_v2_common(config, 'ethernet', iface_name, iface_config) - # configure MAC - if 'match' in iface_config and 'macaddress' in iface_config['match']: - logger.debug("Setting MAC for {}: {}".format( - iface_name, iface_config['match']['macaddress'])) - config.set(['interfaces', 'ethernet', iface_name, 'hw-id'], - value=iface_config['match']['macaddress'], - replace=True) - config.set_tag(['interfaces', 'ethernet']) - # configure interface from networking config version 2 def set_config_interfaces_v2(config, netcfg: dict) -> None: @@ -979,6 +967,64 @@ def set_config_interfaces_v2(config, netcfg: dict) -> None: config_net_v2_vlan(config, iface_name, iface_config) +# record the names the cloud gave its NICs +def record_interface_mapping(netcfg: dict) -> None: + """T3871: a name is anchored to the slot a card sits in, recorded in the + mapping store rather than in the configuration. Which NIC is the first one + is the cloud's to decide and is not visible in the hardware, so the order + the datasource gives has to reach the store before the naming pass reads + it. + """ + bindings = {} + if netcfg.get('version') == 1: + for iface_config in netcfg.get('config', []): + if iface_config.get('type') != 'physical': + continue + if iface_config.get('name') and iface_config.get('mac_address'): + bindings[iface_config['name']] = \ + iface_config['mac_address'].lower() + elif netcfg.get('version') == 2: + for iface_name, iface_config in netcfg.get('ethernets', {}).items(): + mac = iface_config.get('match', {}).get('macaddress') + if mac: + bindings[iface_name] = mac.lower() + + if not bindings: + return + + try: + devices = {device['mac'].lower(): device + for device in discover_devices()} + store = load_store() + except Exception as err: + logger.error("Failed to read the interface mapping: {}".format(err)) + return + + entries = store['interfaces'] + taken = set(entries.values()) + added = False + + for iface_name, mac in bindings.items(): + device = devices.get(mac) + # a NIC the datasource describes but which is not here has nothing to + # anchor a name to + if device is None or not device['key']: + logger.debug("No hardware for {} ({})".format(iface_name, mac)) + continue + # an entry already in the store is authoritative, and a slot already + # spoken for must not gain a second name + if iface_name in entries or device['key'] in taken: + continue + entries[iface_name] = device['key'] + store['hardware'][iface_name] = mac + taken.add(device['key']) + added = True + logger.debug("Mapping {} to {}".format(iface_name, device['key'])) + + if added and not save_store(store): + logger.error("Failed to save the interface mapping") + + # configure SSH server service def set_config_ssh(config): logger.debug("Configuring SSH service") @@ -1131,6 +1177,8 @@ def handle(name, cfg, cloud, log, _args): # process networking configuration data if netcfg and network_configured is False and network_config_vyos != 'disabled': + record_interface_mapping(netcfg) + # check which one version of config we have # version 1 if netcfg['version'] == 1: -- cgit v1.2.3