diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py index 54ee0ec6d66..16b5f82c07c 100644 --- a/cloudinit/sources/DataSourceOpenNebula.py +++ b/cloudinit/sources/DataSourceOpenNebula.py @@ -185,11 +185,22 @@ def mac2ip(self, mac: str) -> str: def get_nameservers(self, dev: str) -> Dict[str, List[str]]: nameservers: Dict[str, List[str]] = {} - dns = self.get_field(dev, "dns", "").split() - dns.extend(self.context.get("DNS", "").split()) + dns: List[str] = [] + for server in ( + self.get_field(dev, "dns", "").split() + + self.context.get("DNS", "").split() + ): + if server not in dns: + dns.append(server) if dns: nameservers["addresses"] = dns - search_domain = self.get_field(dev, "search_domain", "").split() + search_domain: List[str] = [] + for domain in ( + self.get_field(dev, "search_domain", "").split() + + self.context.get("SEARCH_DOMAIN", "").split() + ): + if domain not in search_domain: + search_domain.append(domain) if search_domain: nameservers["search"] = search_domain return nameservers diff --git a/doc/rtd/reference/datasources/opennebula.rst b/doc/rtd/reference/datasources/opennebula.rst index 3e31b20065b..5171704d48c 100644 --- a/doc/rtd/reference/datasources/opennebula.rst +++ b/doc/rtd/reference/datasources/opennebula.rst @@ -59,6 +59,7 @@ the OpenNebula documentation. :: DNS + SEARCH_DOMAIN ETH_IP ETH_NETWORK ETH_MASK @@ -74,7 +75,12 @@ the OpenNebula documentation. ETH_IP6_GATEWAY ETH_ROUTES -Static `network configuration`_. +Static `network configuration`_. ``DNS`` and ``SEARCH_DOMAIN`` are global +values applied to every interface. Per-interface ``ETH_DNS`` and +``ETH_SEARCH_DOMAIN`` (defined in `context-linux`_) take precedence; +duplicate entries across both levels are suppressed. + +.. _context-linux: https://github.com/OpenNebula/one-apps/blob/v7.0.0/context-linux/src/etc/one-context.d/loc-10-network.d/functions#L463-L466 ``ETH_ROUTES`` is a comma-separated list of static routes in the form ``NETWORK via GATEWAY``. For example:: diff --git a/tests/unittests/sources/test_opennebula.py b/tests/unittests/sources/test_opennebula.py index 10ccdea2ed3..1c7e48691dd 100644 --- a/tests/unittests/sources/test_opennebula.py +++ b/tests/unittests/sources/test_opennebula.py @@ -975,6 +975,84 @@ def test_multiple_nics(self): assert expected == net.gen_conf() + @pytest.mark.parametrize( + "context,expected_search", + [ + pytest.param( + {"SEARCH_DOMAIN": "global.example.com global.example.org"}, + ["global.example.com", "global.example.org"], + id="global_only", + ), + pytest.param( + { + "ETH0_SEARCH_DOMAIN": "iface.example.com", + "SEARCH_DOMAIN": "global.example.com", + }, + ["iface.example.com", "global.example.com"], + id="per_interface_and_global", + ), + pytest.param( + {"ETH0_SEARCH_DOMAIN": "iface.example.com"}, + ["iface.example.com"], + id="per_interface_only", + ), + pytest.param( + { + "ETH0_SEARCH_DOMAIN": "shared.example.com", + # extra precedes shared in global; shared must still come + # first because per-interface ordering takes precedence + "SEARCH_DOMAIN": "extra.example.com shared.example.com", + }, + ["shared.example.com", "extra.example.com"], + id="dedup_iface_order_preferred", + ), + ], + ) + def test_get_nameservers_search_domain(self, context, expected_search): + """get_nameservers merges and deduplicates SEARCH_DOMAIN correctly.""" + net = ds.OpenNebulaNetwork(context, mock.Mock()) + val = net.get_nameservers("eth0") + assert val["search"] == expected_search + + @mock.patch(DS_PATH + ".get_physical_nics_by_mac") + def test_gen_conf_global_search_domain(self, m_get_phys_by_mac): + """gen_conf includes global SEARCH_DOMAIN in nameservers.search.""" + context = { + "ETH0_MAC": MACADDR, + "SEARCH_DOMAIN": "global.example.com", + } + 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]["nameservers"]["search"] == [ + "global.example.com" + ] + + @mock.patch(DS_PATH + ".get_physical_nics_by_mac") + def test_gen_conf_global_search_domain_multiple_nics( + self, m_get_phys_by_mac + ): + """Global SEARCH_DOMAIN appears on every NIC.""" + MAC_1 = "02:00:0a:12:01:01" + MAC_2 = "02:00:0a:12:01:02" + context = { + "ETH0_MAC": MAC_1, + "ETH1_MAC": MAC_2, + "SEARCH_DOMAIN": "global.example.com", + } + net = ds.OpenNebulaNetwork( + context, + mock.Mock(), + system_nics_by_mac={MAC_1: "eth0", MAC_2: "eth1"}, + ) + conf = net.gen_conf() + for nic in ("eth0", "eth1"): + assert ( + "global.example.com" + in conf["ethernets"][nic]["nameservers"]["search"] + ) + # ------------------------------------------------------------------ # # ETHx_ROUTES # # ------------------------------------------------------------------ #