diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 5355c05..ca8ee8a 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -26,9 +26,10 @@ jobs: - name: Fetch tags run: git fetch --tags --prune --unshallow - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: pip - uses: shogo82148/actions-setup-redis@v1 with: redis-version: "7.x" @@ -36,14 +37,8 @@ jobs: run: | # sudo apt install redis - pushd .. - git clone https://github.com/bitnami/containers.git - cd containers/bitnami/openldap/2.6/debian-12 - docker build -t bitnami/openldap:latest . - popd - - # Start LDAP - source continuous_integration/scripts/start_LDAP.sh + # Start LDAP in background (wait/seed later) + bash continuous_integration/scripts/start_LDAP.sh --start-only # These packages are installed in the base environment but may be older # versions. Explicitly upgrade them because they often create @@ -62,14 +57,29 @@ jobs: pip install . popd - pip install --upgrade pip pip install . pip install -r requirements-dev.txt # pip install "pydantic${{ matrix.pydantic-version }}" # pip install bluesky==1.11.0 + # Wait for LDAP readiness and seed test users + bash continuous_integration/scripts/start_LDAP.sh --wait-seed + pip list - name: Test with pytest + env: + PYTEST_ADDOPTS: "--durations=20 -n auto --dist=loadfile" run: | coverage run -m pytest -vv coverage report -m + - name: Dump LDAP diagnostics on failure + if: failure() + run: | + docker ps + docker compose -f continuous_integration/docker-configs/ldap-docker-compose.yml ps + LDAP_CONTAINER_ID=$(docker compose -f continuous_integration/docker-configs/ldap-docker-compose.yml ps -q openldap | tr -d '[:space:]') + if [ -n "$LDAP_CONTAINER_ID" ]; then + docker logs --tail 200 "$LDAP_CONTAINER_ID" + else + docker compose -f continuous_integration/docker-configs/ldap-docker-compose.yml logs --tail 200 openldap + fi diff --git a/bluesky_httpserver/tests/conftest.py b/bluesky_httpserver/tests/conftest.py index d5cafdb..b280992 100644 --- a/bluesky_httpserver/tests/conftest.py +++ b/bluesky_httpserver/tests/conftest.py @@ -1,10 +1,17 @@ import os +import sys import time as ttime import pytest import requests -from bluesky_queueserver.manager.comms import zmq_single_request from bluesky_queueserver.manager.tests.common import set_qserver_zmq_encoding # noqa: F401 +from bluesky_queueserver.manager.tests.common import ( + ReManager, + condition_manager_idle, + copy_default_profile_collection, + wait_for_condition, + zmq_secure_request, +) from xprocess import ProcessStarter import bluesky_httpserver.server as bqss @@ -12,6 +19,120 @@ SERVER_ADDRESS = "localhost" SERVER_PORT = "60610" + +def _worker_index(): + worker = os.environ.get("PYTEST_XDIST_WORKER", "") + if worker.startswith("gw"): + try: + return int(worker[2:]) + except ValueError: + return 0 + return 0 + + +def _worker_name(): + return os.environ.get("PYTEST_XDIST_WORKER", "local") + + +def _ports_for_worker(): + # Avoid the queue-server default ports (60615/60625), which may already be + # occupied in shared/dev environments. + base = 62000 + _worker_index() * 100 + return { + "server_port": str(base + 10), + "zmq_control_server": f"tcp://*:{base + 15}", + "zmq_control_client": f"tcp://localhost:{base + 15}", + "zmq_info_server": f"tcp://*:{base + 25}", + "zmq_info_client": f"tcp://localhost:{base + 25}", + } + + +def _redis_name_prefix(*, scope, sequence=None): + parts = ["qs_unit_tests_httpserver", _worker_name(), scope] + if sequence is not None: + parts.append(str(sequence)) + return "_".join(parts) + + +def _get_cli_option_value(params, option): + option_with_eq = f"{option}=" + for n, value in enumerate(params): + if value.startswith(option_with_eq): + return value[len(option_with_eq) :] + if value == option and n + 1 < len(params): + return params[n + 1] + return None + + +def _server_to_client_zmq_addr(addr): + if addr.startswith("tcp://*:"): + return f"tcp://localhost:{addr.rsplit(':', 1)[1]}" + if addr.startswith("tcp://0.0.0.0:"): + return f"tcp://localhost:{addr.rsplit(':', 1)[1]}" + return addr + + +def _set_zmq_env(control_addr, info_addr): + os.environ["QSERVER_ZMQ_CONTROL_ADDRESS"] = control_addr + os.environ["QSERVER_ZMQ_INFO_ADDRESS"] = info_addr + os.environ["_TEST_QSERVER_ZMQ_ADDRESS_"] = control_addr + + +def _ensure_manager_addresses_in_params(params): + ports = _ports_for_worker() + + control_addr = _get_cli_option_value(params, "--zmq-control-addr") + if control_addr is None: + control_addr = ports["zmq_control_server"] + params.append(f"--zmq-control-addr={control_addr}") + + info_addr = _get_cli_option_value(params, "--zmq-info-addr") + if info_addr is None: + info_addr = ports["zmq_info_server"] + params.append(f"--zmq-info-addr={info_addr}") + + return { + "control_server": control_addr, + "control_client": _server_to_client_zmq_addr(control_addr), + "info_server": info_addr, + "info_client": _server_to_client_zmq_addr(info_addr), + } + + +def _xprocess_name(name): + worker = os.environ.get("PYTEST_XDIST_WORKER", "local") + return f"{name}_{worker}" + + +def pytest_configure(config): + del config + global SERVER_PORT + ports = _ports_for_worker() + SERVER_PORT = ports["server_port"] + os.environ["QSERVER_ZMQ_CONTROL_ADDRESS"] = ports["zmq_control_client"] + os.environ["QSERVER_ZMQ_INFO_ADDRESS"] = ports["zmq_info_client"] + os.environ["_TEST_QSERVER_ZMQ_ADDRESS_"] = ports["zmq_control_client"] + + +def _wait_for_manager_ready(timeout=10): + if not wait_for_condition(time=timeout, condition=condition_manager_idle): + raise TimeoutError("Timeout: RE Manager failed to start.") + + +def _reset_queue_mode(): + resp, msg = zmq_secure_request("queue_mode_set", params={"mode": "default"}) + if not resp or resp.get("success") is not True: + raise RuntimeError(msg) + + +def _reset_queue_mode_and_clear_queue(): + _reset_queue_mode() + + resp, msg = zmq_secure_request("queue_clear") + if not resp or resp.get("success") is not True: + raise RuntimeError(msg) + + # Single-user API key used for most of the tests API_KEY_FOR_TESTS = "APIKEYFORTESTS" @@ -44,12 +165,13 @@ class Starter(ProcessStarter): args = f"uvicorn --host={SERVER_ADDRESS} --port {SERVER_PORT} {bqss.__name__}:app".split() # args = f"start-bluesky-httpserver --host={SERVER_ADDRESS} --port {SERVER_PORT}".split() - xprocess.ensure("fastapi_server", Starter) + proc_name = _xprocess_name("fastapi_server") + xprocess.ensure(proc_name, Starter) _wait_for_http_server_ready() yield - xprocess.getinfo("fastapi_server").terminate() + xprocess.getinfo(proc_name).terminate() @pytest.fixture @@ -60,7 +182,11 @@ def fastapi_server_fs(xprocess): to perform additional steps (such as setting environmental variables) before the server is started. """ - def start(http_server_host=SERVER_ADDRESS, http_server_port=SERVER_PORT, api_key=API_KEY_FOR_TESTS): + def start( + http_server_host=SERVER_ADDRESS, + http_server_port=SERVER_PORT, + api_key=API_KEY_FOR_TESTS, + ): class Starter(ProcessStarter): max_read_lines = 53 @@ -71,12 +197,182 @@ class Starter(ProcessStarter): pattern = "Bluesky HTTP Server started successfully" args = f"uvicorn --host={http_server_host} --port {http_server_port} {bqss.__name__}:app".split() - xprocess.ensure("fastapi_server", Starter) + proc_name = _xprocess_name("fastapi_server") + xprocess.ensure(proc_name, Starter) _wait_for_http_server_ready() yield start - xprocess.getinfo("fastapi_server").terminate() + xprocess.getinfo(_xprocess_name("fastapi_server")).terminate() + + +@pytest.fixture +def re_manager(): # noqa: F811 + ports = _ports_for_worker() + + _set_zmq_env(ports["zmq_control_client"], ports["zmq_info_client"]) + + manager = ReManager( + params=[ + f"--zmq-control-addr={ports['zmq_control_server']}", + f"--zmq-info-addr={ports['zmq_info_server']}", + f"--redis-name-prefix={_redis_name_prefix(scope='re_manager')}", + ], + set_redis_name_prefix=False, + ) + failed_to_start = False + + try: + _wait_for_manager_ready() + _reset_queue_mode_and_clear_queue() + yield manager + except Exception: + failed_to_start = True + raise + finally: + if failed_to_start: + manager.kill_manager() + else: + try: + manager.stop_manager(timeout=30) + except Exception: + manager.kill_manager() + + +@pytest.fixture(scope="module") +def re_manager_module(): + ports = _ports_for_worker() + _set_zmq_env(ports["zmq_control_client"], ports["zmq_info_client"]) + + manager = ReManager( + params=[ + f"--zmq-control-addr={ports['zmq_control_server']}", + f"--zmq-info-addr={ports['zmq_info_server']}", + f"--redis-name-prefix={_redis_name_prefix(scope='re_manager_module')}", + ], + set_redis_name_prefix=False, + ) + failed_to_start = False + + try: + _wait_for_manager_ready() + _reset_queue_mode_and_clear_queue() + yield manager + except Exception: + failed_to_start = True + raise + finally: + if failed_to_start: + manager.kill_manager() + else: + try: + manager.stop_manager(timeout=30) + except Exception: + manager.kill_manager() + + +@pytest.fixture +def re_manager_pc_copy(tmp_path): # noqa: F811 + ports = _ports_for_worker() + _set_zmq_env(ports["zmq_control_client"], ports["zmq_info_client"]) + + pc_path = copy_default_profile_collection(tmp_path) + + manager = ReManager( + params=[ + f"--startup-dir={pc_path}", + f"--zmq-control-addr={ports['zmq_control_server']}", + f"--zmq-info-addr={ports['zmq_info_server']}", + f"--redis-name-prefix={_redis_name_prefix(scope='re_manager_pc_copy')}", + ], + set_redis_name_prefix=False, + ) + failed_to_start = False + + try: + _wait_for_manager_ready() + yield pc_path + except Exception: + failed_to_start = True + raise + finally: + if failed_to_start: + manager.kill_manager() + else: + try: + manager.stop_manager(timeout=30) + except Exception: + manager.kill_manager() + + +@pytest.fixture +def re_manager_cmd(): # noqa: F811 + manager = None + failed_to_start = False + manager_sequence = 0 + + def _close_manager(): + nonlocal manager + + if manager is None: + return + + if failed_to_start: + try: + manager.kill_manager() + except Exception: + pass + manager = None + return + + try: + manager.stop_manager(timeout=30) + except Exception: + try: + manager.kill_manager() + except Exception: + pass + finally: + manager = None + + def create_re_manager(params=None, *, stdout=sys.stdout, stderr=sys.stdout, set_redis_name_prefix=True): + nonlocal manager, failed_to_start, manager_sequence + + failed_to_start = False + manager_sequence += 1 + + _close_manager() + + params = list(params or []) + addrs = _ensure_manager_addresses_in_params(params) + _set_zmq_env(addrs["control_client"], addrs["info_client"]) + + # Always force per-worker/per-create Redis prefixes to avoid collisions in parallel runs. + if _get_cli_option_value(params, "--redis-name-prefix") is None: + params.append( + f"--redis-name-prefix={_redis_name_prefix(scope='re_manager_cmd', sequence=manager_sequence)}" + ) + # We explicitly manage the Redis name prefix and do not want defaults from upstream fixture logic. + set_redis_name_prefix = False + + manager = ReManager( + params=params, + stdout=stdout, + stderr=stderr, + set_redis_name_prefix=set_redis_name_prefix, + ) + + if not wait_for_condition(time=10, condition=condition_manager_idle): + failed_to_start = True + manager.kill_manager() + raise TimeoutError("Timeout: RE Manager failed to start.") + + _reset_queue_mode() + return manager + + yield create_re_manager + + _close_manager() def setup_server_with_config_file(*, config_file_str, tmpdir, monkeypatch): @@ -107,20 +403,32 @@ def add_plans_to_queue(): Clear the queue and add 3 fixed plans to the queue. Raises an exception if clearing the queue or adding plans fails. """ - resp1, _ = zmq_single_request("queue_clear") - assert resp1["success"] is True, str(resp1) + resp1, _ = zmq_secure_request("queue_clear") + assert resp1 and (resp1.get("success") is True), str(resp1) user_group = _user_group user = "HTTP unit test setup" - plan1 = {"name": "count", "args": [["det1", "det2"]], "kwargs": {"num": 10, "delay": 1}, "item_type": "plan"} + plan1 = { + "name": "count", + "args": [["det1", "det2"]], + "kwargs": {"num": 10, "delay": 1}, + "item_type": "plan", + } plan2 = {"name": "count", "args": [["det1", "det2"]], "item_type": "plan"} for plan in (plan1, plan2, plan2): - resp2, _ = zmq_single_request("queue_item_add", {"item": plan, "user": user, "user_group": user_group}) - assert resp2["success"] is True, str(resp2) + resp2, _ = zmq_secure_request("queue_item_add", {"item": plan, "user": user, "user_group": user_group}) + assert resp2 and (resp2.get("success") is True), str(resp2) def request_to_json( - request_type, path, *, request_prefix="/api", api_key=API_KEY_FOR_TESTS, token=None, login=None, **kwargs + request_type, + path, + *, + request_prefix="/api", + api_key=API_KEY_FOR_TESTS, + token=None, + login=None, + **kwargs, ): if login: auth = None diff --git a/bluesky_httpserver/tests/test_access_control.py b/bluesky_httpserver/tests/test_access_control.py index e6afdf0..55a0002 100644 --- a/bluesky_httpserver/tests/test_access_control.py +++ b/bluesky_httpserver/tests/test_access_control.py @@ -3,7 +3,7 @@ import pprint import pytest -from bluesky_queueserver.manager.tests.common import re_manager, re_manager_cmd # noqa F401 +from bluesky_queueserver.manager.tests.common import zmq_secure_request from bluesky_httpserver.authorization._defaults import ( _DEFAULT_RESOURCE_ACCESS_GROUP, @@ -20,6 +20,7 @@ ) from .conftest import fastapi_server_fs # noqa: F401 +from .conftest import re_manager_module # noqa: F401 from .conftest import request_to_json, setup_server_with_config_file config_noauth_with_anonymous_access = """ @@ -211,10 +212,22 @@ (config_toy_with_anonymous_access, "", False, True, False), (config_toy_without_anonymous_access, "", False, False, False), (config_noauth_with_anonymous_access, authorization_dict, True, True, False), - (config_noauth_without_anonymous_access, authorization_dict, True, False, False), + ( + config_noauth_without_anonymous_access, + authorization_dict, + True, + False, + False, + ), (config_noauth_with_anonymous_access, "", True, True, False), (config_noauth_without_anonymous_access, "", True, False, False), - ("", authorization_dict, True, False, False), # No authentication settings in config + ( + "", + authorization_dict, + True, + False, + False, + ), # No authentication settings in config ("", "", True, False, False), # No config file ], ) @@ -222,7 +235,7 @@ def test_authentication_and_authorization_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 cfg, access_cfg, @@ -249,7 +262,9 @@ def test_authentication_and_authorization_01( api_access_set = "api_access" in config if config: - setup_server_with_config_file(config_file_str=config, tmpdir=tmpdir, monkeypatch=monkeypatch) + setup_server_with_config_file( + config_file_str=config, tmpdir=tmpdir, monkeypatch=monkeypatch + ) fastapi_server_fs() # Test if anonymous 'public' access works @@ -262,7 +277,9 @@ def test_authentication_and_authorization_01( assert "Not enough permissions" in resp1["detail"] # Make sure that the anonymous 'single-user' access is not allowed - resp2 = request_to_json("get", "/status") # By default, the single user API key is sent + resp2 = request_to_json( + "get", "/status" + ) # By default, the single user API key is sent if single_user_access: assert "msg" in resp2, pprint.pformat(resp1) assert "RE Manager" in resp2["msg"] @@ -280,7 +297,9 @@ def test_authentication_and_authorization_01( auth_fail_msg = "Incorrect username or password" if providers_set else "Not Found" # Login using token: should work in all cases - resp3 = request_to_json("post", "/auth/provider/toy/token", login=("bob", "bob_password")) + resp3 = request_to_json( + "post", "/auth/provider/toy/token", login=("bob", "bob_password") + ) if token_access: assert "access_token" in resp3 token = resp3["access_token"] @@ -292,12 +311,16 @@ def test_authentication_and_authorization_01( assert login_fail_msg in resp3["detail"] # Login using incorrect username - resp5 = request_to_json("post", "/auth/provider/toy/token", login=("incorrect_name", "bob_password")) + resp5 = request_to_json( + "post", "/auth/provider/toy/token", login=("incorrect_name", "bob_password") + ) assert "detail" in resp5 assert auth_fail_msg in resp5["detail"] # Login using invalid password - resp6 = request_to_json("post", "/auth/provider/toy/token", login=("bob", "invalid_password")) + resp6 = request_to_json( + "post", "/auth/provider/toy/token", login=("bob", "invalid_password") + ) assert "detail" in resp6 assert auth_fail_msg in resp6["detail"] @@ -310,7 +333,7 @@ def test_authentication_and_authorization_01( def test_authentication_and_authorization_02( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -354,7 +377,7 @@ def test_authentication_and_authorization_02( def test_authentication_and_authorization_03( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 config, set_ev, @@ -388,7 +411,7 @@ def test_authentication_and_authorization_03( def test_authentication_and_authorization_04( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -426,7 +449,7 @@ def test_authentication_and_authorization_04( def test_authentication_and_authorization_05( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -466,7 +489,7 @@ def test_authentication_and_authorization_05( def test_authentication_and_authorization_06( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -595,7 +618,7 @@ def test_authentication_and_authorization_06( def test_authentication_and_authorization_07( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -641,7 +664,7 @@ def test_authentication_and_authorization_07( def test_authentication_and_authorization_08( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -719,7 +742,7 @@ def test_authentication_and_authorization_08( def test_resource_access_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 config, group, @@ -731,6 +754,9 @@ def test_resource_access_01( setup_server_with_config_file(config_file_str=config, tmpdir=tmpdir, monkeypatch=monkeypatch) fastapi_server_fs() + resp_clear, _ = zmq_secure_request("queue_clear") + assert resp_clear and (resp_clear.get("success") is True), str(resp_clear) + username, password = "bob", "bob_password" resp1 = request_to_json("post", "/auth/provider/toy/token", login=(username, password)) diff --git a/bluesky_httpserver/tests/test_access_policies.py b/bluesky_httpserver/tests/test_access_policies.py index 374d711..5225bbb 100644 --- a/bluesky_httpserver/tests/test_access_policies.py +++ b/bluesky_httpserver/tests/test_access_policies.py @@ -6,7 +6,6 @@ import pytest import requests -from bluesky_queueserver.manager.tests.common import re_manager # noqa F401 from xprocess import ProcessStarter from bluesky_httpserver.authorization import ( @@ -27,7 +26,10 @@ _DEFAULT_USERNAME_SINGLE_USER, ) from bluesky_httpserver.config_schemas.loading import ConfigError -from bluesky_httpserver.tests.conftest import request_to_json, setup_server_with_config_file +from bluesky_httpserver.tests.conftest import ( + request_to_json, + setup_server_with_config_file, +) # ==================================================================================== # API ACCESS POLICIES diff --git a/bluesky_httpserver/tests/test_auth_api.py b/bluesky_httpserver/tests/test_auth_api.py index ad23e5a..0945055 100644 --- a/bluesky_httpserver/tests/test_auth_api.py +++ b/bluesky_httpserver/tests/test_auth_api.py @@ -1,11 +1,10 @@ import pprint import time as ttime -from bluesky_queueserver.manager.tests.common import re_manager, re_manager_cmd # noqa F401 - from bluesky_httpserver.authorization._defaults import _DEFAULT_ROLES from .conftest import fastapi_server_fs # noqa: F401 +from .conftest import re_manager_module # noqa: F401 from .conftest import request_to_json, setup_server_with_config_file config_toy_test = """ @@ -38,7 +37,7 @@ def test_api_auth_post_apikey_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -58,7 +57,10 @@ def test_api_auth_post_apikey_01( # TEST1-1: generate API key using access token: 'inherit' scope resp3 = request_to_json( - "post", "/auth/apikey", json={"expires_in": 900, "note": "API key for testing"}, token=token + "post", + "/auth/apikey", + json={"expires_in": 900, "note": "API key for testing"}, + token=token, ) assert "secret" in resp3, pprint.pformat(resp3) assert "note" in resp3, pprint.pformat(resp3) @@ -72,7 +74,10 @@ def test_api_auth_post_apikey_01( # TEST1-2: generate API key using the existing API key: 'inherit' scope resp4 = request_to_json( - "post", "/auth/apikey", json={"expires_in": 900, "note": "API key - 2"}, api_key=api_key1 + "post", + "/auth/apikey", + json={"expires_in": 900, "note": "API key - 2"}, + api_key=api_key1, ) assert "secret" in resp4, pprint.pformat(resp4) assert "note" in resp4, pprint.pformat(resp4) @@ -86,7 +91,12 @@ def test_api_auth_post_apikey_01( # TEST1-3: generate API key using the existing API key: fixed scope scopes3 = ["read:status", "user:apikeys"] - resp5 = request_to_json("post", "/auth/apikey", json={"expires_in": 900, "scopes": scopes3}, api_key=api_key2) + resp5 = request_to_json( + "post", + "/auth/apikey", + json={"expires_in": 900, "scopes": scopes3}, + api_key=api_key2, + ) assert "secret" in resp5, pprint.pformat(resp5) assert "note" in resp5, pprint.pformat(resp5) assert resp5["note"] is None @@ -112,13 +122,23 @@ def test_api_auth_post_apikey_01( # TEST2-2: generate API key using API key: using scopes that are not allowed scopes5 = ["read:status", "user:apikeys", "read:queue"] - resp7 = request_to_json("post", "/auth/apikey", json={"expires_in": 900, "scopes": scopes5}, api_key=api_key4) + resp7 = request_to_json( + "post", + "/auth/apikey", + json={"expires_in": 900, "scopes": scopes5}, + api_key=api_key4, + ) assert "detail" in resp7, pprint.pformat(resp7) assert "must be a subset of the allowed principal's scopes" in resp7["detail"] # TEST2-3: generate API key using API key: fixed scope scopes6 = ["read:status"] - resp8 = request_to_json("post", "/auth/apikey", json={"expires_in": 900, "scopes": scopes6}, api_key=api_key4) + resp8 = request_to_json( + "post", + "/auth/apikey", + json={"expires_in": 900, "scopes": scopes6}, + api_key=api_key4, + ) assert "secret" in resp8, pprint.pformat(resp8) assert "note" in resp8, pprint.pformat(resp8) assert resp8["note"] is None @@ -133,7 +153,7 @@ def test_api_auth_post_apikey_01( def test_api_auth_get_apikey_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -148,7 +168,10 @@ def test_api_auth_get_apikey_01( token = resp1["access_token"] resp3 = request_to_json( - "post", "/auth/apikey", json={"expires_in": 900, "note": "API key for testing"}, token=token + "post", + "/auth/apikey", + json={"expires_in": 900, "note": "API key for testing"}, + token=token, ) assert "secret" in resp3, pprint.pformat(resp3) assert "note" in resp3, pprint.pformat(resp3) @@ -168,7 +191,7 @@ def test_api_auth_get_apikey_01( def test_api_auth_delete_apikey_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -185,7 +208,10 @@ def test_api_auth_delete_apikey_01( token = resp1["access_token"] resp3 = request_to_json( - "post", "/auth/apikey", json={"expires_in": 900, "note": "API key for testing"}, token=token + "post", + "/auth/apikey", + json={"expires_in": 900, "note": "API key for testing"}, + token=token, ) assert "secret" in resp3, pprint.pformat(resp3) assert "note" in resp3, pprint.pformat(resp3) @@ -210,7 +236,7 @@ def test_api_auth_delete_apikey_01( def test_api_auth_scopes_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -239,7 +265,7 @@ def test_api_auth_scopes_01( def test_api_auth_session_refresh_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -268,7 +294,7 @@ def test_api_auth_session_refresh_01( def test_api_auth_whoami_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -300,7 +326,7 @@ def test_api_auth_whoami_01( def test_api_auth_session_revoke_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -338,7 +364,7 @@ def test_api_auth_session_revoke_01( def test_api_auth_logout_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -355,7 +381,7 @@ def test_api_auth_logout_01( def test_api_admin_auth_principal_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -399,7 +425,7 @@ def test_api_admin_auth_principal_01( def test_api_admin_auth_principal_apikey_01( tmpdir, monkeypatch, - re_manager, # noqa: F811 + re_manager_module, # noqa: F811 fastapi_server_fs, # noqa: F811 ): """ @@ -427,7 +453,10 @@ def test_api_admin_auth_principal_apikey_01( # Get an API key for the user ('inherit' scope) resp4 = request_to_json( - "post", f"/auth/principal/{principals['alice']}/apikey", json={"expires_in": 900}, token=token + "post", + f"/auth/principal/{principals['alice']}/apikey", + json={"expires_in": 900}, + token=token, ) assert "secret" in resp4 api_key1 = resp4["secret"] @@ -455,7 +484,10 @@ def test_api_admin_auth_principal_apikey_01( resp6 = request_to_json( "post", f"/auth/principal/{principals['alice']}/apikey", - json={"expires_in": 900, "scopes": ["admin:apikeys", "read:status", "read:console"]}, + json={ + "expires_in": 900, + "scopes": ["admin:apikeys", "read:status", "read:console"], + }, token=token, ) assert "detail" in resp6 diff --git a/bluesky_httpserver/tests/test_auth_for_websockets.py b/bluesky_httpserver/tests/test_auth_for_websockets.py index 3d26e22..d1ceb32 100644 --- a/bluesky_httpserver/tests/test_auth_for_websockets.py +++ b/bluesky_httpserver/tests/test_auth_for_websockets.py @@ -4,13 +4,13 @@ import time as ttime import pytest -from bluesky_queueserver.manager.tests.common import re_manager, re_manager_cmd # noqa F401 from websockets.sync.client import connect from .conftest import fastapi_server_fs # noqa: F401 -from .conftest import ( +from .conftest import ( # noqa: F401 SERVER_ADDRESS, SERVER_PORT, + re_manager_cmd, request_to_json, setup_server_with_config_file, wait_for_environment_to_be_closed, diff --git a/bluesky_httpserver/tests/test_authenticators.py b/bluesky_httpserver/tests/test_authenticators.py index 53c6bbe..7b7dd4b 100644 --- a/bluesky_httpserver/tests/test_authenticators.py +++ b/bluesky_httpserver/tests/test_authenticators.py @@ -1,4 +1,5 @@ import asyncio +import os import time from typing import Any, Tuple @@ -10,20 +11,28 @@ from respx import MockRouter from starlette.datastructures import URL, QueryParams -# fmt: off from ..authenticators import LDAPAuthenticator, OIDCAuthenticator, ProxiedOIDCAuthenticator, UserSessionState +LDAP_TEST_HOST = os.environ.get("QSERVER_TEST_LDAP_HOST", "localhost") +LDAP_TEST_PORT = int(os.environ.get("QSERVER_TEST_LDAP_PORT", "1389")) +LDAP_TEST_ALT_HOST = os.environ.get("QSERVER_TEST_LDAP_ALT_HOST") +if not LDAP_TEST_ALT_HOST: + LDAP_TEST_ALT_HOST = "127.0.0.1" if LDAP_TEST_HOST == "localhost" else LDAP_TEST_HOST + + +# fmt: off + @pytest.mark.parametrize("ldap_server_address, ldap_server_port", [ - ("localhost", 1389), - ("localhost:1389", 904), # Random port, ignored - ("localhost:1389", None), - ("127.0.0.1", 1389), - ("127.0.0.1:1389", 904), - (["localhost"], 1389), - (["localhost", "127.0.0.1"], 1389), - (["localhost", "127.0.0.1:1389"], 1389), - (["localhost:1389", "127.0.0.1:1389"], None), + (LDAP_TEST_HOST, LDAP_TEST_PORT), + (f"{LDAP_TEST_HOST}:{LDAP_TEST_PORT}", 904), # Random port, ignored + (f"{LDAP_TEST_HOST}:{LDAP_TEST_PORT}", None), + (LDAP_TEST_ALT_HOST, LDAP_TEST_PORT), + (f"{LDAP_TEST_ALT_HOST}:{LDAP_TEST_PORT}", 904), + ([LDAP_TEST_HOST], LDAP_TEST_PORT), + ([LDAP_TEST_HOST, LDAP_TEST_ALT_HOST], LDAP_TEST_PORT), + ([LDAP_TEST_HOST, f"{LDAP_TEST_ALT_HOST}:{LDAP_TEST_PORT}"], LDAP_TEST_PORT), + ([f"{LDAP_TEST_HOST}:{LDAP_TEST_PORT}", f"{LDAP_TEST_ALT_HOST}:{LDAP_TEST_PORT}"], None), ]) # fmt: on @pytest.mark.parametrize("use_tls,use_ssl", [(False, False)]) diff --git a/bluesky_httpserver/tests/test_console_output.py b/bluesky_httpserver/tests/test_console_output.py index 1f089ec..6193db0 100644 --- a/bluesky_httpserver/tests/test_console_output.py +++ b/bluesky_httpserver/tests/test_console_output.py @@ -3,17 +3,16 @@ import re import threading import time as ttime +from typing import Any import pytest import requests -from bluesky_queueserver.manager.tests.common import re_manager_cmd # noqa F401 from websockets.sync.client import connect from bluesky_httpserver.tests.conftest import ( # noqa F401 API_KEY_FOR_TESTS, SERVER_ADDRESS, SERVER_PORT, - fastapi_server_fs, request_to_json, set_qserver_zmq_encoding, wait_for_environment_to_be_closed, @@ -36,37 +35,42 @@ def __init__(self, api_key=API_KEY_FOR_TESTS, **kwargs): self._api_key = api_key def run(self): - kwargs = {"stream": True} + kwargs: dict[str, Any] = {"stream": True} if self._api_key: - auth = None headers = {"Authorization": f"ApiKey {self._api_key}"} - kwargs.update({"auth": auth, "headers": headers}) + kwargs.update({"headers": headers}) + + kwargs["timeout"] = (5, 1) - with requests.get(f"http://{SERVER_ADDRESS}:{SERVER_PORT}/api/stream_console_output", **kwargs) as r: - r.encoding = "utf-8" + while not self._exit: + try: + with requests.get( + f"http://{SERVER_ADDRESS}:{SERVER_PORT}/api/stream_console_output", + **kwargs, + ) as r: + r.encoding = "utf-8" - characters = [] - n_brackets = 0 + characters = [] + n_brackets = 0 - for ch in r.iter_content(decode_unicode=True): - # Note, that some output must be received from the server before the loop exits - if self._exit: - break + for ch in r.iter_content(decode_unicode=True): + if self._exit: + return - characters.append(ch) - if ch == "{": - n_brackets += 1 - elif ch == "}": - n_brackets -= 1 + characters.append(ch) + if ch == "{": + n_brackets += 1 + elif ch == "}": + n_brackets -= 1 - # If the received buffer ('characters') is not empty and the message contains - # equal number of opening and closing brackets then consider the message complete. - if characters and not n_brackets: - line = "".join(characters) - characters = [] + if characters and not n_brackets: + line = "".join(characters) + characters = [] - print(f"{line}") - self.received_data_buffer.append(json.loads(line)) + print(f"{line}") + self.received_data_buffer.append(json.loads(line)) + except requests.exceptions.ReadTimeout: + continue def stop(self): """ @@ -81,7 +85,10 @@ def __del__(self): @pytest.mark.parametrize("zmq_port", (None, 60619)) def test_http_server_stream_console_output_1( - monkeypatch, re_manager_cmd, fastapi_server_fs, zmq_port # noqa F811 + monkeypatch, + re_manager_cmd, + fastapi_server_fs, + zmq_port, # noqa F811 ): """ Test for ``stream_console_output`` API @@ -122,7 +129,8 @@ def test_http_server_stream_console_output_1( assert resp2["items"][0] == resp1["item"] assert resp2["running_item"] == {} - rsc.join() + rsc.join(timeout=10) + assert not rsc.is_alive(), "Timed out waiting for stream_console_output thread to terminate" assert len(rsc.received_data_buffer) >= 2, pprint.pformat(rsc.received_data_buffer) @@ -160,7 +168,11 @@ def test_http_server_stream_console_output_1( @pytest.mark.parametrize("zmq_encoding", (None, "json", "msgpack")) @pytest.mark.parametrize("zmq_port", (None, 60619)) def test_http_server_console_output_1( - monkeypatch, re_manager_cmd, fastapi_server_fs, zmq_port, zmq_encoding # noqa F811 + monkeypatch, + re_manager_cmd, + fastapi_server_fs, + zmq_port, + zmq_encoding, # noqa F811 ): """ Test for ``console_output`` API (not a streaming version). @@ -238,7 +250,10 @@ def test_http_server_console_output_1( @pytest.mark.parametrize("zmq_port", (None, 60619)) def test_http_server_console_output_update_1( - monkeypatch, re_manager_cmd, fastapi_server_fs, zmq_port # noqa F811 + monkeypatch, + re_manager_cmd, + fastapi_server_fs, + zmq_port, # noqa F811 ): """ Test for ``console_output`` API (not a streaming version). @@ -379,7 +394,10 @@ def __del__(self): @pytest.mark.parametrize("zmq_port", (None, 60619)) def test_http_server_console_output_socket_1( - monkeypatch, re_manager_cmd, fastapi_server_fs, zmq_port # noqa F811 + monkeypatch, + re_manager_cmd, + fastapi_server_fs, + zmq_port, # noqa F811 ): """ Test for ``/console_output/ws`` websocket @@ -421,7 +439,8 @@ def test_http_server_console_output_socket_1( assert resp2["items"][0] == resp1["item"] assert resp2["running_item"] == {} - rsc.join() + rsc.join(timeout=10) + assert not rsc.is_alive(), "Timed out waiting for console_output websocket thread to terminate" assert len(rsc.received_data_buffer) >= 2, pprint.pformat(rsc.received_data_buffer) diff --git a/bluesky_httpserver/tests/test_core_api_fs.py b/bluesky_httpserver/tests/test_core_api_fs.py index a00d99a..ff1b9e7 100644 --- a/bluesky_httpserver/tests/test_core_api_fs.py +++ b/bluesky_httpserver/tests/test_core_api_fs.py @@ -5,19 +5,21 @@ from bluesky_queueserver.manager.tests.common import ( # noqa F401 append_code_to_last_startup_file, copy_default_profile_collection, - re_manager, - re_manager_cmd, - re_manager_pc_copy, set_qserver_zmq_address, set_qserver_zmq_public_key, ) -from bluesky_queueserver.manager.tests.plan_lists import create_excel_file_from_plan_list, plan_list_sample +from bluesky_queueserver.manager.tests.plan_lists import ( + create_excel_file_from_plan_list, + plan_list_sample, +) from bluesky_httpserver.tests.conftest import ( # noqa F401 SERVER_ADDRESS, SERVER_PORT, add_plans_to_queue, fastapi_server_fs, + re_manager_cmd, + re_manager_pc_copy, request_to_json, wait_for_environment_to_be_created, wait_for_manager_state_idle, @@ -26,8 +28,17 @@ # Plans used in most of the tests: '_plan1' and '_plan2' are quickly executed '_plan3' runs for 5 seconds. _plan1 = {"name": "count", "args": [["det1", "det2"]], "item_type": "plan"} -_plan2 = {"name": "scan", "args": [["det1", "det2"], "motor", -1, 1, 10], "item_type": "plan"} -_plan3 = {"name": "count", "args": [["det1", "det2"]], "kwargs": {"num": 5, "delay": 1}, "item_type": "plan"} +_plan2 = { + "name": "scan", + "args": [["det1", "det2"], "motor", -1, 1, 10], + "item_type": "plan", +} +_plan3 = { + "name": "count", + "args": [["det1", "det2"]], + "kwargs": {"num": 5, "delay": 1}, + "item_type": "plan", +} def _create_test_excel_file1(tmp_path, *, plan_params, col_names): diff --git a/bluesky_httpserver/tests/test_core_api_main.py b/bluesky_httpserver/tests/test_core_api_main.py index b2b5140..00348ff 100644 --- a/bluesky_httpserver/tests/test_core_api_main.py +++ b/bluesky_httpserver/tests/test_core_api_main.py @@ -9,9 +9,6 @@ append_code_to_last_startup_file, copy_default_profile_collection, ip_kernel_simple_client, - re_manager, - re_manager_cmd, - re_manager_pc_copy, ) from bluesky_httpserver.tests.conftest import ( # noqa F401 @@ -19,6 +16,8 @@ SERVER_PORT, add_plans_to_queue, fastapi_server, + re_manager_cmd, + re_manager_pc_copy, request_to_json, wait_for_environment_to_be_closed, wait_for_environment_to_be_created, @@ -30,8 +29,17 @@ # Plans used in most of the tests: '_plan1' and '_plan2' are quickly executed '_plan3' runs for 5 seconds. _plan1 = {"name": "count", "args": [["det1", "det2"]], "item_type": "plan"} -_plan2 = {"name": "scan", "args": [["det1", "det2"], "motor", -1, 1, 10], "item_type": "plan"} -_plan3 = {"name": "count", "args": [["det1", "det2"]], "kwargs": {"num": 5, "delay": 1}, "item_type": "plan"} +_plan2 = { + "name": "scan", + "args": [["det1", "det2"], "motor", -1, 1, 10], + "item_type": "plan", +} +_plan3 = { + "name": "count", + "args": [["det1", "det2"]], + "kwargs": {"num": 5, "delay": 1}, + "item_type": "plan", +} _instruction_stop = {"name": "queue_stop", "item_type": "instruction"} @@ -515,8 +523,10 @@ def test_http_server_queue_item_update_2_fail(re_manager, fastapi_server, replac resp2 = request_to_json("post", "/queue/item/update", json=params) assert resp2["success"] is False - assert resp2["msg"] == "Failed to add an item: Failed to replace item: " \ - "Item with UID 'incorrect_uid' is not in the queue" + assert ( + resp2["msg"] == "Failed to add an item: Failed to replace item: " + "Item with UID 'incorrect_uid' is not in the queue" + ) resp3 = request_to_json("get", "/queue/get") assert resp3["items"] != [] @@ -1286,16 +1296,33 @@ def test_http_server_history_clear(re_manager, fastapi_server, clear_params, exp def test_http_server_manager_kill(re_manager, fastapi_server): # noqa F811 + timeout_variants = ( + "Request timeout: ZMQ communication error: timeout occurred", + "Request timeout: ZMQ communication error: Resource temporarily unavailable", + ) + request_to_json("post", "/environment/open") assert wait_for_environment_to_be_created(10), "Timeout" resp = request_to_json("post", "/test/manager/kill") assert "success" not in resp - assert "Request timeout: ZMQ communication error: timeout occurred" in resp["detail"] - - ttime.sleep(10) + assert any(_ in resp["detail"] for _ in timeout_variants) + + deadline = ttime.time() + 20 + last_status = None + while ttime.time() < deadline: + ttime.sleep(0.2) + last_status = request_to_json("get", "/status") + if ( + isinstance(last_status, dict) + and last_status.get("manager_state") == "idle" + and last_status.get("worker_environment_exists") is True + ): + break + else: + assert False, f"Timeout while waiting for manager recovery after kill. Last status: {last_status!r}" - resp = request_to_json("get", "/status") + resp = last_status assert resp["msg"].startswith("RE Manager") assert resp["manager_state"] == "idle" assert resp["items_in_queue"] == 0 @@ -1682,7 +1709,14 @@ def check_status(ip_kernel_state, ip_kernel_captured): kernel_int_params = {} if option == "ip_client": - ip_kernel_simple_client.start() + for _ in range(5): + try: + ip_kernel_simple_client.start() + break + except (TypeError, KeyError): + ttime.sleep(1) + else: + pytest.fail("Failed to start IP kernel client after 5 attempts") ip_kernel_simple_client.execute_with_check(_busy_script_01) elif option == "script": resp2 = request_to_json("post", "/script/upload", json={"script": _busy_script_01}) diff --git a/bluesky_httpserver/tests/test_server.py b/bluesky_httpserver/tests/test_server.py index 117f4df..ae1401a 100644 --- a/bluesky_httpserver/tests/test_server.py +++ b/bluesky_httpserver/tests/test_server.py @@ -6,9 +6,6 @@ from bluesky_queueserver.manager.tests.common import ( # noqa F401 append_code_to_last_startup_file, copy_default_profile_collection, - re_manager, - re_manager_cmd, - re_manager_pc_copy, set_qserver_zmq_address, set_qserver_zmq_public_key, ) @@ -18,6 +15,8 @@ SERVER_PORT, add_plans_to_queue, fastapi_server_fs, + re_manager_cmd, + re_manager_pc_copy, request_to_json, setup_server_with_config_file, wait_for_environment_to_be_created, @@ -27,8 +26,17 @@ # Plans used in most of the tests: '_plan1' and '_plan2' are quickly executed '_plan3' runs for 5 seconds. _plan1 = {"name": "count", "args": [["det1", "det2"]], "item_type": "plan"} -_plan2 = {"name": "scan", "args": [["det1", "det2"], "motor", -1, 1, 10], "item_type": "plan"} -_plan3 = {"name": "count", "args": [["det1", "det2"]], "kwargs": {"num": 5, "delay": 1}, "item_type": "plan"} +_plan2 = { + "name": "scan", + "args": [["det1", "det2"], "motor", -1, 1, 10], + "item_type": "plan", +} +_plan3 = { + "name": "count", + "args": [["det1", "det2"]], + "kwargs": {"num": 5, "delay": 1}, + "item_type": "plan", +} _config_public_key = """ @@ -122,7 +130,7 @@ def test_http_server_secure_1(monkeypatch, tmpdir, re_manager_cmd, fastapi_serve @pytest.mark.parametrize("option", ["ev", "cfg_file", "both"]) # fmt: on def test_http_server_set_zmq_address_1( - monkeypatch, tmpdir, re_manager_cmd, fastapi_server_fs, option # noqa: F811 + monkeypatch, tmpdir, re_manager_cmd, fastapi_server_fs, free_tcp_port_factory, option # noqa: F811 ): """ Test if ZMQ address of RE Manager is passed to the HTTP server using 'QSERVER_ZMQ_ADDRESS_CONTROL' @@ -130,11 +138,12 @@ def test_http_server_set_zmq_address_1( channel different from default address, add and execute a plan. """ - # Change ZMQ address to use port 60616 instead of the default port 60615. - zmq_control_address_server = "tcp://*:60616" - zmq_info_address_server = "tcp://*:60617" - zmq_control_address = "tcp://localhost:60616" - zmq_info_address = "tcp://localhost:60617" + zmq_control_port = free_tcp_port_factory() + zmq_info_port = free_tcp_port_factory() + zmq_control_address_server = f"tcp://*:{zmq_control_port}" + zmq_info_address_server = f"tcp://*:{zmq_info_port}" + zmq_control_address = f"tcp://localhost:{zmq_control_port}" + zmq_info_address = f"tcp://localhost:{zmq_info_port}" if option == "ev": monkeypatch.setenv("QSERVER_ZMQ_CONTROL_ADDRESS", zmq_control_address) monkeypatch.setenv("QSERVER_ZMQ_INFO_ADDRESS", zmq_info_address) diff --git a/bluesky_httpserver/tests/test_system_info_socket.py b/bluesky_httpserver/tests/test_system_info_socket.py index 4d25dd6..64ec65b 100644 --- a/bluesky_httpserver/tests/test_system_info_socket.py +++ b/bluesky_httpserver/tests/test_system_info_socket.py @@ -4,7 +4,6 @@ import time as ttime import pytest -from bluesky_queueserver.manager.tests.common import re_manager_cmd # noqa F401 from websockets.sync.client import connect from bluesky_httpserver.tests.conftest import ( # noqa F401 @@ -65,7 +64,11 @@ def __del__(self): @pytest.mark.parametrize("zmq_port", (None, 60619)) @pytest.mark.parametrize("endpoint", ["/info/ws", "/status/ws"]) def test_http_server_system_info_socket_1( - monkeypatch, re_manager_cmd, fastapi_server_fs, zmq_port, endpoint # noqa F811 + monkeypatch, + re_manager_cmd, # noqa: F811 + fastapi_server_fs, # noqa: F811 + zmq_port, + endpoint, # noqa F811 ): """ Test for ``/info/ws`` and ``/status/ws`` websockets diff --git a/continuous_integration/docker-configs/ldap-docker-compose.yml b/continuous_integration/docker-configs/ldap-docker-compose.yml index 2b2c45a..5fbfc53 100644 --- a/continuous_integration/docker-configs/ldap-docker-compose.yml +++ b/continuous_integration/docker-configs/ldap-docker-compose.yml @@ -1,14 +1,13 @@ services: openldap: - image: osixia/openldap:latest + image: osixia/openldap:1.5.0 ports: - - '1389:1389' - - '1636:1636' + - '1389:389' + - '1636:636' environment: - - LDAP_ADMIN_USERNAME=admin + - LDAP_ORGANISATION=Example Inc. + - LDAP_DOMAIN=example.org - LDAP_ADMIN_PASSWORD=adminpassword - - LDAP_USERS=user01,user02 - - LDAP_PASSWORDS=password1,password2 volumes: - 'openldap_data:/var/lib/ldap' diff --git a/docker/test.Dockerfile b/continuous_integration/dockerfiles/test.Dockerfile similarity index 100% rename from docker/test.Dockerfile rename to continuous_integration/dockerfiles/test.Dockerfile diff --git a/continuous_integration/scripts/start_LDAP.sh b/continuous_integration/scripts/start_LDAP.sh index ecfa1cf..90ec8c5 100755 --- a/continuous_integration/scripts/start_LDAP.sh +++ b/continuous_integration/scripts/start_LDAP.sh @@ -1,7 +1,225 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +COMPOSE_FILE="${LDAP_COMPOSE_FILE:-$ROOT_DIR/continuous_integration/docker-configs/ldap-docker-compose.yml}" +COMPOSE_PROJECT="${LDAP_COMPOSE_PROJECT:-}" +LDAP_HOST="${LDAP_HOST:-127.0.0.1}" +LDAP_PORT="${LDAP_PORT:-1389}" +LDAP_ADMIN_DN="cn=admin,dc=example,dc=org" +LDAP_ADMIN_PASSWORD="adminpassword" +LDAP_BASE_DN="dc=example,dc=org" +MODE="${1:-full}" + +compose_cmd() { + if [[ -n "$COMPOSE_PROJECT" ]]; then + docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" "$@" + else + docker compose -f "$COMPOSE_FILE" "$@" + fi +} + +get_openldap_container_id() { + compose_cmd ps -q openldap | tr -d '[:space:]' +} + +wait_for_ldap() { + local timeout_seconds="${1:-60}" + local deadline=$((SECONDS + timeout_seconds)) + + while (( SECONDS < deadline )); do + if python - </dev/null 2>&1 +import socket + +with socket.create_connection(("${LDAP_HOST}", ${LDAP_PORT}), timeout=1): + pass +PY + then + return 0 + fi + sleep 1 + done + + return 1 +} + +wait_for_ldap_bind() { + local container_id="$1" + local timeout_seconds="${2:-60}" + local deadline=$((SECONDS + timeout_seconds)) + local rc=0 + + while (( SECONDS < deadline )); do + rc=0 + docker exec "$container_id" ldapsearch \ + -x \ + -H "ldap://127.0.0.1:389" \ + -D "$LDAP_ADMIN_DN" \ + -w "$LDAP_ADMIN_PASSWORD" \ + -b "$LDAP_BASE_DN" \ + -s base \ + "(objectclass=*)" dn >/dev/null 2>&1 || rc=$? + if [[ "$rc" -eq 0 ]]; then + return 0 + fi + sleep 1 + done + + return 1 +} + +wait_for_ldap_test_user_bind() { + local container_id="$1" + local timeout_seconds="${2:-60}" + local deadline=$((SECONDS + timeout_seconds)) + local rc=0 + + while (( SECONDS < deadline )); do + rc=0 + docker exec "$container_id" ldapwhoami \ + -x \ + -H "ldap://127.0.0.1:389" \ + -D "cn=user01,ou=users,$LDAP_BASE_DN" \ + -w "password1" >/dev/null 2>&1 || rc=$? + if [[ "$rc" -eq 0 ]]; then + return 0 + fi + sleep 1 + done + + return 1 +} + +print_ldap_diagnostics() { + local container_id="${1:-}" + + echo "LDAP startup diagnostics:" >&2 + compose_cmd ps >&2 || true + + if [[ -z "$container_id" ]]; then + container_id="$(get_openldap_container_id)" + fi + + if [[ -n "$container_id" ]]; then + docker logs --tail 200 "$container_id" >&2 || true + else + compose_cmd logs --tail 200 openldap >&2 || true + fi +} + +ldap_entry_exists() { + local container_id="$1" + local dn="$2" + + docker exec "$container_id" ldapsearch \ + -x \ + -H "ldap://127.0.0.1:389" \ + -D "$LDAP_ADMIN_DN" \ + -w "$LDAP_ADMIN_PASSWORD" \ + -b "$dn" \ + -s base \ + "(objectclass=*)" dn >/dev/null 2>&1 +} + +ldap_add_if_missing() { + local container_id="$1" + local dn="$2" + local ldif="$3" + + if ldap_entry_exists "$container_id" "$dn"; then + return 0 + fi + + docker exec -i "$container_id" ldapadd \ + -x \ + -H "ldap://127.0.0.1:389" \ + -D "$LDAP_ADMIN_DN" \ + -w "$LDAP_ADMIN_PASSWORD" >/dev/null <&2 + print_ldap_diagnostics + exit 1 + fi +} + +wait_and_seed_ldap_container() { + if [[ -z "${CONTAINER_ID:-}" ]]; then + CONTAINER_ID="$(get_openldap_container_id)" + fi + if [[ -z "$CONTAINER_ID" ]]; then + echo "Unable to determine LDAP container id from compose project. Start LDAP first." >&2 + print_ldap_diagnostics + exit 1 + fi + + if ! wait_for_ldap 120; then + echo "LDAP port ${LDAP_HOST}:${LDAP_PORT} did not become reachable in time." >&2 + print_ldap_diagnostics "$CONTAINER_ID" + exit 1 + fi + + echo "LDAP port ${LDAP_HOST}:${LDAP_PORT} is reachable. Waiting for slapd initialization..." + sleep 3 + + if ! wait_for_ldap_bind "$CONTAINER_ID" 120; then + echo "LDAP admin bind did not become ready in time." >&2 + print_ldap_diagnostics "$CONTAINER_ID" + exit 1 + fi + + seed_ldap_test_users "$CONTAINER_ID" + + if ! wait_for_ldap_test_user_bind "$CONTAINER_ID" 60; then + echo "LDAP test-user bind did not become ready in time." >&2 + print_ldap_diagnostics "$CONTAINER_ID" + exit 1 + fi +} + +case "$MODE" in + full) + start_ldap_container + wait_and_seed_ldap_container + ;; + --start-only) + start_ldap_container + ;; + --wait-seed) + wait_and_seed_ldap_container + ;; + *) + echo "Usage: $0 [full|--start-only|--wait-seed]" >&2 + exit 2 + ;; +esac -# Start LDAP server in docker container -docker pull osixia/openldap:latest -docker compose -f continuous_integration/docker-configs/ldap-docker-compose.yml up -d docker ps diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 299bdcb..bcae133 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -169,7 +169,7 @@ If you are already in a browser context, open: This redirects to the OIDC provider login page and then back to the server callback. -This can similarly be acheived using ``httpie`` by opening the URL in a browser after getting +This can similarly be acheived using ``httpie`` by opening the URL in a browser after getting the authorization URI from the server:: http POST http://localhost:60610/api/auth/provider/entra/authorize @@ -183,7 +183,7 @@ spawn a browser for the user to log in to the provider. CLI/device flow *************** -For terminal clients (i.e. no browser possible), start with +For terminal clients (i.e. no browser possible), start with ``POST /api/auth/provider//authorize``. The response includes: diff --git a/requirements-dev.txt b/requirements-dev.txt index e47dd72..99ec1a2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,6 +10,7 @@ isort pre-commit pytest pytest-asyncio +pytest-xdist pytest-xprocess py respx diff --git a/scripts/run_ci_docker_parallel.sh b/scripts/run_ci_docker_parallel.sh index c9caee7..efb6594 100755 --- a/scripts/run_ci_docker_parallel.sh +++ b/scripts/run_ci_docker_parallel.sh @@ -8,8 +8,10 @@ CHUNK_COUNT="" PYTHON_VERSIONS="latest" PYTEST_EXTRA_ARGS="" ARTIFACTS_DIR="$ROOT_DIR/.docker-test-artifacts" -DOCKER_NETWORK_NAME="bhs-ci-net" -LDAP_CONTAINER_NAME="bhs-ci-ldap" +LDAP_COMPOSE_FILE="$ROOT_DIR/continuous_integration/docker-configs/ldap-docker-compose.yml" +LDAP_COMPOSE_PROJECT="bhs-ci-ldap-parallel-$$" +LDAP_SERVICE_NAME="openldap" +DOCKER_NETWORK_NAME="${LDAP_COMPOSE_PROJECT}_default" SUMMARY_TSV="" SUMMARY_FAIL_LOGS="" @@ -154,46 +156,16 @@ normalize_python_versions() { echo "${normalized[@]}" } -ensure_ldap_image() { - local image_ref="bitnami/openldap:latest" - if docker image inspect "$image_ref" >/dev/null 2>&1; then - return - fi - - echo "LDAP image $image_ref not found locally; trying docker pull..." - if docker pull "$image_ref"; then - return - fi - - echo "docker pull failed; building bitnami/openldap:latest from source (CI fallback)." - local workdir="$ROOT_DIR/.docker-test-artifacts/bitnami-containers" - rm -rf "$workdir" - git clone --depth 1 https://github.com/bitnami/containers.git "$workdir" - (cd "$workdir/bitnami/openldap/2.6/debian-12" && docker build -t "$image_ref" .) -} - start_services() { - ensure_ldap_image - - docker network rm "$DOCKER_NETWORK_NAME" >/dev/null 2>&1 || true - docker network create "$DOCKER_NETWORK_NAME" >/dev/null - - docker rm -f "$LDAP_CONTAINER_NAME" >/dev/null 2>&1 || true - docker run -d --rm \ - --name "$LDAP_CONTAINER_NAME" \ - --network "$DOCKER_NETWORK_NAME" \ - -e LDAP_ADMIN_USERNAME=admin \ - -e LDAP_ADMIN_PASSWORD=adminpassword \ - -e LDAP_USERS=user01,user02 \ - -e LDAP_PASSWORDS=password1,password2 \ - bitnami/openldap:latest >/dev/null - - sleep 2 + LDAP_COMPOSE_FILE="$LDAP_COMPOSE_FILE" \ + LDAP_COMPOSE_PROJECT="$LDAP_COMPOSE_PROJECT" \ + LDAP_HOST="127.0.0.1" \ + LDAP_PORT="1389" \ + bash "$ROOT_DIR/continuous_integration/scripts/start_LDAP.sh" >/dev/null } stop_services() { - docker rm -f "$LDAP_CONTAINER_NAME" >/dev/null 2>&1 || true - docker network rm "$DOCKER_NETWORK_NAME" >/dev/null 2>&1 || true + docker compose -p "$LDAP_COMPOSE_PROJECT" -f "$LDAP_COMPOSE_FILE" down -v >/dev/null 2>&1 || true } cleanup() { @@ -385,8 +357,8 @@ run_chunk() { -e SHARD_COUNT="$CHUNK_COUNT" \ -e ARTIFACTS_DIR="/artifacts" \ -e PYTEST_EXTRA_ARGS="$PYTEST_EXTRA_ARGS" \ - -e QSERVER_TEST_LDAP_HOST="$LDAP_CONTAINER_NAME" \ - -e QSERVER_TEST_LDAP_PORT="1389" \ + -e QSERVER_TEST_LDAP_HOST="$LDAP_SERVICE_NAME" \ + -e QSERVER_TEST_LDAP_PORT="389" \ -e QSERVER_TEST_REDIS_ADDR="localhost" \ -e QSERVER_HTTP_TEST_BIND_HOST="127.0.0.1" \ -e QSERVER_HTTP_TEST_HOST="127.0.0.1" \ @@ -400,7 +372,7 @@ run_chunk() { } export -f run_chunk -export CHUNK_COUNT PYTEST_EXTRA_ARGS DOCKER_NETWORK_NAME LDAP_CONTAINER_NAME +export CHUNK_COUNT PYTEST_EXTRA_ARGS DOCKER_NETWORK_NAME LDAP_SERVICE_NAME for PYTHON_VERSION in "${SELECTED_PYTHON_VERSIONS[@]}"; do CURRENT_IMAGE_TAG="${IMAGE_TAG_BASE}-py${PYTHON_VERSION}" @@ -410,7 +382,7 @@ for PYTHON_VERSION in "${SELECTED_PYTHON_VERSIONS[@]}"; do echo "==> Building test image: $CURRENT_IMAGE_TAG (Python $PYTHON_VERSION)" docker build \ --build-arg PYTHON_VERSION="$PYTHON_VERSION" \ - -f "$ROOT_DIR/docker/test.Dockerfile" \ + -f "$ROOT_DIR/continuous_integration/dockerfiles/test.Dockerfile" \ -t "$CURRENT_IMAGE_TAG" \ "$ROOT_DIR"