From d3c7e4f8ad9d83688b125b7ec1afd428d17647e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Can=C3=A9vet?= Date: Mon, 30 Mar 2026 17:53:32 +0200 Subject: [PATCH 1/4] feat(opennebula): support ETHx_ALIASn_IP/MASK for anycast addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenNebula's context-linux package supports per-NIC alias addresses (ETHx_ALIAS0_IP, ETHx_ALIAS1_IP, …) used for anycast IPs. Add get_alias_addresses() to OpenNebulaNetwork and wire it into gen_conf() so alias addresses appear in the Netplan v2 output alongside the primary address. Missing MASK defaults to /32. --- cloudinit/sources/DataSourceOpenNebula.py | 27 +++++ doc/rtd/reference/datasources/opennebula.rst | 10 ++ tests/unittests/sources/test_opennebula.py | 104 +++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py index 16b5f82c07c..540a4f3f927 100644 --- a/cloudinit/sources/DataSourceOpenNebula.py +++ b/cloudinit/sources/DataSourceOpenNebula.py @@ -289,6 +289,28 @@ def get_field( # allow empty string to return the default. return default if val in (None, "") else val + def get_alias_addresses(self, c_dev: str) -> List[str]: + """Return list of alias IP/prefix strings for context device c_dev. + + Scans context for ETHx_ALIASn_IP / ETHx_ALIASn_MASK keys, where x + matches c_dev (e.g. 'ETH0'). Missing MASK defaults to /32. + Stops at the first gap in the alias index sequence. + """ + aliases: List[str] = [] + prefix = c_dev.upper() + "_ALIAS" + idx = 0 + while True: + ip_key = "%s%d_IP" % (prefix, idx) + ip = self.context.get(ip_key) + if not ip: + break + mask_key = "%s%d_MASK" % (prefix, idx) + mask = self.context.get(mask_key) or "255.255.255.255" + net_prefix = str(net.ipv4_mask_to_net_prefix(mask)) + aliases.append("%s/%s" % (ip, net_prefix)) + idx += 1 + return aliases + def gen_conf(self) -> Dict[str, Any]: netconf: Dict[str, Any] = {"version": 2, "ethernets": {}} @@ -311,6 +333,11 @@ def gen_conf(self) -> Dict[str, Any]: prefix = str(net.ipv4_mask_to_net_prefix(mask)) devconf["addresses"].append(self.get_ip(c_dev, mac) + "/" + prefix) + # Set alias (anycast) IPv4 addresses + alias_addresses: List[str] = self.get_alias_addresses(c_dev) + if alias_addresses: + devconf["addresses"].extend(alias_addresses) + # Set IPv6 Global and ULA address addresses6 = self.get_ip6(c_dev) if addresses6: diff --git a/doc/rtd/reference/datasources/opennebula.rst b/doc/rtd/reference/datasources/opennebula.rst index 5171704d48c..ad6c774289c 100644 --- a/doc/rtd/reference/datasources/opennebula.rst +++ b/doc/rtd/reference/datasources/opennebula.rst @@ -87,6 +87,16 @@ duplicate entries across both levels are suppressed. ETH0_ROUTES="10.0.0.0/8 via 192.168.1.1, 172.16.0.0/12 via 192.168.1.254" +:: + + ETH_ALIAS_IP + ETH_ALIAS_MASK + +Additional (anycast) IPv4 addresses for interface ``ETH``. Aliases are +numbered from 0 (e.g. ``ETH0_ALIAS0_IP``, ``ETH0_ALIAS1_IP``, …). The +``MASK`` field defaults to ``255.255.255.255`` (``/32``) when absent. All +alias addresses are added to the interface alongside the primary address. + :: SET_HOSTNAME diff --git a/tests/unittests/sources/test_opennebula.py b/tests/unittests/sources/test_opennebula.py index 7cd100d8872..4b223b4145a 100644 --- a/tests/unittests/sources/test_opennebula.py +++ b/tests/unittests/sources/test_opennebula.py @@ -1140,6 +1140,110 @@ def test_gen_conf_no_routes_key_when_absent(self, m_get_phys_by_mac): conf = net.gen_conf() assert "routes" not in conf["ethernets"]["eth0"] + # ------------------------------------------------------------------ # + # ETHx_ALIASn # + # ------------------------------------------------------------------ # + + def test_get_alias_addresses_single(self): + """Single alias on ETH0 produces one extra address.""" + context = { + "ETH0_ALIAS0_IP": "192.168.1.10", + "ETH0_ALIAS0_MASK": "255.255.255.0", + } + net = ds.OpenNebulaNetwork(context, mock.Mock()) + aliases = net.get_alias_addresses("ETH0") + assert aliases == ["192.168.1.10/24"] + + def test_get_alias_addresses_multiple(self): + """Multiple aliases on same interface are all returned.""" + context = { + "ETH0_ALIAS0_IP": "192.168.1.10", + "ETH0_ALIAS0_MASK": "255.255.255.0", + "ETH0_ALIAS1_IP": "192.168.1.11", + "ETH0_ALIAS1_MASK": "255.255.255.0", + "ETH0_ALIAS2_IP": "192.168.1.12", + "ETH0_ALIAS2_MASK": "255.255.255.0", + } + net = ds.OpenNebulaNetwork(context, mock.Mock()) + aliases = net.get_alias_addresses("ETH0") + assert aliases == [ + "192.168.1.10/24", + "192.168.1.11/24", + "192.168.1.12/24", + ] + + def test_get_alias_addresses_none(self): + """No alias variables → empty list.""" + net = ds.OpenNebulaNetwork({}, mock.Mock()) + aliases = net.get_alias_addresses("ETH0") + assert aliases == [] + + def test_get_alias_addresses_default_mask(self): + """Alias without MASK uses default /32.""" + context = { + "ETH0_ALIAS0_IP": "10.0.0.5", + } + net = ds.OpenNebulaNetwork(context, mock.Mock()) + aliases = net.get_alias_addresses("ETH0") + assert aliases == ["10.0.0.5/32"] + + @mock.patch(DS_PATH + ".get_physical_nics_by_mac") + def test_gen_conf_aliases_in_addresses(self, m_get_phys_by_mac): + """gen_conf includes alias IPs in addresses list.""" + context = { + "ETH0_MAC": MACADDR, + "ETH0_IP": PUBLIC_IP, + "ETH0_MASK": "255.255.255.0", + "ETH0_ALIAS0_IP": "192.168.1.10", + "ETH0_ALIAS0_MASK": "255.255.255.0", + "ETH0_ALIAS1_IP": "192.168.1.11", + "ETH0_ALIAS1_MASK": "255.255.255.0", + } + for nic in self.system_nics: + m_get_phys_by_mac.return_value = {MACADDR: nic} + net = ds.OpenNebulaNetwork(context, mock.Mock()) + conf = net.gen_conf() + addresses = conf["ethernets"][nic]["addresses"] + assert PUBLIC_IP + "/24" in addresses + assert "192.168.1.10/24" in addresses + assert "192.168.1.11/24" in addresses + + @mock.patch(DS_PATH + ".get_physical_nics_by_mac") + def test_gen_conf_no_aliases_unchanged(self, m_get_phys_by_mac): + """gen_conf without aliases produces same output as before.""" + context = { + "ETH0_MAC": MACADDR, + "ETH0_IP": PUBLIC_IP, + "ETH0_MASK": "255.255.255.0", + } + for nic in self.system_nics: + m_get_phys_by_mac.return_value = {MACADDR: nic} + net = ds.OpenNebulaNetwork(context, mock.Mock()) + conf = net.gen_conf() + assert conf["ethernets"][nic]["addresses"] == [PUBLIC_IP + "/24"] + + @mock.patch(DS_PATH + ".get_physical_nics_by_mac") + def test_gen_conf_aliases_on_second_nic(self, m_get_phys_by_mac): + """Aliases on a second NIC do not bleed into the first.""" + MAC_1 = "02:00:0a:12:01:01" + MAC_2 = "02:00:0a:12:01:02" + context = { + "ETH0_MAC": MAC_1, + "ETH0_IP": "10.0.0.1", + "ETH1_MAC": MAC_2, + "ETH1_IP": "10.0.1.1", + "ETH1_ALIAS0_IP": "10.0.1.100", + "ETH1_ALIAS0_MASK": "255.255.255.0", + } + net = ds.OpenNebulaNetwork( + context, + mock.Mock(), + system_nics_by_mac={MAC_1: "eth0", MAC_2: "eth1"}, + ) + conf = net.gen_conf() + assert conf["ethernets"]["eth0"]["addresses"] == ["10.0.0.1/24"] + assert "10.0.1.100/24" in conf["ethernets"]["eth1"]["addresses"] + class TestParseShellConfig: @pytest.mark.allow_subp_for("bash", "sh") From 23735762250066cd549083d33914a6b8c34fbe70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Can=C3=A9vet?= Date: Thu, 9 Jul 2026 08:52:41 +0200 Subject: [PATCH 2/4] fix(opennebula): warn on gap in ETHx_ALIASn context indices get_alias_addresses() silently ignored any alias entries beyond a gap in the index sequence (e.g. ALIAS0 and ALIAS2 present but ALIAS1 missing), giving no indication that context was likely misconfigured. Now log a warning naming the skipped keys. --- cloudinit/sources/DataSourceOpenNebula.py | 17 ++++++++++++++++- tests/unittests/sources/test_opennebula.py | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py index 540a4f3f927..0b5be729b82 100644 --- a/cloudinit/sources/DataSourceOpenNebula.py +++ b/cloudinit/sources/DataSourceOpenNebula.py @@ -294,7 +294,9 @@ def get_alias_addresses(self, c_dev: str) -> List[str]: Scans context for ETHx_ALIASn_IP / ETHx_ALIASn_MASK keys, where x matches c_dev (e.g. 'ETH0'). Missing MASK defaults to /32. - Stops at the first gap in the alias index sequence. + Stops at the first gap in the alias index sequence and warns if + further alias indices exist beyond that gap, since that likely + indicates a misconfigured context. """ aliases: List[str] = [] prefix = c_dev.upper() + "_ALIAS" @@ -309,6 +311,19 @@ def get_alias_addresses(self, c_dev: str) -> List[str]: net_prefix = str(net.ipv4_mask_to_net_prefix(mask)) aliases.append("%s/%s" % (ip, net_prefix)) idx += 1 + + key_re = re.compile(r"^%s(\d+)_IP$" % re.escape(prefix)) + skipped = sorted( + int(m.group(1)) for m in map(key_re.match, self.context) if m + ) + skipped = [i for i in skipped if i >= idx] + if skipped: + LOG.warning( + "Ignoring %s: found gap at %s%d_IP", + ", ".join("%s%d_IP" % (prefix, i) for i in skipped), + prefix, + idx, + ) return aliases def gen_conf(self) -> Dict[str, Any]: diff --git a/tests/unittests/sources/test_opennebula.py b/tests/unittests/sources/test_opennebula.py index 4b223b4145a..7abc94c50d8 100644 --- a/tests/unittests/sources/test_opennebula.py +++ b/tests/unittests/sources/test_opennebula.py @@ -1,6 +1,7 @@ # This file is part of cloud-init. See LICENSE file for license information. # pylint: disable=attribute-defined-outside-init +import logging import os import pwd from unittest import mock @@ -1187,6 +1188,23 @@ def test_get_alias_addresses_default_mask(self): aliases = net.get_alias_addresses("ETH0") assert aliases == ["10.0.0.5/32"] + def test_get_alias_addresses_gap_ignored_and_warned(self, caplog): + """A gap in the alias index stops the scan and logs a warning + naming the alias index(es) beyond the gap that were ignored.""" + context = { + "ETH0_ALIAS0_IP": "192.168.1.10", + "ETH0_ALIAS0_MASK": "255.255.255.0", + "ETH0_ALIAS2_IP": "192.168.1.12", + "ETH0_ALIAS2_MASK": "255.255.255.0", + } + net = ds.OpenNebulaNetwork(context, mock.Mock()) + aliases = net.get_alias_addresses("ETH0") + assert aliases == ["192.168.1.10/24"] + warnings = [r for r in caplog.record_tuples if r[1] == logging.WARNING] + assert len(warnings) == 1 + assert "ETH0_ALIAS2_IP" in warnings[0][2] + assert "ETH0_ALIAS1_IP" in warnings[0][2] + @mock.patch(DS_PATH + ".get_physical_nics_by_mac") def test_gen_conf_aliases_in_addresses(self, m_get_phys_by_mac): """gen_conf includes alias IPs in addresses list.""" From 84e42b3a2113a861527cf5102a7c8c6e4886c956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Can=C3=A9vet?= Date: Tue, 28 Jul 2026 10:13:09 +0200 Subject: [PATCH 3/4] refactor(opennebula): scan context keys once for ETHx_ALIASn gap detection get_alias_addresses() previously scanned self.context twice: a sequential while-loop to build the alias list, then a separate regex pass to find skipped indices for the gap warning. Consolidate into a single regex scan that extracts and numerically sorts all alias indices up front, then one pass over that (small) list builds aliases and tracks the first gap. Addresses review feedback on PR #6876. --- cloudinit/sources/DataSourceOpenNebula.py | 25 ++++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py index 0b5be729b82..88c84aed0ea 100644 --- a/cloudinit/sources/DataSourceOpenNebula.py +++ b/cloudinit/sources/DataSourceOpenNebula.py @@ -300,23 +300,24 @@ def get_alias_addresses(self, c_dev: str) -> List[str]: """ aliases: List[str] = [] prefix = c_dev.upper() + "_ALIAS" + key_re = re.compile(r"^%s(\d+)_IP$" % re.escape(prefix)) + indices = sorted( + int(m.group(1)) for m in map(key_re.match, self.context) if m + ) + idx = 0 - while True: - ip_key = "%s%d_IP" % (prefix, idx) - ip = self.context.get(ip_key) - if not ip: - break - mask_key = "%s%d_MASK" % (prefix, idx) - mask = self.context.get(mask_key) or "255.255.255.255" + skipped = [] + for i in indices: + if i != idx: + skipped.append(i) + continue + ip = self.context["%s%d_IP" % (prefix, i)] + mask = self.context.get("%s%d_MASK" % (prefix, i)) + mask = mask or "255.255.255.255" net_prefix = str(net.ipv4_mask_to_net_prefix(mask)) aliases.append("%s/%s" % (ip, net_prefix)) idx += 1 - key_re = re.compile(r"^%s(\d+)_IP$" % re.escape(prefix)) - skipped = sorted( - int(m.group(1)) for m in map(key_re.match, self.context) if m - ) - skipped = [i for i in skipped if i >= idx] if skipped: LOG.warning( "Ignoring %s: found gap at %s%d_IP", From 401f37e18dec5ff4b620ae627e87f424f6321d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Can=C3=A9vet?= Date: Tue, 28 Jul 2026 10:14:50 +0200 Subject: [PATCH 4/4] fix(opennebula): clarify alias gap warning message wording Reword the LOG.warning() in get_alias_addresses() to state which keys are ignored and why, per review feedback on PR #6876. --- cloudinit/sources/DataSourceOpenNebula.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py index 88c84aed0ea..48dd3e8e686 100644 --- a/cloudinit/sources/DataSourceOpenNebula.py +++ b/cloudinit/sources/DataSourceOpenNebula.py @@ -320,7 +320,7 @@ def get_alias_addresses(self, c_dev: str) -> List[str]: if skipped: LOG.warning( - "Ignoring %s: found gap at %s%d_IP", + "Ignoring network config keys %s due to missing %s%d_IP", ", ".join("%s%d_IP" % (prefix, i) for i in skipped), prefix, idx,