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 f9c00e04..2efab830 100644 --- a/coriolis/tests/integration/base.py +++ b/coriolis/tests/integration/base.py @@ -13,10 +13,10 @@ """ import os -import subprocess import time import unittest from unittest import mock +import uuid from coriolisclient import client as coriolis_client from keystoneauth1 import session as ks_session @@ -67,8 +67,11 @@ 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._storage_mappings = cls._harness.imp_storage_mappings cls._client = cls.get_client() @@ -135,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, @@ -192,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. @@ -239,17 +246,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() @@ -289,8 +286,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). @@ -301,15 +296,19 @@ 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, 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 +324,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( @@ -426,6 +467,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) @@ -434,6 +478,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) @@ -441,6 +487,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 b96d4a58..531a77aa 100644 --- a/coriolis/tests/integration/harness.py +++ b/coriolis/tests/integration/harness.py @@ -25,8 +25,11 @@ import socket import subprocess import tempfile +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 @@ -50,11 +53,14 @@ 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 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 from coriolis import utils as coriolis_utils @@ -75,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*.""" @@ -273,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, @@ -302,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 = { @@ -309,12 +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_conn_info = { + self.imp_provider = providers_factory.get_provider( + self.imp_provider_platform, + constants.PROVIDER_TYPE_TRANSFER_IMPORT, + event_handler=mock.MagicMock(), + ) + 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 = providers_config["destination"]["environment"] + self.imp_storage_mappings = ( + providers_config["destination"]["storage_mappings"] + ) self._wsgi_server = None self._wsgi_server_thread = None @@ -332,6 +397,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() @@ -514,3 +580,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/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/coriolis/tests/integration/test_provider/imp.py b/coriolis/tests/integration/test_provider/imp.py index b37d4c78..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 @@ -55,7 +65,6 @@ class TestImportProvider( ``target_environment`` (per-transfer destination settings) has the form:: { - "devices": ["/dev/sdY", ...], # pre-allocated destination devs } """ @@ -64,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): @@ -86,12 +125,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 +165,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 +244,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 +266,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 +310,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/). 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) 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]