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
17 changes: 16 additions & 1 deletion cloudinit/sources/DataSourceOpenNebula.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@ def get_routes(self, dev: str) -> List[Dict[str, str]]:
)
return routes

def get_metric(self, dev: str) -> Optional[str]:
return self.get_field(dev, "metric")

@overload
def get_field(self, dev: str, name: str) -> Optional[str]: ...
@overload
Expand Down Expand Up @@ -309,9 +312,21 @@ def gen_conf(self) -> Dict[str, Any]:
)

# Set IPv4 default gateway
# When a metric is specified, emit an explicit route so the metric
# can be attached to it. Otherwise use the simpler gateway4 key.
gateway = self.get_gateway(c_dev)
metric: Optional[str] = self.get_metric(c_dev)
if gateway:
devconf["gateway4"] = gateway
if metric is not None:
devconf.setdefault("routes", []).append(
{
"to": "default",
"via": gateway,
"metric": int(metric),
}
)
Comment on lines +320 to +327
Comment on lines +321 to +327
else:
devconf["gateway4"] = gateway

# Set IPv6 default gateway
gateway6 = self.get_gateway6(c_dev)
Expand Down
7 changes: 7 additions & 0 deletions doc/rtd/reference/datasources/opennebula.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ the OpenNebula documentation.
ETH<x>_DNS
ETH<x>_SEARCH_DOMAIN
ETH<x>_MTU
ETH<x>_METRIC
ETH<x>_IP6
ETH<x>_IP6_ULA
ETH<x>_IP6_PREFIX_LENGTH
Expand All @@ -81,6 +82,12 @@ 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"

When ``ETH<x>_METRIC`` is set, the default gateway is expressed as an explicit
route with that metric value rather than the simple ``gateway4`` key. This
allows correct route priority on multi-homed VMs. For example, setting
``ETH0_METRIC=0`` and ``ETH1_METRIC=1`` makes interface 0 the preferred
default route.

::

SET_HOSTNAME
Expand Down
108 changes: 108 additions & 0 deletions tests/unittests/sources/test_opennebula.py
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,114 @@ 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_METRIC #
# ------------------------------------------------------------------ #

def test_get_metric_absent(self):
"""get_metric returns None when ETHx_METRIC is not set."""
net = ds.OpenNebulaNetwork({}, mock.Mock())
assert net.get_metric("eth0") is None

def test_get_metric_set(self):
"""get_metric returns the value string when ETHx_METRIC is set."""
net = ds.OpenNebulaNetwork({"ETH0_METRIC": "1"}, mock.Mock())
assert net.get_metric("eth0") == "1"

def test_get_metric_zero(self):
"""get_metric returns '0' when ETHx_METRIC is explicitly zero."""
net = ds.OpenNebulaNetwork({"ETH0_METRIC": "0"}, mock.Mock())
assert net.get_metric("eth0") == "0"

@mock.patch(DS_PATH + ".get_physical_nics_by_mac")
def test_gen_conf_metric_replaces_gateway4(self, m_get_phys_by_mac):
"""When metric is set, gateway is emitted as an explicit route."""
context = {
"ETH0_MAC": MACADDR,
"ETH0_IP": PUBLIC_IP,
"ETH0_GATEWAY": "10.0.0.1",
"ETH0_METRIC": "100",
}
m_get_phys_by_mac.return_value = {MACADDR: "eth0"}
net = ds.OpenNebulaNetwork(context, mock.Mock())
conf = net.gen_conf()
eth0 = conf["ethernets"]["eth0"]
assert "gateway4" not in eth0
expected_route = {"to": "default", "via": "10.0.0.1", "metric": 100}
assert expected_route in eth0["routes"]

Comment on lines +1067 to +1083
@mock.patch(DS_PATH + ".get_physical_nics_by_mac")
def test_gen_conf_metric_zero(self, m_get_phys_by_mac):
"""Metric value '0' is emitted as integer 0 in the route."""
context = {
"ETH0_MAC": MACADDR,
"ETH0_IP": PUBLIC_IP,
"ETH0_GATEWAY": "10.0.0.1",
"ETH0_METRIC": "0",
}
m_get_phys_by_mac.return_value = {MACADDR: "eth0"}
net = ds.OpenNebulaNetwork(context, mock.Mock())
conf = net.gen_conf()
eth0 = conf["ethernets"]["eth0"]
assert "gateway4" not in eth0
expected_route = {"to": "default", "via": "10.0.0.1", "metric": 0}
assert expected_route in eth0["routes"]

@mock.patch(DS_PATH + ".get_physical_nics_by_mac")
def test_gen_conf_no_metric_uses_gateway4(self, m_get_phys_by_mac):
"""Without ETHx_METRIC the gateway4 key is used (no regression)."""
context = {
"ETH0_MAC": MACADDR,
"ETH0_IP": PUBLIC_IP,
"ETH0_GATEWAY": "10.0.0.1",
}
m_get_phys_by_mac.return_value = {MACADDR: "eth0"}
net = ds.OpenNebulaNetwork(context, mock.Mock())
conf = net.gen_conf()
eth0 = conf["ethernets"]["eth0"]
assert eth0["gateway4"] == "10.0.0.1"
assert "routes" not in eth0

@mock.patch(DS_PATH + ".get_physical_nics_by_mac")
def test_gen_conf_metric_without_gateway(self, m_get_phys_by_mac):
"""Metric set but no gateway: no routes list, no gateway4 key."""
context = {
"ETH0_MAC": MACADDR,
"ETH0_IP": PUBLIC_IP,
"ETH0_METRIC": "10",
}
m_get_phys_by_mac.return_value = {MACADDR: "eth0"}
net = ds.OpenNebulaNetwork(context, mock.Mock())
conf = net.gen_conf()
eth0 = conf["ethernets"]["eth0"]
assert "gateway4" not in eth0
assert "routes" not in eth0

@mock.patch(DS_PATH + ".get_physical_nics_by_mac")
def test_gen_conf_multi_nic_different_metrics(self, m_get_phys_by_mac):
"""Each NIC gets its own metric in its own explicit route."""
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.5",
"ETH0_GATEWAY": "10.0.0.1",
"ETH0_METRIC": "0",
"ETH1_MAC": MAC_2,
"ETH1_IP": "185.70.43.5",
"ETH1_GATEWAY": "185.70.43.1",
"ETH1_METRIC": "1",
}
m_get_phys_by_mac.return_value = {MAC_1: "eth0", MAC_2: "eth1"}
net = ds.OpenNebulaNetwork(context, mock.Mock())
conf = net.gen_conf()
assert {"to": "default", "via": "10.0.0.1", "metric": 0} in (
conf["ethernets"]["eth0"]["routes"]
)
assert {"to": "default", "via": "185.70.43.1", "metric": 1} in (
conf["ethernets"]["eth1"]["routes"]
)


class TestParseShellConfig:
@pytest.mark.allow_subp_for("bash", "sh")
Expand Down
Loading