diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py index 54ee0ec6d66..b44335dfd17 100644 --- a/cloudinit/sources/DataSourceOpenNebula.py +++ b/cloudinit/sources/DataSourceOpenNebula.py @@ -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 @@ -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), + } + ) + else: + devconf["gateway4"] = gateway # Set IPv6 default gateway gateway6 = self.get_gateway6(c_dev) diff --git a/doc/rtd/reference/datasources/opennebula.rst b/doc/rtd/reference/datasources/opennebula.rst index 3e31b20065b..853c80598b5 100644 --- a/doc/rtd/reference/datasources/opennebula.rst +++ b/doc/rtd/reference/datasources/opennebula.rst @@ -68,6 +68,7 @@ the OpenNebula documentation. ETH_DNS ETH_SEARCH_DOMAIN ETH_MTU + ETH_METRIC ETH_IP6 ETH_IP6_ULA ETH_IP6_PREFIX_LENGTH @@ -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_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 diff --git a/tests/unittests/sources/test_opennebula.py b/tests/unittests/sources/test_opennebula.py index 10ccdea2ed3..d5e71b64602 100644 --- a/tests/unittests/sources/test_opennebula.py +++ b/tests/unittests/sources/test_opennebula.py @@ -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"] + + @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")