From 50ed958953043affc37af8557ed33cdf43d8ce92 Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Mon, 27 Apr 2026 15:16:13 +0000 Subject: [PATCH 1/2] cleanup: Refactor SSH client creation SSH clients are being created in multiple places. We should add an util function for it. --- coriolis/osmorphing/osmount/base.py | 7 ++-- coriolis/providers/backup_writers.py | 34 ++++--------------- coriolis/providers/replicator.py | 16 +++------ .../providers/test_provider/imp.py | 5 +-- coriolis/tests/providers/test_replicator.py | 24 +++++++++++-- coriolis/utils.py | 33 ++++++++++++++++++ 6 files changed, 67 insertions(+), 52 deletions(-) diff --git a/coriolis/osmorphing/osmount/base.py b/coriolis/osmorphing/osmount/base.py index da714288..b7a1a585 100644 --- a/coriolis/osmorphing/osmount/base.py +++ b/coriolis/osmorphing/osmount/base.py @@ -11,7 +11,6 @@ import uuid from oslo_log import log as logging -import paramiko from six import with_metaclass from coriolis import exception @@ -82,10 +81,8 @@ def _connect(self): self._event_manager.progress_update( "Connecting through SSH to OSMorphing host on: %(ip)s:%(port)s" % ({"ip": ip, "port": port})) - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect(hostname=ip, port=port, username=username, pkey=pkey, - password=password) + ssh = utils.connect_ssh( + ip, port, username, pkey=pkey, password=password) ssh.set_log_channel("paramiko.morpher.%s.%s" % (ip, port)) self._ssh = ssh diff --git a/coriolis/providers/backup_writers.py b/coriolis/providers/backup_writers.py index 2f947742..811ca3d8 100644 --- a/coriolis/providers/backup_writers.py +++ b/coriolis/providers/backup_writers.py @@ -564,20 +564,9 @@ def _copy_helper_cmd(self, ssh): def _connect_ssh(self): LOG.info("Connecting to SSH host: %(ip)s:%(port)s" % {"ip": self._ip, "port": self._port}) - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - ssh.connect( - hostname=self._ip, - port=self._port, - username=self._username, - pkey=self._pkey, - password=self._password) - except (Exception, KeyboardInterrupt): - # No need to log the error as we just raise - ssh.close() - raise - return ssh + return utils.connect_ssh( + self._ip, self._port, self._username, + pkey=self._pkey, password=self._password) class HTTPBackupWriterImpl(BaseBackupWriterImpl): @@ -957,20 +946,9 @@ def __init__(self, ssh_conn_info, writer_port): def _connect_ssh(self): LOG.info("Connecting to SSH host: %(ip)s:%(port)s" % {"ip": self._ip, "port": self._port}) - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - ssh.connect( - hostname=self._ip, - port=self._port, - username=self._username, - pkey=self._pkey, - password=self._password) - except (Exception, KeyboardInterrupt): - # No need to log the error as we just raise - ssh.close() - raise - return ssh + return utils.connect_ssh( + self._ip, self._port, self._username, + pkey=self._pkey, password=self._password) def _inject_dport_allow_rule(self, ssh): cmd = ( diff --git a/coriolis/providers/replicator.py b/coriolis/providers/replicator.py index 88f0b5d2..6db55e52 100644 --- a/coriolis/providers/replicator.py +++ b/coriolis/providers/replicator.py @@ -496,18 +496,10 @@ def _get_ssh_client(self, args): """ gets a paramiko SSH client """ - try: - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - ssh.connect(**args) - return ssh - except Exception: - ssh.close() - raise - except paramiko.ssh_exception.SSHException as ex: - raise exception.CoriolisException( - "Failed to setup SSH client: %s" % str(ex)) from ex + return utils.connect_ssh( + args["hostname"], args["port"], args["username"], + pkey=args.get("pkey"), password=args.get("password"), + banner_timeout=args.get("banner_timeout")) def _parse_source_ssh_conn_info(self, conn_info): # if we get valid SSH connection info we can diff --git a/coriolis/tests/integration/providers/test_provider/imp.py b/coriolis/tests/integration/providers/test_provider/imp.py index 85168a1e..a413cf28 100644 --- a/coriolis/tests/integration/providers/test_provider/imp.py +++ b/coriolis/tests/integration/providers/test_provider/imp.py @@ -259,10 +259,7 @@ def validate_replica_deployment_input( # Helpers def _ssh_connect(pkey_path): pkey = paramiko.RSAKey.from_private_key_file(pkey_path) - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect(hostname="127.0.0.1", username="root", pkey=pkey) - return ssh + return utils.connect_ssh("127.0.0.1", 22, "root", pkey=pkey) def _read_file(path): diff --git a/coriolis/tests/providers/test_replicator.py b/coriolis/tests/providers/test_replicator.py index 919a5776..7c45b6a3 100644 --- a/coriolis/tests/providers/test_replicator.py +++ b/coriolis/tests/providers/test_replicator.py @@ -673,12 +673,21 @@ def test__get_ssh_client(self, mock_ssh_client): original_get_ssh_client = testutils.get_wrapped_function( self.replicator._get_ssh_client) - result = original_get_ssh_client(self.replicator, self.conn_info) + arg = { + "hostname": self.conn_info["ip"], + "port": self.conn_info["port"], + "username": self.conn_info["username"], + "password": self.conn_info["password"], + "pkey": None, + "banner_timeout": ( + replicator_module.CONF.replicator.default_requests_timeout), + } + result = original_get_ssh_client(self.replicator, arg) mock_ssh_client.assert_called_once() self._ssh.set_missing_host_key_policy.assert_called_once_with( mock.ANY) - self._ssh.connect.assert_called_once_with(**self.conn_info) + self._ssh.connect.assert_called_once_with(**arg) self.assertEqual(result, mock_ssh_client.return_value) @@ -691,8 +700,17 @@ def test__get_ssh_client_exception(self, mock_ssh_client): original_get_ssh_client = testutils.get_wrapped_function( self.replicator._get_ssh_client) + arg = { + "hostname": self.conn_info["ip"], + "port": self.conn_info["port"], + "username": self.conn_info["username"], + "password": self.conn_info["password"], + "pkey": None, + "banner_timeout": ( + replicator_module.CONF.replicator.default_requests_timeout), + } self.assertRaises(exception.CoriolisException, original_get_ssh_client, - self.replicator, self.conn_info) + self.replicator, arg) def test__parse_source_ssh_conn_info(self): expected_arg = { diff --git a/coriolis/utils.py b/coriolis/utils.py index 920efdbc..0655e05a 100644 --- a/coriolis/utils.py +++ b/coriolis/utils.py @@ -523,6 +523,39 @@ def deserialize_key(key_bytes, password=None): return paramiko.RSAKey.from_private_key(key_io, password) +def connect_ssh(hostname, port, username, pkey=None, password=None, + connect_timeout=None, banner_timeout=None): + """Open and return a connected paramiko SSHClient. + + :param pkey: a paramiko.PKey instance or None. + :param password: plaintext password or None. + :param connect_timeout: socket-level timeout in seconds (None = default). + :param banner_timeout: banner timeout in seconds passed to paramiko. + :raises: exception.CoriolisException on failure. + """ + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + kwargs = dict( + hostname=hostname, port=port, username=username, + pkey=pkey, password=password) + + if connect_timeout is not None: + kwargs["timeout"] = connect_timeout + if banner_timeout is not None: + kwargs["banner_timeout"] = banner_timeout + + try: + ssh.connect(**kwargs) + except paramiko.ssh_exception.SSHException as ex: + raise exception.CoriolisException( + "Failed to setup SSH client: %s" % str(ex)) from ex + except (Exception, KeyboardInterrupt): + ssh.close() + raise + + return ssh + + def is_serializable(obj): pickle.dumps(obj) From 1e4ff74bc1f3826e85fe196b8ae077c1ffc2291e Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Wed, 22 Apr 2026 12:12:43 +0000 Subject: [PATCH 2/2] integration: Adds Docker container usage for minions Adds docker-related utils. Updates the test provider to create docker container minions instead of using 127.0.0.1 as a minion. Adds data-minion Dockerfile. The container image will have openssh-server (Coriolis will SSH into it) and systemd installed in it (Coriolis will set up systemd units for the replicator and the writer) The integration tests now require a container image for minions to run. --- .github/workflows/integration-tests.yml | 13 +- coriolis/tests/integration/README.md | 10 -- coriolis/tests/integration/base.py | 27 +++-- .../dockerfiles/data-minion/Dockerfile | 27 +++++ coriolis/tests/integration/harness.py | 10 ++ .../providers/test_provider/exp.py | 81 ++++++++----- .../providers/test_provider/imp.py | 91 +++++++------- coriolis/tests/integration/test_endpoints.py | 6 +- coriolis/tests/integration/utils.py | 112 ++++++++++++++++++ 9 files changed, 271 insertions(+), 106 deletions(-) create mode 100644 coriolis/tests/integration/dockerfiles/data-minion/Dockerfile diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 33eddf60..0ad00746 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -32,18 +32,11 @@ jobs: run: | sudo apt-get install -y linux-modules-extra-$(uname -r) - - name: Set up SSH for localhost + - name: Build Docker image for integration test minions shell: bash run: | - sudo apt-get install -y openssh-server - sudo mkdir -p /root/.ssh - sudo chmod 700 /root/.ssh - sudo ssh-keygen -t rsa -b 4096 -N "" -f /root/.ssh/id_rsa - sudo bash -c 'cat /root/.ssh/id_rsa.pub >> /root/.ssh/authorized_keys' - sudo chmod 600 /root/.ssh/authorized_keys - sudo bash -c 'echo "PermitRootLogin yes" >> /etc/ssh/sshd_config' - sudo systemctl start ssh - sudo ssh-keyscan -H 127.0.0.1 | sudo tee -a /root/.ssh/known_hosts + docker build -t coriolis-data-minion:test \ + coriolis/tests/integration/dockerfiles/data-minion/ - name: Run integration tests shell: bash diff --git a/coriolis/tests/integration/README.md b/coriolis/tests/integration/README.md index 4af6543c..57732c37 100644 --- a/coriolis/tests/integration/README.md +++ b/coriolis/tests/integration/README.md @@ -52,16 +52,6 @@ Key packages used by the harness: - `keystoneauth1`: session used by `coriolisclient` (auth is bypassed in tests) - `oslo.messaging`, `oslo.config`, `oslo.log`, `oslo.service` -### SSH key (for provider connection info) - -The test provider connection info includes a `pkey_path` field that -defaults to `/root/.ssh/id_rsa`. Override it with the environment -variable `CORIOLIS_TEST_SSH_KEY_PATH` if the key lives elsewhere. - -> The key is passed through to the provider's connection info dictionary -> but the smoke tests and current provider implementation do not actually -> open an SSH connection, so any readable file path satisfies the field. - ### Root access The tests must run as root because: diff --git a/coriolis/tests/integration/base.py b/coriolis/tests/integration/base.py index bb5fccf1..3f3bae35 100644 --- a/coriolis/tests/integration/base.py +++ b/coriolis/tests/integration/base.py @@ -13,6 +13,7 @@ """ import os +import subprocess import time import unittest from unittest import mock @@ -33,11 +34,6 @@ CONF = cfg.CONF LOG = logging.getLogger(__name__) -# Path to the SSH private key used to connect to the (local) provider. -# Override via the CORIOLIS_TEST_SSH_KEY_PATH environment variable. -_TEST_SSH_KEY_PATH = os.environ.get( - 'CORIOLIS_TEST_SSH_KEY_PATH', '/root/.ssh/id_rsa') - class CoriolisIntegrationTestBase(test_base.CoriolisBaseTestCase): """Base class for integration tests.""" @@ -108,6 +104,23 @@ def f(*args, **kwargs): 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) + + super().setUpClass() + def setUp(self): super().setUp() @@ -126,7 +139,7 @@ def setUp(self): description="integration source endpoint", connection_info={ "block_device_path": self._src_device, - "pkey_path": _TEST_SSH_KEY_PATH, + "pkey_path": self._harness.ssh_key_path, }, ) @@ -136,7 +149,7 @@ def setUp(self): description="integration destination endpoint", connection_info={ "devices": [self._dst_device], - "pkey_path": _TEST_SSH_KEY_PATH, + "pkey_path": self._harness.ssh_key_path, }, ) diff --git a/coriolis/tests/integration/dockerfiles/data-minion/Dockerfile b/coriolis/tests/integration/dockerfiles/data-minion/Dockerfile new file mode 100644 index 00000000..3781bd81 --- /dev/null +++ b/coriolis/tests/integration/dockerfiles/data-minion/Dockerfile @@ -0,0 +1,27 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +FROM ubuntu:24.04 + +# dbus is required for systemd to fully manage units; +# sudo is used by replicator / writer setup. +RUN apt-get update && apt-get install -y --no-install-recommends \ + dbus \ + openssh-server \ + sudo \ + systemd \ + && rm -rf /var/lib/apt/lists/* + +RUN systemctl enable ssh + +RUN sed -i \ + -e 's/^#\?PermitRootLogin.*/PermitRootLogin yes/' \ + -e 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' \ + -e 's/^#\?AuthorizedKeysFile.*/AuthorizedKeysFile .ssh\/authorized_keys/' \ + /etc/ssh/sshd_config && \ + echo 'StrictModes no' >> /etc/ssh/sshd_config + +# systemd requires these folders. +VOLUME ["/run", "/run/lock"] + +CMD ["/lib/systemd/systemd"] diff --git a/coriolis/tests/integration/harness.py b/coriolis/tests/integration/harness.py index 62ea907e..fc0ce6ab 100644 --- a/coriolis/tests/integration/harness.py +++ b/coriolis/tests/integration/harness.py @@ -22,6 +22,7 @@ import queue import shutil import socket +import subprocess import tempfile from unittest import mock import uuid @@ -221,6 +222,15 @@ def __init__(self): self._mysql_password = "coriolis" self._mysql_database = "coriolis" + self.ssh_key_path = os.path.join(self.workdir, "id_rsa") + subprocess.run( + ["ssh-keygen", "-t", "rsa", "-b", "2048", + "-f", self.ssh_key_path, "-N", ""], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + coriolis_conf.init_common_opts() cfg.CONF([], project='coriolis', version='1.0.0', default_config_files=[], default_config_dirs=[]) diff --git a/coriolis/tests/integration/providers/test_provider/exp.py b/coriolis/tests/integration/providers/test_provider/exp.py index 8417ad16..0933e1a6 100644 --- a/coriolis/tests/integration/providers/test_provider/exp.py +++ b/coriolis/tests/integration/providers/test_provider/exp.py @@ -4,11 +4,12 @@ """ Export-side (source) implementation of the test provider. -Uses Replicator (via SSH to 127.0.0.1) to deploy and manage the -coriolis-replicator service and perform disk replication. +Uses Replicator (via SSH to a Docker data-minion container) to deploy and +manage the coriolis-replicator service and perform disk replication. """ import os +import uuid from oslo_config import cfg from oslo_log import log as logging @@ -22,6 +23,7 @@ from coriolis.providers.base import BaseReplicaExportValidationProvider from coriolis.providers.base import BaseUpdateSourceReplicaProvider from coriolis.providers import replicator as replicator_module +from coriolis.tests.integration import utils as test_utils CONF = cfg.CONF LOG = logging.getLogger(__name__) @@ -51,16 +53,21 @@ def __init__(self, event_handler): def _event_manager(self): return events.EventManager(self._event_handler) - def _make_replicator(self, pkey_path, event_mgr, volumes_info, repl_state): - # TODO(claudiub): Use containers instead of using 127.0.0.1. - pkey = paramiko.RSAKey.from_private_key_file(pkey_path) - conn_info = { - "ip": "127.0.0.1", - "username": "root", + def _make_replicator(self, conn_info, event_mgr, volumes_info, repl_state): + """Build a Replicator that connects via SSH to *conn_info*. + + *conn_info* must contain ``ip``, ``port``, ``username``, and + ``pkey_path`` keys. + """ + pkey = paramiko.RSAKey.from_private_key_file(conn_info["pkey_path"]) + repl_conn_info = { + "ip": conn_info["ip"], + "port": conn_info.get("port", 22), + "username": conn_info.get("username", "root"), "pkey": pkey, } return replicator_module.Replicator( - conn_info, event_mgr, volumes_info, repl_state) + repl_conn_info, event_mgr, volumes_info, repl_state) # BaseProvider / BaseEndpointProvider @@ -179,42 +186,56 @@ def deploy_replica_source_resources( block_device_path = connection_info["block_device_path"] pkey_path = connection_info["pkey_path"] - replicator = self._make_replicator( - pkey_path, self._event_manager(), [], None) - replicator.init_replicator() - - disk_id = os.path.basename(block_device_path) - return { - "connection_info": { - "ip": "127.0.0.1", + container_name = "coriolis-replicator-%s" % uuid.uuid4().hex[:8] + container_id = test_utils.start_container( + test_utils.DATA_MINION_IMAGE, + container_name, + is_systemd=True, + ssh_key=f"{pkey_path}.pub", + devices=[block_device_path], + ) + + try: + container_ip = test_utils.get_container_ip(container_id) + test_utils.wait_for_ssh(container_ip, 22, "root", pkey_path) + + src_conn_info = { + "ip": container_ip, "port": 22, "username": "root", "pkey_path": pkey_path, - }, - "migr_resources": { - "disk_mappings": {disk_id: block_device_path}, - }, - } + } + replicator = self._make_replicator( + src_conn_info, self._event_manager(), [], None) + replicator.init_replicator() + + disk_id = os.path.basename(block_device_path) + return { + "connection_info": src_conn_info, + "migr_resources": { + "container_id": container_id, + "disk_mappings": {disk_id: block_device_path}, + }, + } + except Exception: + test_utils.stop_container(container_id) + raise def delete_replica_source_resources( self, ctxt, connection_info, source_environment, migr_resources_dict): - pkey_path = connection_info.get("pkey_path") - if not pkey_path: - return - replicator = self._make_replicator( - pkey_path, self._event_manager(), [], None) - replicator.stop() + container_id = (migr_resources_dict or {}).get("container_id") + if container_id: + test_utils.stop_container(container_id) def replicate_disks( self, ctxt, connection_info, source_environment, instance_name, source_resources, source_conn_info, target_conn_info, volumes_info, incremental): - pkey_path = source_conn_info["pkey_path"] repl_state = _extract_repl_state(volumes_info) if incremental else None replicator = self._make_replicator( - pkey_path, self._event_manager(), volumes_info, repl_state) + source_conn_info, self._event_manager(), volumes_info, repl_state) replicator.init_replicator() replicator.wait_for_chunks() diff --git a/coriolis/tests/integration/providers/test_provider/imp.py b/coriolis/tests/integration/providers/test_provider/imp.py index a413cf28..e4e9f479 100644 --- a/coriolis/tests/integration/providers/test_provider/imp.py +++ b/coriolis/tests/integration/providers/test_provider/imp.py @@ -4,12 +4,13 @@ """ Import-side (destination) implementation of the test provider. -Uses HTTPBackupWriterBootstrapper (via SSH to 127.0.0.1) to deploy and manage -the coriolis-writer service and provides the target_conn_info that -BackupWritersFactory expects. +Uses HTTPBackupWriterBootstrapper (via SSH to a Docker data-minion container) +to deploy and manage the coriolis-writer service and provides the +target_conn_info that BackupWritersFactory expects. """ import os +import uuid from oslo_log import log as logging import paramiko @@ -22,13 +23,12 @@ from coriolis.providers.base import BaseReplicaImportProvider from coriolis.providers.base import BaseReplicaImportValidationProvider from coriolis.providers.base import BaseUpdateDestinationReplicaProvider -from coriolis import utils +from coriolis.tests.integration import utils as test_utils LOG = logging.getLogger(__name__) -# Port used by the test writer binary. Chosen to avoid collision with the -# production default (6677). -WRITER_TEST_PORT = 16677 +# Port used by the test writer binary inside the container. +WRITER_TEST_PORT = 6677 class TestImportProvider( @@ -145,39 +145,50 @@ def deploy_replica_disks( def deploy_replica_target_resources( self, ctxt, connection_info, target_environment, volumes_info): pkey_path = connection_info["pkey_path"] - pkey = paramiko.RSAKey.from_private_key_file(pkey_path) - ssh_conn_info = { - "ip": "127.0.0.1", - "port": 22, - "username": "root", - "pkey": pkey, - } + dest_devices = [vol["volume_dev"] for vol in volumes_info] + container_name = "coriolis-writer-%s" % uuid.uuid4().hex[:8] - bootstrapper = backup_writers.HTTPBackupWriterBootstrapper( - ssh_conn_info, WRITER_TEST_PORT) - writer_conn_details = bootstrapper.setup_writer() + container_id = test_utils.start_container( + test_utils.DATA_MINION_IMAGE, + container_name, + is_systemd=True, + ssh_key=f"{pkey_path}.pub", + devices=dest_devices, + ) - return { - "volumes_info": volumes_info, - "connection_info": { - "backend": "http_backup_writer", - "connection_details": writer_conn_details, - }, - "migr_resources": {}, - } + try: + container_ip = test_utils.get_container_ip(container_id) + test_utils.wait_for_ssh(container_ip, 22, "root", pkey_path) + + pkey = paramiko.RSAKey.from_private_key_file(pkey_path) + ssh_conn_info = { + "ip": container_ip, + "port": 22, + "username": "root", + "pkey": pkey, + } + bootstrapper = backup_writers.HTTPBackupWriterBootstrapper( + ssh_conn_info, WRITER_TEST_PORT) + writer_conn_details = bootstrapper.setup_writer() + + return { + "volumes_info": volumes_info, + "connection_info": { + "backend": "http_backup_writer", + "connection_details": writer_conn_details, + }, + "migr_resources": {"container_id": container_id}, + } + except Exception: + test_utils.stop_container(container_id) + raise def delete_replica_target_resources( self, ctxt, connection_info, target_environment, migr_resources_dict): - pkey_path = connection_info.get("pkey_path") - if not pkey_path: - return - ssh = _ssh_connect(pkey_path) - try: - utils.stop_service( - ssh, backup_writers._CORIOLIS_HTTP_WRITER_CMD) - finally: - ssh.close() + container_id = (migr_resources_dict or {}).get("container_id") + if container_id: + test_utils.stop_container(container_id) def delete_replica_disks( self, ctxt, connection_info, target_environment, volumes_info): @@ -254,15 +265,3 @@ def validate_replica_import_input( def validate_replica_deployment_input( self, ctxt, connection_info, target_environment, export_info): return {} - - -# Helpers -def _ssh_connect(pkey_path): - pkey = paramiko.RSAKey.from_private_key_file(pkey_path) - return utils.connect_ssh("127.0.0.1", 22, "root", pkey=pkey) - - -def _read_file(path): - """Return the contents of *path* as a string.""" - with open(path) as fh: - return fh.read() diff --git a/coriolis/tests/integration/test_endpoints.py b/coriolis/tests/integration/test_endpoints.py index 137fae8a..ae803704 100644 --- a/coriolis/tests/integration/test_endpoints.py +++ b/coriolis/tests/integration/test_endpoints.py @@ -26,7 +26,7 @@ def setUp(self): endpoint_type="test-src", connection_info={ "block_device_path": "/dev/null", - "pkey_path": base._TEST_SSH_KEY_PATH, + "pkey_path": self._harness.ssh_key_path, }, ) # Empty devices list passes the destination validate_connection loop. @@ -35,7 +35,7 @@ def setUp(self): endpoint_type="test-dest", connection_info={ "devices": [], - "pkey_path": base._TEST_SSH_KEY_PATH, + "pkey_path": self._harness.ssh_key_path, }, ) @@ -54,7 +54,7 @@ def test_validate_connection_failure(self): endpoint_type="test-src", connection_info={ "block_device_path": "/dev/coriolis-no-such-device", - "pkey_path": base._TEST_SSH_KEY_PATH, + "pkey_path": self._harness.ssh_key_path, }, ) valid, message = self._client.endpoints.validate_connection( diff --git a/coriolis/tests/integration/utils.py b/coriolis/tests/integration/utils.py index 63961757..a45e6a3e 100644 --- a/coriolis/tests/integration/utils.py +++ b/coriolis/tests/integration/utils.py @@ -7,11 +7,15 @@ import json import os +import socket import subprocess import tempfile import time from oslo_log import log as logging +import paramiko + +from coriolis import utils as coriolis_utils LOG = logging.getLogger(__name__) @@ -23,6 +27,8 @@ # writing "-1" removes the most-recently added host (LIFO). _SCSI_DEBUG_ADD_HOST = "/sys/bus/pseudo/drivers/scsi_debug/add_host" +DATA_MINION_IMAGE = "coriolis-data-minion:test" + def _lsblk_disk_names() -> set: """Return the set of disk-type block device names visible to lsblk.""" @@ -145,3 +151,109 @@ def _run(cmd, check=True): stderr=subprocess.DEVNULL, check=check, ) + + +def wait_for_ssh(host, port, username, pkey_path, timeout=30): + """Block until SSH on *host*:*port* accepts connections. + + :param host: hostname or IP + :param port: SSH port + :param username: SSH username + :param pkey_path: path to the private key file + :param timeout: seconds before raising AssertionError + """ + pkey = paramiko.RSAKey.from_private_key_file(pkey_path) + deadline = time.monotonic() + timeout + last_exc = None + while time.monotonic() < deadline: + try: + client = coriolis_utils.connect_ssh( + host, port, username, pkey=pkey, connect_timeout=5) + client.close() + return + except (paramiko.SSHException, socket.error, OSError) as exc: + last_exc = exc + time.sleep(1) + raise AssertionError( + "SSH %s@%s:%d not ready after %ds: %s" % ( + username, host, port, timeout, last_exc)) + + +# Docker utils + + +def _start_container(image, name, extra_args=None): + cmd = ["docker", "run", "--detach", "--name", name] + if extra_args: + cmd.extend(extra_args) + cmd.append(image) + result = _run(cmd) + return result.stdout.decode().strip() + + +def start_container( + image, name, is_systemd=False, ssh_key=None, volumes=None, devices=None, + extra_args=None, +): + """Start a detached Docker container and return its container ID. + + :param image: Docker image name / tag to run. + :param name: Name to assign to the container. + :param is_systemd: If the container is running systemd. If true, the + necessary volumes, security opts, and caps are added for it to run. + :param ssh_key: SSH key to add as a volume to the authorized_keys. + :param volumes: List of volumes to attach to the container. + :param devices: List of devices to attach to the container. + :param extra_args: Optional list of extra ``docker run`` arguments. + :returns: container ID string (stripped). + """ + volumes = volumes or [] + devices = devices or [] + extra_args = extra_args or [] + sec_opts = [] + caps = [] + + if is_systemd: + volumes += ["/sys/fs/cgroup:/sys/fs/cgroup:rw"] + sec_opts = ["apparmor=unconfined"] + caps = ["SYS_ADMIN"] + extra_args += ["--cgroupns=host"] + + if ssh_key: + volumes = [f"{ssh_key}:/root/.ssh/authorized_keys:ro"] + volumes + + for volume in volumes: + extra_args += ["--volume", volume] + + for device in devices: + extra_args += ["--device", f"{device}:{device}"] + + for cap in caps: + extra_args += ["--cap-add", cap] + + for sec_opt in sec_opts: + extra_args += ["--security-opt", sec_opt] + + return _start_container(image, name, extra_args) + + +def stop_container(container_id): + """Stop and remove a Docker container, ignoring errors. + + :param container_id: container ID or name to stop / remove. + """ + _run(["docker", "stop", "--time", "5", container_id], check=False) + _run(["docker", "rm", "--force", container_id], check=False) + + +def get_container_ip(container_id): + """Return the first bridge-network IP address of *container_id*. + + :param container_id: container ID or name + :returns: IP address string + """ + result = _run( + ["docker", "inspect", "--format", + "{{.NetworkSettings.IPAddress}}", + container_id]) + return result.stdout.decode().strip()