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')