freeipa/ipatests/pytest_ipa/integration/resolver.py
Stanislav Levin 4352bd5a50 pylint: Fix cyclic-import
Most of `cyclic-import` issues reported by Pylint are false-positive
and they are already handled in the code, but several ones are the
actual errors.

Fixes: https://pagure.io/freeipa/issue/9232
Fixes: https://pagure.io/freeipa/issue/9278
Signed-off-by: Stanislav Levin <slev@altlinux.org>
Reviewed-By: Stanislav Levin <slev@altlinux.org>
2023-01-10 08:30:58 +01:00

354 lines
12 KiB
Python

import os
import abc
import logging
import re
import textwrap
import time
from ipaplatform.paths import paths
logger = logging.getLogger(__name__)
class Resolver(abc.ABC):
def __init__(self, host):
self.host = host
self.backups = []
self.current_state = self._get_state()
logger.info('Obtained initial resolver state for host %s: %s',
self.host, self.current_state)
def setup_resolver(self, nameservers, searchdomains=None):
"""Configure DNS resolver
:param nameservers: IP address of nameserver or a list of addresses
:param searchdomains: searchdomain or list of searchdomains.
None - do not configure
Resolver.backup() must be called prior to using this method.
Raises exception if configuration was changed externally since last call
to any method of Resolver class.
"""
if len(self.backups) == 0:
raise Exception(
'Changing resolver state without backup is forbidden')
self.check_state_expected()
if isinstance(nameservers, str):
nameservers = [nameservers]
if isinstance(searchdomains, str):
searchdomains = [searchdomains]
if searchdomains is None:
searchdomains = []
logger.info(
'Setting up resolver for host %s: nameservers=%s, searchdomains=%s',
self.host, nameservers, searchdomains
)
state = self._make_state_from_args(nameservers, searchdomains)
self._set_state(state)
def backup(self):
"""Saves current configuration to stack
Raises exception if configuration was changed externally since last call
to any method of Resolver class.
"""
self.check_state_expected()
self.backups.append(self._get_state())
logger.info(
'Saved resolver state for host %s, number of saved states: %s',
self.host, len(self.backups)
)
def restore(self):
"""Restore configuration from stack of backups.
Raises exception if configuration was changed externally since last call
to any method of Resolver class.
"""
if len(self.backups) == 0:
raise Exception('No resolver backups found for host {}'.format(
self.host))
self.check_state_expected()
self._set_state(self.backups.pop())
logger.info(
'Restored resolver state for host %s, number of saved states: %s',
self.host, len(self.backups)
)
def has_backups(self):
"""Checks if stack of backups is not empty"""
return bool(self.backups)
def check_state_expected(self):
"""Checks if resolver configuration has not changed.
Raises AssertionError if actual configuration has changed since last
call to any method of Resolver
"""
assert self._get_state() == self.current_state, (
'Resolver state changed unexpectedly at host {}'.format(self.host))
def __enter__(self):
self.backup()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.restore()
def _set_state(self, state):
self._apply_state(state)
logger.info('Applying resolver state for host %s: %s', self.host, state)
self.current_state = state
@classmethod
@abc.abstractmethod
def is_our_resolver(cls, host):
"""Checks if the class is appropriate for managing resolver on the host.
"""
@abc.abstractmethod
def _make_state_from_args(self, nameservers, searchdomains):
"""
:param nameservers: list of ip addresses of nameservers
:param searchdomains: list of searchdomain, can be an empty list
:return: internal state object specific to subclass implementaion
"""
@abc.abstractmethod
def _get_state(self):
"""Acquire actual host configuration.
:return: internal state object specific to subclass implementaion
"""
@abc.abstractmethod
def _apply_state(self, state):
"""Apply configuration to host.
:param state: internal state object specific to subclass implementaion
"""
def uses_localhost_as_dns(self):
"""Return true if the localhost is set as DNS server.
Default implementation checks the content of /etc/resolv.conf
"""
resolvconf = self.host.get_file_contents(paths.RESOLV_CONF, 'utf-8')
patterns = [r"^\s*nameserver\s+127\.0\.0\.1\s*$",
r"^\s*nameserver\s+::1\s*$"]
return any(re.search(p, resolvconf, re.MULTILINE) for p in patterns)
class ResolvedResolver(Resolver):
RESOLVED_RESOLV_CONF = {
"/run/systemd/resolve/stub-resolv.conf",
"/run/systemd/resolve/resolv.conf",
"/lib/systemd/resolv.conf",
"/usr/lib/systemd/resolv.conf",
}
RESOLVED_CONF_FILE = (
'/etc/systemd/resolved.conf.d/zzzz-ipatests-nameservers.conf')
RESOLVED_CONF = textwrap.dedent('''
# generated by IPA tests
[Resolve]
DNS={nameservers}
Domains=~. {searchdomains}
''')
@classmethod
def is_our_resolver(cls, host):
res = host.run_command(
['stat', '--format', '%F', paths.RESOLV_CONF])
filetype = res.stdout_text.strip()
if filetype == 'symbolic link':
res = host.run_command(['realpath', paths.RESOLV_CONF])
return (res.stdout_text.strip() in cls.RESOLVED_RESOLV_CONF)
return False
def _restart_resolved(self):
# Restarting service at rapid pace (which is what happens in some test
# scenarios) can exceed the threshold configured in systemd option
# StartLimitIntervalSec. In that case restart fails, but we can simply
# continue trying until it succeeds
from . import tasks # pylint: disable=cyclic-import
tasks.run_repeatedly(
self.host, ['systemctl', 'restart', 'systemd-resolved.service'],
timeout=15)
def _make_state_from_args(self, nameservers, searchdomains):
return {
'resolved_config': self.RESOLVED_CONF.format(
nameservers=' '.join(nameservers),
searchdomains=' '.join(searchdomains))
}
def _get_state(self):
exists = self.host.transport.file_exists(self.RESOLVED_CONF_FILE)
return {
'resolved_config':
self.host.get_file_contents(self.RESOLVED_CONF_FILE, 'utf-8')
if exists else None
}
def _apply_state(self, state):
if state['resolved_config'] is None:
self.host.run_command(['rm', '-f', self.RESOLVED_CONF_FILE])
else:
self.host.run_command(
['mkdir', '-p', os.path.dirname(self.RESOLVED_CONF_FILE)])
self.host.put_file_contents(
self.RESOLVED_CONF_FILE, state['resolved_config'])
self._restart_resolved()
def uses_localhost_as_dns(self):
"""Return true if the localhost is set as DNS server.
When systemd-resolved is in use, the DNS can be found using
the command resolvectldns.
"""
dnsconf = self.host.run_command(['resolvectl', 'dns']).stdout_text
patterns = [r"^Global:.*\s+127.0.0.1\s+.*$",
r"^Global:.*\s+::1\s+.*$"]
return any(re.search(p, dnsconf, re.MULTILINE) for p in patterns)
class PlainFileResolver(Resolver):
IPATESTS_RESOLVER_COMMENT = '# created by ipatests'
@classmethod
def is_our_resolver(cls, host):
res = host.run_command(
['stat', '--format', '%F', paths.RESOLV_CONF])
filetype = res.stdout_text.strip()
if filetype == 'regular file':
# We want to be sure that /etc/resolv.conf is not generated
# by NetworkManager or systemd-resolved. When it is then
# the first line of the file is a comment of the form:
#
# Generated by NetworkManager
#
# or
#
# This file is managed by man:systemd-resolved(8). Do not edit.
#
# So we check that either first line of resolv.conf
# is not a comment or the comment does not mention NM or
# systemd-resolved
resolv_conf = host.get_file_contents(paths.RESOLV_CONF, 'utf-8')
line = resolv_conf.splitlines()[0].strip()
return not line.startswith('#') or all([
'resolved' not in line,
'NetworkManager' not in line
])
return False
def _make_state_from_args(self, nameservers, searchdomains):
contents_lines = [self.IPATESTS_RESOLVER_COMMENT]
contents_lines.extend('nameserver {}'.format(r) for r in nameservers)
if searchdomains:
contents_lines.append('search {}'.format(' '.join(searchdomains)))
contents = '\n'.join(contents_lines)
return {'resolv_conf': contents}
def _get_state(self):
return {
'resolv_conf': self.host.get_file_contents(
paths.RESOLV_CONF, 'utf-8')
}
def _apply_state(self, state):
self.host.put_file_contents(paths.RESOLV_CONF, state['resolv_conf'])
class NetworkManagerResolver(Resolver):
NM_CONF_FILE = '/etc/NetworkManager/conf.d/zzzz-ipatests.conf'
NM_CONF = textwrap.dedent('''
# generated by IPA tests
[main]
dns=default
[global-dns]
searches={searchdomains}
[global-dns-domain-*]
servers={nameservers}
''')
@classmethod
def is_our_resolver(cls, host):
res = host.run_command(
['stat', '--format', '%F', paths.RESOLV_CONF])
filetype = res.stdout_text.strip()
if filetype == 'regular file':
resolv_conf = host.get_file_contents(paths.RESOLV_CONF, 'utf-8')
return resolv_conf.startswith('# Generated by NetworkManager')
return False
def _restart_network_manager(self):
# Restarting service at rapid pace (which is what happens in some test
# scenarios) can exceed the threshold configured in systemd option
# StartLimitIntervalSec. In that case restart fails, but we can simply
# continue trying until it succeeds
from . import tasks # pylint: disable=cyclic-import
tasks.run_repeatedly(
self.host, ['systemctl', 'restart', 'NetworkManager.service'],
timeout=15)
def _make_state_from_args(self, nameservers, searchdomains):
return {'nm_config': self.NM_CONF.format(
nameservers=','.join(nameservers),
searchdomains=','.join(searchdomains))}
def _get_state(self):
exists = self.host.transport.file_exists(self.NM_CONF_FILE)
return {
'nm_config':
self.host.get_file_contents(self.NM_CONF_FILE, 'utf-8')
if exists else None
}
def _apply_state(self, state):
def get_resolv_conf_mtime():
"""Get mtime of /etc/resolv.conf.
Returns mtime with sub-second precision as a string with format
"2020-08-25 14:35:05.980503425 +0200"
"""
return self.host.run_command(
['stat', '-c', '%y', paths.RESOLV_CONF]).stdout_text.strip()
if state['nm_config'] is None:
self.host.run_command(['rm', '-f', self.NM_CONF_FILE])
else:
self.host.run_command(
['mkdir', '-p', os.path.dirname(self.NM_CONF_FILE)])
self.host.put_file_contents(
self.NM_CONF_FILE, state['nm_config'])
# NetworkManager writes /etc/resolv.conf few moments after
# `systemctl restart` returns so we need to wait until the file is
# updated
mtime_before = get_resolv_conf_mtime()
self._restart_network_manager()
wait_until = time.time() + 10
while time.time() < wait_until:
if get_resolv_conf_mtime() != mtime_before:
break
time.sleep(1)
else:
raise Exception('NetworkManager did not update /etc/resolv.conf '
'in 10 seconds after restart')
def resolver(host):
for cls in [ResolvedResolver, NetworkManagerResolver,
PlainFileResolver]:
if cls.is_our_resolver(host):
logger.info('Detected DNS resolver manager for host %s is %s',
host.hostname, cls)
return cls(host)
raise Exception('Resolver manager could not be detected')