diff --git a/test.py b/test.py index 4c29ebd5aa..2fdba11d04 100755 --- a/test.py +++ b/test.py @@ -37,6 +37,7 @@ from abc import ABC, abstractmethod from io import StringIO from scripts import coverage # type: ignore from test.pylib.artifact_registry import ArtifactRegistry +from test.pylib.cpp.ldap.prepare_instance import try_something_backoff, can_connect, setup from test.pylib.host_registry import HostRegistry from test.pylib.pool import Pool from test.pylib.s3_proxy import S3ProxyServer @@ -62,72 +63,6 @@ all_modes = {'debug': 'Debug', 'coverage': 'Coverage'} debug_modes = {'debug', 'sanitize'} -LDAP_SERVER_CONFIGURATION_FILE = os.path.join(os.path.dirname(__file__), 'test', 'resource', 'slapd.conf') - -DEFAULT_ENTRIES = [ - """dn: dc=example,dc=com -objectClass: dcObject -objectClass: organization -dc: example -o: Example -description: Example directory. -""", - """dn: cn=root,dc=example,dc=com -objectClass: organizationalRole -cn: root -description: Directory manager. -""", - """dn: ou=People,dc=example,dc=com -objectClass: organizationalUnit -ou: People -description: Our people. -""", - """# Default superuser for Scylla -dn: uid=cassandra,ou=People,dc=example,dc=com -objectClass: organizationalPerson -objectClass: uidObject -cn: cassandra -ou: People -sn: cassandra -userid: cassandra -userPassword: cassandra -""", - """dn: uid=jsmith,ou=People,dc=example,dc=com -objectClass: organizationalPerson -objectClass: uidObject -cn: Joe Smith -ou: People -sn: Smith -userid: jsmith -userPassword: joeisgreat -""", - """dn: uid=jdoe,ou=People,dc=example,dc=com -objectClass: organizationalPerson -objectClass: uidObject -cn: John Doe -ou: People -sn: Doe -userid: jdoe -userPassword: pa55w0rd -""", - """dn: cn=role1,dc=example,dc=com -objectClass: groupOfUniqueNames -cn: role1 -uniqueMember: uid=jsmith,ou=People,dc=example,dc=com -uniqueMember: uid=cassandra,ou=People,dc=example,dc=com -""", - """dn: cn=role2,dc=example,dc=com -objectClass: groupOfUniqueNames -cn: role2 -uniqueMember: uid=cassandra,ou=People,dc=example,dc=com -""", - """dn: cn=role3,dc=example,dc=com -objectClass: groupOfUniqueNames -cn: role3 -uniqueMember: uid=jdoe,ou=People,dc=example,dc=com -""", -] - def create_formatter(*decorators) -> Callable[[Any], str]: """Return a function which decorates its argument with the given @@ -975,37 +910,6 @@ class BoostTest(Test): print(read_log(self.log_filename)) -def can_connect(address, family=socket.AF_INET): - s = socket.socket(family) - try: - s.connect(address) - return True - except OSError as e: - if 'AF_UNIX path too long' in str(e): - raise OSError(e.errno, "{} ({})".format(str(e), address)) from None - else: - return False - except: - return False - - -def try_something_backoff(something): - sleep_time = 0.05 - while not something(): - if sleep_time > 30: - return False - time.sleep(sleep_time) - sleep_time *= 2 - return True - -def make_saslauthd_conf(port, instance_path): - """Creates saslauthd.conf with appropriate contents under instance_path. Returns the path to the new file.""" - saslauthd_conf_path = os.path.join(instance_path, 'saslauthd.conf') - with open(saslauthd_conf_path, 'w') as f: - f.write('ldap_servers: ldap://localhost:{}\nldap_search_base: dc=example,dc=com'.format(port)) - return saslauthd_conf_path - - class LdapTest(BoostTest): """A unit test which can produce its own XML output, and needs an ldap server""" @@ -1013,73 +917,10 @@ class LdapTest(BoostTest): super().__init__(test_no, shortname, args, suite, None, False, None) async def setup(self, port, options): - instances_root = os.path.join(options.tmpdir, self.mode, 'ldap_instances'); - instance_path = os.path.join(os.path.abspath(instances_root), str(port)) - slapd_pid_file = os.path.join(instance_path, 'slapd.pid') - saslauthd_socket_path = TemporaryDirectory() - os.makedirs(instance_path, exist_ok=True) - # This will always fail because it lacks the permissions to read the default slapd data - # folder but it does create the instance folder so we don't want to fail here. - try: - subprocess.check_output(['slaptest', '-f', LDAP_SERVER_CONFIGURATION_FILE, '-F', instance_path], - stderr=subprocess.DEVNULL) - except: - pass - # Set up failure injection. - proxy_name = 'p{}'.format(port) - subprocess.check_output([ - 'toxiproxy-cli', 'c', proxy_name, - '--listen', 'localhost:{}'.format(port + 2), '--upstream', 'localhost:{}'.format(port)]) - # Sever the connection after byte_limit bytes have passed through: + instances_root = pathlib.Path(options.tmpdir) / self.mode / 'ldap_instances' byte_limit = options.byte_limit if options.byte_limit else randint(0, 2000) - subprocess.check_output(['toxiproxy-cli', 't', 'a', proxy_name, '-t', 'limit_data', '-n', 'limiter', - '-a', 'bytes={}'.format(byte_limit)]) - # Change the data folder in the default config. - replace_expression = 's/olcDbDirectory:.*/olcDbDirectory: {}/g'.format( - os.path.abspath(instance_path).replace('/', r'\/')) - subprocess.check_output( - ['find', instance_path, '-type', 'f', '-exec', 'sed', '-i', replace_expression, '{}', ';']) - # Change the pid file to be kept with the instance. - replace_expression = 's/olcPidFile:.*/olcPidFile: {}/g'.format( - os.path.abspath(slapd_pid_file).replace('/', r'\/')) - subprocess.check_output( - ['find', instance_path, '-type', 'f', '-exec', 'sed', '-i', replace_expression, '{}', ';']) - # Put the test data in. - cmd = ['slapadd', '-F', instance_path] - subprocess.check_output( - cmd, input='\n\n'.join(DEFAULT_ENTRIES).encode('ascii'), stderr=subprocess.STDOUT) - # Set up the server. - SLAPD_URLS='ldap://:{}/ ldaps://:{}/'.format(port, port + 1) - def can_connect_to_slapd(): - return can_connect(('127.0.0.1', port)) and can_connect(('127.0.0.1', port + 1)) and can_connect(('127.0.0.1', port + 2)) - def can_connect_to_saslauthd(): - return can_connect(os.path.join(saslauthd_socket_path.name, 'mux'), socket.AF_UNIX) - slapd_proc = subprocess.Popen(['prlimit', '-n1024', 'slapd', '-F', instance_path, '-h', SLAPD_URLS, '-d', '0']) - saslauthd_conf_path = make_saslauthd_conf(port, instance_path) - test_env = { - "SEASTAR_LDAP_PORT" : str(port), - "SASLAUTHD_MUX_PATH" : os.path.join(saslauthd_socket_path.name, "mux") - } - - saslauthd_proc = subprocess.Popen( - ['saslauthd', '-d', '-n', '1', '-a', 'ldap', '-O', saslauthd_conf_path, '-m', saslauthd_socket_path.name], - stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) - def finalize(): - slapd_proc.terminate() - slapd_proc.wait() # Wait for slapd to remove slapd.pid, so it doesn't race with rmtree below. - saslauthd_proc.kill() # Somehow, invoking terminate() here also terminates toxiproxy-server. o_O - shutil.rmtree(instance_path) - saslauthd_socket_path.cleanup() - subprocess.check_output(['toxiproxy-cli', 'd', proxy_name]) - try: - if not try_something_backoff(can_connect_to_slapd): - raise Exception('Unable to connect to slapd') - if not try_something_backoff(can_connect_to_saslauthd): - raise Exception('Unable to connect to saslauthd') - except: - finalize() - raise - return finalize, '--byte-limit={}'.format(byte_limit), test_env + project_root = pathlib.Path(os.path.dirname(__file__)) + return setup(project_root=project_root, port=port, instance_root=instances_root, byte_limit=byte_limit) class CQLApprovalTest(Test): diff --git a/test/ldap/__init__.py b/test/ldap/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/ldap/conftest.py b/test/ldap/conftest.py new file mode 100644 index 0000000000..3d41836866 --- /dev/null +++ b/test/ldap/conftest.py @@ -0,0 +1,54 @@ +# +# Copyright (C) 2025-present ScyllaDB +# +# SPDX-License-Identifier: LicenseRef-ScyllaDB-Source-Available-1.0 +# +import sys +from pathlib import PosixPath +from random import randint + +import pytest +from pytest import Collector + +from test.pylib.cpp.boost.boost_facade import BoostTestFacade +from test.pylib.cpp.ldap.prepare_instance import get_env_manager +from test.pylib.cpp.common_cpp_conftest import collect_items, get_modes_to_run, get_root_path, get_combined_tests + + +def pytest_addoption(parser): + parser.addoption('--byte-limit', action="store", default=None, type=int, + help="Specific byte limit for failure injection (random by default)") + + +def pytest_collect_file(file_path: PosixPath, parent: Collector): + """ + Method triggered automatically by pytest to collect files from a directory. + These tests can use BoostFacade since they're Boost tests located in different directory. + """ + if file_path.suffix == '.cc': + return collect_items(file_path, parent, facade=BoostTestFacade(parent.config)) + + +@pytest.hookimpl(wrapper=True) +def pytest_runtestloop(session): + """ + https://docs.pytest.org/en/stable/reference/reference.html#pytest.hookspec.pytest_runtestloop + This hook is needed to start the Minio and S3 mock servers before tests. After starting the servers, the default + pytest's runtestloop takes control. Finally part is responsible for stopping servers regardless of failure in the tests. + """ + # make a collection only without starting unnecessary services + if session.config.getoption('collectonly'): + yield + return + root_dir = get_root_path(session) + temp_dir = root_dir / session.config.getoption('tmpdir') + try: + byte_limit = session.config.getoption('byte-limit') + except ValueError: + byte_limit = randint(0, 2000) + modes = get_modes_to_run(session) + worker_id = 'master' + if 'xdist' in sys.modules: + worker_id = sys.modules['xdist'].get_xdist_worker_id(session) + with get_env_manager(root_dir, temp_dir, worker_id, modes, byte_limit): + yield diff --git a/test/pylib/cpp/ldap/README.md b/test/pylib/cpp/ldap/README.md new file mode 100644 index 0000000000..839813a3c5 --- /dev/null +++ b/test/pylib/cpp/ldap/README.md @@ -0,0 +1,17 @@ +# Running tests with pytest + +To run test with pytest execute +```bash +pytest test/ldap +``` +To execute only one file, provide the path filename +```bash +pytest test/unit/role_manager_test.cc +``` +Since it's a normal path, autocompletion works in the terminal out of the box. + +To provide a specific mode, use the next parameter `--mode dev`, +if parameter isn't provided pytest tries to use `ninja mode_list` to find out the compiled modes. + +Parallel execution is controlled by `pytest-xdist` and the parameter `-n auto`. +This command starts tests with the number of workers equal to CPU cores. diff --git a/test/pylib/cpp/ldap/__init__.py b/test/pylib/cpp/ldap/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/pylib/cpp/ldap/prepare_instance.py b/test/pylib/cpp/ldap/prepare_instance.py new file mode 100644 index 0000000000..4bb0b456be --- /dev/null +++ b/test/pylib/cpp/ldap/prepare_instance.py @@ -0,0 +1,318 @@ +# +# Copyright (C) 2025-present ScyllaDB +# +# SPDX-License-Identifier: LicenseRef-ScyllaDB-Source-Available-1.0 +# +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import time +from contextlib import contextmanager +from pathlib import Path +from tempfile import TemporaryDirectory +from time import sleep +from typing import Any, Generator + +LDAP_SERVER_CONFIGURATION_FILE = Path('test', 'resource', 'slapd.conf') +DEFAULT_ENTRIES = ["""dn: dc=example,dc=com +objectClass: dcObject +objectClass: organization +dc: example +o: Example +description: Example directory. +""", """dn: cn=root,dc=example,dc=com +objectClass: organizationalRole +cn: root +description: Directory manager. +""", """dn: ou=People,dc=example,dc=com +objectClass: organizationalUnit +ou: People +description: Our people. +""", """# Default superuser for Scylla +dn: uid=cassandra,ou=People,dc=example,dc=com +objectClass: organizationalPerson +objectClass: uidObject +cn: cassandra +ou: People +sn: cassandra +userid: cassandra +userPassword: cassandra +""", """dn: uid=jsmith,ou=People,dc=example,dc=com +objectClass: organizationalPerson +objectClass: uidObject +cn: Joe Smith +ou: People +sn: Smith +userid: jsmith +userPassword: joeisgreat +""", """dn: uid=jdoe,ou=People,dc=example,dc=com +objectClass: organizationalPerson +objectClass: uidObject +cn: John Doe +ou: People +sn: Doe +userid: jdoe +userPassword: pa55w0rd +""", """dn: cn=role1,dc=example,dc=com +objectClass: groupOfUniqueNames +cn: role1 +uniqueMember: uid=jsmith,ou=People,dc=example,dc=com +uniqueMember: uid=cassandra,ou=People,dc=example,dc=com +""", """dn: cn=role2,dc=example,dc=com +objectClass: groupOfUniqueNames +cn: role2 +uniqueMember: uid=cassandra,ou=People,dc=example,dc=com +""", """dn: cn=role3,dc=example,dc=com +objectClass: groupOfUniqueNames +cn: role3 +uniqueMember: uid=jdoe,ou=People,dc=example,dc=com +""", ] + + +class PrepareChildProcessEnv: + """ + Class responsible to get environment variables from the main thread through the shared file and set them for the process + """ + + def __init__(self, root_dir, temp_dir: Path, modes: list[str], env_file: Path, byte_limit: int, worker_id): + self.id = int(worker_id[2:]) + 1 + self.temp_dir = temp_dir + self.modes = modes + self.root_dir = root_dir + self.byte_limit = byte_limit + self.env_file = env_file + self.finalize = None + + def prepare(self) -> None: + """ + Setup LDAP proxy and set environment variables + """ + ldap_port = 5000 + (self.id * 3) % 55000 + + timeout = 10 + sleep_for = 0.01 + start_time = time.time() + while True: + if os.path.exists(self.env_file): + (self.finalize, _, test_env) = setup(self.root_dir, ldap_port, self.temp_dir / 'ldap_instances', + self.byte_limit) + + for key, value in test_env.items(): + os.environ[key] = value + break + + if time.time() - start_time > timeout: + raise TimeoutError(f"Timeout waiting for file {self.env_file}") + # Sleep needed to wait when the controller will create a file with environment variables. + # Without sleep checking of the file existence will be too fast, + # so it will finish before the file is created + time.sleep(sleep_for) + sleep_for *= 2 + + + def cleanup(self) -> None: + """ + Stop LDAP + """ + if self.finalize: + self.finalize() + + def __enter__(self): + try: + self.prepare() + except Exception: + self.cleanup() + raise + + def __exit__(self, exc_type, exc_val, exc_tb): + self.cleanup() + + +class PrepareMainProcessEnv: + """ + A class responsible for starting additional services needed by tests. + + It starts up a Minio server and an S3 mock server. + The environment settings are saved to a file for later consumption by child processes. + """ + + def __init__(self, root_dir, temp_dir: Path, modes: list[str], env_file: Path, byte_limit: int): + self.temp_dir = temp_dir + self.modes = modes + self.root_dir = root_dir + pytest_dirs = [self.temp_dir / mode / 'pytest' for mode in modes] + for directory in [self.temp_dir, *pytest_dirs]: + if not directory.exists(): + os.makedirs(directory, exist_ok=True) + self.env_file = env_file + self.tp_server = subprocess.Popen('toxiproxy-server', stderr=subprocess.DEVNULL) + + def can_connect_to_toxiproxy(): + return can_connect(('127.0.0.1', 8474)) + + if not try_something_backoff(can_connect_to_toxiproxy): + raise Exception('Could not connect to toxiproxy') + self.ldap_port = 5000 + self.byte_limit = byte_limit + self.finalize = None + + def prepare(self) -> None: + """ + Start the LDAP. + Create a file with environment variables for connecting to them. + """ + (self.finalize, _, test_env) = setup(self.root_dir, self.ldap_port, self.temp_dir / 'ldap_instances', + self.byte_limit) + + for key, value in test_env.items(): + os.environ[key] = value + + with open(self.env_file, 'w') as file: + for key, value in test_env.items(): + file.write(f"{key}={value}\n") + + def cleanup(self) -> None: + """ + Stop LDAP. + Remove the file with environment variables to not mess for consecutive runs. + """ + if os.path.exists(self.env_file): + self.env_file.unlink() + if self.finalize: + self.finalize() + if self.tp_server is not None: + self.tp_server.terminate() + + def __enter__(self): + try: + self.prepare() + except Exception: + self.cleanup() + raise + + def __exit__(self, exc_type, exc_val, exc_tb): + self.cleanup() + + +@contextmanager +def get_env_manager(root_dir: Path, temp_dir: Path, worker_id: str, modes: list[str], + byte_limit: int) -> Generator[None, Any, None]: + """ + xdist helps to execute test in parallel. + For that purpose it creates one main controller and workers. + Pytest itself doesn't know if it's a worker or controller, so it will execute all fixtures and methods. + Tests need S3 mock server and minio to start only once for the whole run, since they can share the one instance and + share the environment variables with workers. + + So the part of starting the servers executes on non-workers' machines. + That means when xdist isn't used, servers start as intended in the main process. + Tests on workers should know the endpoints of the servers, so the controller prepares this information. + According classes responsible for configuration controller and workers. + """ + env_file = Path(f"{temp_dir}/test_env").absolute() + if worker_id != 'master': + with PrepareChildProcessEnv(root_dir, temp_dir, modes, env_file, byte_limit, worker_id): + yield + else: + with PrepareMainProcessEnv(root_dir, temp_dir, modes, env_file, byte_limit): + yield + + +def can_connect(address, family=socket.AF_INET): + s = socket.socket(family) + try: + s.connect(address) + return True + except OSError as e: + if 'AF_UNIX path too long' in str(e): + raise OSError(e.errno, "{} ({})".format(str(e), address)) from None + else: + return False + except: + return False + + +def try_something_backoff(something): + sleep_time = 0.05 + while not something(): + if sleep_time > 30: + return False + time.sleep(sleep_time) + sleep_time *= 2 + return True + + +def make_saslauthd_conf(port, instance_path): + """Creates saslauthd.conf with appropriate contents under instance_path. Returns the path to the new file.""" + saslauthd_conf_path = os.path.join(instance_path, 'saslauthd.conf') + with open(saslauthd_conf_path, 'w') as f: + f.write('ldap_servers: ldap://localhost:{}\nldap_search_base: dc=example,dc=com'.format(port)) + return saslauthd_conf_path + + +def setup(project_root: Path, port: int, instance_root: Path, byte_limit: int): + instance_path = instance_root / str(port) + slapd_pid_file = instance_path / 'slapd.pid' + saslauthd_socket_path = TemporaryDirectory() + os.makedirs(instance_path, exist_ok=True) + # This will always fail because it lacks the permissions to read the default slapd data + # folder but it does create the instance folder so we don't want to fail here. + try: + subprocess.check_output(['slaptest', '-f', project_root / LDAP_SERVER_CONFIGURATION_FILE, '-F', instance_path], + stderr=subprocess.DEVNULL) + except: + pass + # Set up failure injection. + proxy_name = 'p{}'.format(port) + subprocess.check_output( + ['toxiproxy-cli', 'c', proxy_name, '--listen', 'localhost:{}'.format(port + 2), '--upstream', + 'localhost:{}'.format(port)]) + subprocess.check_output(['toxiproxy-cli', 't', 'a', proxy_name, '-t', 'limit_data', '-n', 'limiter', '-a', + 'bytes={}'.format(byte_limit)]) + # Change the data folder in the default config. + replace_expression = 's/olcDbDirectory:.*/olcDbDirectory: {}/g'.format(str(instance_path).replace('/', r'\/')) + subprocess.check_output(['find', instance_path, '-type', 'f', '-exec', 'sed', '-i', replace_expression, '{}', ';']) + # Change the pid file to be kept with the instance. + replace_expression = 's/olcPidFile:.*/olcPidFile: {}/g'.format(str(slapd_pid_file).replace('/', r'\/')) + subprocess.check_output(['find', instance_path, '-type', 'f', '-exec', 'sed', '-i', replace_expression, '{}', ';']) + # Put the test data in. + cmd = ['slapadd', '-F', instance_path] + subprocess.check_output(cmd, input='\n\n'.join(DEFAULT_ENTRIES).encode('ascii'), stderr=subprocess.STDOUT) + # Set up the server. + SLAPD_URLS = 'ldap://:{}/ ldaps://:{}/'.format(port, port + 1) + + def can_connect_to_slapd(): + return can_connect(('127.0.0.1', port)) and can_connect(('127.0.0.1', port + 1)) and can_connect( + ('127.0.0.1', port + 2)) + + def can_connect_to_saslauthd(): + return can_connect(os.path.join(saslauthd_socket_path.name, 'mux'), socket.AF_UNIX) + + slapd_proc = subprocess.Popen(['prlimit', '-n1024', 'slapd', '-F', instance_path, '-h', SLAPD_URLS, '-d', '0']) + saslauthd_conf_path = make_saslauthd_conf(port, instance_path) + test_env = {"SEASTAR_LDAP_PORT": str(port), "SASLAUTHD_MUX_PATH": os.path.join(saslauthd_socket_path.name, "mux")} + + saslauthd_proc = subprocess.Popen( + ['saslauthd', '-d', '-n', '1', '-a', 'ldap', '-O', saslauthd_conf_path, '-m', saslauthd_socket_path.name], + stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) + + def finalize(): + slapd_proc.terminate() + slapd_proc.wait() # Wait for slapd to remove slapd.pid, so it doesn't race with rmtree below. + saslauthd_proc.kill() # Somehow, invoking terminate() here also terminates toxiproxy-server. o_O + shutil.rmtree(instance_path) + saslauthd_socket_path.cleanup() + subprocess.check_output(['toxiproxy-cli', 'd', proxy_name]) + + try: + if not try_something_backoff(can_connect_to_slapd): + raise Exception('Unable to connect to slapd') + if not try_something_backoff(can_connect_to_saslauthd): + raise Exception('Unable to connect to saslauthd') + except: + finalize() + raise + return finalize, '--byte-limit={}'.format(byte_limit), test_env