From 9d205312a9a58348087cf8a450012b7f2f41da79 Mon Sep 17 00:00:00 2001 From: Chris Patterson Date: Wed, 25 Mar 2026 13:28:05 +0000 Subject: [PATCH 1/5] feat(azure): add apply_network_config_set_name option to disable renames Azure IMDS does not guarantee NIC ordering across reboots, which can cause interface name instability when using set-name to rename interfaces to ethX. Add a new datasource config option apply_network_config_set_name (default: True) that controls whether set-name directives are included in the generated network config. When disabled, interfaces are identified by MAC address using the naming format nicXXXXXXXXXXXX (e.g., nic000d3a047598) instead of ethX, allowing the kernel/udev to assign stable names. Changes: - Add apply_network_config_set_name to BUILTIN_DS_CONFIG - Set default via _unpickle for backward compatibility with pickled state - Toggle netplan outputs per configuration - Update azure.rst documentation with IMDS reference link - Add parametrized unit tests for both enabled/disabled paths Signed-off-by: Chris Patterson --- cloudinit/sources/DataSourceAzure.py | 22 +++- doc/rtd/reference/datasources/azure.rst | 17 +++ tests/unittests/sources/test_azure.py | 142 +++++++++++++++++++++--- 3 files changed, 161 insertions(+), 20 deletions(-) diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py index 535268d9f01..406eea94acf 100644 --- a/cloudinit/sources/DataSourceAzure.py +++ b/cloudinit/sources/DataSourceAzure.py @@ -295,6 +295,7 @@ def get_resource_disk_on_freebsd(port_id) -> Optional[str]: "disk_aliases": {"ephemeral0": RESOURCE_DISK_PATH}, "apply_network_config": True, # Use IMDS published network configuration "apply_network_config_for_secondary_ips": True, # Configure secondary ips + "apply_network_config_set_name": True, # Use set-name for NICs "experimental_skip_ready_report": False, # Skip final ready report } @@ -362,6 +363,10 @@ def _unpickle(self, ci_pkl_version: int) -> None: self._system_uuid = None self._vm_id = None self._wireserver_endpoint = DEFAULT_WIRESERVER_ENDPOINT + self.ds_cfg.setdefault( + "apply_network_config_set_name", + BUILTIN_DS_CONFIG["apply_network_config_set_name"], + ) def __str__(self): root = sources.DataSource.__str__(self) @@ -1619,6 +1624,9 @@ def _generate_network_config(self): apply_network_config_for_secondary_ips=self.ds_cfg.get( "apply_network_config_for_secondary_ips" ), + apply_network_config_set_name=self.ds_cfg.get( + "apply_network_config_set_name" + ), ) except Exception as e: LOG.error( @@ -2095,6 +2103,7 @@ def generate_network_config_from_instance_network_metadata( network_metadata: dict, *, apply_network_config_for_secondary_ips: bool, + apply_network_config_set_name: bool = True, ) -> dict: """Convert imds network metadata dictionary to network v2 configuration. @@ -2108,7 +2117,11 @@ def generate_network_config_from_instance_network_metadata( # First IPv4 and/or IPv6 address will be obtained via DHCP. # Any additional IPs of each type will be set as static # addresses. - nicname = "eth{idx}".format(idx=idx) + mac = normalize_mac_address(intf["macAddress"]) + if apply_network_config_set_name: + nicname = "eth{idx}".format(idx=idx) + else: + nicname = "nic{mac}".format(mac=mac.replace(":", "")) dhcp_override = {"route-metric": (idx + 1) * 100} # DNS resolution through secondary NICs is not supported, disable it. if idx > 0: @@ -2152,10 +2165,9 @@ def generate_network_config_from_instance_network_metadata( "{ip}/{prefix}".format(ip=privateIp, prefix=netPrefix) ) if dev_config and has_ip_address: - mac = normalize_mac_address(intf["macAddress"]) - dev_config.update( - {"match": {"macaddress": mac.lower()}, "set-name": nicname} - ) + dev_config["match"] = {"macaddress": mac.lower()} + if apply_network_config_set_name: + dev_config["set-name"] = nicname driver = determine_device_driver_for_mac(mac) if driver: dev_config["match"]["driver"] = driver diff --git a/doc/rtd/reference/datasources/azure.rst b/doc/rtd/reference/datasources/azure.rst index a3c6ffa0d98..b50a2d70123 100644 --- a/doc/rtd/reference/datasources/azure.rst +++ b/doc/rtd/reference/datasources/azure.rst @@ -45,6 +45,22 @@ The settings that may be configured are: Boolean to configure secondary IP address(es) for each NIC per IMDS configuration. Default is True. + +* :command:`apply_network_config_set_name` + + Boolean to include ``set-name`` directives in the generated network + configuration, which renames interfaces to ``ethX`` naming. When set to + False, interfaces are matched by MAC address only and retain kernel-assigned + names. Default is True. + + Azure's IMDS does not guarantee the ordering of NICs in the network metadata + response (see `Azure IMDS documentation + `_). + Because cloud-init derives ``ethX`` names from the IMDS response order, + NIC names may change between reboots. Disabling this option avoids that + problem by matching interfaces on MAC address only, allowing the kernel or + udev to assign and retain stable names. + * :command:`data_dir` Path used to read meta-data files and write crawled data. @@ -67,6 +83,7 @@ An example configuration with the default values is provided below: Azure: apply_network_config: true apply_network_config_for_secondary_ips: true + apply_network_config_set_name: false data_dir: /var/lib/waagent disk_aliases: ephemeral0: /dev/disk/cloud/azure_resource diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index e032c25f279..ddac5676d05 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -989,6 +989,95 @@ def test_parsing_scenarios( == expected ) + @pytest.mark.parametrize( + "set_name,expected", + [ + ( + True, + { + "ethernets": { + "eth0": { + "dhcp4": True, + "dhcp4-overrides": {"route-metric": 100}, + "dhcp6": True, + "dhcp6-overrides": {"route-metric": 100}, + "match": {"macaddress": "00:0d:3a:04:75:98"}, + "set-name": "eth0", + }, + "eth1": { + "dhcp4": True, + "dhcp4-overrides": { + "route-metric": 200, + "use-dns": False, + }, + "dhcp6": False, + "match": {"macaddress": "22:0d:3a:04:75:98"}, + "set-name": "eth1", + }, + }, + "version": 2, + }, + ), + ( + False, + { + "ethernets": { + "nic000d3a047598": { + "dhcp4": True, + "dhcp4-overrides": {"route-metric": 100}, + "dhcp6": True, + "dhcp6-overrides": {"route-metric": 100}, + "match": {"macaddress": "00:0d:3a:04:75:98"}, + }, + "nic220d3a047598": { + "dhcp4": True, + "dhcp4-overrides": { + "route-metric": 200, + "use-dns": False, + }, + "dhcp6": False, + "match": {"macaddress": "22:0d:3a:04:75:98"}, + }, + }, + "version": 2, + }, + ), + ], + ) + def test_set_name_config(self, mock_get_interfaces, set_name, expected): + """Verify set-name with two NICs (primary with IPv6, secondary).""" + two_nic_metadata = { + "interface": [ + { + "macAddress": "000D3A047598", + "ipv6": { + "subnet": [ + {"prefix": "64", "address": "fd00::"} + ], + "ipAddress": [ + {"privateIpAddress": "fd00::4"} + ], + }, + "ipv4": { + "subnet": [{"prefix": "24", "address": "10.0.0.0"}], + "ipAddress": [ + { + "privateIpAddress": "10.0.0.4", + "publicIpAddress": "104.46.124.81", + } + ], + }, + }, + SECONDARY_INTERFACE, + ] + } + result = dsaz.generate_network_config_from_instance_network_metadata( + two_nic_metadata, + apply_network_config_for_secondary_ips=True, + apply_network_config_set_name=set_name, + ) + assert result == expected + class TestNetworkConfig: fallback_config = { @@ -1004,22 +1093,45 @@ class TestNetworkConfig: ], } - def test_single_ipv4_nic_configuration( - self, azure_ds, mock_get_interfaces - ): - """Network config emits dhcp on single nic with ipv4""" - expected = { - "ethernets": { - "eth0": { - "dhcp4": True, - "dhcp4-overrides": {"route-metric": 100}, - "dhcp6": False, - "match": {"macaddress": "00:0d:3a:04:75:98"}, - "set-name": "eth0", + @pytest.mark.parametrize( + "set_name,expected", + [ + ( + True, + { + "ethernets": { + "eth0": { + "dhcp4": True, + "dhcp4-overrides": {"route-metric": 100}, + "dhcp6": False, + "match": {"macaddress": "00:0d:3a:04:75:98"}, + "set-name": "eth0", + }, + }, + "version": 2, }, - }, - "version": 2, - } + ), + ( + False, + { + "ethernets": { + "nic000d3a047598": { + "dhcp4": True, + "dhcp4-overrides": {"route-metric": 100}, + "dhcp6": False, + "match": {"macaddress": "00:0d:3a:04:75:98"}, + }, + }, + "version": 2, + }, + ), + ], + ) + def test_set_name_ds_cfg( + self, azure_ds, mock_get_interfaces, set_name, expected + ): + """Verify network_config via ds_cfg for set-name enabled/disabled.""" + azure_ds.ds_cfg["apply_network_config_set_name"] = set_name azure_ds._metadata_imds = NETWORK_METADATA assert azure_ds.network_config == expected From 723f4e9a9f566b77c8906e8b80ee7b1ec9425ac0 Mon Sep 17 00:00:00 2001 From: Chris Patterson Date: Fri, 27 Mar 2026 23:31:18 +0000 Subject: [PATCH 2/5] use enxXXXX --- cloudinit/sources/DataSourceAzure.py | 2 +- tests/unittests/sources/test_azure.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py index 406eea94acf..74a32fca5df 100644 --- a/cloudinit/sources/DataSourceAzure.py +++ b/cloudinit/sources/DataSourceAzure.py @@ -2121,7 +2121,7 @@ def generate_network_config_from_instance_network_metadata( if apply_network_config_set_name: nicname = "eth{idx}".format(idx=idx) else: - nicname = "nic{mac}".format(mac=mac.replace(":", "")) + nicname = "enx{mac}".format(mac=mac.replace(":", "")) dhcp_override = {"route-metric": (idx + 1) * 100} # DNS resolution through secondary NICs is not supported, disable it. if idx > 0: diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index ddac5676d05..620736e89df 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -1022,14 +1022,14 @@ def test_parsing_scenarios( False, { "ethernets": { - "nic000d3a047598": { + "enx000d3a047598": { "dhcp4": True, "dhcp4-overrides": {"route-metric": 100}, "dhcp6": True, "dhcp6-overrides": {"route-metric": 100}, "match": {"macaddress": "00:0d:3a:04:75:98"}, }, - "nic220d3a047598": { + "enx220d3a047598": { "dhcp4": True, "dhcp4-overrides": { "route-metric": 200, @@ -1115,7 +1115,7 @@ class TestNetworkConfig: False, { "ethernets": { - "nic000d3a047598": { + "enx000d3a047598": { "dhcp4": True, "dhcp4-overrides": {"route-metric": 100}, "dhcp6": False, From 9ffd1d777325953322147b4f59f9d1ab44a7dd80 Mon Sep 17 00:00:00 2001 From: Chris Patterson Date: Fri, 27 Mar 2026 23:40:19 +0000 Subject: [PATCH 3/5] formatting lint --- tests/unittests/sources/test_azure.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index 620736e89df..103cac8b98a 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -1051,12 +1051,8 @@ def test_set_name_config(self, mock_get_interfaces, set_name, expected): { "macAddress": "000D3A047598", "ipv6": { - "subnet": [ - {"prefix": "64", "address": "fd00::"} - ], - "ipAddress": [ - {"privateIpAddress": "fd00::4"} - ], + "subnet": [{"prefix": "64", "address": "fd00::"}], + "ipAddress": [{"privateIpAddress": "fd00::4"}], }, "ipv4": { "subnet": [{"prefix": "24", "address": "10.0.0.0"}], From 4e4a6ebe61050cf5b458418e9b0a4d5e95a5881c Mon Sep 17 00:00:00 2001 From: Chris Patterson Date: Mon, 30 Mar 2026 12:18:25 -0400 Subject: [PATCH 4/5] test_network_config --- tests/unittests/sources/test_azure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index 103cac8b98a..1d94cbadc4e 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -1123,7 +1123,7 @@ class TestNetworkConfig: ), ], ) - def test_set_name_ds_cfg( + def test_network_config( self, azure_ds, mock_get_interfaces, set_name, expected ): """Verify network_config via ds_cfg for set-name enabled/disabled.""" From eff78f70e324678538427965501b25e6261b6e5f Mon Sep 17 00:00:00 2001 From: Chris Patterson Date: Wed, 6 May 2026 04:54:36 -0700 Subject: [PATCH 5/5] copilot suggestion for docs Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/rtd/reference/datasources/azure.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/rtd/reference/datasources/azure.rst b/doc/rtd/reference/datasources/azure.rst index b50a2d70123..f89069f7963 100644 --- a/doc/rtd/reference/datasources/azure.rst +++ b/doc/rtd/reference/datasources/azure.rst @@ -50,16 +50,16 @@ The settings that may be configured are: Boolean to include ``set-name`` directives in the generated network configuration, which renames interfaces to ``ethX`` naming. When set to - False, interfaces are matched by MAC address only and retain kernel-assigned - names. Default is True. + False, interfaces are matched by MAC address (and optionally driver) + without renaming, and retain kernel-assigned names. Default is True. Azure's IMDS does not guarantee the ordering of NICs in the network metadata response (see `Azure IMDS documentation `_). Because cloud-init derives ``ethX`` names from the IMDS response order, NIC names may change between reboots. Disabling this option avoids that - problem by matching interfaces on MAC address only, allowing the kernel or - udev to assign and retain stable names. + problem by matching interfaces on MAC address (and optionally driver), + allowing the kernel or udev to assign and retain stable names. * :command:`data_dir`