From 80942eeefbe987edefb4e89cc152b85cf279f264 Mon Sep 17 00:00:00 2001 From: Mihaela Balutoiu Date: Wed, 25 Feb 2026 12:28:45 +0200 Subject: [PATCH 1/2] Update `systemd` unit path detection On newer distros, `systemd` default configurations have been moved from `/lib` to `/usr/lib`. Signed-off-by: Mihaela Balutoiu --- coriolis/tests/test_utils.py | 59 +++++++++++++++++++++++++++--------- coriolis/utils.py | 24 ++++++++++++--- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/coriolis/tests/test_utils.py b/coriolis/tests/test_utils.py index 807897d0..02552865 100644 --- a/coriolis/tests/test_utils.py +++ b/coriolis/tests/test_utils.py @@ -915,14 +915,16 @@ def test_read_ssh_ini_config_file_check_true_path_not_exists( def test_write_systemd(self, mock_uuid, mock_test_ssh, mock_write_ssh_file, mock_exec_ssh_cmd): mock_uuid.return_value = 'uuid' - mock_test_ssh.return_value = False + mock_test_ssh.side_effect = [True, False] mock_write_ssh_file.return_value = None utils._write_systemd(self.mock_ssh, 'cmdline', 'svc_name') mock_uuid.assert_called_once_with() - mock_test_ssh.assert_called_once_with( - self.mock_ssh, '/lib/systemd/system/svc_name.service') + mock_test_ssh.assert_has_calls([ + mock.call(self.mock_ssh, '/lib/systemd/system'), + mock.call(self.mock_ssh, + '/lib/systemd/system/svc_name.service')]) mock_write_ssh_file.assert_called_once_with(self.mock_ssh, '/tmp/uuid.service', mock.ANY) @@ -936,14 +938,37 @@ def test_write_systemd(self, mock_uuid, mock_test_ssh, mock.call(self.mock_ssh, 'sudo systemctl start svc_name', get_pty=True)]) + @mock.patch('coriolis.utils.exec_ssh_cmd') + @mock.patch('coriolis.utils.write_ssh_file') + @mock.patch('coriolis.utils.test_ssh_path') + @mock.patch.object(uuid, 'uuid4') + def test_write_systemd_usr_lib(self, mock_uuid, mock_test_ssh, + mock_write_ssh_file, mock_exec_ssh_cmd): + mock_uuid.return_value = 'uuid' + mock_test_ssh.side_effect = [False, False] + mock_write_ssh_file.return_value = None + + utils._write_systemd(self.mock_ssh, 'cmdline', 'svc_name') + + mock_test_ssh.assert_has_calls([ + mock.call(self.mock_ssh, '/lib/systemd/system'), + mock.call(self.mock_ssh, + '/usr/lib/systemd/system/svc_name.service')]) + mock_exec_ssh_cmd.assert_has_calls([ + mock.call(self.mock_ssh, 'sudo mv /tmp/uuid.service ' + '/usr/lib/systemd/system/svc_name.service', + get_pty=True)]) + @mock.patch('coriolis.utils.test_ssh_path') def test_write_systemd_service_exists(self, mock_test_ssh): mock_test_ssh.return_value = True utils._write_systemd(self.mock_ssh, 'cmdline', 'svc_name') - mock_test_ssh.assert_called_once_with( - self.mock_ssh, '/lib/systemd/system/svc_name.service') + mock_test_ssh.assert_has_calls([ + mock.call(self.mock_ssh, '/lib/systemd/system'), + mock.call(self.mock_ssh, + '/lib/systemd/system/svc_name.service')]) @mock.patch('coriolis.utils.exec_ssh_cmd') @mock.patch('coriolis.utils.write_ssh_file') @@ -954,7 +979,7 @@ def test_write_systemd_service_selinux_exception(self, mock_uuid, mock_write_ssh_file, mock_exec_ssh_cmd): mock_uuid.return_value = 'uuid' - mock_test_ssh.return_value = False + mock_test_ssh.side_effect = [True, False] mock_write_ssh_file.return_value = None mock_exec_ssh_cmd.side_effect = [ None, exception.CoriolisException(), None, None] @@ -967,8 +992,10 @@ def test_write_systemd_service_selinux_exception(self, mock_uuid, start=True) mock_uuid.assert_called_once_with() - mock_test_ssh.assert_called_once_with( - self.mock_ssh, '/lib/systemd/system/svc_name.service') + mock_test_ssh.assert_has_calls([ + mock.call(self.mock_ssh, '/lib/systemd/system'), + mock.call(self.mock_ssh, + '/lib/systemd/system/svc_name.service')]) mock_write_ssh_file.assert_called_once_with(self.mock_ssh, '/tmp/uuid.service', mock.ANY) @@ -982,14 +1009,16 @@ def test_test_write_systemd_with_run_as(self, mock_uuid, mock_test_ssh, mock_exec_ssh_cmd): mock_uuid.return_value = 'uuid' - mock_test_ssh.return_value = False + mock_test_ssh.side_effect = [True, False] utils._write_systemd(self.mock_ssh, 'cmdline', 'svc_name', run_as='test_user') mock_uuid.assert_called_once_with() - mock_test_ssh.assert_called_once_with( - self.mock_ssh, '/lib/systemd/system/svc_name.service') + mock_test_ssh.assert_has_calls([ + mock.call(self.mock_ssh, '/lib/systemd/system'), + mock.call(self.mock_ssh, + '/lib/systemd/system/svc_name.service')]) mock_write_ssh_file.assert_called_once_with( self.mock_ssh, '/tmp/uuid.service', utils.SYSTEMD_TEMPLATE % { @@ -1081,7 +1110,7 @@ def test_create_service_systemd(self, mock_test_ssh, mock_write_systemd): @mock.patch('coriolis.utils._write_upstart') @mock.patch('coriolis.utils.test_ssh_path') def test_create_service_upstart(self, mock_test_ssh, mock_write_upstart): - mock_test_ssh.side_effect = [False, True] + mock_test_ssh.side_effect = [False, False, True] utils.create_service(self.mock_ssh, 'cmdline', 'svc_name', run_as='user', start=True) @@ -1119,7 +1148,7 @@ def test_restart_service_with_systemd(self, mock_test_ssh, @mock.patch('coriolis.utils.test_ssh_path') def test_restart_service_with_upstart(self, mock_test_ssh, mock_exec_ssh_cmd): - mock_test_ssh.side_effect = [False, True] + mock_test_ssh.side_effect = [False, False, True] utils.restart_service(self.mock_ssh, 'svc_name') @@ -1153,7 +1182,7 @@ def test_start_service_with_systemd(self, mock_test_ssh, @mock.patch('coriolis.utils.test_ssh_path') def test_start_service_with_upstart(self, mock_test_ssh, mock_exec_ssh_cmd): - mock_test_ssh.side_effect = [False, True] + mock_test_ssh.side_effect = [False, False, True] utils.start_service(self.mock_ssh, 'svc_name') @@ -1187,7 +1216,7 @@ def test_stop_service_with_systemd(self, mock_test_ssh, @mock.patch('coriolis.utils.test_ssh_path') def test_stop_service_with_upstart(self, mock_test_ssh, mock_exec_ssh_cmd): - mock_test_ssh.side_effect = [False, True] + mock_test_ssh.side_effect = [False, False, True] utils.stop_service(self.mock_ssh, 'svc_name') diff --git a/coriolis/utils.py b/coriolis/utils.py index e0a47c59..1bbef33e 100644 --- a/coriolis/utils.py +++ b/coriolis/utils.py @@ -718,8 +718,16 @@ def read_ssh_ini_config_file(ssh, path, check_exists=False): return {} +def _get_systemd_unit_path(ssh): + """Returns the systemd unit directory path on the remote system.""" + if test_ssh_path(ssh, "/lib/systemd/system"): + return "/lib/systemd/system" + return "/usr/lib/systemd/system" + + def _write_systemd(ssh, cmdline, svcname, run_as=None, start=True): - serviceFilePath = "/lib/systemd/system/%s.service" % svcname + systemd_unit_dir = _get_systemd_unit_path(ssh) + serviceFilePath = "%s/%s.service" % (systemd_unit_dir, svcname) if test_ssh_path(ssh, serviceFilePath): return @@ -787,6 +795,12 @@ def _write_upstart(ssh, cmdline, svcname, run_as=None, start=True): exec_ssh_cmd(ssh, "start %s" % svcname) +def _has_systemd(ssh): + """Check if the remote system uses systemd as its init system.""" + return (test_ssh_path(ssh, "/lib/systemd/system") or + test_ssh_path(ssh, "/usr/lib/systemd/system")) + + @retry_on_error() def create_service(ssh, cmdline, svcname, run_as=None, start=True): # Simplistic check for init system. We usually use official images, @@ -794,7 +808,7 @@ def create_service(ssh, cmdline, svcname, run_as=None, start=True): # and systemd installed side by side. So if /lib/systemd/system # exists, it's usually systemd enabled. If not, but /etc/init # exists, it's upstart - if test_ssh_path(ssh, "/lib/systemd/system"): + if _has_systemd(ssh): _write_systemd(ssh, cmdline, svcname, run_as=run_as, start=start) elif test_ssh_path(ssh, "/etc/init"): _write_upstart(ssh, cmdline, svcname, run_as=run_as, start=start) @@ -805,7 +819,7 @@ def create_service(ssh, cmdline, svcname, run_as=None, start=True): @retry_on_error() def restart_service(ssh, svcname): - if test_ssh_path(ssh, "/lib/systemd/system"): + if _has_systemd(ssh): exec_ssh_cmd(ssh, "sudo systemctl restart %s" % svcname, get_pty=True) elif test_ssh_path(ssh, "/etc/init"): exec_ssh_cmd(ssh, "restart %s" % svcname) @@ -815,7 +829,7 @@ def restart_service(ssh, svcname): @retry_on_error() def start_service(ssh, svcname): - if test_ssh_path(ssh, "/lib/systemd/system"): + if _has_systemd(ssh): exec_ssh_cmd(ssh, "sudo systemctl start %s" % svcname, get_pty=True) elif test_ssh_path(ssh, "/etc/init"): exec_ssh_cmd(ssh, "start %s" % svcname) @@ -825,7 +839,7 @@ def start_service(ssh, svcname): @retry_on_error() def stop_service(ssh, svcname): - if test_ssh_path(ssh, "/lib/systemd/system"): + if _has_systemd(ssh): exec_ssh_cmd(ssh, "sudo systemctl stop %s" % svcname, get_pty=True) elif test_ssh_path(ssh, "/etc/init"): exec_ssh_cmd(ssh, "stop %s" % svcname) From a8697eeb484f7073cb8a71b5d0c817d9d701db43 Mon Sep 17 00:00:00 2001 From: Mihaela Balutoiu Date: Wed, 25 Feb 2026 12:40:53 +0200 Subject: [PATCH 2/2] Add support for `SUSE` machines Add `SUSEOSMountTools` to detect `SUSE` machines as osmorphing workers. Also, handle `sshd_config` relocation to `/usr/etc/ssh/` on newer `SUSE` versions by copying the vendor config to `/etc/ssh/` before modifying it. Skip `zypper install` when `lvm2` is already present, as some minimal images ship without repositories configured. Signed-off-by: Mihaela Balutoiu --- coriolis/osmorphing/osmount/factory.py | 4 +- coriolis/osmorphing/osmount/suse.py | 54 ++++++++ .../tests/osmorphing/osmount/test_factory.py | 14 +- .../tests/osmorphing/osmount/test_suse.py | 125 ++++++++++++++++++ 4 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 coriolis/osmorphing/osmount/suse.py create mode 100644 coriolis/tests/osmorphing/osmount/test_suse.py diff --git a/coriolis/osmorphing/osmount/factory.py b/coriolis/osmorphing/osmount/factory.py index 958df134..890eb771 100644 --- a/coriolis/osmorphing/osmount/factory.py +++ b/coriolis/osmorphing/osmount/factory.py @@ -8,6 +8,7 @@ from coriolis import constants from coriolis import exception from coriolis.osmorphing.osmount import redhat +from coriolis.osmorphing.osmount import suse from coriolis.osmorphing.osmount import ubuntu from coriolis.osmorphing.osmount import windows @@ -17,7 +18,8 @@ def get_os_mount_tools(os_type, connection_info, event_manager, ignore_devices, operation_timeout): os_mount_tools = {constants.OS_TYPE_LINUX: [ubuntu.UbuntuOSMountTools, - redhat.RedHatOSMountTools], + redhat.RedHatOSMountTools, + suse.SUSEOSMountTools], constants.OS_TYPE_WINDOWS: [windows.WindowsMountTools]} if os_type and os_type not in os_mount_tools: diff --git a/coriolis/osmorphing/osmount/suse.py b/coriolis/osmorphing/osmount/suse.py new file mode 100644 index 00000000..8063dfe2 --- /dev/null +++ b/coriolis/osmorphing/osmount/suse.py @@ -0,0 +1,54 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +from oslo_log import log as logging + +from coriolis import exception +from coriolis.osmorphing.osmount import base +from coriolis import utils + +LOG = logging.getLogger(__name__) + +SUSE_DISTRO_IDENTIFIERS = [ + 'sles', 'opensuse-leap', 'opensuse-tumbleweed', 'opensuse'] + +SSHD_CONFIG_PATH = "/etc/ssh/sshd_config" +USR_SSHD_CONFIG_PATH = "/usr/etc/ssh/sshd_config" + + +class SUSEOSMountTools(base.BaseLinuxOSMountTools): + def check_os(self): + os_info = utils.get_linux_os_info(self._ssh) + if os_info and os_info[0] in SUSE_DISTRO_IDENTIFIERS: + return True + + def _allow_ssh_env_vars(self): + if not utils.test_ssh_path(self._ssh, SSHD_CONFIG_PATH): + self._exec_cmd( + "sudo cp %s %s" % (USR_SSHD_CONFIG_PATH, SSHD_CONFIG_PATH)) + self._exec_cmd( + 'sudo sed -i -e "\\$aAcceptEnv *" %s' % SSHD_CONFIG_PATH) + try: + utils.restart_service(self._ssh, "sshd") + except exception.CoriolisException: + LOG.warning( + "Could not restart sshd service. The SSH connection " + "may have been reset during the restart.") + return True + + def setup(self): + super(SUSEOSMountTools, self).setup() + if not self._check_pkg_installed("lvm2"): + retry_ssh_cmd = utils.retry_on_error( + max_attempts=10, sleep_seconds=30)(self._exec_cmd) + retry_ssh_cmd( + "sudo -E zypper --non-interactive install lvm2") + self._exec_cmd("sudo modprobe dm-mod") + self._exec_cmd("sudo rm -f /etc/lvm/devices/system.devices") + + def _check_pkg_installed(self, pkg_name): + try: + self._exec_cmd("rpm -q %s" % pkg_name) + return True + except Exception: + return False diff --git a/coriolis/tests/osmorphing/osmount/test_factory.py b/coriolis/tests/osmorphing/osmount/test_factory.py index ce6e2cb8..f9a15406 100644 --- a/coriolis/tests/osmorphing/osmount/test_factory.py +++ b/coriolis/tests/osmorphing/osmount/test_factory.py @@ -25,11 +25,13 @@ def test_get_os_mount_tools_unsupported_os_type(self): return_value=False) @mock.patch.object(factory.redhat.RedHatOSMountTools, 'check_os', return_value=False) + @mock.patch.object(factory.suse.SUSEOSMountTools, 'check_os', + return_value=False) @mock.patch.object(factory.windows.WindowsMountTools, 'check_os', return_value=False) def test_get_os_mount_tools_no_os_found( - self, mock_windows_check, mock_redhat_check, mock_ubuntu_check, - mock_exec_cmd, mock_connect): + self, mock_windows_check, mock_suse_check, mock_redhat_check, + mock_ubuntu_check, mock_exec_cmd, mock_connect): mock_exec_cmd.return_value = ("Ubuntu", "") self.assertRaises( exception.CoriolisException, factory.get_os_mount_tools, @@ -39,6 +41,7 @@ def test_get_os_mount_tools_no_os_found( mock_redhat_check.assert_called_once_with() mock_ubuntu_check.assert_called_once_with() + mock_suse_check.assert_called_once_with() mock_windows_check.assert_not_called() @mock.patch.object(base.BaseSSHOSMountTools, '_connect') @@ -47,11 +50,13 @@ def test_get_os_mount_tools_no_os_found( return_value=True) @mock.patch.object(factory.redhat.RedHatOSMountTools, 'check_os', return_value=False) + @mock.patch.object(factory.suse.SUSEOSMountTools, 'check_os', + return_value=False) @mock.patch.object(factory.windows.WindowsMountTools, 'check_os', return_value=False) def test_get_os_mount_tools_os_found( - self, mock_windows_check, mock_redhat_check, mock_ubuntu_check, - mock_exec_cmd, mock_connect): + self, mock_windows_check, mock_suse_check, mock_redhat_check, + mock_ubuntu_check, mock_exec_cmd, mock_connect): mock_exec_cmd.return_value = ("Ubuntu", "") tools = factory.get_os_mount_tools( factory.constants.OS_TYPE_LINUX, mock_connect, @@ -61,4 +66,5 @@ def test_get_os_mount_tools_os_found( mock_ubuntu_check.assert_called_once_with() mock_redhat_check.assert_not_called() + mock_suse_check.assert_not_called() mock_windows_check.assert_not_called() diff --git a/coriolis/tests/osmorphing/osmount/test_suse.py b/coriolis/tests/osmorphing/osmount/test_suse.py new file mode 100644 index 00000000..2b75aade --- /dev/null +++ b/coriolis/tests/osmorphing/osmount/test_suse.py @@ -0,0 +1,125 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +from unittest import mock + +from coriolis.osmorphing.osmount import suse +from coriolis.tests import test_base + + +class BaseSUSEOSMountToolsTestCase(test_base.CoriolisBaseTestCase): + """Test suite for the SUSEOSMountTools class.""" + + @mock.patch.object(suse.base.BaseSSHOSMountTools, '_connect') + def setUp(self, mock_connect): + super(BaseSUSEOSMountToolsTestCase, self).setUp() + self.ssh = mock.MagicMock() + + self.tools = suse.SUSEOSMountTools( + self.ssh, mock.sentinel.event_manager, + mock.sentinel.ignore_devices, + mock.sentinel.operation_timeout) + + mock_connect.assert_called_once_with() + + self.tools._ssh = self.ssh + + @mock.patch.object(suse.utils, 'get_linux_os_info') + def test_check_os(self, mock_get_linux_os_info): + mock_get_linux_os_info.return_value = ['sles'] + + result = self.tools.check_os() + self.assertTrue(result) + + @mock.patch.object(suse.utils, 'get_linux_os_info') + def test_check_os_opensuse_leap(self, mock_get_linux_os_info): + mock_get_linux_os_info.return_value = ['opensuse-leap'] + + result = self.tools.check_os() + self.assertTrue(result) + + @mock.patch.object(suse.utils, 'get_linux_os_info') + def test_check_os_opensuse_tumbleweed(self, mock_get_linux_os_info): + mock_get_linux_os_info.return_value = ['opensuse-tumbleweed'] + + result = self.tools.check_os() + self.assertTrue(result) + + @mock.patch.object(suse.utils, 'get_linux_os_info') + def test_check_os_not_suse(self, mock_get_linux_os_info): + mock_get_linux_os_info.return_value = ['ubuntu'] + + result = self.tools.check_os() + self.assertIsNone(result) + + @mock.patch.object(suse.utils, 'retry_on_error') + @mock.patch.object(suse.base.BaseSSHOSMountTools, '_exec_cmd') + @mock.patch.object(suse.base.BaseSSHOSMountTools, 'setup') + def test_setup_lvm2_not_installed( + self, mock_setup, mock_exec_cmd, mock_retry_on_error): + mock_retry_on_error.return_value = lambda f: f + mock_exec_cmd.side_effect = [ + Exception("not installed"), + None, None, None] + result = self.tools.setup() + self.assertIsNone(result) + + mock_setup.assert_called_once_with() + mock_retry_on_error.assert_called_once_with( + max_attempts=10, sleep_seconds=30) + mock_exec_cmd.assert_has_calls([ + mock.call("rpm -q lvm2"), + mock.call( + "sudo -E zypper --non-interactive install lvm2"), + mock.call("sudo modprobe dm-mod"), + mock.call("sudo rm -f /etc/lvm/devices/system.devices") + ]) + + @mock.patch.object(suse.utils, 'retry_on_error') + @mock.patch.object(suse.base.BaseSSHOSMountTools, '_exec_cmd') + @mock.patch.object(suse.base.BaseSSHOSMountTools, 'setup') + def test_setup_lvm2_already_installed( + self, mock_setup, mock_exec_cmd, mock_retry_on_error): + result = self.tools.setup() + self.assertIsNone(result) + + mock_setup.assert_called_once_with() + mock_retry_on_error.assert_not_called() + mock_exec_cmd.assert_has_calls([ + mock.call("rpm -q lvm2"), + mock.call("sudo modprobe dm-mod"), + mock.call("sudo rm -f /etc/lvm/devices/system.devices") + ]) + + @mock.patch.object(suse.base.BaseSSHOSMountTools, '_exec_cmd') + @mock.patch.object(suse.utils, 'restart_service') + @mock.patch.object(suse.utils, 'test_ssh_path', return_value=True) + def test__allow_ssh_env_vars( + self, mock_test_ssh_path, mock_restart_service, mock_exec_cmd): + result = self.tools._allow_ssh_env_vars() + self.assertTrue(result) + + mock_test_ssh_path.assert_called_once_with( + self.ssh, suse.SSHD_CONFIG_PATH) + mock_exec_cmd.assert_called_once_with( + 'sudo sed -i -e "\\$aAcceptEnv *" %s' % suse.SSHD_CONFIG_PATH) + mock_restart_service.assert_called_once_with(self.ssh, "sshd") + + @mock.patch.object(suse.base.BaseSSHOSMountTools, '_exec_cmd') + @mock.patch.object(suse.utils, 'restart_service') + @mock.patch.object(suse.utils, 'test_ssh_path', return_value=False) + def test__allow_ssh_env_vars_usr_etc( + self, mock_test_ssh_path, mock_restart_service, mock_exec_cmd): + result = self.tools._allow_ssh_env_vars() + self.assertTrue(result) + + mock_test_ssh_path.assert_called_once_with( + self.ssh, suse.SSHD_CONFIG_PATH) + mock_exec_cmd.assert_has_calls([ + mock.call( + "sudo cp %s %s" % ( + suse.USR_SSHD_CONFIG_PATH, suse.SSHD_CONFIG_PATH)), + mock.call( + 'sudo sed -i -e "\\$aAcceptEnv *" %s' % + suse.SSHD_CONFIG_PATH)]) + mock_restart_service.assert_called_once_with(self.ssh, "sshd")