Skip to content

Commit a766c33

Browse files
claudiubeluDany9966
authored andcommitted
integration: Adds OS morphing deployment integration test
Replace the stub `deploy_os_morphing_resources` / `delete_os_morphing_resources` with a real implementation that starts a container with openssh-server, injects the test public key via bind mount, and returns SSH connection info for the OS morphing pipeline. Adds OS morphing test, in which we prepare a source disk with an Ubuntu filesystem, based on the Ubuntu container image. The test expects that a package (jq) will be present after the OS morphing process.
1 parent 28bd228 commit a766c33

10 files changed

Lines changed: 194 additions & 22 deletions

File tree

coriolis/tests/integration/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ def f(*args, **kwargs):
230230
class ReplicaIntegrationTestBase(CoriolisIntegrationTestBase):
231231

232232
_CREATE_MINION_POOLS = False
233+
_SCSI_DEBUG_SIZE_MB = 16
233234

234235
@classmethod
235236
def setUpClass(cls):
@@ -280,6 +281,10 @@ def setUpClass(cls):
280281
"Pool did not reach ALLOCATED (got %s)" % pool_obj.status,
281282
)
282283

284+
# (re)init the scsi_debug module.
285+
test_utils.destroy_scsi_debug()
286+
test_utils.init_scsi_debug(size_mb=cls._SCSI_DEBUG_SIZE_MB)
287+
283288
def setUp(self):
284289
super().setUp()
285290

File renamed without changes.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright 2026 Cloudbase Solutions Srl
2+
# All Rights Reserved.
3+
4+
"""Integration tests for the OS morphing deployments.
5+
6+
Exercises deployments with skip_os_morphing=False, OS detection, and package
7+
installation in the target OS.
8+
"""
9+
10+
from coriolis.tests.integration import base as integration_base
11+
from coriolis.tests.integration import utils as test_utils
12+
13+
14+
class OsMorphingDeploymentTest(integration_base.ReplicaIntegrationTestBase):
15+
16+
# NOTE(claudiub): Size must be high enough to contain the tested OS and
17+
# any new packages to be added during OS morphing.
18+
_SCSI_DEBUG_SIZE_MB = 256
19+
20+
def setUp(self):
21+
super().setUp()
22+
test_utils.write_os_image_to_disk(self._src_device, "ubuntu:24.04")
23+
24+
def test_deployment_with_os_morphing(self):
25+
self.assertFalse(
26+
test_utils.path_exists_on_device(self._src_device, "usr/bin/jq"),
27+
"jq was found on the source device before OS morphing",
28+
)
29+
30+
self._execute_and_wait(self._transfer.id)
31+
32+
deployment = self._client.deployments.create_from_transfer(
33+
self._transfer.id,
34+
skip_os_morphing=False,
35+
)
36+
self.addCleanup(self._client.deployments.delete, deployment.id)
37+
38+
self.assertDeploymentCompleted(deployment.id)
39+
self.assertTrue(
40+
test_utils.path_exists_on_device(self._dst_device, "usr/bin/jq"),
41+
"jq was not found on the destination device after OS morphing",
42+
)

coriolis/tests/integration/dockerfiles/data-minion/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ FROM ubuntu:24.04
55

66
# dbus is required for systemd to fully manage units;
77
# sudo is used by replicator / writer setup.
8+
# kmod is required during OS morphing (modprobe is being called).
89
RUN apt-get update && apt-get install -y --no-install-recommends \
910
dbus \
11+
kmod \
1012
openssh-server \
1113
sudo \
1214
systemd \

coriolis/tests/integration/harness.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,6 @@ def __init__(self):
298298
group='minion_manager')
299299

300300
coriolis_utils.setup_logging()
301-
test_utils.init_scsi_debug()
302301

303302
# Policy enforcer: reset so it re-reads the new CONF (no policy file).
304303
policy_module.reset()

coriolis/tests/integration/providers/__init__.py

Whitespace-only changes.

coriolis/tests/integration/test_provider/imp.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from coriolis.providers.base import BaseReplicaImportProvider
2525
from coriolis.providers.base import BaseReplicaImportValidationProvider
2626
from coriolis.providers.base import BaseUpdateDestinationReplicaProvider
27+
from coriolis.tests.integration.test_provider import osmorphing
2728
from coriolis.tests.integration import utils as test_utils
2829
from coriolis import utils as coriolis_utils
2930

