mirror of
https://github.com/openvswitch/ovs
synced 2025-10-13 14:07:02 +00:00
This adds a Python version of the async DNS support added in:
771680d96
DNS: Add basic support for asynchronous DNS resolving
The above version uses the unbound C library, and this
implimentation uses the SWIG-wrapped Python version of that.
In the event that the Python unbound library is not available,
a warning will be logged and the resolve() method will just
return None. For the case where inet_parse_active() is passed
an IP address, it will not try to resolve it, so existing
behavior should be preserved in the case that the unbound
library is unavailable.
Intentional differences from the C version are as follows:
OVS_HOSTS_FILE environment variable can bet set to override
the system 'hosts' file. This is primarily to allow testing to
be done without requiring network connectivity.
Since resolution can still be done via hosts file lookup, DNS
lookups are not disabled when resolv.conf cannot be loaded.
The Python socket_util module has fallen behind its C equivalent.
The bare minimum change was done to inet_parse_active() to support
sync/async dns, as there is no equivalent to
parse_sockaddr_components(), inet_parse_passive(), etc. A TODO
was added to bring socket_util.py up to equivalency to the C
version.
Signed-off-by: Terry Wilson <twilson@redhat.com>
Signed-off-by: Ilya Maximets <i.maximets@ovn.org>
281 lines
9.1 KiB
Python
281 lines
9.1 KiB
Python
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)
|