ソースを参照

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.
Claudiu Belu 3 週間 前
コミット
640780e061

+ 3 - 10
.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

+ 0 - 10
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:

+ 20 - 7
coriolis/tests/integration/base.py

@@ -13,6 +13,7 @@ Subclasses must be run as root.
 """
 
 import os
+import subprocess
 import time
 import unittest
 from unittest import mock
@@ -33,11 +34,6 @@ from coriolis.tests import test_base
 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 @@ class CoriolisIntegrationTestBase(test_base.CoriolisBaseTestCase):
 
 
 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 @@ class ReplicaIntegrationTestBase(CoriolisIntegrationTestBase):
             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 @@ class ReplicaIntegrationTestBase(CoriolisIntegrationTestBase):
             description="integration destination endpoint",
             connection_info={
                 "devices": [self._dst_device],
-                "pkey_path": _TEST_SSH_KEY_PATH,
+                "pkey_path": self._harness.ssh_key_path,
             },
         )
 

+ 27 - 0
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"]

+ 10 - 0
coriolis/tests/integration/harness.py

@@ -22,6 +22,7 @@ import os
 import queue
 import shutil
 import socket
+import subprocess
 import tempfile
 from unittest import mock
 import uuid
@@ -221,6 +222,15 @@ class _IntegrationHarness:
         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=[])

+ 51 - 30
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 BaseReplicaExportProvider
 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 @@ class TestExportProvider(
     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 @@ class TestExportProvider(
         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()
 

+ 45 - 46
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 BaseEndpointStorageProvider
 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 @@ class TestImportProvider(
     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 @@ class TestImportProvider(
     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()

+ 3 - 3
coriolis/tests/integration/test_endpoints.py

@@ -26,7 +26,7 @@ class EndpointCapabilitiesTest(base.CoriolisIntegrationTestBase):
             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 @@ class EndpointCapabilitiesTest(base.CoriolisIntegrationTestBase):
             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 @@ class EndpointCapabilitiesTest(base.CoriolisIntegrationTestBase):
             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(

+ 112 - 0
coriolis/tests/integration/utils.py

@@ -7,11 +7,15 @@ Integration test utils.
 
 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 @@ _POLL_INTERVAL = 1
 # 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()