@@ -155,8 +156,9 @@ def deploy_replica_disks(
155156

156157
def deploy_replica_target_resources(
157158
self, ctxt, connection_info, target_environment, volumes_info):
159+
devices = [vol["volume_dev"] for vol in volumes_info]
158160
result = self._create_minion(
159-
"coriolis-writer", connection_info, volumes_info)
161+
"coriolis-writer", connection_info, devices)
160162

161163
return {
162164
"volumes_info": volumes_info,
@@ -165,18 +167,18 @@ def deploy_replica_target_resources(
165167
}
166168

167169
def _create_minion(
168-
self, name_prefix, connection_info, volumes_info,
169-
device_cgroup_rules=None):
170+
self, name_prefix, connection_info, devices=None, volumes=None,
171+
device_cgroup_rules=None, setup_writer=True):
170172
pkey_path = connection_info["pkey_path"]
171-
dest_devices = [vol["volume_dev"] for vol in volumes_info]
172173
container_name = "%s-%s" % (name_prefix, uuid.uuid4().hex[:8])
173174

174175
container_id = test_utils.run_container(
175176
test_utils.DATA_MINION_IMAGE,
176177
container_name,
177178
is_systemd=True,
178179
ssh_key=f"{pkey_path}.pub",
179-
devices=dest_devices,
180+
devices=devices,
181+
volumes=volumes,
180182
device_cgroup_rules=device_cgroup_rules,
181183
)
182184

@@ -189,20 +191,23 @@ def _create_minion(
189191
"ip": container_ip,
190192
"port": 22,
191193
"username": "root",
192-
"pkey": pkey,
194+
"pkey": coriolis_utils.serialize_key(pkey),
193195
}
194-
bootstrapper = backup_writers.HTTPBackupWriterBootstrapper(
195-
ssh_conn_info, WRITER_TEST_PORT)
196-
writer_conn_details = bootstrapper.setup_writer()
197196

