Import Upstream version 4.12.4

This commit is contained in:
geos_one
2025-08-12 22:28:56 +02:00
parent 03a8170b15
commit 9181ee2487
1629 changed files with 874094 additions and 554378 deletions

View File

@@ -28,12 +28,15 @@ import os
import tempfile
import shutil
import re
import functools
import pytest
from pytest_multihost import make_multihost_fixture
from ipapython import ipautil
from ipaplatform.paths import paths
from ipaplatform.constants import constants
from . import fips
from .config import Config
from .env_config import get_global_config
from . import tasks
@@ -41,15 +44,19 @@ from . import tasks
logger = logging.getLogger(__name__)
CLASS_LOGFILES = [
# BIND logs
os.path.join(paths.NAMED_VAR_DIR, constants.NAMED_DATA_DIR),
# dirsrv logs
paths.VAR_LOG_DIRSRV,
# IPA install logs
paths.IPASERVER_INSTALL_LOG,
paths.IPASERVER_ADTRUST_INSTALL_LOG,
paths.IPASERVER_DNS_INSTALL_LOG,
paths.IPASERVER_KRA_INSTALL_LOG,
paths.IPACLIENT_INSTALL_LOG,
paths.IPAREPLICA_INSTALL_LOG,
paths.IPAREPLICA_CONNCHECK_LOG,
paths.IPAREPLICA_CA_INSTALL_LOG,
paths.IPASERVER_KRA_INSTALL_LOG,
paths.IPA_CUSTODIA_AUDIT_LOG,
paths.IPACLIENTSAMBA_INSTALL_LOG,
paths.IPACLIENTSAMBA_UNINSTALL_LOG,
@@ -62,6 +69,8 @@ CLASS_LOGFILES = [
# IPA backup and restore logs
paths.IPARESTORE_LOG,
paths.IPABACKUP_LOG,
# EPN log
paths.IPAEPN_LOG,
# kerberos related logs
paths.KADMIND_LOG,
paths.KRB5KDC_LOG,
@@ -69,6 +78,12 @@ CLASS_LOGFILES = [
paths.VAR_LOG_HTTPD_DIR,
# dogtag logs
paths.VAR_LOG_PKI_DIR,
# dogtag conf
paths.PKI_TOMCAT_SERVER_XML,
paths.PKI_TOMCAT + "/ca/CS.cfg",
paths.PKI_TOMCAT + "/kra/CS.cfg",
paths.PKI_TOMCAT_ALIAS_DIR,
paths.PKI_TOMCAT_ALIAS_PWDFILE_TXT,
# selinux logs
paths.VAR_LOG_AUDIT,
# sssd
@@ -76,6 +91,15 @@ CLASS_LOGFILES = [
# system
paths.RESOLV_CONF,
paths.HOSTS,
# IPA renewal lock
paths.IPA_RENEWAL_LOCK,
paths.LETS_ENCRYPT_LOG,
# resolvers management
paths.NETWORK_MANAGER_CONFIG,
paths.NETWORK_MANAGER_CONFIG_DIR,
paths.SYSTEMD_RESOLVED_CONF,
paths.SYSTEMD_RESOLVED_CONF_DIR,
'/var/log/samba',
]
@@ -195,8 +219,17 @@ def collect_logs(name, logs_dict, logfile_dir=None, beakerlib_plugin=None):
tmpname = cmd.stdout_text.strip()
# Tar up the logs on the remote server
cmd = host.run_command(
['tar', 'cJvf', tmpname, '--ignore-failed-read'] + logs,
log_stdout=False, raiseonerr=False)
[
"tar",
"cJvf",
tmpname,
"--ignore-failed-read",
"--warning=no-failed-read",
"--dereference",
] + logs,
log_stdout=False,
raiseonerr=False,
)
if cmd.returncode:
logger.warning('Could not collect all requested logs')
# fetch tar file
@@ -262,8 +295,8 @@ class IntegrationLogs:
def init_method_logs(self):
"""Initilize method logs with the class ones"""
self._method_logs = {}
for k in self._class_logs:
self._method_logs[k] = list(self._class_logs[k])
for host, logs in self._class_logs.items():
self._method_logs[host] = list(logs)
def collect_class_log(self, host, filename):
"""Add class scope log
@@ -470,3 +503,18 @@ def del_compat_attrs(cls):
del cls.ad_subdomains
del cls.ad_treedomains
del cls.ad_domains
def skip_if_fips(reason='Not supported in FIPS mode', host='master'):
if callable(reason):
raise TypeError('Invalid decorator usage, add "()"')
def decorator(test_method):
@functools.wraps(test_method)
def wrapper(instance, *args, **kwargs):
if fips.is_fips_enabled(getattr(instance, host)):
pytest.skip(reason)
else:
test_method(instance, *args, **kwargs)
return wrapper
return decorator

View File

@@ -44,6 +44,9 @@ class Config(pytest_multihost.config.Config):
'domain_level',
'log_journal_since',
'fips_mode',
'token_name',
'token_password',
'token_library',
}
def __init__(self, **kwargs):
@@ -69,6 +72,9 @@ class Config(pytest_multihost.config.Config):
if self.domain_level is None:
self.domain_level = MAX_DOMAIN_LEVEL
self.fips_mode = kwargs.get('fips_mode', False)
self.token_name = kwargs.get('token_name', None)
self.token_password = kwargs.get('token_password', None)
self.token_library = kwargs.get('token_library', None)
def get_domain_class(self):
return Domain
@@ -100,11 +106,11 @@ class Config(pytest_multihost.config.Config):
@classmethod
def from_env(cls, env):
from ipatests.pytest_ipa.integration.env_config import config_from_env
from .env_config import config_from_env # pylint: disable=cyclic-import
return config_from_env(env)
def to_env(self, **kwargs):
from ipatests.pytest_ipa.integration.env_config import config_to_env
from .env_config import config_to_env # pylint: disable=cyclic-import
return config_to_env(self, **kwargs)
def filter(self, descriptions):
@@ -156,7 +162,7 @@ class Domain(pytest_multihost.config.Domain):
raise LookupError(self.type)
def get_host_class(self, host_dict):
from ipatests.pytest_ipa.integration.host import Host, WinHost
from .host import Host, WinHost # pylint: disable=cyclic-import
if self.is_ipa_type:
return Host

View File

@@ -0,0 +1,183 @@
import re
import textwrap
from ipatests.pytest_ipa.integration import tasks
def setup_scim_server(host, version="main"):
dir = "/opt/ipa-tuura"
password = host.config.admin_password
tasks.install_packages(host, ["unzip", "java-11-openjdk-headless",
"openssl", "maven", "wget", "git",
"firefox", "xorg-x11-server-Xvfb",
"python3-pip"])
# Download ipa-tuura project
url = "https://github.com/freeipa/ipa-tuura"
host.run_command(["git", "clone", "-b", f"{version}", f"{url}", f"{dir}"])
# Prepare SSSD config
host.run_command(["python", "./prepare_sssd.py"],
cwd=f"{dir}/src/install")
# Get keytab for scim bridge service
master = host.domain.hosts_by_role("master")[0].hostname
princ = f"admin@{host.domain.realm}"
ktfile = "/root/scim.keytab"
sendpass = f"{password}\n{password}"
tasks.kdestroy_all(host)
tasks.kinit_admin(host)
host.run_command(["ipa-getkeytab", "-s", master, "-p", princ,
"-P", "-k", ktfile], stdin_text=sendpass)
host.run_command(["kinit", "-k", "-t", ktfile, princ])
# Install django requirements
django_reqs = f"{dir}/src/install/requirements.txt"
host.run_command(["pip", "install", "-r", f"{django_reqs}"])
# Prepare models and database
host.run_command(["python", "manage.py", "makemigrations", "scim"],
cwd=f"{dir}/src/ipa-tuura")
host.run_command(["python", "manage.py", "migrate"],
cwd=f"{dir}/src/ipa-tuura")
# Add necessary admin vars to bashrc
env_vars = textwrap.dedent(f"""
export DJANGO_SUPERUSER_PASSWORD={password}
export DJANGO_SUPERUSER_USERNAME=scim
export DJANGO_SUPERUSER_EMAIL=scim@{host.domain.name}
""")
tasks.backup_file(host, '/etc/bashrc')
content = host.get_file_contents('/etc/bashrc', encoding='utf-8')
new_content = content + f"\n{env_vars}"
host.put_file_contents('/etc/bashrc', new_content)
host.run_command(['bash'])
# Create django admin
host.run_command(["python", "manage.py", "createsuperuser",
"--scim_username", "scim", "--noinput"],
cwd=f"{dir}/src/ipa-tuura")
# Open allowed hosts to any for testing
regex = r"^(ALLOWED_HOSTS) .*$"
replace = r"\1 = ['*']"
settings_file = f"{dir}/src/ipa-tuura/root/settings.py"
settings = host.get_file_contents(settings_file, encoding='utf-8')
new_settings = re.sub(regex, replace, settings, flags=re.MULTILINE)
host.put_file_contents(settings_file, new_settings)
# Setup keycloak service and config files
contents = textwrap.dedent(f"""
DJANGO_SUPERUSER_USERNAME=scim
DJANGO_SUPERUSER_PASSWORD={password}
DJANGO_SUPERUSER_EMAIL=scim@{host.domain.name}
""")
host.put_file_contents("/etc/sysconfig/scim", contents)
manage = f"{dir}/src/ipa-tuura/manage.py"
contents = textwrap.dedent(f"""
[Unit]
Description=SCIMv2 Bridge Server
After=network.target
[Service]
Type=idle
WorkingDirectory={dir}/src/ipa-tuura/
EnvironmentFile=/etc/sysconfig/scim
# Fix this later
# User=scim
# Group=scim
ExecStart=/usr/bin/python {manage} runserver 0.0.0.0:8000
TimeoutStartSec=600
TimeoutStopSec=600
[Install]
WantedBy=multi-user.target
""")
host.put_file_contents("/etc/systemd/system/scim.service", contents)
host.run_command(["systemctl", "daemon-reload"])
host.run_command(["systemctl", "start", "scim"])
def setup_keycloak_scim_plugin(host, bridge_server):
dir = "/opt/keycloak"
password = host.config.admin_password
# Install needed packages
tasks.install_packages(host, ["unzip", "java-11-openjdk-headless",
"openssl", "maven"])
# Add necessary admin vars to bashrc
env_vars = textwrap.dedent(f"""
export KEYCLOAK_PATH={dir}
""")
content = host.get_file_contents('/etc/bashrc', encoding='utf-8')
new_content = content + f"\n{env_vars}"
host.put_file_contents('/etc/bashrc', new_content)
host.run_command(['bash'])
# Download keycloak plugin
zipfile = "scim-keycloak-user-storage-spi/archive/refs/tags/0.1.zip"
url = f"https://github.com/justin-stephenson/{zipfile}"
dest = "/tmp/keycloak-scim-plugin.zip"
host.run_command(["wget", "-O", dest, url])
# Unzip keycloak plugin
host.run_command(["unzip", dest, "-d", "/tmp"])
# Install plugin
host.run_command(["./redeploy-plugin.sh"],
cwd="/tmp/scim-keycloak-user-storage-spi-0.1")
# Fix ownership of plugin files
host.run_command(["chown", "-R", "keycloak:keycloak", dir])
# Restore SELinux contexts
host.run_command(["restorecon", "-R", f"{dir}"])
# Rerun Keycloak build step and restart to pickup plugin
# This relies on the KC_* vars set in /etc/bashrc from create_keycloak.py
host.run_command(['su', '-', 'keycloak', '-c',
'/opt/keycloak/bin/kc.sh build'])
host.run_command(["systemctl", "restart", "keycloak"])
host.run_command(["/opt/keycloak/bin/kc.sh", "show-config"])
# Login to keycloak as admin
kcadmin_sh = "/opt/keycloak/bin/kcadm.sh"
kcadmin = [kcadmin_sh, "config", "credentials", "--server",
f"https://{host.hostname}:8443/auth/",
"--realm", "master", "--user", "admin",
"--password", password]
tasks.run_repeatedly(host, kcadmin, timeout=60)
# Configure SCIM User Storage to point to Bridge server
provider_type = "org.keycloak.storage.UserStorageProvider"
host.run_command([kcadmin_sh, "create", "components",
"-r", "master",
"-s", "name=scimprov",
"-s", "providerId=scim",
"-s", f"providerType={provider_type}",
"-s", "parentId=master",
"-s", f'config.scimurl=["{bridge_server}:8000"]',
"-s", 'config.loginusername=["scim"]',
"-s", f'config.loginpassword=["{password}"]'])
def uninstall_scim_server(host):
host.run_command(["systemctl", "stop", "scim"], raiseonerr=False)
host.run_command(["rm", "-rf", "/opt/ipa-tuura",
"/etc/sysconfig/scim",
"/etc/systemd/system/scim.service",
"/tmp/scim-keycloak-user-storage-spi-0.1",
"/tmp/keycloak-scim-plugin.zip",
"/root/scim.keytab"])
host.run_command(["systemctl", "daemon-reload"])
tasks.restore_files(host)
def uninstall_scim_plugin(host):
host.run_command(["rm", "-rf",
"/tmp/scim-keycloak-user-storage-spi-0.1",
"/tmp/keycloak-scim-plugin.zip"])

View File

@@ -102,7 +102,7 @@ class KRB5PrincipalName(univ.Sequence):
def profile_ca(builder, ca_nick, ca):
now = datetime.datetime.utcnow()
now = datetime.datetime.now(tz=datetime.timezone.utc)
builder = builder.not_valid_before(now)
builder = builder.not_valid_after(now + 10 * YEAR)
@@ -174,7 +174,7 @@ def profile_ca(builder, ca_nick, ca):
def profile_server(builder, ca_nick, ca,
warp=datetime.timedelta(days=0), dns_name=None,
badusage=False, wildcard=False):
now = datetime.datetime.utcnow() + warp
now = datetime.datetime.now(tz=datetime.timezone.utc) + warp
builder = builder.not_valid_before(now)
builder = builder.not_valid_after(now + YEAR)
@@ -231,7 +231,7 @@ def profile_server(builder, ca_nick, ca,
def profile_kdc(builder, ca_nick, ca,
warp=datetime.timedelta(days=0), dns_name=None,
badusage=False):
now = datetime.datetime.utcnow() + warp
now = datetime.datetime.now(tz=datetime.timezone.utc) + warp
builder = builder.not_valid_before(now)
builder = builder.not_valid_after(now + YEAR)
@@ -347,7 +347,7 @@ def gen_cert(profile, nick_base, subject, ca=None, **kwargs):
def revoke_cert(ca, serial):
now = datetime.datetime.utcnow()
now = datetime.datetime.now(tz=datetime.timezone.utc)
crl_builder = x509.CertificateRevocationListBuilder()
crl_builder = crl_builder.issuer_name(ca.cert.subject)
@@ -570,7 +570,7 @@ def create_pki():
x509.NameAttribute(NameOID.COMMON_NAME, server2)
])
)
ca1 = gen_subtree(u'ca1', u'Example Organization')
ca1 = gen_subtree(u'ca1', u'Example Organization Espa\xf1a')
gen_subtree(u'subca', u'Subsidiary Example Organization', ca1)
gen_subtree(u'ca2', u'Other Example Organization')
ca3 = gen_subtree(u'ca3', u'Unknown Organization')

View File

@@ -0,0 +1,178 @@
import os
import textwrap
import time
from ipaplatform.paths import paths
from ipatests.pytest_ipa.integration import tasks
def setup_keycloakserver(host, version='17.0.0'):
dir = "/opt/keycloak"
password = host.config.admin_password
tasks.install_packages(host, ["unzip", "java-11-openjdk-headless",
"openssl", "maven", "wget",
"firefox", "xorg-x11-server-Xvfb"])
# add keycloak system user/group and folder
url = "https://github.com/keycloak/keycloak/releases/download/{0}/keycloak-{0}.zip".format(version) # noqa: E501
host.run_command(["wget", url, "-O", "{0}-{1}.zip".format(dir, version)])
host.run_command(
["unzip", "{0}-{1}.zip".format(dir, version), "-d", "/opt/"]
)
host.run_command(["mv", "{0}-{1}".format(dir, version), dir])
host.run_command(["groupadd", "keycloak"])
host.run_command(
["useradd", "-r", "-g", "keycloak", "-d", dir, "keycloak"]
)
host.run_command(["chown", "-R", "keycloak:", dir])
host.run_command(["chmod", "o+x", "{0}/bin/".format(dir)])
host.run_command(["restorecon", "-R", dir])
# setup TLS certificate using IPA CA
host.run_command(["kinit", "-k"])
host.run_command(["ipa", "service-add", "HTTP/{0}".format(host.hostname)])
key = os.path.join(paths.OPENSSL_PRIVATE_DIR, "keycloak.key")
crt = os.path.join(paths.OPENSSL_PRIVATE_DIR, "keycloak.crt")
keystore = os.path.join(paths.OPENSSL_PRIVATE_DIR, "keycloak.store")
host.run_command(["ipa-getcert", "request", "-K",
"HTTP/{0}".format(host.hostname),
"-D", host.hostname, "-o", "keycloak",
"-O", "keycloak", "-m", "0600",
"-M", "0644",
"-k", key, "-f", crt, "-w"])
host.run_command(["keytool", "-import", "-keystore", keystore,
"-file", "/etc/ipa/ca.crt",
"-alias", "ipa_ca",
"-trustcacerts", "-storepass", password, "-noprompt"])
host.run_command(["chown", "keycloak:keycloak", keystore])
# Setup keycloak service and config files
contents = textwrap.dedent("""
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD={admin_pswd}
KC_HOSTNAME={host}:8443
KC_HTTPS_CERTIFICATE_FILE={crt}
KC_HTTPS_CERTIFICATE_KEY_FILE={key}
KC_HTTPS_TRUST_STORE_FILE={store}
KC_HTTPS_TRUST_STORE_PASSWORD={store_pswd}
KC_HTTP_RELATIVE_PATH=/auth
""").format(admin_pswd=password, host=host.hostname, crt=crt, key=key,
store=keystore, store_pswd=password)
host.put_file_contents("/etc/sysconfig/keycloak", contents)
contents = textwrap.dedent("""
[Unit]
Description=Keycloak Server
After=network.target
[Service]
Type=idle
EnvironmentFile=/etc/sysconfig/keycloak
User=keycloak
Group=keycloak
ExecStart=/opt/keycloak/bin/kc.sh start
TimeoutStartSec=600
TimeoutStopSec=600
[Install]
WantedBy=multi-user.target
""")
host.put_file_contents("/etc/systemd/system/keycloak.service", contents)
host.run_command(["systemctl", "daemon-reload"])
# Run build stage first
env_vars = textwrap.dedent("""
export KEYCLOAK_ADMIN=admin
export KC_HOSTNAME={hostname}:8443
export KC_HTTPS_CERTIFICATE_FILE=/etc/pki/tls/certs/keycloak.crt
export KC_HTTPS_CERTIFICATE_KEY_FILE=/etc/pki/tls/private/keycloak.key
export KC_HTTPS_TRUST_STORE_FILE=/etc/pki/tls/private/keycloak.store
export KC_HTTPS_TRUST_STORE_PASSWORD={STORE_PASS}
export KEYCLOAK_ADMIN_PASSWORD={ADMIN_PASS}
export KC_HTTP_RELATIVE_PATH=/auth
""").format(hostname=host.hostname, STORE_PASS=password,
ADMIN_PASS=password)
tasks.backup_file(host, '/etc/bashrc')
content = host.get_file_contents('/etc/bashrc',
encoding='utf-8')
new_content = content + "\n{}".format(env_vars)
host.put_file_contents('/etc/bashrc', new_content)
host.run_command(['bash'])
host.run_command(
['su', '-', 'keycloak', '-c', '/opt/keycloak/bin/kc.sh build'])
host.run_command(["systemctl", "start", "keycloak"])
host.run_command(["/opt/keycloak/bin/kc.sh", "show-config"])
# Setup keycloak for use:
kcadmin_sh = "/opt/keycloak/bin/kcadm.sh"
host.run_command([kcadmin_sh, "config", "truststore",
"--trustpass", password, keystore])
kcadmin = [kcadmin_sh, "config", "credentials", "--server",
"https://{0}:8443/auth/".format(host.hostname),
"--realm", "master", "--user", "admin",
"--password", password
]
tasks.run_repeatedly(
host, kcadmin, timeout=60)
host.run_command(
[kcadmin_sh, "create", "users", "-r", "master",
"-s", "username=testuser1", "-s", "enabled=true",
"-s", "email=testuser1@ipa.test"]
)
host.run_command(
[kcadmin_sh, "set-password", "-r", "master",
"--username", "testuser1", "--new-password", password]
)
def setup_keycloak_client(host):
password = host.config.admin_password
host.run_command(["/opt/keycloak/bin/kcreg.sh",
"config", "credentials", "--server",
"https://{0}:8443/auth/".format(host.hostname),
"--realm", "master", "--user", "admin",
"--password", password]
)
client_json = textwrap.dedent("""
{{
"enabled" : true,
"clientAuthenticatorType" : "client-secret",
"redirectUris" : [ "https://ipa-ca.{redirect}/ipa/idp/*" ],
"webOrigins" : [ "https://ipa-ca.{web}" ],
"protocol" : "openid-connect",
"attributes" : {{
"oauth2.device.authorization.grant.enabled" : "true",
"oauth2.device.polling.interval": "5"
}}
}}
""").format(redirect=host.domain.name, web=host.domain.name)
host.put_file_contents("/tmp/ipa_client.json", client_json)
host.run_command(["/opt/keycloak/bin/kcreg.sh", "create",
"-f", "/tmp/ipa_client.json",
"-s", "clientId=ipa_oidc_client",
"-s", "secret={0}".format(password)]
)
time.sleep(60)
def uninstall_keycloak(host):
key = os.path.join(paths.OPENSSL_PRIVATE_DIR, "keycloak.key")
crt = os.path.join(paths.OPENSSL_PRIVATE_DIR, "keycloak.crt")
keystore = os.path.join(paths.OPENSSL_PRIVATE_DIR, "keycloak.store")
host.run_command(["systemctl", "stop", "keycloak"], raiseonerr=False)
host.run_command(["getcert", "stop-tracking", "-k", key, "-f", crt],
raiseonerr=False)
host.run_command(["rm", "-rf", "/opt/keycloak",
"/etc/sysconfig/keycloak",
"/etc/systemd/system/keycloak.service",
key, crt, keystore])
host.run_command(["systemctl", "daemon-reload"])
host.run_command(["userdel", "keycloak"])
host.run_command(["groupdel", "keycloak"], raiseonerr=False)
tasks.restore_files(host)

View File

@@ -0,0 +1,155 @@
import time
import logging
import pexpect
from pexpect.exceptions import ExceptionPexpect, TIMEOUT
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class IpaTestExpect(pexpect.spawn):
"""A wrapper class around pexpect.spawn for easier usage in automated tests
Please see pexpect documentation at
https://pexpect.readthedocs.io/en/stable/api/index.html for general usage
instructions. Note that usage of "+", "*" and '?' at the end of regular
expressions arguments to .expect() is meaningless.
This wrapper adds ability to use the class as a context manager, which
will take care of verifying process return status and terminating
the process if it did not do it normally. The context manager is the
recommended way of using the class in tests.
Basic usage example:
```
with IpaTestExpect('some_command') as e:
e.expect_exact('yes or no?')
e.sendline('yes')
```
At exit from context manager the following checks are performed by default:
1. there is nothing in output since last call to .expect()
2. the process has terminated
3. return code is 0
If any check fails, an exceptio is raised. If you want to override checks
1 and 3 you can call .expect_exit() explicitly:
```
with IpaTestExpect('some_command') as e:
...
e.expect_exit(ok_returncode=1, ignore_remaining_output=True)
```
All .expect* methods are strict, meaning that if they do not find the
pattern in the output during given amount of time, the exception is raised.
So they can directly be used to verify output for presence of specific
strings.
Another addition is .get_last_output() method which can be used get process
output from penultimate up to the last call to .expect(). The result can
be used for more complex checks which can not be expressed as simple
regexes, for example we can check for absence of string in output:
```
with IpaTestExpect('some_command') as e:
...
e.expect('All done')
output = e.get_last_output()
assert 'WARNING' not in output
```
"""
def __init__(self, argv, default_timeout=10, encoding='utf-8'):
if isinstance(argv, str):
command = argv
args = []
else:
command = argv[0]
args = argv[1:]
logger.debug('Expect will spawn command "%s" with args %s',
command, args)
super().__init__(
command, args, timeout=default_timeout, encoding=encoding,
echo=False
)
def expect_exit(self, timeout=-1, ok_returncode=0, raiseonerr=True,
ignore_remaining_output=False):
if timeout == -1:
timeout = self.timeout
wait_to_exit_until = time.time() + timeout
if not self.eof():
self.expect(pexpect.EOF, timeout)
errors = []
if not ignore_remaining_output and self.before.strip():
errors.append('Unexpected output at program exit: {!r}'
.format(self.before))
while time.time() < wait_to_exit_until:
if not self.isalive():
break
time.sleep(0.1)
else:
errors.append('Program did not exit after waiting for {} seconds'
.format(self.timeout))
if (not self.isalive() and raiseonerr
and self.exitstatus != ok_returncode):
errors.append('Program exited with unexpected status {}'
.format(self.exitstatus))
self.exit_checked = True
if errors:
raise ExceptionPexpect(
'Program exited with an unexpected state:\n'
+ '\n'.join(errors))
def send(self, s):
"""Wrapper to provide logging input string"""
logger.debug('Sending %r', s)
return super().send(s)
def expect_list(self, pattern_list, *args, **kwargs):
"""Wrapper to provide logging output string and expected patterns"""
try:
result = super().expect_list(pattern_list, *args, **kwargs)
finally:
self._log_output(pattern_list)
return result
def expect_exact(self, pattern_list, *args, **kwargs):
"""Wrapper to provide logging output string and expected patterns"""
try:
result = super().expect_exact(pattern_list, *args, **kwargs)
finally:
self._log_output(pattern_list)
return result
def get_last_output(self):
"""Return output consumed by last call to .expect*()"""
output = self.before
if isinstance(self.after, str):
output += self.after
return output
def _log_output(self, expected):
logger.debug('Output received: %r, expected: "%s", ',
self.get_last_output(), expected)
def __enter__(self):
self.exit_checked = False
return self
def __exit__(self, exc_type, exc_val, exc_tb):
exception_occurred = bool(exc_type)
try:
if not self.exit_checked:
self.expect_exit(raiseonerr=not exception_occurred,
ignore_remaining_output=exception_occurred)
except TIMEOUT:
if not exception_occurred:
raise
finally:
if self.isalive():
logger.error('Command still active, terminating.')
self.terminate(True)

View File

@@ -37,6 +37,9 @@ def enable_userspace_fips(host):
# fake Kernel FIPS mode with bind mount
host.run_command(["mkdir", "-p", FIPS_OVERLAY_DIR])
host.put_file_contents(FIPS_OVERLAY, "1\n")
host.run_command(
["chcon", "-t", "sysctl_crypto_t", "-u", "system_u", FIPS_OVERLAY]
)
host.run_command(
["mount", "--bind", FIPS_OVERLAY, paths.PROC_FIPS_ENABLED]
)
@@ -49,7 +52,7 @@ def enable_userspace_fips(host):
["openssl", "md5", "/dev/null"], raiseonerr=False
)
assert result.returncode == 1
assert "EVP_DigestInit_ex:disabled for FIPS" in result.stderr_text
assert "Error setting digest" in result.stderr_text
def disable_userspace_fips(host):
@@ -65,3 +68,9 @@ def disable_userspace_fips(host):
# sanity check
assert not is_fips_enabled(host)
host.run_command(["openssl", "md5", "/dev/null"])
def enable_crypto_subpolicy(host, subpolicy):
result = host.run_command(["update-crypto-policies", "--show"])
policy = result.stdout_text.strip() + ":" + subpolicy
host.run_command(["update-crypto-policies", "--set", policy])

View File

@@ -231,7 +231,7 @@ class Firewall(FirewallBase):
def __init__(self, host):
"""Initialize with host where firewall changes should be applied"""
# break circular dependency
from .tasks import get_platform
from .tasks import get_platform # pylint: disable=cyclic-import
self.host = host
platform = get_platform(host)

View File

@@ -32,6 +32,7 @@ from .fips import (
is_fips_enabled, enable_userspace_fips, disable_userspace_fips
)
from .transport import IPAOpenSSHTransport
from .resolver import resolver
FIPS_NOISE_RE = re.compile(br"FIPS mode initialized\r?\n?")
@@ -78,6 +79,7 @@ class Host(pytest_multihost.host.Host):
)
self._fips_mode = None
self._userspace_fips = False
self.resolver = resolver(self)
@property
def is_fips_mode(self):
@@ -204,6 +206,11 @@ class Host(pytest_multihost.host.Host):
else:
return result
def spawn_expect(self, argv, default_timeout=10, encoding='utf-8',
extra_ssh_options=None):
"""Run command on remote host using IpaTestExpect"""
return self.transport.spawn_expect(argv, default_timeout, encoding,
extra_ssh_options)
class WinHost(pytest_multihost.host.WinHost):
"""

View File

@@ -0,0 +1,353 @@
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')

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@ Provides SSH password login for OpenSSH transport
"""
import os
from .expect import IpaTestExpect
from pytest_multihost.transport import OpenSSHTransport
@@ -46,3 +48,12 @@ class IPAOpenSSHTransport(OpenSSHTransport):
self.log.debug("SSH invocation: %s", argv)
return argv
def spawn_expect(self, argv, default_timeout, encoding, extra_ssh_options):
self.log.debug('Starting pexpect ssh session')
if isinstance(argv, str):
argv = [argv]
if extra_ssh_options is None:
extra_ssh_options = []
argv = self._get_ssh_argv() + ['-q'] + extra_ssh_options + argv
return IpaTestExpect(argv, default_timeout, encoding)