diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index f66ab43b0bf..47d239f1086 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -183,10 +183,10 @@ jobs: run: sudo apt update || true - name: install common dependencies run: sudo apt install -y ${{ env.dependencies }} - - name: install libunbound libunwind + - name: install libunbound libunwind python3-unbound # GitHub Actions doesn't have 32-bit versions of these libraries. if: matrix.m32 == '' - run: sudo apt install -y libunbound-dev libunwind-dev + run: sudo apt install -y libunbound-dev libunwind-dev python3-unbound - name: install 32-bit libraries if: matrix.m32 != '' run: sudo apt install -y gcc-multilib diff --git a/Documentation/intro/install/general.rst b/Documentation/intro/install/general.rst index 42b5682fd87..19e360d47ce 100644 --- a/Documentation/intro/install/general.rst +++ b/Documentation/intro/install/general.rst @@ -90,7 +90,7 @@ need the following software: If libcap-ng is installed, then Open vSwitch will automatically build with support for it. -- Python 3.4 or later. +- Python 3.6 or later. - Unbound library, from http://www.unbound.net, is optional but recommended if you want to enable ovs-vswitchd and other utilities to use DNS names when @@ -208,7 +208,7 @@ simply install and run Open vSwitch you require the following software: from iproute2 (part of all major distributions and available at https://wiki.linuxfoundation.org/networking/iproute2). -- Python 3.4 or later. +- Python 3.6 or later. On Linux you should ensure that ``/dev/urandom`` exists. To support TAP devices, you must also ensure that ``/dev/net/tun`` exists. diff --git a/Documentation/intro/install/rhel.rst b/Documentation/intro/install/rhel.rst index d1fc42021a6..f2151d89071 100644 --- a/Documentation/intro/install/rhel.rst +++ b/Documentation/intro/install/rhel.rst @@ -92,7 +92,7 @@ Once that is completed, remove the file ``/tmp/ovs.spec``. If python3-sphinx package is not available in your version of RHEL, you can install it via pip with 'pip install sphinx'. -Open vSwitch requires python 3.4 or newer which is not available in older +Open vSwitch requires python 3.6 or newer which is not available in older distributions. In the case of RHEL 6.x and its derivatives, one option is to install python34 from `EPEL`_. diff --git a/Documentation/intro/install/windows.rst b/Documentation/intro/install/windows.rst index 78f60f35acf..fce099d5dc1 100644 --- a/Documentation/intro/install/windows.rst +++ b/Documentation/intro/install/windows.rst @@ -56,7 +56,7 @@ The following explains the steps in some detail. 'C:/MinGW /mingw'. -- Python 3.4 or later. +- Python 3.6 or later. Install the latest Python 3.x from python.org and verify that its path is part of Windows' PATH environment variable. diff --git a/NEWS b/NEWS index 01e8219bfa5..bda41ad4c53 100644 --- a/NEWS +++ b/NEWS @@ -50,6 +50,9 @@ Post-v3.1.0 table to check the status. - Linux TC offload: * Add support for offloading VXLAN tunnels with the GBP extensions. + - Python + * Added async DNS support. + * Dropped support for Python < 3.6. v3.1.0 - 16 Feb 2023 diff --git a/debian/control.in b/debian/control.in index 19f590d0645..64b0a4ce018 100644 --- a/debian/control.in +++ b/debian/control.in @@ -287,6 +287,7 @@ Depends: Suggests: python3-netaddr, python3-pyparsing, + python3-unbound, Description: Python 3 bindings for Open vSwitch Open vSwitch is a production quality, multilayer, software-based, Ethernet virtual switch. It is designed to enable massive network diff --git a/m4/openvswitch.m4 b/m4/openvswitch.m4 index 47f486be49b..47aa9da16a1 100644 --- a/m4/openvswitch.m4 +++ b/m4/openvswitch.m4 @@ -375,16 +375,16 @@ dnl Checks for valgrind/valgrind.h. AC_DEFUN([OVS_CHECK_VALGRIND], [AC_CHECK_HEADERS([valgrind/valgrind.h])]) -dnl Checks for Python 3.4 or later. +dnl Checks for Python 3.6 or later. AC_DEFUN([OVS_CHECK_PYTHON3], [AC_CACHE_CHECK( - [for Python 3 (version 3.4 or later)], + [for Python 3 (version 3.6 or later)], [ovs_cv_python3], [if test -n "$PYTHON3"; then ovs_cv_python3=$PYTHON3 else ovs_cv_python3=no - for binary in python3 python3.4 python3.5 python3.6 python3.7; do + for binary in python3 python3.6 python3.7 python3.8 python3.9 python3.10 python3.11 python3.12; do ovs_save_IFS=$IFS; IFS=$PATH_SEPARATOR for dir in $PATH; do IFS=$ovs_save_IFS @@ -401,7 +401,7 @@ else: done fi]) if test "$ovs_cv_python3" = no; then - AC_MSG_ERROR([Python 3.4 or later is required but not found in $PATH, please install it or set $PYTHON3 to point to it]) + AC_MSG_ERROR([Python 3.6 or later is required but not found in $PATH, please install it or set $PYTHON3 to point to it]) fi AC_ARG_VAR([PYTHON3]) PYTHON3=$ovs_cv_python3]) diff --git a/python/TODO.rst b/python/TODO.rst index 3a53489f128..acc5461e2f2 100644 --- a/python/TODO.rst +++ b/python/TODO.rst @@ -32,3 +32,10 @@ Python Bindings To-do List * Support write-only-changed monitor mode (equivalent of OVSDB_IDL_WRITE_CHANGED_ONLY). + +* socket_util: + + * Add equivalent fuctions to inet_parse_passive, parse_sockaddr_components, + et al. to better support using async dns. The reconnect code will + currently log a warning when inet_parse_active() returns w/o yet having + resolved an address, but will continue to connect and eventually succeed. diff --git a/python/automake.mk b/python/automake.mk index d00911828c6..82a50878741 100644 --- a/python/automake.mk +++ b/python/automake.mk @@ -16,6 +16,7 @@ ovs_pyfiles = \ python/ovs/compat/sortedcontainers/sorteddict.py \ python/ovs/compat/sortedcontainers/sortedset.py \ python/ovs/daemon.py \ + python/ovs/dns_resolve.py \ python/ovs/db/__init__.py \ python/ovs/db/custom_index.py \ python/ovs/db/data.py \ @@ -55,6 +56,7 @@ ovs_pyfiles = \ ovs_pytests = \ python/ovs/tests/test_decoders.py \ + python/ovs/tests/test_dns_resolve.py \ python/ovs/tests/test_filter.py \ python/ovs/tests/test_kv.py \ python/ovs/tests/test_list.py \ diff --git a/python/ovs/dns_resolve.py b/python/ovs/dns_resolve.py new file mode 100644 index 00000000000..41546ad5ca4 --- /dev/null +++ b/python/ovs/dns_resolve.py @@ -0,0 +1,286 @@ +# Copyright (c) 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import collections +import enum +import functools +import ipaddress +import os +import time +import typing + +try: + import unbound # type: ignore +except ImportError: + pass + +import ovs.vlog + +vlog = ovs.vlog.Vlog("dns_resolve") + + +class ReqState(enum.Enum): + INVALID = 0 + PENDING = 1 + GOOD = 2 + ERROR = 3 + + +class DNSRequest: + def __init__(self, name: str): + self.name: str = name + self.state: ReqState = ReqState.INVALID + self.time: typing.Optional[float] = None + # set by DNSResolver._callback + self.result: typing.Optional[str] = None + self.ttl: typing.Optional[float] = None + + @property + def expired(self): + return time.time() > self.time + self.ttl + + @property + def is_valid(self): + return self.state == ReqState.GOOD and not self.expired + + def __str__(self): + return (f"DNSRequest(name={self.name}, state={self.state}, " + f"time={self.time}, result={self.result})") + + +class DefaultReqDict(collections.defaultdict): + def __init__(self): + super().__init__(DNSRequest) + + def __missing__(self, key): + ret = self.default_factory(key) + self[key] = ret + return ret + + +class UnboundException(Exception): + def __init__(self, message, errno): + try: + msg = f"{message}: {unbound.ub_strerror(errno)}" + except NameError: + msg = message + super().__init__(msg) + + +def dns_enabled(func): + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + if self.dns_enabled: + return func(self, *args, **kwargs) + vlog.err("DNS support requires the python unbound library") + return wrapper + + +class DNSResolver: + def __init__(self, is_daemon: bool = False): + """Create a resolver instance + + If is_daemon is true, set the resolver to handle requests + asynchronously. The following environment variables are processed: + + OVS_UNBOUND_CONF: The filename for an unbound.conf file + OVS_RESOLV_CONF: A filename to override the system default resolv.conf + OVS_HOSTS_FILE: A filename to override the system default hosts file + + In the event that the unbound library is missing or fails to initialize + DNS lookup support will be disabled and the resolve() method will + return None. + """ + self._is_daemon = is_daemon + try: + self._ctx = unbound.ub_ctx() + self.dns_enabled = True + except Exception: + # The unbound docs mention that this could thrown an exception + # but do not specify what exception that is. This can also + # happen with a missing unbound library. + self.dns_enabled = False + vlog.err("Failed to initialize the unbound library") + return + + # NOTE(twilson) This cache, like the C version, can grow without bound + # and has no cleanup or aging mechanism. Given our usage patterns, this + # should not be a problem. But this should not be used to resolve an + # unbounded list of addresses in a long-running daemon. + self._requests = DefaultReqDict() + + self._ub_call(self._set_unbound_conf) + + # NOTE(twilson) The C version disables DNS in this case. I didn't do + # that here since it could still be useful to resolve addresses from + # /etc/hosts even w/o resolv.conf + self._ub_call(self._set_resolv_conf) + self._ub_call(self._set_hosts_file) + + self._ctx.set_async(True) # Sets threaded behavior for resolve_async() + + def _ub_call(self, fn, *args, **kwargs): + """Convert UnboundExceptions into vlog warnings""" + try: + return fn(*args, **kwargs) + except UnboundException as e: + vlog.warn(e) + + @dns_enabled + def _set_unbound_conf(self): + ub_cfg = os.getenv("OVS_UNBOUND_CONF") + if ub_cfg: + retval = self._ctx.config(ub_cfg) + if retval != 0: + raise UnboundException( + "Failed to set libunbound context config", retval) + + @dns_enabled + def _set_resolv_conf(self): + filename = os.getenv("OVS_RESOLV_CONF") + # The C lib checks that the file exists and also sets filename to + # /etc/resolv.conf on non-Windows, but resolvconf already does this. + retval = self._ctx.resolvconf(filename) + if retval != 0: + location = filename or "system default nameserver" + raise UnboundException(location, retval) + + @dns_enabled + def _set_hosts_file(self): + # The C lib doesn't have the ability to set a hosts file, but it is + # useful to have, especially for writing tests that don't rely on + # network connectivity. hosts(None) uses /etc/hosts. + filename = os.getenv("OVS_HOSTS_FILE") + retval = self._ctx.hosts(filename) + if retval != 0: + location = filename or "system default hosts file" + raise UnboundException(location, retval) + + @dns_enabled + def _callback(self, req: DNSRequest, err: int, result): + if err != 0 or (result.qtype == unbound.RR_TYPE_AAAA + and not result.havedata): + req.state = ReqState.ERROR + vlog.warn(f"{req.name}: failed to resolve") + return + if result.qtype == unbound.RR_TYPE_A and not result.havedata: + self._resolve_async(req, unbound.RR_TYPE_AAAA) + return + try: + ip_str = next(iter(result.data.as_raw_data())) + ip = ipaddress.ip_address(ip_str) # test if IP is valid + # NOTE (twilson) For some reason, accessing result data outside of + # _callback causes a segfault. So just grab and store what we need. + req.result = str(ip) + req.ttl = result.ttl + req.state = ReqState.GOOD + req.time = time.time() + except (ValueError, StopIteration): + req.state = ReqState.ERROR + vlog.err(f"{req.name}: failed to resolve") + + @dns_enabled + def _resolve_sync(self, name: str) -> typing.Optional[str]: + for qtype in (unbound.RR_TYPE_A, unbound.RR_TYPE_AAAA): + err, result = self._ctx.resolve(name, qtype) + if err != 0: + return None + if not result.havedata: + continue + try: + ip = ipaddress.ip_address( + next(iter(result.data.as_raw_data()))) + except (ValueError, StopIteration): + return None + return str(ip) + + return None + + @dns_enabled + def _resolve_async(self, req: DNSRequest, qtype) -> None: + err, _ = self._ctx.resolve_async(req.name, req, self._callback, + qtype) + if err != 0: + req.state = ReqState.ERROR + return None + + req.state = ReqState.PENDING + return None + + @dns_enabled + def resolve(self, name: str) -> typing.Optional[str]: + """Resolve a host name to an IP address + + If the resolver is set to handle requests asynchronously, resolve() + should be recalled until it returns a non-None result. Errors will be + logged. + + :param name: The host name to resolve + :returns: The IP address or None on error or not (yet) found + """ + if not self._is_daemon: + return self._resolve_sync(name) + retval = self._ctx.process() + if retval != 0: + vlog.err(f"dns-resolve error: {unbound.ub_strerror(retval)}") + return None + req = self._requests[name] # Creates a DNSRequest if not found + if req.is_valid: + return req.result + elif req.state != ReqState.PENDING: + self._resolve_async(req, unbound.RR_TYPE_A) + return None + + +_global_resolver: typing.Optional[DNSResolver] = None + + +def init(is_daemon: bool = False) -> DNSResolver: + """Initialize a global DNSResolver + + See DNSResolver.__init__ for more details + """ + global _global_resolver + _global_resolver = DNSResolver(is_daemon) + return _global_resolver + + +def resolve(name: str) -> typing.Optional[str]: + """Resolve a host name to an IP address + + If a DNSResolver instance has not been instantiated, or if it has been + created with is_daemon=False, resolve() will synchronously resolve the + hostname. If DNSResolver has been initialized with is_daemon=True, it + will instead resolve asynchornously and resolve() will return None until + the hostname has been resolved. + + :param name: The host name to resolve + :returns: The IP address or None on error or not (yet) found + """ + if _global_resolver is None: + init() + + # mypy doesn't understand that init() sets _global_resolver, so ignore type + return _global_resolver.resolve(name) # type: ignore + + +def destroy(): + """Destroy the global DNSResolver + + This destroys the global DNSResolver instance and any outstanding + asynchronouse requests. + """ + global _global_resolver + del _global_resolver + _global_resolver = None # noqa: F841 diff --git a/python/ovs/socket_util.py b/python/ovs/socket_util.py index 7b41dc44bf1..a26298b75ca 100644 --- a/python/ovs/socket_util.py +++ b/python/ovs/socket_util.py @@ -13,12 +13,14 @@ # limitations under the License. import errno +import ipaddress import os import os.path import random import socket import sys +from ovs import dns_resolve import ovs.fatal_signal import ovs.poller import ovs.vlog @@ -216,7 +218,7 @@ def is_valid_ipv4_address(address): return True -def inet_parse_active(target, default_port): +def _inet_parse_active(target, default_port): address = target.split(":") if len(address) >= 2: host_name = ":".join(address[0:-1]).lstrip('[').rstrip(']') @@ -229,9 +231,24 @@ def inet_parse_active(target, default_port): host_name = address[0] if not host_name: raise ValueError("%s: bad peer name format" % target) + try: + host_name = str(ipaddress.ip_address(host_name)) + except ValueError: + host_name = dns_resolve.resolve(host_name) + if not host_name: + raise ValueError("%s: bad peer name format" % target) return (host_name, port) +def inet_parse_active(target, default_port, raises=True): + try: + return _inet_parse_active(target, default_port) + except ValueError: + if raises: + raise + return ("", default_port) + + def inet_create_socket_active(style, address): try: is_addr_inet = is_valid_ipv4_address(address[0]) @@ -262,7 +279,7 @@ def inet_connect_active(sock, address, family, dscp): def inet_open_active(style, target, default_port, dscp): - address = inet_parse_active(target, default_port) + address = inet_parse_active(target, default_port, raises=False) family, sock = inet_create_socket_active(style, address) if sock is None: return family, sock diff --git a/python/ovs/stream.py b/python/ovs/stream.py index b32341076ca..82fbb0d6883 100644 --- a/python/ovs/stream.py +++ b/python/ovs/stream.py @@ -784,7 +784,7 @@ def needs_probes(): @staticmethod def _open(suffix, dscp): - address = ovs.socket_util.inet_parse_active(suffix, 0) + address = ovs.socket_util.inet_parse_active(suffix, 0, raises=False) family, sock = ovs.socket_util.inet_create_socket_active( socket.SOCK_STREAM, address) if sock is None: diff --git a/python/ovs/tests/test_dns_resolve.py b/python/ovs/tests/test_dns_resolve.py new file mode 100644 index 00000000000..0698e8f77d9 --- /dev/null +++ b/python/ovs/tests/test_dns_resolve.py @@ -0,0 +1,280 @@ +import contextlib +import ipaddress +import sys +import time +from unittest import mock + +import pytest + +from ovs import dns_resolve +from ovs import socket_util + + +skip_no_unbound = pytest.mark.skipif("unbound" not in dns_resolve.__dict__, + reason="Unbound not installed") + +HOSTS = [("192.0.2.1", "fake.ip4.domain", "192.0.2.1"), + ("2001:db8:2::1", "fake.ip6.domain", "2001:db8:2::1"), + ("192.0.2.2", "fake.both.domain", "192.0.2.2"), + ("2001:db8:2::2", "fake.both.domain", "192.0.2.2")] + + +def _tmp_file(path, content): + path.write_text(content) + assert content == path.read_text() + return path + + +@pytest.fixture(params=[False, True], ids=["not_daemon", "daemon"]) +def resolver_factory(monkeypatch, tmp_path, hosts_file, request): + # Allow delaying the instantiation of the DNSResolver + def resolver_factory(): + with monkeypatch.context() as m: + m.setenv("OVS_HOSTS_FILE", str(hosts_file)) + # Test with both is_daemon False and True + resolver = dns_resolve.init(request.param) + assert resolver._is_daemon == request.param + return resolver + + return resolver_factory + + +@contextlib.contextmanager +def DNSResolver(*args, **kwargs): + """Clean up after returning a dns_resolver.DNSResolver""" + resolver = dns_resolve.init(*args, **kwargs) + try: + yield resolver + finally: + dns_resolve.destroy() + assert dns_resolve._global_resolver is None + + +@pytest.fixture +def unbound_conf(tmp_path): + path = tmp_path / "unbound.conf" + content = """ + server: + verbosity: 1 + """ + return _tmp_file(path, content) + + +@pytest.fixture +def resolv_conf(tmp_path): + path = tmp_path / "resolv.conf" + content = "nameserver 127.0.0.1" + return _tmp_file(path, content) + + +@pytest.fixture +def hosts_file(tmp_path): + path = tmp_path / "hosts" + content = "\n".join(f"{ip}\t{host}" for ip, host, _ in HOSTS) + return _tmp_file(path, content) + + +@pytest.fixture +def missing_file(tmp_path): + f = tmp_path / "missing_file" + assert not f.exists() + return f + + +@pytest.fixture(params=[False, True], ids=["with unbound", "without unbound"]) +def missing_unbound(monkeypatch, request): + if request.param: + if "unbound" in dns_resolve.__dict__: + monkeypatch.setitem(sys.modules, 'unbound', None) + monkeypatch.delitem(dns_resolve.__dict__, "unbound") + elif "unbound" not in dns_resolve.__dict__: + pytest.skip("Unbound not installed") + return request.param + + +def test_missing_unbound(missing_unbound, resolver_factory): + resolver = resolver_factory() # Dont fail even w/o unbound + assert resolver.dns_enabled == (not missing_unbound) + + +def test_DNSRequest_defaults(): + req = dns_resolve.DNSRequest(HOSTS[0][1]) + assert HOSTS[0][1] == req.name + assert req.state == dns_resolve.ReqState.INVALID + assert req.time == req.result == req.ttl is None + assert str(req) + + +def _resolve(resolver, host, fn=dns_resolve.resolve): + """Handle sync/async lookups, giving up if more than 1 second has passed""" + + timeout = 1 + start = time.time() + name = fn(host) + if resolver and resolver._is_daemon: + while name is None: + name = fn(host) + if name: + break + time.sleep(0.01) + end = time.time() + if end - start > timeout: + break + if name: + return name + raise LookupError(f"{host} not found") + + +@pytest.mark.parametrize("ip,host,expected", HOSTS) +def test_resolve_addresses(missing_unbound, resolver_factory, ip, host, + expected): + resolver = resolver_factory() + if missing_unbound: + with pytest.raises(LookupError): + _resolve(resolver, host) + else: + result = _resolve(resolver, host) + assert ipaddress.ip_address(expected) == ipaddress.ip_address(result) + + +@pytest.mark.parametrize("ip,host,expected", HOSTS) +def test_resolve_without_init(monkeypatch, missing_unbound, ip, host, expected, + hosts_file): + # make sure we don't have a global resolver + dns_resolve.destroy() + with monkeypatch.context() as m: + m.setenv("OVS_HOSTS_FILE", str(hosts_file)) + if missing_unbound: + with pytest.raises(LookupError): + _resolve(None, host) + else: + res = _resolve(None, host) + assert dns_resolve._global_resolver is not None + assert dns_resolve._global_resolver._is_daemon is False + assert ipaddress.ip_address(expected) == ipaddress.ip_address(res) + + +def test_resolve_unknown_host(missing_unbound, resolver_factory): + resolver = resolver_factory() + with pytest.raises(LookupError): + _resolve(resolver, "fake.notadomain") + + +@skip_no_unbound +def test_resolve_process_error(): + with DNSResolver(True) as resolver: + with mock.patch.object(resolver._ctx, "process", return_value=-1): + assert resolver.resolve("fake.domain") is None + + +@skip_no_unbound +def test_resolve_resolve_error(): + with DNSResolver(False) as resolver: + with mock.patch.object(resolver._ctx, "resolve", + return_value=(-1, None)): + assert resolver.resolve("fake.domain") is None + + +@skip_no_unbound +def test_resolve_resolve_async_error(): + with DNSResolver(True) as resolver: + with mock.patch.object(resolver._ctx, "resolve_async", + return_value=(-1, None)): + with pytest.raises(LookupError): + _resolve(resolver, "fake.domain") + + +@pytest.mark.parametrize("file,raises", + [(None, False), + ("missing_file", dns_resolve.UnboundException), + ("unbound_conf", False)]) +def test_set_unbound_conf(monkeypatch, missing_unbound, resolver_factory, + request, file, raises): + if file: + file = str(request.getfixturevalue(file)) + monkeypatch.setenv("OVS_UNBOUND_CONF", file) + resolver = resolver_factory() # Doesn't raise + if missing_unbound: + assert resolver._set_unbound_conf() is None + return + with mock.patch.object(resolver._ctx, "config", + side_effect=resolver._ctx.config) as c: + if raises: + with pytest.raises(raises): + resolver._set_unbound_conf() + else: + resolver._set_unbound_conf() + if file: + c.assert_called_once_with(file) + else: + c.assert_not_called() + + +@pytest.mark.parametrize("file,raises", + [(None, False), + ("missing_file", dns_resolve.UnboundException), + ("resolv_conf", False)]) +def test_resolv_conf(monkeypatch, missing_unbound, resolver_factory, request, + file, raises): + if file: + file = str(request.getfixturevalue(file)) + monkeypatch.setenv("OVS_RESOLV_CONF", file) + resolver = resolver_factory() # Doesn't raise + if missing_unbound: + assert resolver._set_resolv_conf() is None + return + with mock.patch.object(resolver._ctx, "resolvconf", + side_effect=resolver._ctx.resolvconf) as c: + if raises: + with pytest.raises(raises): + resolver._set_resolv_conf() + else: + resolver._set_resolv_conf() + c.assert_called_once_with(file) + + +@pytest.mark.parametrize("file,raises", + [(None, False), + ("missing_file", dns_resolve.UnboundException), + ("hosts_file", False)]) +def test_hosts(monkeypatch, missing_unbound, resolver_factory, request, file, + raises): + if file: + file = str(request.getfixturevalue(file)) + monkeypatch.setenv("OVS_HOSTS_FILE", file) + resolver = resolver_factory() # Doesn't raise + if missing_unbound: + assert resolver._set_hosts_file() is None + return + with mock.patch.object(resolver._ctx, "hosts", + side_effect=resolver._ctx.hosts) as c: + if raises: + with pytest.raises(raises): + resolver._set_hosts_file() + else: + resolver._set_hosts_file() + c.assert_called_once_with(file) + + +def test_UnboundException(missing_unbound): + with pytest.raises(dns_resolve.UnboundException): + raise dns_resolve.UnboundException("Fake exception", -1) + + +@skip_no_unbound +@pytest.mark.parametrize("ip,host,expected", HOSTS) +def test_inet_parse_active(resolver_factory, ip, host, expected): + resolver = resolver_factory() + + def fn(name): + # Return the same thing _resolve() would so we can call + # this multiple times for the is_daemon=True case + return socket_util.inet_parse_active(f"{name}:6640", 6640, + raises=False)[0] or None + + # parsing IPs still works + IP = _resolve(resolver, ip, fn) + assert ipaddress.ip_address(ip) == ipaddress.ip_address(IP) + # parsing hosts works + IP = _resolve(resolver, host, fn) + assert ipaddress.ip_address(IP) == ipaddress.ip_address(expected) diff --git a/python/setup.py b/python/setup.py index 27684c40469..bcf832ce9ba 100644 --- a/python/setup.py +++ b/python/setup.py @@ -99,8 +99,7 @@ def build_extension(self, ext): 'Topic :: System :: Networking', 'License :: OSI Approved :: Apache Software License', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', ], ext_modules=[setuptools.Extension("ovs._json", sources=["ovs/_json.c"], @@ -110,7 +109,8 @@ def build_extension(self, ext): cmdclass={'build_ext': try_build_ext}, install_requires=['sortedcontainers'], extras_require={':sys_platform == "win32"': ['pywin32 >= 1.0'], - 'flow': ['netaddr', 'pyparsing']}, + 'flow': ['netaddr', 'pyparsing'], + 'dns': ['unbound']}, ) try: diff --git a/rhel/openvswitch-fedora.spec.in b/rhel/openvswitch-fedora.spec.in index 44899c1ca74..343a5716d16 100644 --- a/rhel/openvswitch-fedora.spec.in +++ b/rhel/openvswitch-fedora.spec.in @@ -113,7 +113,7 @@ Summary: Open vSwitch python3 bindings License: ASL 2.0 BuildArch: noarch Requires: python3 -Suggests: python3-netaddr python3-pyparsing +Suggests: python3-netaddr python3-pyparsing python3-unbound %{?python_provide:%python_provide python3-openvswitch = %{version}-%{release}} %description -n python3-openvswitch diff --git a/tests/vlog.at b/tests/vlog.at index 3e92e70a93c..785014956e7 100644 --- a/tests/vlog.at +++ b/tests/vlog.at @@ -385,6 +385,7 @@ AT_CHECK([APPCTL -t test-unixctl.py vlog/list], [0], [dnl console syslog file ------- ------ ------ daemon info info info +dns_resolve info info info fatal-signal info info info jsonrpc info info info poller info info info @@ -404,6 +405,7 @@ unixctl_server info info info console syslog file ------- ------ ------ daemon info err dbg +dns_resolve info info dbg fatal-signal info info dbg jsonrpc info info dbg poller info info dbg