From 49429d717d0a269c0b7679d59decfedc6ebc4a7d Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Thu, 28 May 2026 13:01:16 +0000 Subject: [PATCH 1/3] integration: Move destination device initialization to the provider Currently, we create a destination device in the test base. However, the import provider is supposed to create the disks for transfered as required. Moving this part into the test provider will make it easier for us to swap in other providers later on. --- coriolis/tests/integration/base.py | 51 +++++++++++++++++-- coriolis/tests/integration/harness.py | 8 +++ .../tests/integration/test_provider/imp.py | 39 ++++++-------- 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/coriolis/tests/integration/base.py b/coriolis/tests/integration/base.py index f9c00e04..4e58b3a9 100644 --- a/coriolis/tests/integration/base.py +++ b/coriolis/tests/integration/base.py @@ -67,8 +67,10 @@ def setUpClass(cls): cls._exp_platform = cls._harness.exp_provider_platform cls._exp_conn_info = cls._harness.exp_conn_info + cls._imp_provider = cls._harness.imp_provider cls._imp_platform = cls._harness.imp_provider_platform cls._imp_conn_info = cls._harness.imp_conn_info + cls._imp_env_options = cls._harness.imp_env_options cls._client = cls.get_client() @@ -289,8 +291,6 @@ def setUp(self): self._src_device = test_utils.add_scsi_debug_device() self.addCleanup(test_utils.remove_scsi_debug_device) - self._dst_device = test_utils.add_scsi_debug_device() - self.addCleanup(test_utils.remove_scsi_debug_device) # Write a test pattern on the src device. # Incremental transfer tests update the second chunk (offset=4096). @@ -308,8 +308,11 @@ def setUp(self): instances=[self._instance_name], destination_minion_pool_id=self._pool_id, source_environment={"block_device_path": self._src_device}, - destination_environment={"devices": [self._dst_device]}, ) + # Safety-net cleanup for destination devices allocated by the provider. + # Must be registered after the transfer, so it runs (LIFO) before the + # transfer delete, while the volumes_info is still in the DB. + self.addCleanup(self._cleanup_provider_dst_devices) # mock a few commands that are going to be ran through ssh; they won't # pass anyway. @@ -325,6 +328,48 @@ def setUp(self): mocker.start() self.addCleanup(mocker.stop) + @property + def _dst_device(self): + """First destination dev path from the transfer's volumes_info.""" + ctxt = self._get_db_context() + + transfer = db_api.get_transfer( + ctxt, self._transfer.id, include_task_info=True) + info = transfer.get("info", {}).get(self._instance_name, {}) + for vol in info.get("volumes_info", []): + if vol.get("volume_dev"): + return vol["volume_dev"] + + return None + + def _cleanup_provider_dst_devices(self): + """Remove any devices the provider allocated for this test.""" + ctxt = self._get_db_context() + + try: + transfer = db_api.get_transfer( + ctxt, self._transfer.id, include_task_info=True) + volumes_info = transfer.get("info", {}).get( + self._instance_name, {}).get('volumes_info', []) + except Exception as ex: + LOG.warn("Could not get volumes info for cleanup. Ex: %s", ex) + return + + if not volumes_info: + LOG.info("No volume info. Nothing to cleanup.") + return + + try: + self._imp_provider.delete_replica_disks( + ctxt, + self._imp_conn_info, + self._imp_env_options, + volumes_info, + ) + except Exception as ex: + LOG.warn( + "Could not clean up provider dst devices. Ex: %s", ex) + def _execute_and_wait(self, transfer_id, timeout=300): """Trigger one execution of *transfer_id* and wait for completion.""" execution = self._client.transfer_executions.create( diff --git a/coriolis/tests/integration/harness.py b/coriolis/tests/integration/harness.py index b96d4a58..29317bef 100644 --- a/coriolis/tests/integration/harness.py +++ b/coriolis/tests/integration/harness.py @@ -25,6 +25,7 @@ import socket import subprocess import tempfile +from unittest import mock import uuid from cheroot.workers import threadpool as cheroot_threadpool @@ -50,6 +51,7 @@ from coriolis import exception from coriolis.minion_manager.rpc import server as minion_manager_rpc_server from coriolis import policy as policy_module +from coriolis.providers import factory as providers_factory from coriolis import rpc as rpc_module from coriolis.scheduler.rpc import server as scheduler_rpc_server from coriolis import service @@ -311,10 +313,16 @@ def __init__(self): self.imp_provider_class = _get_provider(_TEST_IMPORT_PROVIDER) self.imp_provider_platform = self.imp_provider_class.platform + self.imp_provider = providers_factory.get_provider( + self.imp_provider_platform, + constants.PROVIDER_TYPE_TRANSFER_IMPORT, + event_handler=mock.MagicMock(), + ) self.imp_conn_info = { "pkey_path": self.ssh_key_path, "role": "destination", } + self.imp_env_options = {} self._wsgi_server = None self._wsgi_server_thread = None diff --git a/coriolis/tests/integration/test_provider/imp.py b/coriolis/tests/integration/test_provider/imp.py index b37d4c78..897dec77 100644 --- a/coriolis/tests/integration/test_provider/imp.py +++ b/coriolis/tests/integration/test_provider/imp.py @@ -55,7 +55,6 @@ class TestImportProvider( ``target_environment`` (per-transfer destination settings) has the form:: { - "devices": ["/dev/sdY", ...], # pre-allocated destination devs } """ @@ -86,12 +85,7 @@ def validate_connection(self, ctxt, connection_info): def get_target_environment_schema(self): return { "type": "object", - "properties": { - "devices": { - "type": "array", - "items": {"type": "string"}, - }, - }, + "properties": {}, "required": [], } @@ -131,25 +125,14 @@ def check_update_destination_environment_params( def deploy_replica_disks( self, ctxt, connection_info, target_environment, instance_name, export_info, volumes_info): - """Map each source disk in export_info to a destination device. - - Returns a volumes_info list where each entry has ``disk_id`` (from - the source) and ``volume_dev`` (the destination block device path). - """ - dest_devices = list(target_environment["devices"]) + """Allocate disks and return volumes_info.""" src_disks = export_info.get("devices", {}).get("disks", []) - if len(src_disks) > len(dest_devices): - raise ValueError( - "Not enough destination devices (%d) for %d source disks" - % (len(dest_devices), len(src_disks)) - ) - result = [] for i, disk in enumerate(src_disks): result.append({ "disk_id": disk["id"], - "volume_dev": dest_devices[i], + "volume_dev": test_utils.add_scsi_debug_device(), }) return result @@ -221,7 +204,10 @@ def delete_replica_target_resources( def delete_replica_disks( self, ctxt, connection_info, target_environment, volumes_info): - # scsi_debug devices are managed externally; nothing to delete here. + for vol in volumes_info: + device = vol.get('volume_dev') + if device and os.path.exists(device): + test_utils.remove_scsi_debug_device() return volumes_info def create_replica_disk_snapshots( @@ -240,7 +226,14 @@ def restore_replica_disk_snapshots( def deploy_replica_instance( self, ctxt, connection_info, target_environment, instance_name, export_info, volumes_info, clone_disks): - return {"instance_deployment_info": {}} + devices = [ + vol["volume_dev"] for vol in volumes_info if vol.get("volume_dev") + ] + return { + "instance_deployment_info": { + "devices": devices, + }, + } def finalize_replica_instance_deployment( self, ctxt, connection_info, target_environment, @@ -277,7 +270,7 @@ def get_os_morphing_tools(self, os_type, osmorphing_info): def deploy_os_morphing_resources( self, ctxt, connection_info, target_environment, instance_deployment_info): - devices = list(target_environment.get("devices", [])) + devices = list(instance_deployment_info.get("devices", [])) # lsblk inside the container sees all the host block devices because # Docker containers share the host kernel's sysfs (/sys/block/). From 79b0b6c35ac80980fa5c5ca9e434ee08b27a90b9 Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Thu, 28 May 2026 08:15:22 +0000 Subject: [PATCH 2/3] integration: Add BaseTestImportProvider abstraction Introduces an `BaseTestImportProvider` ABC that contains provider-specific logic not currently defined in the import providers, meant to be used for testing-only purposes: - detect leaked resources - delete deployed replicas Adds an implementation for it, in `TestImportProvider`. --- coriolis/tests/integration/base.py | 27 +++++----- .../deployments/test_osmorphing.py | 10 ++++ coriolis/tests/integration/harness.py | 10 ++++ .../tests/integration/provider_test_base.py | 49 +++++++++++++++++++ .../tests/integration/test_provider/imp.py | 42 +++++++++++++++- .../integration/transfers/test_transfer.py | 32 ++++++------ coriolis/tests/integration/utils.py | 23 +++++++++ 7 files changed, 165 insertions(+), 28 deletions(-) create mode 100644 coriolis/tests/integration/provider_test_base.py diff --git a/coriolis/tests/integration/base.py b/coriolis/tests/integration/base.py index 4e58b3a9..70a1408c 100644 --- a/coriolis/tests/integration/base.py +++ b/coriolis/tests/integration/base.py @@ -13,7 +13,6 @@ """ import os -import subprocess import time import unittest from unittest import mock @@ -241,17 +240,7 @@ class ReplicaIntegrationTestBase(CoriolisIntegrationTestBase): @classmethod def setUpClass(cls): - result = subprocess.run( - ["docker", "image", "inspect", test_utils.DATA_MINION_IMAGE], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - if result.returncode != 0: - raise unittest.SkipTest( - "Docker image not found; build it with: " - "docker build -t %s " - "coriolis/tests/integration/dockerfiles/data-minion/" - % test_utils.DATA_MINION_IMAGE) + harness._IntegrationHarness.get().imp_provider.check_prerequisites() super().setUpClass() @@ -471,6 +460,9 @@ def _cleanup_deployment(self, deployment_id): that occurs when a deployment is still in-flight at cleanup time, which can happen with slow providers when a test fails or times out before the deployment completes. + + Calls ``_imp_provider.delete_deployed_instance`` for every deployment + instance, so that finalized VMs at the destination are destroyed. """ ctxt = self._get_db_context() deployment = db_api.get_deployment(ctxt, deployment_id) @@ -479,6 +471,8 @@ def _cleanup_deployment(self, deployment_id): "Deployment '%s' not found. Skip cleanup.", deployment_id) return + instances = list(deployment.instances or []) + if deployment.last_execution_status in ( constants.ACTIVE_EXECUTION_STATUSES): self._client.deployments.cancel(deployment_id) @@ -486,6 +480,15 @@ def _cleanup_deployment(self, deployment_id): self._client.deployments.delete(deployment_id) + for instance_name in instances: + try: + self._imp_provider.delete_deployed_instance( + self._imp_conn_info, instance_name) + except Exception as ex: + LOG.warning( + "Could not clean up deployed instance '%s': %s", + instance_name, ex) + def wait_for_deployment(self, deployment_id, timeout=300, desired_statuses=None): """Block until *deployment_id* reaches any terminal state. diff --git a/coriolis/tests/integration/deployments/test_osmorphing.py b/coriolis/tests/integration/deployments/test_osmorphing.py index ab49d341..48a2b27f 100644 --- a/coriolis/tests/integration/deployments/test_osmorphing.py +++ b/coriolis/tests/integration/deployments/test_osmorphing.py @@ -7,9 +7,11 @@ installation in the target OS. """ +import unittest import uuid from coriolis.tests.integration import base as integration_base +from coriolis.tests.integration import harness as integration_harness from coriolis.tests.integration import utils as test_utils @@ -19,6 +21,14 @@ class OsMorphingDeploymentTest(integration_base.ReplicaIntegrationTestBase): # any new packages to be added during OS morphing. _SCSI_DEBUG_SIZE_MB = 256 + @classmethod + def setUpClass(cls): + harness = integration_harness._IntegrationHarness.get() + if not harness.uses_test_import_provider(): + raise unittest.SkipTest( + "OS morphing tests require local disk access") + super().setUpClass() + def setUp(self): super().setUp() test_utils.write_os_image_to_disk(self._src_device, "ubuntu:24.04") diff --git a/coriolis/tests/integration/harness.py b/coriolis/tests/integration/harness.py index 29317bef..dddf051d 100644 --- a/coriolis/tests/integration/harness.py +++ b/coriolis/tests/integration/harness.py @@ -57,6 +57,7 @@ from coriolis import service from coriolis.taskflow import runner as taskflow_runner from coriolis.tasks import factory as task_runners_factory +from coriolis.tests.integration.test_provider import imp as test_provider_imp from coriolis.tests.integration import utils as test_utils from coriolis.transfer_cron.rpc import server as transfer_cron_rpc_server from coriolis import utils as coriolis_utils @@ -322,6 +323,7 @@ def __init__(self): "pkey_path": self.ssh_key_path, "role": "destination", } + self.imp_provider.initialize(self.imp_conn_info) self.imp_env_options = {} self._wsgi_server = None @@ -340,6 +342,7 @@ def __init__(self): sqlalchemy_api._facade = None rpc_module._TRANSPORT = None + atexit.register(self.imp_provider.teardown, self.imp_conn_info) atexit.register(self._teardown) self._start_db_container() @@ -522,3 +525,10 @@ def _teardown(self): test_utils.destroy_scsi_debug() except Exception: pass + + def uses_test_import_provider(self): + """Returns True when the test import provider is being used.""" + return isinstance( + self.imp_provider, + test_provider_imp.TestImportProvider, + ) diff --git a/coriolis/tests/integration/provider_test_base.py b/coriolis/tests/integration/provider_test_base.py new file mode 100644 index 00000000..901d3536 --- /dev/null +++ b/coriolis/tests/integration/provider_test_base.py @@ -0,0 +1,49 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +""" +Abstract base class for test import providers. + +Based on the Base* provider convention from coriolis/providers/base.py. + +The BaseTestImportProvider contains provider-specific logic not currently +defined in the import providers, meant to be used for testing-only purposes: + - detect leaked resources + - delete deployed replicas +""" + +import abc + +from oslo_log import log as logging + +LOG = logging.getLogger(__name__) + + +class BaseTestImportProvider(abc.ABC): + + def initialize(self, connection_info: dict): + """One-time initialization, before any tests run. + + Can be used to list the current resources on the target provider, + which can then be used to check if any test resources leaked and + clean them. + """ + + def teardown(self, connection_info: dict): + """One-time teardown called at atexit. + + Can be used to check and clean any leaked test resources. + """ + + def check_prerequisites(self): + """Raise ``unittest.SkipTest`` if required infrastructure is absent.""" + + def delete_deployed_instance( + self, connection_info: dict, instance_name: str, + ): + """Destroy the VM created at the destination by a completed deployment. + + Called during integration test cleanup after each deployment test, so + that finalized VMs do not accumulate across runs and cause failures in + later tests (e.g. name collisions, resource exhaustion). + """ diff --git a/coriolis/tests/integration/test_provider/imp.py b/coriolis/tests/integration/test_provider/imp.py index 897dec77..3939765b 100644 --- a/coriolis/tests/integration/test_provider/imp.py +++ b/coriolis/tests/integration/test_provider/imp.py @@ -10,6 +10,7 @@ """ import os +import unittest import uuid from oslo_log import log as logging @@ -24,6 +25,7 @@ from coriolis.providers.base import BaseReplicaImportProvider from coriolis.providers.base import BaseReplicaImportValidationProvider from coriolis.providers.base import BaseUpdateDestinationReplicaProvider +from coriolis.tests.integration import provider_test_base from coriolis.tests.integration.test_provider import osmorphing from coriolis.tests.integration import utils as test_utils from coriolis import utils as coriolis_utils @@ -33,6 +35,13 @@ # Port used by the test writer binary inside the container. WRITER_TEST_PORT = 6677 +# Name prefixes used by _create_minion callers. +_CONTAINER_PREFIXES = ( + "coriolis-writer-", + "coriolis-osmorphing-", + "coriolis-pool-minion-", +) + class TestImportProvider( BaseEndpointProvider, @@ -42,7 +51,8 @@ class TestImportProvider( BaseUpdateDestinationReplicaProvider, BaseReplicaImportProvider, BaseReplicaImportValidationProvider, - BaseDestinationMinionPoolProvider): + BaseDestinationMinionPoolProvider, + provider_test_base.BaseTestImportProvider): """Destination-side provider backed by a local `scsi_debug` block device. ``connection_info`` (the destination endpoint's connection info) has the @@ -63,6 +73,36 @@ class TestImportProvider( def __init__(self, event_handler): self._event_handler = event_handler + # BaseTestImportProvider - test only + + def initialize(self, connection_info: dict): + self._initial_containers = test_utils.list_containers( + _CONTAINER_PREFIXES + ) + + def teardown(self, connection_info: dict): + new_containers = test_utils.list_containers(_CONTAINER_PREFIXES) + leaked_containers = new_containers - self._initial_containers + + if not leaked_containers: + return + + for name in leaked_containers: + test_utils.remove_container(name) + + raise AssertionError( + "Found leaked containers during teardown: %s" % leaked_containers + ) + + def check_prerequisites(self): + if not test_utils.container_image_exists(test_utils.DATA_MINION_IMAGE): + raise unittest.SkipTest( + "Docker image '%s' not found; build it with: " + "docker build -t %s " + "coriolis/tests/integration/dockerfiles/data-minion/" + % (test_utils.DATA_MINION_IMAGE, test_utils.DATA_MINION_IMAGE) + ) + # BaseProvider / BaseEndpointProvider def get_connection_info_schema(self): diff --git a/coriolis/tests/integration/transfers/test_transfer.py b/coriolis/tests/integration/transfers/test_transfer.py index 23e8625b..32d7c92f 100644 --- a/coriolis/tests/integration/transfers/test_transfer.py +++ b/coriolis/tests/integration/transfers/test_transfer.py @@ -41,19 +41,19 @@ def test_incremental_replica_transfer(self): - Create source / destination endpoints and a Replica transfer via the Coriolis REST API (using coriolisclient). - Execute the transfer and wait for it to complete. - - Assert that the destination device contains the same data as the - source. - Overwrite a single chunk on the source device. - Execute a second transfer run (incremental=True). - - Assert that the destination now matches the updated source. + + The content is verified only if the test import provider is being used. """ # First run: full transfer self._execute_and_wait(self._transfer.id) - self.assertTrue( - test_utils.devices_match(self._src_device, self._dst_device), - "Devices do not match after initial full transfer", - ) + if self._harness.uses_test_import_provider(): + self.assertTrue( + test_utils.devices_match(self._src_device, self._dst_device), + "Devices do not match after initial full transfer", + ) # Mutate source: write a different pattern at the second chunk test_utils.write_bytes_at_offset( @@ -61,18 +61,20 @@ def test_incremental_replica_transfer(self): offset=4096, data=b"\xff\xfe\xfd\xfc" * 1024, ) - self.assertFalse( - test_utils.devices_match(self._src_device, self._dst_device), - "Devices should differ after mutating the source", - ) + if self._harness.uses_test_import_provider(): + self.assertFalse( + test_utils.devices_match(self._src_device, self._dst_device), + "Devices should differ after mutating the source", + ) # Second run: incremental self._execute_and_wait(self._transfer.id) - self.assertTrue( - test_utils.devices_match(self._src_device, self._dst_device), - "Destination does not match source after incremental transfer", - ) + if self._harness.uses_test_import_provider(): + self.assertTrue( + test_utils.devices_match(self._src_device, self._dst_device), + "Destination does not match source after incremental transfer", + ) class MinionPoolTransferTest( diff --git a/coriolis/tests/integration/utils.py b/coriolis/tests/integration/utils.py index b5efc6d7..de9cd499 100644 --- a/coriolis/tests/integration/utils.py +++ b/coriolis/tests/integration/utils.py @@ -197,6 +197,29 @@ def wait_for_ssh(host, port, username, pkey_path, timeout=30): # Docker utils +def list_containers(prefixes) -> set: + result = subprocess.run( + ["docker", "ps", "-a", "--format", "{{.Names}}"], + capture_output=True, + text=True, + ) + + return { + name for name in result.stdout.splitlines() + if any(name.startswith(p) for p in prefixes) + } + + +def container_image_exists(image_name): + result = subprocess.run( + ["docker", "image", "inspect", image_name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + return result.returncode == 0 + + def start_container(container_id): """Start a stopped Docker container.""" _run(["docker", "start", container_id], check=False) From 7da7ce5bbaa2b6f1f51ca36b94b467682b0e2333 Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Thu, 28 May 2026 08:16:30 +0000 Subject: [PATCH 3/3] integration: Add providers.yaml support for external providers - `_load_providers_config()`: parses the YAML file present at the CORIOLIS_PROVIDERS_YAML environment variable, which describes the destination provider, its connection info, target environment, storage mappings. If CORIOLIS_PROVIDERS_YAML is unset, the default test provider will be used instead. - `CORIOLIS_CONFIG_FILE`: optional `oslo.config` file path, so provider packages can register and read their own config options. - `providers.yaml.sample`: sample file for declaring providers. - `tox.ini`: pass all `CORIOLIS_*` env vars through; set `PBR_VERSION` to avoid pbr version-detection failures. - Randomizes `instance_name` used for transfer tests to avoid conflict. - Fixes `connection_info` reference in `test_minion_pools.py`. - Machine Pool allocation may take longer for other providers. 600s gives sufficient headroom. --- coriolis/tests/integration/README.md | 33 ++++++++++ coriolis/tests/integration/base.py | 15 +++-- coriolis/tests/integration/harness.py | 65 +++++++++++++++++-- .../tests/integration/providers.yaml.sample | 28 ++++++++ .../tests/integration/test_minion_pools.py | 5 +- tox.ini | 11 +++- 6 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 coriolis/tests/integration/providers.yaml.sample diff --git a/coriolis/tests/integration/README.md b/coriolis/tests/integration/README.md index 28286d60..b7e5b24e 100644 --- a/coriolis/tests/integration/README.md +++ b/coriolis/tests/integration/README.md @@ -98,6 +98,39 @@ sudo tox -e integration -- --no-discover coriolis.tests.integration.transfers.te > `sudo` is required because `tox` itself must run as root so that the > test process inherits root privileges. +## Using an external destination provider + +By default, the harness uses the built-in Docker test provider for both source +and destination. To run the integration suite against a real destination +provider, install the provider package via `CORIOLIS_PROVIDER_PACKAGE` and +supply provider configuration via `CORIOLIS_PROVIDERS_YAML`. + +### What the harness does with `providers.yaml` + +1. Registers the destination provider class with `oslo.config`. +2. Creates a destination endpoint with `destination.connection_info`. +3. Uses `destination.environment` as `destination_environment` and + `destination.storage_mappings` as `storage_mappings` for each transfer. + +### Running + +Set `CORIOLIS_PROVIDER_PACKAGE` to a local path or any pip-compatible specifier +(`git+file://`, `git+https://`, etc.); tox installs it into the virtualenv +before running the tests. Leave it unset to use only the built-in test provider. + +```bash +sudo -E CORIOLIS_PROVIDER_PACKAGE=/path/to/provider \ + CORIOLIS_PROVIDERS_YAML=./providers.yaml tox -e integration +``` + +Supply `CORIOLIS_CONFIG_FILE` when provider-specific configurations are required: + +```bash +sudo -E CORIOLIS_PROVIDER_PACKAGE=/path/to/provider \ + CORIOLIS_CONFIG_FILE=./provider.conf \ + CORIOLIS_PROVIDERS_YAML=./providers.yaml tox -e integration +``` + ## Test modules ### No block devices (extend `CoriolisIntegrationTestBase`) diff --git a/coriolis/tests/integration/base.py b/coriolis/tests/integration/base.py index 70a1408c..2efab830 100644 --- a/coriolis/tests/integration/base.py +++ b/coriolis/tests/integration/base.py @@ -16,6 +16,7 @@ import time import unittest from unittest import mock +import uuid from coriolisclient import client as coriolis_client from keystoneauth1 import session as ks_session @@ -70,6 +71,7 @@ def setUpClass(cls): cls._imp_platform = cls._harness.imp_provider_platform cls._imp_conn_info = cls._harness.imp_conn_info cls._imp_env_options = cls._harness.imp_env_options + cls._storage_mappings = cls._harness.imp_storage_mappings cls._client = cls.get_client() @@ -136,15 +138,19 @@ def _create_transfer( destination_environment=None, **kwargs): """Create a Replica transfer object and return its ID.""" + destination_environment = ( + destination_environment or self._imp_env_options + ) + transfer = self._client.transfers.create( origin_endpoint_id=src_id, destination_endpoint_id=dst_id, source_environment=source_environment or {}, - destination_environment=destination_environment or {}, + destination_environment=destination_environment, instances=instances, transfer_scenario=constants.TRANSFER_SCENARIO_REPLICA, network_map={}, - storage_mappings={}, + storage_mappings=self._storage_mappings, notes="integration test replica", skip_os_morphing=True, **kwargs, @@ -193,7 +199,7 @@ def _safe_delete_pool(cls, pool_id): cls._client.minion_pools.delete(pool_id) @classmethod - def _wait_for_pool(cls, pool_id, terminal_statuses, timeout=180): + def _wait_for_pool(cls, pool_id, terminal_statuses, timeout=600): """Poll the DB until *pool_id* reaches one of *terminal_statuses*. :returns: minion pool ORM object. @@ -290,7 +296,8 @@ def setUp(self): # Create transfer replica. # Use basename as instance name; real VM names do not contain slashes, # and some providers use the name as is in resource indentifiers. - self._instance_name = os.path.basename(self._src_device) + self._instance_name = "%s-%s" % ( + os.path.basename(self._src_device), uuid.uuid4().hex[:8]) self._transfer = self._create_transfer( self._src_endpoint.id, self._dst_endpoint.id, diff --git a/coriolis/tests/integration/harness.py b/coriolis/tests/integration/harness.py index dddf051d..531a77aa 100644 --- a/coriolis/tests/integration/harness.py +++ b/coriolis/tests/integration/harness.py @@ -28,6 +28,8 @@ from unittest import mock import uuid +import yaml + from cheroot.workers import threadpool as cheroot_threadpool from cheroot import wsgi as cheroot_wsgi from oslo_config import cfg @@ -57,6 +59,7 @@ from coriolis import service 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.test_provider import imp as test_provider_imp from coriolis.tests.integration import utils as test_utils from coriolis.transfer_cron.rpc import server as transfer_cron_rpc_server @@ -78,6 +81,44 @@ # 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") + + +def _load_providers_config(): + """Returns the provider configurations. + + If set, loads and returns the YAML file from the CORIOLIS_PROVIDERS_YAML + env variable. + If not set, returns a default configuration for the test providers. + """ + providers_config = {} + + if _PROVIDERS_YAML: + with open(_PROVIDERS_YAML) as f: + providers_config = yaml.safe_load(f) or {} + + dest_config = providers_config.get("destination", {}) + dest_provider_path = dest_config.get("provider") or _TEST_IMPORT_PROVIDER + dest_provider_cls = _get_provider(dest_provider_path) + + if not issubclass( + dest_provider_cls, provider_test_base.BaseTestImportProvider + ): + raise TypeError( + "%s must subclass BaseTestImportProvider" % dest_provider_path) + + return { + "destination": { + "provider": dest_provider_path, + "provider_cls": dest_provider_cls, + "connection_info": dest_config.get("connection_info"), + "environment": dest_config.get("environment") or {}, + "storage_mappings": dest_config.get("storage_mappings") or {}, + }, + } + def _get_provider(dotted_path): """Return the class at the *dotted_path*.""" @@ -276,11 +317,18 @@ def __init__(self): ) coriolis_conf.init_common_opts() - cfg.CONF([], project='coriolis', version='1.0.0', + _config_file = os.environ.get("CORIOLIS_CONFIG_FILE") + _conf_args = ( + ['--config-file', _config_file] if _config_file else [] + ) + cfg.CONF(_conf_args, project='coriolis', version='1.0.0', default_config_files=[], default_config_dirs=[]) cfg.CONF.set_override('messaging_transport_url', 'fake://') + + providers_config = _load_providers_config() + imp_provider = providers_config["destination"]["provider"] cfg.CONF.set_override( - 'providers', [_TEST_EXPORT_PROVIDER, _TEST_IMPORT_PROVIDER]) + 'providers', [_TEST_EXPORT_PROVIDER, imp_provider]) db_url = ('mysql+pymysql://%(user)s:%(password)s' '@localhost:13306/%(database)s') % { "user": self._mysql_username, @@ -305,6 +353,7 @@ def __init__(self): # Policy enforcer: reset so it re-reads the new CONF (no policy file). policy_module.reset() + # Init exporter. self.exp_provider_class = _get_provider(_TEST_EXPORT_PROVIDER) self.exp_provider_platform = self.exp_provider_class.platform self.exp_conn_info = { @@ -312,19 +361,25 @@ def __init__(self): "role": "source", } - self.imp_provider_class = _get_provider(_TEST_IMPORT_PROVIDER) + # Init importer. + imp_provider_cls = providers_config["destination"]["provider_cls"] + self.imp_provider_class = imp_provider_cls self.imp_provider_platform = self.imp_provider_class.platform self.imp_provider = providers_factory.get_provider( self.imp_provider_platform, constants.PROVIDER_TYPE_TRANSFER_IMPORT, event_handler=mock.MagicMock(), ) - self.imp_conn_info = { + conn_info = providers_config["destination"]["connection_info"] + self.imp_conn_info = conn_info or { "pkey_path": self.ssh_key_path, "role": "destination", } self.imp_provider.initialize(self.imp_conn_info) - self.imp_env_options = {} + self.imp_env_options = providers_config["destination"]["environment"] + self.imp_storage_mappings = ( + providers_config["destination"]["storage_mappings"] + ) self._wsgi_server = None self._wsgi_server_thread = None diff --git a/coriolis/tests/integration/providers.yaml.sample b/coriolis/tests/integration/providers.yaml.sample new file mode 100644 index 00000000..6663d386 --- /dev/null +++ b/coriolis/tests/integration/providers.yaml.sample @@ -0,0 +1,28 @@ +# Sample providers.yaml - built-in Docker test provider as destination. +# +# This sample uses the provider that ships with the test suite itself and +# requires no external packages or credentials. It is the same provider the +# harness uses when CORIOLIS_PROVIDERS_YAML is not set, so it is mainly useful +# as a reference for the file format. +# +# To use a real destination provider, copy this file, adjust the ``provider`` +# dotted path to point at your provider's Import Provider, and fill in +# ``connection_info``, ``environment``, and ``storage_mappings`` as required by +# that provider. +# +# Run with: +# sudo -E CORIOLIS_PROVIDER_PACKAGE=/path/to/provider \ +# CORIOLIS_PROVIDERS_YAML=./providers.yaml.sample tox -e integration + +destination: + # Dotted path to an Import Provider to test. Must also implement BaseTestImportProvider + provider: "coriolis.tests.integration.test_provider.imp.TestImportProvider" + + # connection_info is passed to the destination endpoint. + connection_info: null + + # destination_environment options forwarded to each transfer / deployment. + environment: {} + + # Storage backend mapping (source identifier -> destination pool / datastore). + storage_mappings: {} diff --git a/coriolis/tests/integration/test_minion_pools.py b/coriolis/tests/integration/test_minion_pools.py index 433b0ffe..93748944 100644 --- a/coriolis/tests/integration/test_minion_pools.py +++ b/coriolis/tests/integration/test_minion_pools.py @@ -22,10 +22,7 @@ def setUp(self): self._endpoint = self._create_endpoint( name="pool-dst", endpoint_type=self._imp_platform, - connection_info={ - "devices": [], - "pkey_path": self._harness.ssh_key_path, - }, + connection_info=self._imp_conn_info, ) def test_minion_pool_crud(self): diff --git a/tox.ini b/tox.ini index 43804ad1..e85d641e 100644 --- a/tox.ini +++ b/tox.ini @@ -30,12 +30,19 @@ commands = # Must be run as root: sudo -E tox -e integration # Requires the scsi_debug kernel module: modinfo scsi_debug # Requires kernel version 5.11 or newer (scsi_debug: per_host_store=1 parameter) -setenv = {[testenv]setenv} +# +# To test with an external provider, set CORIOLIS_PROVIDER_PACKAGE to a local +# path or pip-compatible specifier (git+file://, git+https://, etc.) and run: +# sudo -E CORIOLIS_PROVIDER_PACKAGE=/path/to/provider tox -e integration +setenv = + {[testenv]setenv} + PBR_VERSION = 0.0.1 passenv = - CORIOLIS_TEST_SSH_KEY_PATH + CORIOLIS_* deps = {[testenv]deps} git+https://github.com/cloudbase/python-coriolisclient.git + {env:CORIOLIS_PROVIDER_PACKAGE:} commands = stestr run --slowest --concurrency=1 --test-path coriolis/tests/integration/ {posargs} [testenv:venv]