diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py index 54ee0ec6d66..4b5c9d7285f 100644 --- a/cloudinit/sources/DataSourceOpenNebula.py +++ b/cloudinit/sources/DataSourceOpenNebula.py @@ -186,10 +186,15 @@ 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()) + for server in 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() + for domain in 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..713d518d25f 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,9 @@ the OpenNebula documentation. ETH_IP6_GATEWAY ETH_ROUTES -Static `network configuration`_. +Static `network configuration`_. ``DNS`` and ``SEARCH_DOMAIN`` are global +fallbacks applied to every interface. Per-interface ``ETH_DNS`` and +``ETH_SEARCH_DOMAIN`` take precedence; duplicate entries are suppressed. ``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..a9af42ff5d6 100644 --- a/tests/unittests/sources/test_opennebula.py +++ b/tests/unittests/sources/test_opennebula.py @@ -975,6 +975,83 @@ def test_multiple_nics(self): assert expected == net.gen_conf() + def test_get_nameservers_global_search_domain(self): + """Global SEARCH_DOMAIN is appended to the search list.""" + context = { + "SEARCH_DOMAIN": "global.example.com global.example.org", + } + net = ds.OpenNebulaNetwork(context, mock.Mock()) + val = net.get_nameservers("eth0") + assert val["search"] == ["global.example.com", "global.example.org"] + + def test_get_nameservers_global_and_per_interface_search_domain(self): + """Per-interface and global SEARCH_DOMAIN are merged.""" + context = { + "ETH0_SEARCH_DOMAIN": "iface.example.com", + "SEARCH_DOMAIN": "global.example.com", + } + net = ds.OpenNebulaNetwork(context, mock.Mock()) + val = net.get_nameservers("eth0") + assert val["search"] == ["iface.example.com", "global.example.com"] + + def test_get_nameservers_no_global_search_domain_no_regression(self): + """Absent global SEARCH_DOMAIN does not break existing behaviour.""" + context = { + "ETH0_SEARCH_DOMAIN": "iface.example.com", + } + net = ds.OpenNebulaNetwork(context, mock.Mock()) + val = net.get_nameservers("eth0") + assert val["search"] == ["iface.example.com"] + + def test_get_nameservers_global_search_domain_not_duplicated(self): + """Same domain in per-interface and global appears only once.""" + context = { + "ETH0_SEARCH_DOMAIN": "shared.example.com", + "SEARCH_DOMAIN": "shared.example.com extra.example.com", + } + net = ds.OpenNebulaNetwork(context, mock.Mock()) + val = net.get_nameservers("eth0") + assert val["search"] == ["shared.example.com", "extra.example.com"] + + @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 # # ------------------------------------------------------------------ #