Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions cloudinit/sources/DataSourceOpenNebula.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,43 @@ 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 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"
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
Comment thread
mcanevet marked this conversation as resolved.

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]
Comment on lines +303 to +319

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels a bit like we are processing the data in self.context twice through two separate loops which makes it a teeny bit harder to maintain when we look at this in the future.

Could we instead try processing these keys once?
something like (completely untested):

key_re = re.compile(rf"^{c_dev.upper()}_ALIAS(?P<alias_idx>\d+)_IP$" % re.escape(prefix))
for key, value  in sorted(self.content.items()):
    match = key_re.match(key)
    if not match
        continue
    if int(match['alias_idx']) == idx:
        idx += 1
        mask_key = self.context.get(key.replace("IP", "MASK"))
        mask = self.context.key(mask_key) or "255.255.255.255"
        aliases.append(f"{value}/{mask}")
    else:
        skipped.append(key)

if skipped:
LOG.warning(
"Ignoring %s: found gap at %s%d_IP",
", ".join("%s%d_IP" % (prefix, i) for i in skipped),
Comment on lines +322 to +323

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gap doesn't provide enough context in the message. Let's be a bit more explicit about the problem.

Ignoring network config keys %s due to missing %s

prefix,
idx,
)
return aliases

def gen_conf(self) -> Dict[str, Any]:
netconf: Dict[str, Any] = {"version": 2, "ethernets": {}}

Expand All @@ -311,6 +348,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:
Expand Down
10 changes: 10 additions & 0 deletions doc/rtd/reference/datasources/opennebula.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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<x>_ALIAS<n>_IP
ETH<x>_ALIAS<n>_MASK

Additional (anycast) IPv4 addresses for interface ``ETH<x>``. 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
Expand Down
122 changes: 122 additions & 0 deletions tests/unittests/sources/test_opennebula.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -1140,6 +1141,127 @@ 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",
Comment thread
mcanevet marked this conversation as resolved.
"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"]

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."""
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")
Expand Down
Loading