diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py index 54ee0ec6d66..9ac0764a414 100644 --- a/cloudinit/sources/DataSourceOpenNebula.py +++ b/cloudinit/sources/DataSourceOpenNebula.py @@ -278,6 +278,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": {}} @@ -300,6 +322,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 3e31b20065b..72e5dbf5bd3 100644 --- a/doc/rtd/reference/datasources/opennebula.rst +++ b/doc/rtd/reference/datasources/opennebula.rst @@ -81,6 +81,16 @@ Static `network configuration`_. 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 10ccdea2ed3..6ab5b074a04 100644 --- a/tests/unittests/sources/test_opennebula.py +++ b/tests/unittests/sources/test_opennebula.py @@ -1045,6 +1045,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")