198-
return {
197+
info = {
199198
"container_id": container_id,
200199
"ssh_connection_info": ssh_conn_info,
201-
"backup_writer_connection_info": {
200+
}
201+
if setup_writer:
202+
bootstrapper = backup_writers.HTTPBackupWriterBootstrapper(
203+
ssh_conn_info, WRITER_TEST_PORT)
204+
writer_conn_details = bootstrapper.setup_writer()
205+
info["backup_writer_connection_info"] = {
202206
"backend": "http_backup_writer",
203207
"connection_details": writer_conn_details,
204-
},
205-
}
208+
}
209+
210+
return info
206211
except Exception:
207212
test_utils.remove_container(container_id)
208213
raise
@@ -265,19 +270,47 @@ def cleanup_failed_replica_instance_deployment(
265270
# BaseInstanceProvider
266271

267272
def get_os_morphing_tools(self, os_type, osmorphing_info):
268-
return []
273+
return osmorphing.OS_MORPHERS
269274

270275
# BaseImportInstanceProvider
271276

272277
def deploy_os_morphing_resources(
273278
self, ctxt, connection_info, target_environment,
274279
instance_deployment_info):
275-
return {}
280+
devices = list(target_environment.get("devices", []))
281+
282+
# lsblk inside the container sees all the host block devices because
283+
# Docker containers share the host kernel's sysfs (/sys/block/).
284+
# Populate ignore_devices with every host disk except the target
285+
# so osmorphing only considers the devices we actually attached.
286+
ignore_devices = list(
287+
test_utils.get_host_disk_devices() - set(devices)
288+
)
289+
290+
# Mount the host's /lib/modules tree so that modprobe can
291+
# resolve built-in modules.
292+
volumes = ["/lib/modules:/lib/modules:ro"]
293+
result = self._create_minion(
294+
"coriolis-osmorphing", connection_info, devices,
295+
volumes, setup_writer=False,
296+
)
297+
298+
return {
299+
"os_morphing_resources": {"container_id": result["container_id"]},
300+
"osmorphing_connection_info": result["ssh_connection_info"],
301+
"osmorphing_info": {
302+
"os_type": instance_deployment_info.get("os_type", "linux"),
303+
"ignore_devices": ignore_devices,
304+
},
305+
}
276306

277307
def delete_os_morphing_resources(
278308
self, ctxt, connection_info, target_environment,
279309
os_morphing_resources):
280-
pass
310+
if os_morphing_resources:
311+
container_id = os_morphing_resources.get("container_id")
312+
if container_id:
313+
test_utils.remove_container(container_id)
281314

282315
# BaseReplicaImportValidationProvider
283316

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Copyright 2026 Cloudbase Solutions Srl
2+
# All Rights Reserved.
3+
4+
from coriolis.osmorphing import base
5+
from coriolis.tests.integration.test_provider.osmorphing import ubuntu
6+
7+
8+
OS_MORPHERS: list[base.BaseLinuxOSMorphingTools] = [
9+
ubuntu.TestUbuntuOSMorphingTools,
10+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Copyright 2026 Cloudbase Solutions Srl
2+
# All Rights Reserved.
3+
4+
"""
5+
Ubuntu OS Morphing tools.
6+
"""
7+
8+
from coriolis.osmorphing import ubuntu
9+
10+
11+
class TestUbuntuOSMorphingTools(ubuntu.BaseUbuntuMorphingTools):
12+
"""Ubuntu OSMorphing tools for integration tests."""
13+
14+
# Package meant to be installed during OS morphing.
15+
# jq is a very small package which is not available by default.
16+
_packages = {
17+
None: [("jq", True)],
18+
}

coriolis/tests/integration/utils.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@
3030
DATA_MINION_IMAGE = "coriolis-data-minion:test"
3131

3232

33+
def get_host_disk_devices() -> set:
34+
"""Return the /dev paths of disk-type block devices visible on the host."""
35+
disk_names = _lsblk_disk_names()
36+
return {"/dev/" + disk_name for disk_name in disk_names}
37+
38+
3339
def _lsblk_disk_names() -> set:
3440
"""Return the set of disk-type block device names visible to lsblk."""
3541
result = _run(["lsblk", "-Jb", "-o", "NAME,TYPE"], check=False)
@@ -62,12 +68,13 @@ def _poll_for_new_disks(before, count, timeout=_SETTLE_TIMEOUT):
6268
)
6369

6470

65-
def init_scsi_debug(size_mb=64):
66-
"""Load scsi_debug with per_host_store=1.
71+
def init_scsi_debug(size_mb=16):
72+
"""Load scsi_debug with per_host_store=1 and size_mb per device.
6773
68-
Must be called once per process before any ``add_scsi_debug_device``
69-
calls. With ``per_host_store=1`` every host added via the sysfs knob
70-
gets its own independent backing store, so devices never share storage.
74+
Call ``destroy_scsi_debug`` first if the module is already loaded with a
75+
different size. With ``per_host_store=1`` every host added via the sysfs
76+
knob gets its own independent backing store, so devices never share
77+
storage.
7178
"""
7279
_run([
7380
"modprobe",
@@ -303,3 +310,59 @@ def unplug_device_from_container(container_id, device_path):
303310
"nsenter", "--target", str(pid), "--mount", "--",
304311
"rm", "-f", device_path,
305312
], check=False)
313+
314+
315+
# OS Morphing utils
316+
317+
318+
def write_os_image_to_disk(device_path, container_image):
319+
"""Write a real Linux rootfs to *device_path*.
320+
321+
Exports the filesystem of a container image via ``docker export`` and
322+
extracts it onto an ext4-formatted device, giving a chroot-able root with
323+
that container OS' standard filesystem and binaries present.
324+
"""
325+
_run(["mkfs.ext4", "-F", device_path])
326+
327+
result = _run(["docker", "create", container_image])
328+
container_id = result.stdout.decode().strip()
329+
330+
try:
331+
with tempfile.TemporaryDirectory() as mount_point:
332+
_run(["mount", device_path, mount_point])
333+
334+
try:
335+
export = subprocess.Popen(
336+
["docker", "export", container_id],
337+
stdout=subprocess.PIPE,
338+
stderr=subprocess.DEVNULL,
339+
)
340+
subprocess.run(
341+
["tar", "-x", "-C", mount_point],
342+
stdin=export.stdout,
343+
stdout=subprocess.DEVNULL,
344+
stderr=subprocess.DEVNULL,
345+
check=True,
346+
)
347+
export.stdout.close()
348+
export.wait()
349+
finally:
350+
_run(["umount", mount_point])
351+
352+
finally:
353+
_run(["docker", "rm", "-f", container_id], check=False)
354+
355+
356+
def path_exists_on_device(device_path, rel_path):
357+
"""Checks if *path* exists on the filesystem of *device_path*.
358+
359+
Mounts the device read-only into a temporary directory, checks for the
360+
path, then unmounts.
361+
"""
362+
with tempfile.TemporaryDirectory() as mount_point:
363+
_run(["mount", "-o", "ro", device_path, mount_point])
364+
365+
try:
366+
return os.path.exists(os.path.join(mount_point, rel_path))
367+
finally:
368+
_run(["umount", mount_point])

0 commit comments

Comments
 (0)