diff --git a/coriolis/tests/integration/base.py b/coriolis/tests/integration/base.py index 5efec403..47e38975 100644 --- a/coriolis/tests/integration/base.py +++ b/coriolis/tests/integration/base.py @@ -20,7 +20,6 @@ import oslo_messaging as messaging from coriolisclient import client as coriolis_client from keystoneauth1 import session as ks_session -from keystoneauth1 import token_endpoint from oslo_config import cfg from oslo_db.sqlalchemy import models from oslo_log import log as logging @@ -82,8 +81,6 @@ def setUp(self): # Prevent the test runner from being killed by Coriolis sending # SIGINT to in-process workers. "psutil.Process.send_signal", - # We're not using keystone for tests. This is called in a few places. - "coriolis.keystone.delete_trust", ] for thing in to_patch: patcher = mock.patch(thing) @@ -113,13 +110,12 @@ def _call(self, ctxt, method, **kwargs): @classmethod def get_client(cls): """Return a coriolisclient.Client pointed at the in-process API.""" - auth = token_endpoint.Token( - endpoint='http://127.0.0.1:%d' % cls._api_port, - token='integration-dummy-token', - ) + auth = cls._harness.keystone.get_auth() return coriolis_client.Client( session=ks_session.Session(auth=auth), - project_id=harness._TEST_PROJECT_ID, + project_id=cls._harness.keystone.project_id, + # Not "endpoint": that appends "/v1", which the test router doesn't use. + endpoint_override='http://127.0.0.1:%d' % cls._api_port, ) @classmethod @@ -220,10 +216,7 @@ def _safe_delete_pool(cls, pool_id): cls._client.minion_pools.deallocate_minion_pool(pool_id, force=True) cls._wait_for_pool(pool_id, MINION_DEALLOCATED_TERMINAL) - with mock.patch("coriolis.keystone.delete_trust", autospec=False): - # When removing minion pools, this is also called. - # There is no Keystone, so it needs to be mocked. - cls._client.minion_pools.delete(pool_id) + cls._client.minion_pools.delete(pool_id) @classmethod def _wait_for_pool(cls, pool_id, terminal_statuses, timeout=600): @@ -246,11 +239,11 @@ def _wait_for_pool(cls, pool_id, terminal_statuses, timeout=600): % (pool_id, terminal_statuses, timeout, last) ) - @staticmethod - def _get_db_context(): + @classmethod + def _get_db_context(cls): return context.RequestContext( user='int-test', - project_id=harness._TEST_PROJECT_ID, + project_id=cls._harness.keystone.project_id, is_admin=True, ) diff --git a/coriolis/tests/integration/harness.py b/coriolis/tests/integration/harness.py index 5d8571c7..f6c5ab26 100644 --- a/coriolis/tests/integration/harness.py +++ b/coriolis/tests/integration/harness.py @@ -6,8 +6,8 @@ Starts conductor, scheduler, and worker services in-process using oslo.messaging's fake:// transport and a temporary SQLite database. Serves -the Coriolis REST API via cheroot on a random local port. No RabbitMQ, -Keystone, or Barbican are required. +the Coriolis REST API via cheroot on a random local port, a keystonemiddleware +auth_token + CoriolisKeystoneContext pipeline. No RabbitMQ or Barbican are required. Tasks are executed in-process as greenlets rather than subprocesses. The fake:// oslo.messaging transport is in-memory and process-local; subprocess @@ -28,10 +28,10 @@ import uuid from unittest import mock -import webob.dec import yaml from cheroot import wsgi as cheroot_wsgi from cheroot.workers import threadpool as cheroot_threadpool +from keystonemiddleware import auth_token as ks_auth_token from oslo_config import cfg from oslo_log import log as logging from oslo_middleware import request_id as request_id_middleware @@ -39,11 +39,11 @@ from coriolis import api as api_module from coriolis import conf as coriolis_conf -from coriolis import constants, context, exception, service +from coriolis import constants, exception, service from coriolis import policy as policy_module from coriolis import rpc as rpc_module from coriolis import utils as coriolis_utils -from coriolis.api import wsgi as api_wsgi +from coriolis.api.middleware import auth as keystone_context_middleware from coriolis.api.middleware import fault as fault_middleware from coriolis.api.v1 import router as api_v1_router from coriolis.conductor.rpc import server as conductor_rpc_server @@ -56,7 +56,7 @@ from coriolis.scheduler.rpc import server as scheduler_rpc_server from coriolis.taskflow import runner as taskflow_runner from coriolis.tasks import factory as task_runners_factory -from coriolis.tests.integration import provider_test_base +from coriolis.tests.integration import keystone_utils, provider_test_base from coriolis.tests.integration import utils as test_utils from coriolis.tests.integration.test_provider import exp as test_provider_exp from coriolis.tests.integration.test_provider import imp as test_provider_imp @@ -75,9 +75,6 @@ "coriolis.tests.integration.test_provider.imp.TestImportProvider" ) -# Fixed project used for all test requests. -_TEST_PROJECT_ID = 'integration-project' - # Path to an optional YAML file that configures the destination provider, # its connection_info, environment, and storage mappings. _PROVIDERS_YAML = os.environ.get("CORIOLIS_PROVIDERS_YAML") @@ -161,19 +158,20 @@ def __init__(self, *args, **kwargs): cheroot_threadpool.WorkerThread = DaemonCherootWorker -class _NoAuthMiddleware(api_wsgi.Middleware): - """Injects a fixed admin RequestContext; replaces keystonecontext.""" +class _AdminRoleMiddleware: + """Marks contexts holding the admin role as is_admin. - @webob.dec.wsgify(RequestClass=api_wsgi.Request) - def __call__(self, req): - req.environ['coriolis.context'] = context.RequestContext( - user='integration-test', - project_id=_TEST_PROJECT_ID, - is_admin=True, - # Skip Keystone trust creation / deletion. - trust_id='integration-dummy-trust', - ) - return self.application + Sits after CoriolisKeystoneContext, which builds the contexts. + """ + + def __init__(self, app): + self._app = app + + def __call__(self, environ, start_response): + ctx = environ.get('coriolis.context') + if ctx is not None and keystone_utils.ROLE_NAME in ctx.roles: + ctx.is_admin = True + return self._app(environ, start_response) class _TestAPIRouter(api_v1_router.APIRouter): @@ -321,8 +319,8 @@ def __init__(self): self.lock_path = os.path.join(self.workdir, "locks") os.makedirs(self.lock_path) - session_tag = str(uuid.uuid4()).split("-")[0] - self._mysql_container_name = "coriolis-test-mysql-%s" % session_tag + self._session_tag = str(uuid.uuid4()).split("-")[0] + self._mysql_container_name = "coriolis-test-mysql-%s" % self._session_tag self._mysql_username = "root" self._mysql_password = "coriolis" self._mysql_database = "coriolis" @@ -443,6 +441,16 @@ def __init__(self): engine = db_api.get_engine() db_migration.db_sync(engine) + self.keystone = keystone_utils.KeystoneContainer( + self.workdir, + self._session_tag, + self._mysql_container_name, + self._mysql_ip, + self._mysql_username, + self._mysql_password, + ) + self.keystone.setup() + self._start_coriolis_services() def _start_db_container(self): @@ -563,10 +571,13 @@ def _start_coriolis_services(self): ) self._worker_host_svc.start() - # API: build the WSGI stack without keystonemiddleware and serve it - # on a random local port. + # API: build the same pipeline as api-paste.ini (request_id, faultwrap, + # authtoken, keystonecontext, apiv1) and serve it on a random local port. + # authtoken validates tokens against the Keystone container started above. wsgi_app = _TestAPIRouter() - wsgi_app = _NoAuthMiddleware(wsgi_app) + wsgi_app = _AdminRoleMiddleware(wsgi_app) + wsgi_app = keystone_context_middleware.CoriolisKeystoneContext(wsgi_app) + wsgi_app = ks_auth_token.AuthProtocol(wsgi_app, {}) wsgi_app = fault_middleware.FaultWrapper(wsgi_app) wsgi_app = request_id_middleware.RequestId(wsgi_app) @@ -622,6 +633,11 @@ def _teardown(self): self._mysql_container_name, ) + try: + self.keystone.teardown() + except Exception: + LOG.exception("Failed to stop / remove the Keystone container.") + shutil.rmtree(self.workdir, True) try: test_utils.destroy_leaked_loop_devices() diff --git a/coriolis/tests/integration/keystone_utils.py b/coriolis/tests/integration/keystone_utils.py new file mode 100644 index 00000000..18e6fb3b --- /dev/null +++ b/coriolis/tests/integration/keystone_utils.py @@ -0,0 +1,352 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +""" +Keystone container helpers for the integration tests. + +Starts Keystone (using the official kolla image) backed by a "keystone" schema +on a running mariadb container, bootstraps it, and points coriolis' [keystone], +[trustee] and [keystone_authtoken] config groups at it. +""" + +import json +import os +import time +import urllib.error +import urllib.request +import uuid + +from keystoneauth1 import identity as ks_identity +from keystoneauth1 import loading as ks_loading +from keystoneauth1 import session as ks_session +from keystoneclient.v3 import client as kc_v3 +from oslo_config import cfg +from oslo_log import log as logging + +from coriolis import exception +from coriolis import utils as coriolis_utils +from coriolis.tests.integration import utils as test_utils + +LOG = logging.getLogger(__name__) + +# kolla publishes the official Keystone images that kolla-ansible deploys in +# production OpenStack. +KEYSTONE_IMAGE = "quay.io/openstack.kolla/keystone:2024.1-ubuntu-noble" + +# Project / domain / role name used to bootstrap Keystone for this session. +PROJECT_NAME = 'integration-project' +DOMAIN_NAME = 'Default' +ROLE_NAME = 'admin' + +_STARTUP_SH = """#!/bin/bash -x +set -o errexit +set -o pipefail + +FERNET_KEY_DIR="/etc/keystone/fernet-keys" +n=0 + +while [ ! -f "${FERNET_KEY_DIR}/0" ]; do + if [ $n -gt 30 ]; then + exit 1 + fi + n=$(( n + 1 )) + sleep 5 +done + +exec /usr/sbin/apache2 -DFOREGROUND +""" + +_WSGI_CONF = """ +Listen 0.0.0.0:5000 + +ServerSignature Off +ServerTokens Prod +TraceEnable off + +ErrorLog "/var/log/kolla/keystone/apache-error.log" + + CustomLog "/var/log/kolla/keystone/apache-access.log" common + + + + + AllowOverride None + Options None + Require all granted + + + + + WSGIDaemonProcess keystone-public processes=2 threads=1 \ +user=keystone group=keystone display-name=keystone-public + WSGIProcessGroup keystone-public + WSGIScriptAlias / /var/lib/kolla/venv/bin/keystone-wsgi-public + WSGIApplicationGroup %{GLOBAL} + WSGIPassAuthorization On + ErrorLog "/var/log/kolla/keystone/keystone-apache-public-error.log" + CustomLog "/var/log/kolla/keystone/keystone-apache-public-access.log" common + +""" + + +class KeystoneContainer: + """A bootstrapped Keystone container for the integration tests session.""" + + def __init__( + self, workdir, session_tag, db_container_name, db_ip, db_username, db_password + ): + self._workdir = workdir + self._db_container_name = db_container_name + self._db_ip = db_ip + self._db_username = db_username + self._db_password = db_password + + self.container_name = "coriolis-test-keystone-%s" % session_tag + self.admin_username = "admin" + self.admin_password = str(uuid.uuid4()) + self.auth_url = None + # Real project ID assigned by Keystone at bootstrap time; resolved in start(). + self.project_id = None + + def setup(self): + """Start and bootstrap the container, and configure coriolis. + + Reuses the mariadb container for a second "keystone" schema. + """ + self._create_database() + config_dir = self._write_config_files() + + coriolis_utils.exec_process( + [ + "docker", + "run", + "-d", + "--name", + self.container_name, + # Required by kolla_set_configs, the image's entrypoint + # config renderer, to actually copy our config_files in. + "-e", + "KOLLA_CONFIG_STRATEGY=COPY_ALWAYS", + "-v", + f"{config_dir}:/var/lib/kolla/config_files:ro", + KEYSTONE_IMAGE, + ] + ) + + keystone_ip = test_utils.get_container_ip(self.container_name) + self.auth_url = "http://%s:5000/v3" % keystone_ip + self._bootstrap("http://%s:5000/v3/" % keystone_ip) + self._wait_until_ready() + + keystone_client = kc_v3.Client(session=self.get_admin_session()) + self.project_id = keystone_client.projects.find(name=PROJECT_NAME).id + + self._configure_coriolis() + + def teardown(self): + coriolis_utils.exec_process(["docker", "stop", self.container_name]) + coriolis_utils.exec_process(["docker", "rm", self.container_name]) + + def get_auth(self): + """Returns a keystoneauth plugin for the bootstrapped admin user.""" + return ks_identity.v3.Password( + auth_url=self.auth_url, + username=self.admin_username, + password=self.admin_password, + project_name=PROJECT_NAME, + user_domain_name=DOMAIN_NAME, + project_domain_name=DOMAIN_NAME, + ) + + def get_admin_session(self): + return ks_session.Session(auth=self.get_auth()) + + # mariadb may not have finished starting up right after `docker run -d`, and the + # one-shot `docker exec mysql` below has no retry of its own. + @coriolis_utils.retry_on_error( + max_attempts=60, + sleep_seconds=1, + retried_exceptions=(exception.CoriolisException,), + ) + def _create_database(self): + """Create the "keystone" schema on the shared mariadb container.""" + coriolis_utils.exec_process( + [ + "docker", + "exec", + self._db_container_name, + "mysql", + "-u", + self._db_username, + f"-p{self._db_password}", + "-e", + "CREATE DATABASE IF NOT EXISTS keystone;", + ] + ) + + def _write_config_files(self): + """Writes the kolla config files and returns their directory.""" + db_url = ('mysql+pymysql://%(user)s:%(password)s@%(host)s:3306/keystone') % { + "user": self._db_username, + "password": self._db_password, + "host": self._db_ip, + } + + config_dir = os.path.join(self._workdir, "keystone_config") + os.makedirs(config_dir) + + keystone_conf = ( + "[DEFAULT]\n" + "log_file = /var/log/kolla/keystone/keystone.log\n" + "use_stderr = True\n\n" + "[database]\n" + "connection = %(db_url)s\n" + "max_retries = -1\n" + "retry_interval = 1\n\n" + "[token]\n" + "provider = fernet\n\n" + "[cache]\n" + "enabled = False\n\n" + "[oslo_concurrency]\n" + "lock_path = /var/lib/keystone/tmp\n" + ) % {"db_url": db_url} + + with open(os.path.join(config_dir, "keystone.conf"), "w") as f: + f.write(keystone_conf) + with open(os.path.join(config_dir, "wsgi-keystone.conf"), "w") as f: + f.write(_WSGI_CONF) + with open(os.path.join(config_dir, "keystone-startup.sh"), "w") as f: + f.write(_STARTUP_SH) + + # Its only purpose is to force kolla_set_configs to create the + # /var/log/kolla/keystone directory that Apache logs into (it only + # creates parent directories of "config_files" destinations). + with open(os.path.join(config_dir, "log_dir_placeholder"), "w"): + pass + + config_json = { + "command": "/usr/bin/keystone-startup.sh", + "config_files": [ + { + "source": "/var/lib/kolla/config_files/keystone-startup.sh", + "dest": "/usr/bin/keystone-startup.sh", + "owner": "root", + "perm": "0755", + }, + { + "source": "/var/lib/kolla/config_files/keystone.conf", + "dest": "/etc/keystone/keystone.conf", + "owner": "keystone", + "perm": "0600", + }, + { + "source": "/var/lib/kolla/config_files/wsgi-keystone.conf", + "dest": "/etc/apache2/conf-enabled/wsgi-keystone.conf", + "owner": "root", + "perm": "0644", + }, + { + "source": "/var/lib/kolla/config_files/log_dir_placeholder", + "dest": "/var/log/kolla/keystone/.placeholder", + "owner": "root", + "perm": "0644", + }, + ], + } + with open(os.path.join(config_dir, "config.json"), "w") as f: + json.dump(config_json, f) + + return config_dir + + def _manage(self, *args): + coriolis_utils.exec_process( + [ + "docker", + "exec", + self.container_name, + "keystone-manage", + "--config-file", + "/etc/keystone/keystone.conf", + ] + + list(args) + ) + + def _bootstrap(self, keystone_url): + self._manage("db_sync") + self._manage( + "fernet_setup", + "--keystone-user", + "keystone", + "--keystone-group", + "keystone", + ) + self._manage( + "bootstrap", + "--bootstrap-username", + self.admin_username, + "--bootstrap-password", + self.admin_password, + "--bootstrap-project-name", + PROJECT_NAME, + "--bootstrap-role-name", + ROLE_NAME, + "--bootstrap-service-name", + "keystone", + "--bootstrap-region-id", + "RegionOne", + "--bootstrap-admin-url", + keystone_url, + "--bootstrap-internal-url", + keystone_url, + "--bootstrap-public-url", + keystone_url, + ) + + def _wait_until_ready(self, timeout=120): + last_exc = None + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(self.auth_url, timeout=5) as resp: + if resp.status in (200, 300): + return + except (urllib.error.URLError, OSError) as exc: + last_exc = exc + + time.sleep(2) + + raise exception.CoriolisException( + "Keystone at %s not ready after %ds: %s" + % (self.auth_url, timeout, last_exc) + ) + + def _configure_coriolis(self): + cfg.CONF.set_override('auth_url', self.auth_url, group='keystone') + cfg.CONF.set_override('identity_api_version', 3, group='keystone') + + # The "password" auth plugin's own options (auth_url, username, ...) + # only get registered into CONF once something loads that plugin for + # a given group; register them upfront so set_override() below works. + password_plugin_opts = ks_loading.get_auth_plugin_conf_options('password') + for group in ('trustee', 'keystone_authtoken'): + cfg.CONF.register_opts(password_plugin_opts, group=group) + cfg.CONF.set_override('auth_type', 'password', group=group) + cfg.CONF.set_override('auth_url', self.auth_url, group=group) + cfg.CONF.set_override('username', self.admin_username, group=group) + cfg.CONF.set_override('password', self.admin_password, group=group) + cfg.CONF.set_override('user_domain_name', DOMAIN_NAME, group=group) + + if group != 'trustee': + # coriolis.keystone loads [trustee] with an explicit trust_id once a + # trust exists. A plugin can't be scoped to both a project and a trust + # at once, so [trustee] must stay unscoped (unset project_name / + # project_domain_name), while [keystone_authtoken]'s own SA plugin + # (used to validate incoming tokens, never trust-scoped) needs both. + cfg.CONF.set_override('project_name', PROJECT_NAME, group=group) + cfg.CONF.set_override('project_domain_name', DOMAIN_NAME, group=group) + + cfg.CONF.set_override( + 'www_authenticate_uri', self.auth_url, group='keystone_authtoken' + ) + cfg.CONF.set_override('interface', 'public', group='keystone_authtoken')