From 8b8caa85e0d410e169ff3f018d8647c858926231 Mon Sep 17 00:00:00 2001 From: Cristian Matiut Date: Tue, 8 Sep 2026 12:49:48 +0300 Subject: [PATCH 1/2] Replace Windows WinRM with SSH. --- coriolis/osmorphing/osmount/windows.py | 13 +- coriolis/osmorphing/windows.py | 12 +- coriolis/providers/provider_utils.py | 55 +- .../tests/osmorphing/osmount/test_windows.py | 16 +- coriolis/tests/osmorphing/test_windows.py | 10 +- .../tests/providers/test_provider_utils.py | 66 +- coriolis/tests/test_windows_ssh.py | 588 ++++++++++++++++ coriolis/tests/test_wsman.py | 238 +------ coriolis/windows_ssh.py | 634 ++++++++++++++++++ coriolis/wsman.py | 249 +------ requirements.txt | 1 - 11 files changed, 1316 insertions(+), 566 deletions(-) create mode 100644 coriolis/tests/test_windows_ssh.py create mode 100644 coriolis/windows_ssh.py diff --git a/coriolis/osmorphing/osmount/windows.py b/coriolis/osmorphing/osmount/windows.py index 9dfea037d..0a8b1a6e0 100644 --- a/coriolis/osmorphing/osmount/windows.py +++ b/coriolis/osmorphing/osmount/windows.py @@ -6,7 +6,7 @@ from oslo_log import log as logging -from coriolis import constants, exception, utils, wsman +from coriolis import constants, exception, utils, windows_ssh from coriolis.osmorphing.osmount import base LOG = logging.getLogger(__name__) @@ -21,16 +21,13 @@ def __init__(self, *args, **kwargs): self._unlocked_volumes: list[str] = [] def _connect(self): - connection_info = self._connection_info - - host = connection_info["ip"] - port = connection_info.get("port", 5986) + host = self._connection_info["ip"] self._event_manager.progress_update( - "Connecting to WinRM host: %(host)s:%(port)s" % {"host": host, "port": port} + "Connecting through SSH to OSMorphing host on: %s" % host ) - self._conn = wsman.WSManConnection.from_connection_info( - connection_info, self._osmount_operation_timeout + self._conn = windows_ssh.WindowsSSHConnection.from_connection_info( + self._connection_info, self._osmount_operation_timeout ) def get_connection(self): diff --git a/coriolis/osmorphing/windows.py b/coriolis/osmorphing/windows.py index 1bafd766b..443a77106 100644 --- a/coriolis/osmorphing/windows.py +++ b/coriolis/osmorphing/windows.py @@ -272,12 +272,14 @@ def _add_dism_driver(self, driver_path): LOG.info("Adding driver: %s" % driver_path) dism_path = self._get_dism_path() try: + # Pass a raw /driver: path. Nested quotes become part of the + # path after SSH wraps the command in cmd.exe /c. return self._conn.exec_command( dism_path, [ "/add-driver", "/image:%s" % self._os_root_dir, - "/driver:\"%s\"" % driver_path, + "/driver:%s" % driver_path, "/recurse", "/forceunsigned", ], @@ -304,7 +306,7 @@ def _add_dism_driver(self, driver_path): def _mount_disk_image(self, path): LOG.info("Mounting disk image: %s" % path) drive_letter, stderr = self._conn.exec_ps_command( - "(Mount-DiskImage '%s' -PassThru | Get-Volume).DriveLetter" % path, + '"$((Mount-DiskImage \'%s\' -PassThru | Get-Volume).DriveLetter)"' % path, include_stderr=True, ) if not drive_letter: @@ -341,6 +343,7 @@ def _expand_archive(self, path, destination, overwrite=True): self._conn.exec_ps_command("rm -recurse -force %s" % destination) self._conn.exec_ps_command( + "$ProgressPreference = 'SilentlyContinue'; " "Expand-Archive -LiteralPath '%(path)s' " "-DestinationPath '%(destination)s' -Force" % {"path": path, "destination": destination}, @@ -1024,8 +1027,9 @@ def _setup_qemu_agent_installation_local_script( ) else: self._conn.exec_ps_command( - "Copy-Item '%s' -Destination '%s'" - % (msi_source_path, msi_dest_path) + "Copy-Item -Force '%s' -Destination '%s'" + % (msi_source_path, msi_dest_path), + ignore_stdout=True, ) local_script = QEMU_GUEST_AGENT_INSTALL_SCRIPT_FORMAT % { "agent_msi_path": "%s\\%s" diff --git a/coriolis/providers/provider_utils.py b/coriolis/providers/provider_utils.py index e13be2723..f05637c00 100644 --- a/coriolis/providers/provider_utils.py +++ b/coriolis/providers/provider_utils.py @@ -6,7 +6,7 @@ import requests from oslo_log import log as logging -from coriolis import constants, exception, utils, wsman +from coriolis import constants, exception, utils LOG = logging.getLogger(__name__) @@ -170,6 +170,10 @@ def _poll_instance_until_reachable_ssh( timeout: int = 600, poll_interval: int = 10, ): + if not connection_info.get("password") and not connection_info.get("pkey"): + raise exception.InvalidInput( + "SSH connection info must include password or pkey." + ) start = time.time() while (time.time() - start) < timeout: try: @@ -177,8 +181,8 @@ def _poll_instance_until_reachable_ssh( hostname=connection_info["ip"], port=connection_info["port"], username=connection_info["username"], - password=connection_info["password"], - pkey=connection_info["pkey"], + password=connection_info.get("password"), + pkey=connection_info.get("pkey"), ) try: # "exit 0" should work across platforms. @@ -201,30 +205,6 @@ def _poll_instance_until_reachable_ssh( ) -def _poll_instance_until_reachable_winrm( - connection_info: dict, - timeout: int = 600, - poll_interval: int = 10, -): - start = time.time() - while (time.time() - start) < timeout: - try: - conn = wsman.WSManConnection.from_connection_info(connection_info) - conn.exec_ps_command("whoami") - return - except Exception as ex: - LOG.debug( - f"Could not conect to Windows host: {str(ex)}. " - f"Retrying, time left: {timeout - (time.time() - start)}." - ) - time.sleep(poll_interval) - - raise exception.CoriolisException( - f"Operation timed out after waiting {timeout}s for Windows host to " - f"be accessible via WinRM." - ) - - def poll_instance_until_reachable( connection_info: dict, protocol: str = constants.PROTOCOL_SSH, @@ -239,22 +219,19 @@ def poll_instance_until_reachable( * username * password * pkey - Paramiko keypair - :param protocol: connection protocol, "ssh" or "winrm" + :param protocol: connection protocol. Only "ssh" is supported. :param timeout: the maximum amount of time to wait :param poll_interval: the amount of time to wait between retries """ - # TODO(lpetrut): consider including the connection protocol in the - # connection info. We'd have to modify a few schemas used during os - # morphing. We currently pick the protocol based on the OS type but - # we may want to use SSH on Windows as well. - if protocol == constants.PROTOCOL_SSH: - helper = _poll_instance_until_reachable_ssh - elif protocol == constants.PROTOCOL_WINRM: - helper = _poll_instance_until_reachable_winrm - else: + if protocol != constants.PROTOCOL_SSH: raise exception.InvalidInput( f"Unsupported instance connection protocol: {protocol}" ) - return helper( - connection_info=connection_info, timeout=timeout, poll_interval=poll_interval + ssh_connection_info = dict(connection_info) + if ssh_connection_info.get("port") is None: + ssh_connection_info["port"] = 22 + return _poll_instance_until_reachable_ssh( + connection_info=ssh_connection_info, + timeout=timeout, + poll_interval=poll_interval, ) diff --git a/coriolis/tests/osmorphing/osmount/test_windows.py b/coriolis/tests/osmorphing/osmount/test_windows.py index 3d2ffe222..71fc00c72 100644 --- a/coriolis/tests/osmorphing/osmount/test_windows.py +++ b/coriolis/tests/osmorphing/osmount/test_windows.py @@ -24,8 +24,8 @@ class CoriolisTestException(Exception): class WindowsMountToolsTestCase(test_base.CoriolisBaseTestCase): """Test suite for the WindowsMountTools class.""" - @mock.patch.object(windows.wsman, 'WSManConnection') - def setUp(self, mock_wsman_connection): + @mock.patch.object(windows.windows_ssh.WindowsSSHConnection, 'from_connection_info') + def setUp(self, mock_from_connection_info): super(WindowsMountToolsTestCase, self).setUp() self.event_manager = mock.MagicMock() self.ssh = mock.MagicMock() @@ -43,15 +43,21 @@ def setUp(self, mock_wsman_connection): mock.sentinel.ignore_devices, mock.sentinel.operation_timeout, ) - self.tools._conn = mock_wsman_connection + self.tools._conn = mock.MagicMock() - @mock.patch.object(windows.wsman.WSManConnection, 'from_connection_info') + @mock.patch.object(windows.windows_ssh.WindowsSSHConnection, 'from_connection_info') def test__connect(self, mock_from_connection_info): result = self.tools._connect() self.assertIsNone(result) + expected_conn_info = { + "ip": "127.0.0.1", + "username": "random_username", + "password": "random_password", + "pkey": "random_pkey", + } mock_from_connection_info.assert_called_once_with( - self.conn_info, mock.sentinel.operation_timeout + expected_conn_info, mock.sentinel.operation_timeout ) def test_get_connection(self): diff --git a/coriolis/tests/osmorphing/test_windows.py b/coriolis/tests/osmorphing/test_windows.py index 220eff560..ce6c7fa58 100644 --- a/coriolis/tests/osmorphing/test_windows.py +++ b/coriolis/tests/osmorphing/test_windows.py @@ -168,7 +168,7 @@ def test__add_dism_driver(self, mock_get_worker_os_drive_path): [ '/add-driver', '/image:%s' % self.os_root_dir, - '/driver:"%s"' % mock.sentinel.driver_path, + '/driver:%s' % mock.sentinel.driver_path, '/recurse', '/forceunsigned', ], @@ -209,7 +209,7 @@ def test__mount_disk_image(self): result = self.morphing_tools._mount_disk_image(mock.sentinel.path) self.conn.exec_ps_command.assert_called_once_with( - "(Mount-DiskImage '%s' -PassThru | Get-Volume).DriveLetter" + '"$((Mount-DiskImage \'%s\' -PassThru | Get-Volume).DriveLetter)"' % mock.sentinel.path, include_stderr=True, ) @@ -251,6 +251,7 @@ def test__expand_archive_remove_destination(self): [ mock.call("rm -recurse -force %s" % destination), mock.call( + "$ProgressPreference = 'SilentlyContinue'; " "Expand-Archive -LiteralPath '%(path)s' " "-DestinationPath '%(destination)s' -Force" % {"path": mock.sentinel.archive_path, "destination": destination}, @@ -1441,8 +1442,9 @@ def test_setup_qemu_agent_installation_local_script_from_path( exp_msi_dest_path = "C:\\Cloudbase-Init\\qemu-ga.msi" self.morphing_tools._conn.download_file.assert_not_called() self.morphing_tools._conn.exec_ps_command.assert_called_once_with( - "Copy-Item '%s' -Destination '%s'" - % (fake_msi_source_path, exp_msi_dest_path) + "Copy-Item -Force '%s' -Destination '%s'" + % (fake_msi_source_path, exp_msi_dest_path), + ignore_stdout=True, ) exp_script = windows.QEMU_GUEST_AGENT_INSTALL_SCRIPT_FORMAT % { diff --git a/coriolis/tests/providers/test_provider_utils.py b/coriolis/tests/providers/test_provider_utils.py index 7884019ba..de6a6dd4f 100644 --- a/coriolis/tests/providers/test_provider_utils.py +++ b/coriolis/tests/providers/test_provider_utils.py @@ -351,52 +351,42 @@ def test_poll_instance_ssh_timeout( poll_interval=poll_interval, ) - @mock.patch("coriolis.wsman.WSManConnection", new_callable=mock.Mock) - @mock.patch("time.sleep") - def test_poll_instance_winrm( - self, - mock_sleep, - mock_wsman, - ): - mock_conn = mock.Mock() - mock_conn.exec_ps_command.side_effect = [Exception, mock.sentinel.stdout] - mock_wsman.from_connection_info.return_value = mock_conn - poll_interval = 5 + def test_poll_instance_missing_auth(self): + connection_info = { + "ip": "1.2.3.4", + "port": 22, + "username": "Administrator", + } + self.assertRaises( + exception.InvalidInput, + provider_utils.poll_instance_until_reachable, + connection_info=connection_info, + protocol=constants.PROTOCOL_SSH, + timeout=600, + poll_interval=5, + ) - connection_info = self._get_mock_conn_info() - provider_utils.poll_instance_until_reachable( + def test_poll_instance_winrm_rejected(self): + connection_info = { + "ip": "1.2.3.4", + "port": 5986, + "username": "Administrator", + "password": "pwned", + } + self.assertRaises( + exception.InvalidInput, + provider_utils.poll_instance_until_reachable, connection_info=connection_info, protocol=constants.PROTOCOL_WINRM, timeout=30, - poll_interval=poll_interval, - ) - - mock_wsman.from_connection_info.assert_has_calls( - [mock.call(connection_info)] * 2, any_order=True + poll_interval=5, ) - mock_conn.exec_ps_command.assert_has_calls([mock.call("whoami")] * 2) - mock_sleep.assert_called_once_with(poll_interval) - @mock.patch("coriolis.wsman.WSManConnection", new_callable=mock.Mock) - @mock.patch("time.sleep") - @mock.patch("time.time") - def test_poll_instance_winrm_timeout( - self, - mock_time, - mock_sleep, - mock_wsman, - ): - poll_interval = 5 - mock_time.side_effect = [x * 10 for x in range(20)] - mock_conn = mock.Mock() - mock_conn.exec_ps_command.side_effect = IOError - mock_wsman.from_connection_info.return_value = mock_conn + def test_poll_instance_unsupported_protocol(self): connection_info = self._get_mock_conn_info() self.assertRaises( - exception.CoriolisException, + exception.InvalidInput, provider_utils.poll_instance_until_reachable, connection_info=connection_info, - protocol=constants.PROTOCOL_WINRM, - timeout=30, - poll_interval=poll_interval, + protocol="ftp", ) diff --git a/coriolis/tests/test_windows_ssh.py b/coriolis/tests/test_windows_ssh.py new file mode 100644 index 000000000..3b66f0d31 --- /dev/null +++ b/coriolis/tests/test_windows_ssh.py @@ -0,0 +1,588 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +import logging +import socket +from unittest import mock + +import paramiko + +from coriolis import exception, windows_ssh +from coriolis.tests import test_base + + +def _fake_exec_channel(stdout=b"std_out", stderr=b"std_err", exit_code=0): + """SSH channel that yields stdout/stderr once, then reports exit.""" + out = [stdout] + err = [stderr] + channel = mock.Mock() + + def recv_ready(): + return bool(out[0]) + + def recv_stderr_ready(): + return bool(err[0]) + + def recv(_size): + data = out[0] + out[0] = b"" + return data + + def recv_stderr(_size): + data = err[0] + err[0] = b"" + return data + + channel.recv_ready.side_effect = recv_ready + channel.recv_stderr_ready.side_effect = recv_stderr_ready + channel.recv.side_effect = recv + channel.recv_stderr.side_effect = recv_stderr + channel.exit_status_ready.return_value = True + channel.recv_exit_status.return_value = exit_code + return channel + + +class WindowsSSHConnectionTestCase(test_base.CoriolisBaseTestCase): + """Test suite for WindowsSSHConnection.""" + + def setUp(self): + super(WindowsSSHConnectionTestCase, self).setUp() + self.conn = windows_ssh.WindowsSSHConnection() + self.conn._ssh = mock.Mock() + self.cmd = "test_cmd" + self.args = ["-RecoveryPassword", "'ShouldNotBeLogged'"] + self.url = "http://example.com/file" + self.remote_path = "/remote/path" + + def test__init__timeout(self): + connection = windows_ssh.WindowsSSHConnection() + self.assertEqual(connection._conn_timeout, windows_ssh.DEFAULT_TIMEOUT) + + def test__init__timeout_set(self): + connection = windows_ssh.WindowsSSHConnection(timeout=100) + self.assertEqual(connection._conn_timeout, 100) + + @mock.patch.object(windows_ssh.WindowsSSHConnection, "_start_ps_session") + @mock.patch.object(windows_ssh.utils, "connect_ssh") + @mock.patch.object(windows_ssh.utils, "wait_for_port_connectivity") + def test_from_connection_info_uses_ssh( + self, mock_wait_for_port, mock_connect_ssh, mock_start_ps + ): + mock_ssh = mock.Mock() + mock_connect_ssh.return_value = mock_ssh + connection_info = { + "ip": "127.0.0.1", + "username": "user", + "password": "pass", + "port": 22, + } + + result = windows_ssh.WindowsSSHConnection.from_connection_info(connection_info) + + mock_wait_for_port.assert_called_once_with("127.0.0.1", 22) + mock_connect_ssh.assert_called_once_with( + hostname="127.0.0.1", + port=22, + username="user", + password="pass", + pkey=None, + ) + mock_start_ps.assert_called_once_with() + self.assertIsInstance(result, windows_ssh.WindowsSSHConnection) + self.assertIs(result._ssh, mock_ssh) + + @mock.patch.object(windows_ssh.WindowsSSHConnection, "_start_ps_session") + @mock.patch.object(windows_ssh.utils, "connect_ssh") + @mock.patch.object(windows_ssh.utils, "wait_for_port_connectivity") + def test_from_connection_info_omitted_port_defaults_to_ssh( + self, mock_wait_for_port, mock_connect_ssh, mock_start_ps + ): + mock_connect_ssh.return_value = mock.Mock() + connection_info = { + "ip": "127.0.0.1", + "username": "user", + "password": "pass", + } + + windows_ssh.WindowsSSHConnection.from_connection_info(connection_info) + + mock_wait_for_port.assert_called_once_with("127.0.0.1", 22) + mock_connect_ssh.assert_called_once_with( + hostname="127.0.0.1", + port=22, + username="user", + password="pass", + pkey=None, + ) + mock_start_ps.assert_called_once_with() + + @mock.patch.object(windows_ssh.WindowsSSHConnection, "_start_ps_session") + @mock.patch.object(windows_ssh.utils, "connect_ssh") + @mock.patch.object(windows_ssh.utils, "wait_for_port_connectivity") + def test_from_connection_info_keeps_custom_ssh_port( + self, mock_wait_for_port, mock_connect_ssh, mock_start_ps + ): + mock_connect_ssh.return_value = mock.Mock() + connection_info = { + "ip": "127.0.0.1", + "username": "user", + "password": "pass", + "port": 2222, + } + + windows_ssh.WindowsSSHConnection.from_connection_info(connection_info) + + mock_wait_for_port.assert_called_once_with("127.0.0.1", 2222) + mock_connect_ssh.assert_called_once_with( + hostname="127.0.0.1", + port=2222, + username="user", + password="pass", + pkey=None, + ) + mock_start_ps.assert_called_once_with() + + def test_from_connection_info_missing_keys(self): + self.assertRaises( + ValueError, + windows_ssh.WindowsSSHConnection.from_connection_info, + {"username": "user", "password": "pass"}, + ) + + def test_from_connection_info_invalid_type(self): + self.assertRaises( + ValueError, + windows_ssh.WindowsSSHConnection.from_connection_info, + "invalid-connection-type", + ) + + def test_from_connection_info_missing_auth(self): + self.assertRaises( + ValueError, + windows_ssh.WindowsSSHConnection.from_connection_info, + {"ip": "127.0.0.1", "username": "user"}, + ) + + def test_disconnect(self): + ssh = self.conn._ssh + self.conn._close_ps_session = mock.Mock() + self.conn.disconnect() + self.conn._close_ps_session.assert_called_once_with() + ssh.close.assert_called_once_with() + self.assertIsNone(self.conn._ssh) + + def test_set_timeout(self): + self.conn.set_timeout(50) + self.assertEqual(self.conn._conn_timeout, 50) + + @mock.patch.object(windows_ssh.utils, "connect_ssh") + def test_connect_invalid_credentials(self, mock_connect_ssh): + auth_error = paramiko.AuthenticationException("bad auth") + connect_error = exception.CoriolisException( + "Failed to setup SSH client: bad auth" + ) + connect_error.__cause__ = auth_error + mock_connect_ssh.side_effect = connect_error + conn = windows_ssh.WindowsSSHConnection() + self.assertRaises( + exception.NotAuthorized, + conn.connect, + host="127.0.0.1", + port=22, + username="user", + password="pass", + ) + + def test__exec_command(self): + stdout = mock.Mock() + stdout.channel = _fake_exec_channel() + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + + std_out, std_err, exit_code = self.conn._exec_command(self.cmd, self.args) + + self.assertEqual(std_out, "std_out") + self.assertEqual(std_err, "std_err") + self.assertEqual(exit_code, 0) + self.conn._ssh.exec_command.assert_called_once_with( + "cmd.exe /c \"test_cmd -RecoveryPassword 'ShouldNotBeLogged'\"", + timeout=float(self.conn._conn_timeout), + ) + + def test__exec_command_reads_stderr_progress_without_stdout(self): + stdout = mock.Mock() + stdout.channel = _fake_exec_channel(stdout=b"", stderr=b"progress" * 100) + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + + std_out, std_err, exit_code = self.conn._exec_command("dism.exe", []) + + self.assertEqual(std_out, "") + self.assertIn("progress", std_err) + self.assertEqual(exit_code, 0) + + def test__exec_command_timeout(self): + self.conn._ssh.exec_command.side_effect = socket.timeout + self.assertRaises( + exception.OSMorphingSSHOperationTimeout, + self.conn._exec_command, + self.cmd, + self.args, + ) + + def test_exec_command(self): + stdout = mock.Mock() + stdout.channel = _fake_exec_channel() + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + + sanitized_cmd = "cmd.exe /c \"test_cmd -RecoveryPassword '***'\"" + exp_log = "DEBUG:coriolis.windows_ssh:Executing Windows SSH command: %s" % ( + sanitized_cmd + ) + with self.assertLogs("coriolis.windows_ssh", level=logging.DEBUG) as log_cm: + std_out = self.conn.exec_command(self.cmd, self.args) + self.assertEqual(std_out, "std_out") + self.assertIn(exp_log, log_cm.output) + + def test_exec_command_exception(self): + stdout = mock.Mock() + stdout.channel = _fake_exec_channel(exit_code=1) + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + self.assertRaises( + exception.CoriolisException, self.conn.exec_command, self.cmd, self.args + ) + + def test_is_reg_exe(self): + self.assertTrue(windows_ssh._is_reg_exe("reg.exe")) + self.assertTrue(windows_ssh._is_reg_exe("C:\\Windows\\System32\\reg.exe")) + self.assertFalse(windows_ssh._is_reg_exe("dism.exe")) + + def test_exec_command_releases_ps_handles_for_reg(self): + self.conn._release_ps_registry_handles = mock.Mock() + stdout = mock.Mock() + stdout.channel = _fake_exec_channel() + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + self.conn.exec_command("reg.exe", ["unload", "HKLM\\x"]) + self.conn._release_ps_registry_handles.assert_called_once_with() + + def test_exec_command_skips_handle_release_for_other_cmds(self): + self.conn._release_ps_registry_handles = mock.Mock() + stdout = mock.Mock() + stdout.channel = _fake_exec_channel() + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + self.conn.exec_command("dism.exe", ["/Get-WimInfo"]) + self.conn._release_ps_registry_handles.assert_not_called() + + def test_release_ps_registry_handles_closes_session(self): + self.conn._close_ps_session = mock.Mock() + self.conn._release_ps_registry_handles() + self.conn._close_ps_session.assert_called_once_with(wait=True) + + def test_escape_trailing_backslash_for_ssh(self): + odd = "dism.exe /get-drivers /image:F:\\" + self.assertEqual( + windows_ssh._escape_trailing_backslash_for_ssh(odd), + "dism.exe /get-drivers /image:F:\\\\", + ) + self.assertEqual( + windows_ssh._escape_trailing_backslash_for_ssh("reg.exe unload HKLM\\x"), + "reg.exe unload HKLM\\x", + ) + even = "cmd /c dir C:\\\\" + self.assertEqual( + windows_ssh._escape_trailing_backslash_for_ssh(even), + even, + ) + + def test_exec_ps_command(self): + self.conn._invoke_persistent_ps = mock.Mock() + self.conn._invoke_persistent_ps.return_value = ("std_out\r\n", "", 0) + result = self.conn.exec_ps_command(self.cmd, include_stderr=False) + self.conn._invoke_persistent_ps.assert_called_once_with(self.cmd, timeout=None) + self.assertEqual(result, "std_out") + + def test_exec_ps_command_with_stderr(self): + self.conn._invoke_persistent_ps = mock.Mock() + self.conn._invoke_persistent_ps.return_value = ("std_out\n", "stderr", 0) + result = self.conn.exec_ps_command(self.cmd, include_stderr=True) + self.assertEqual(result, ("std_out", "stderr")) + + def test_exec_ps_command_nonzero_exit(self): + self.conn._invoke_persistent_ps = mock.Mock() + self.conn._invoke_persistent_ps.return_value = ("std_out", "stderr", 1) + self.assertRaises( + exception.CoriolisException, self.conn.exec_ps_command, self.cmd + ) + + def test_test_path(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.exec_ps_command.return_value = "True" + result = self.conn.test_path("test_path") + self.conn.exec_ps_command.assert_called_once_with('Test-Path -Path "test_path"') + self.assertTrue(result) + + def test_download_file(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.download_file(self.url, self.remote_path) + self.conn.exec_ps_command.assert_called_once() + + def test_download_file_exception(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.exec_ps_command.side_effect = exception.CoriolisException + self.assertRaises( + exception.CoriolisException, + self.conn.download_file, + self.url, + self.remote_path, + ) + + def test_write_file(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.write_file(self.remote_path, b"file content") + self.conn.exec_ps_command.assert_called_once_with( + "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" + % (self.remote_path, "ZmlsZSBjb250ZW50"), + ignore_stdout=True, + ) + + def test_format_windows_command_quotes_spaces(self): + result = windows_ssh._format_windows_command( + "reg.exe", ["load", "HKLM\\x", "C:\\Program Files\\hive"] + ) + self.assertEqual(result, 'reg.exe load HKLM\\x "C:\\Program Files\\hive"') + + def test_format_windows_command_quotes_icacls_grant(self): + grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" + result = windows_ssh._format_windows_command( + "icacls.exe", + [ + "F:\\Windows\\System32\\DriverStore\\FileRepository", + "/grant", + grant, + ], + ) + self.assertEqual( + result, + 'icacls.exe F:\\Windows\\System32\\DriverStore\\FileRepository ' + '/grant "%s"' % grant, + ) + + def test_build_ssh_exec_command_wraps_cmd_and_quotes_grant(self): + grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" + result = windows_ssh._build_ssh_exec_command( + "icacls.exe", + [ + "F:\\Windows\\System32\\DriverStore\\FileRepository", + "/grant", + grant, + ], + ) + self.assertEqual( + result, + 'cmd.exe /c "icacls.exe ' + 'F:\\Windows\\System32\\DriverStore\\FileRepository ' + '/grant ""%s"""' % grant, + ) + + def test_build_ssh_exec_command_dism_image_trailing_backslash(self): + result = windows_ssh._build_ssh_exec_command( + "dism.exe", ["/Get-Drivers", "/image:F:\\"] + ) + self.assertEqual( + result, + 'cmd.exe /c "dism.exe /Get-Drivers /image:F:\\\\"', + ) + + def test_build_ssh_exec_command_dism_add_driver_no_nested_quotes(self): + driver = "G:\\Balloon\\2k19\\amd64" + result = windows_ssh._build_ssh_exec_command( + "C:\\Windows\\System32\\dism.exe", + [ + "/add-driver", + "/image:F:\\", + "/driver:%s" % driver, + "/recurse", + "/forceunsigned", + ], + ) + self.assertEqual( + result, + 'cmd.exe /c "C:\\Windows\\System32\\dism.exe /add-driver ' + '/image:F:\\ /driver:G:\\Balloon\\2k19\\amd64 /recurse ' + '/forceunsigned"', + ) + self.assertNotIn("/driver:\"", result) + + def test_split_on_marker_line(self): + buf = b"True\r\nCORIOLIS_PS_DONE_abc:0\r\nleftover" + parsed = windows_ssh._split_on_marker_line(buf, "CORIOLIS_PS_DONE_abc") + self.assertEqual(parsed[0], b"True\r\n") + self.assertEqual(parsed[1], "CORIOLIS_PS_DONE_abc:0") + self.assertEqual(parsed[2], b"leftover") + + def test_split_on_marker_line_incomplete(self): + buf = b"True\r\nCORIOLIS_PS_DONE_abc:0" + self.assertIsNone( + windows_ssh._split_on_marker_line(buf, "CORIOLIS_PS_DONE_abc") + ) + + def test_strip_ps_output_trims_blank_lines(self): + self.assertEqual(windows_ssh._strip_ps_output("\r\nG\r\n"), "G") + self.assertEqual(windows_ssh._strip_ps_output("True\r\n"), "True") + + def test_ps_session_is_alive(self): + channel = mock.Mock() + channel.closed = False + channel.exit_status_ready.return_value = False + transport = mock.Mock() + transport.is_active.return_value = True + self.conn._ssh.get_transport.return_value = transport + self.conn._ps_channel = channel + self.assertTrue(self.conn._ps_session_is_alive()) + + def test_ps_session_is_not_alive_when_channel_closed(self): + channel = mock.Mock() + channel.closed = True + channel.exit_status_ready.return_value = False + transport = mock.Mock() + transport.is_active.return_value = True + self.conn._ssh.get_transport.return_value = transport + self.conn._ps_channel = channel + self.assertFalse(self.conn._ps_session_is_alive()) + + def test_ps_session_is_not_alive_when_ssh_dead(self): + self.conn._ssh.get_transport.return_value = None + self.conn._ps_channel = mock.Mock() + self.assertFalse(self.conn._ps_session_is_alive()) + + def test_ensure_ps_session_restarts_dead_session(self): + self.conn._ps_session_is_alive = mock.Mock(return_value=False) + self.conn._restart_ps_session = mock.Mock() + self.conn._ensure_ps_session() + self.conn._restart_ps_session.assert_called_once_with() + + def test_ensure_ps_session_restarts_after_failed_alive_check(self): + self.conn._ps_session_is_alive = mock.Mock(return_value=True) + self.conn._should_probe_alive = mock.Mock(return_value=True) + self.conn._ping_ps_session = mock.Mock(return_value=False) + self.conn._restart_ps_session = mock.Mock() + self.conn._ensure_ps_session() + self.conn._ping_ps_session.assert_called_once_with() + self.conn._restart_ps_session.assert_called_once_with() + + def test_ensure_ps_session_skips_restart_when_alive(self): + self.conn._ps_session_is_alive = mock.Mock(return_value=True) + self.conn._should_probe_alive = mock.Mock(return_value=False) + self.conn._restart_ps_session = mock.Mock() + self.conn._ensure_ps_session() + self.conn._restart_ps_session.assert_not_called() + + @mock.patch.object(windows_ssh.time, "monotonic") + def test_should_probe_alive_after_idle_interval(self, mock_monotonic): + mock_monotonic.return_value = 100.0 + self.conn._ps_last_alive = 100.0 - windows_ssh.PS_ALIVE_CHECK_INTERVAL + self.assertTrue(self.conn._should_probe_alive()) + self.conn._ps_last_alive = 90.0 + self.assertFalse(self.conn._should_probe_alive()) + + def test_build_ps_wrapper_uses_encoded_command(self): + wrapper = self.conn._build_ps_wrapper("Test-Path", "abc") + self.assertIn("CORIOLIS_PS_DONE_abc", wrapper) + self.assertIn("Invoke-Expression", wrapper) + self.assertIn("$ProgressPreference = 'SilentlyContinue'", wrapper) + self.assertIn("VABlAHMAdAAtAFAAYQB0AGgA", wrapper) + + def test_start_ps_session_waits_for_ready(self): + stdin = mock.Mock() + stdout = mock.Mock() + stdout.channel = mock.Mock() + self.conn._ssh.exec_command.return_value = (stdin, stdout, mock.Mock()) + self.conn._read_until_marker = mock.Mock( + return_value=("", "", "CORIOLIS_PS_READY") + ) + self.conn._host = "10.0.0.1" + self.conn._port = 22 + + self.conn._start_ps_session() + + self.conn._ssh.exec_command.assert_called_once_with( + windows_ssh._PS_START_COMMAND, + timeout=float(self.conn._conn_timeout), + ) + stdin.write.assert_called() + self.conn._read_until_marker.assert_called_once_with( + windows_ssh._PS_READY_MARKER, timeout=windows_ssh.PS_START_TIMEOUT + ) + self.assertIs(self.conn._ps_stdin, stdin) + self.assertIs(self.conn._ps_channel, stdout.channel) + + def test_read_until_marker(self): + channel = mock.Mock() + channel.recv_stderr_ready.return_value = False + channel.exit_status_ready.return_value = False + channel.recv_ready.side_effect = [True, False] + channel.recv.side_effect = [b"True\r\nCORIOLIS_PS_DONE_abc:0\r\n"] + self.conn._ps_channel = channel + self.conn._ps_stdout_buf = bytearray() + + stdout, stderr, line = self.conn._read_until_marker( + "CORIOLIS_PS_DONE_abc", timeout=5 + ) + + self.assertEqual(stdout, "True\r\n") + self.assertEqual(stderr, "") + self.assertEqual(line, "CORIOLIS_PS_DONE_abc:0") + + def test_read_until_marker_drains_stderr_progress(self): + channel = mock.Mock() + channel.exit_status_ready.return_value = False + channel.recv_stderr_ready.side_effect = [True, False] + channel.recv_stderr.return_value = b"progress" + channel.recv_ready.side_effect = [True, False] + channel.recv.side_effect = [b"CORIOLIS_PS_DONE_abc:0\r\n"] + self.conn._ps_channel = channel + self.conn._ps_stdout_buf = bytearray() + + stdout, stderr, line = self.conn._read_until_marker( + "CORIOLIS_PS_DONE_abc", timeout=5 + ) + + self.assertEqual(stdout, "") + self.assertEqual(stderr, "progress") + self.assertEqual(line, "CORIOLIS_PS_DONE_abc:0") + + def test_read_until_marker_timeout(self): + channel = mock.Mock() + channel.recv_stderr_ready.return_value = False + channel.exit_status_ready.return_value = False + channel.recv.side_effect = socket.timeout + self.conn._ps_channel = channel + self.conn._ps_stdout_buf = bytearray() + self.assertRaises( + exception.OSMorphingSSHOperationTimeout, + self.conn._read_until_marker, + "CORIOLIS_PS_DONE_abc", + timeout=0, + ) + + def test_invoke_persistent_ps(self): + self.conn._ensure_ps_session = mock.Mock() + self.conn._write_ps_stdin = mock.Mock() + self.conn._read_until_marker = mock.Mock( + return_value=("True\r\n", "", "CORIOLIS_PS_DONE_abc:0") + ) + with mock.patch.object(windows_ssh.uuid, "uuid4") as mock_uuid: + mock_uuid.return_value.hex = "abc" + stdout, stderr, exit_code = self.conn._invoke_persistent_ps("Test-Path") + + self.conn._ensure_ps_session.assert_called_once_with() + self.conn._write_ps_stdin.assert_called_once() + self.assertEqual(stdout, "True\r\n") + self.assertEqual(stderr, "") + self.assertEqual(exit_code, 0) + + def test_configure_ssh_keepalive(self): + transport = mock.Mock() + self.conn._ssh.get_transport.return_value = transport + self.conn._configure_ssh_keepalive() + transport.set_keepalive.assert_called_once_with( + windows_ssh.SSH_KEEPALIVE_INTERVAL + ) diff --git a/coriolis/tests/test_wsman.py b/coriolis/tests/test_wsman.py index 93206e6b2..6cbb5c410 100644 --- a/coriolis/tests/test_wsman.py +++ b/coriolis/tests/test_wsman.py @@ -1,239 +1,17 @@ -# Copyright 2023 Cloudbase Solutions Srl +# Copyright 2026 Cloudbase Solutions Srl # All Rights Reserved. -import logging -from unittest import mock - -import requests -from winrm import protocol - from coriolis import exception, wsman from coriolis.tests import test_base -class WSManConnectionTestCase(test_base.CoriolisBaseTestCase): - """Test suite for the Coriolis WSManConnection class.""" - - def setUp(self): - super(WSManConnectionTestCase, self).setUp() - self.conn = wsman.WSManConnection() - self.conn._protocol = mock.Mock() - self.conn._conn_timeout = 10 - self.cmd = "test_cmd" - self.args = ["-RecoveryPassword", "'ShouldNotBeLogged'"] - self.sanitized_cmd = "test_cmd -RecoveryPassword '***'" - self.url = "http://example.com/file" - self.remote_path = "/remote/path" - - def test__init__timeout(self): - self.connection = wsman.WSManConnection() - self.assertEqual(self.connection._conn_timeout, wsman.DEFAULT_TIMEOUT) - - def test__init__timeout_set(self): - self.connection = wsman.WSManConnection(timeout=100) - self.assertEqual(self.connection._conn_timeout, 100) - - @mock.patch.object(protocol, 'Protocol') - def test_connect(self, mock_protocol): - self.conn.connect('url', 'username', cert_pem='test_cert') - mock_protocol.assert_called_once_with( - endpoint='url', - transport='ssl', - username='username', - password=None, - cert_pem="test_cert", - cert_key_pem=None, - ) - - @mock.patch.object(protocol, 'Protocol') - def test_connect_no_auth(self, mock_protocol): - self.conn.connect('url', 'username') - mock_protocol.assert_called_once_with( - endpoint='url', - transport='plaintext', - username='username', - password=None, - cert_pem=None, - cert_key_pem=None, - ) - - @mock.patch.object(wsman.WSManConnection, 'connect') - @mock.patch('coriolis.utils.wait_for_port_connectivity') - def test_from_connection_info(self, mock_wait_for_port_connectivity, mock_connect): - connection_info = { - "ip": "127.0.0.1", - "username": "user", - "password": "pass", - } - result = self.conn.from_connection_info(connection_info) - mock_wait_for_port_connectivity.assert_called_once_with("127.0.0.1", 5986) - mock_connect.assert_called_once_with( - url="https://127.0.0.1:5986/wsman", - username="user", - password="pass", - cert_pem=None, - cert_key_pem=None, - ) - self.assertIsInstance(result, self.conn.__class__) - - def test_from_connection_info_missing_keys(self): - self.assertRaises( - ValueError, - self.conn.from_connection_info, - {"username": "user", "password": "pass"}, - ) - - def test_from_connection_info_invalid_type(self): - self.assertRaises( - ValueError, self.conn.from_connection_info, 'invalid-connection-type' - ) - - def test_disconnect(self): - self.conn.disconnect() - self.assertIsNone(self.conn._protocol) - - def test_set_timeout(self): - self.conn.set_timeout(self.conn._conn_timeout) - self.assertEqual(self.conn._protocol.transport.timeout, self.conn._conn_timeout) - self.assertEqual(self.conn._protocol.timeout, self.conn._conn_timeout) - - def test__exec_command(self): - self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 0) - std_out, std_err, exit_code = self.conn._exec_command(self.cmd, self.args) - self.assertEqual(std_out, "std_out") - self.assertEqual(std_err, "std_err") - self.assertEqual(exit_code, 0) - - self.conn._protocol.open_shell.assert_called_once_with( - codepage=wsman.CODEPAGE_UTF8 - ) - shell_id = self.conn._protocol.open_shell.return_value - self.conn._protocol.run_command.assert_called_once_with( - shell_id, self.cmd, self.args - ) - command_id = self.conn._protocol.run_command.return_value - self.conn._protocol.get_command_output.assert_called_once_with( - shell_id, command_id - ) - self.conn._protocol.cleanup_command.assert_called_once_with( - shell_id, command_id - ) - self.conn._protocol.close_shell.assert_called_once_with(shell_id) - - def test__exec_command_exception(self): - self.conn._protocol.get_command_output.side_effect = ( - requests.exceptions.ReadTimeout - ) - self.assertRaises( - exception.OSMorphingWinRMOperationTimeout, - self.conn._exec_command, - self.cmd, - self.args, - ) - self.conn._protocol.cleanup_command.assert_called_once_with(mock.ANY, mock.ANY) - self.conn._protocol.close_shell.assert_called_once_with(mock.ANY) - - @mock.patch("time.sleep") - def test__exec_command_invalid_credentials(self, mock_sleep): - self.conn._protocol.open_shell.side_effect = ( - wsman.winrm_exceptions.InvalidCredentialsError - ) - +class WSManShimTestCase(test_base.CoriolisBaseTestCase): + def test_from_connection_info_raises(self): self.assertRaises( - exception.NotAuthorized, self.conn._exec_command, self.cmd, self.args + exception.InvalidInput, + wsman.WSManConnection.from_connection_info, + {"ip": "10.0.0.1", "username": "admin", "password": "x"}, ) - self.conn._protocol.close_shell.assert_not_called() - def test_exec_command(self): - self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 0) - exp_sanitized_log = ( - "DEBUG:coriolis.wsman:Executing WSMAN command: %s" % self.sanitized_cmd - ) - with self.assertLogs("coriolis.wsman", level=logging.DEBUG) as log_cm: - std_out = self.conn.exec_command(self.cmd, self.args) - self.assertEqual(std_out, "std_out") - self.assertIn(exp_sanitized_log, log_cm.output) - - def test_exec_command_exception(self): - self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 1) - self.assertRaises( - exception.CoriolisException, self.conn.exec_command, self.cmd, self.args - ) - - def test_exec_ps_command(self): - self.conn.exec_command = mock.Mock() - self.conn.exec_command.return_value = "std_out\n\n" - result = self.conn.exec_ps_command( - self.cmd, - include_stderr=False, - ) - self.conn.exec_command.assert_called_once_with( - "powershell.exe", - [ - "-EncodedCommand", - 'dABlAHMAdABfAGMAbQBkAA==', - '-NonInteractive', - '-ExecutionPolicy', - 'RemoteSigned', - ], - timeout=None, - sanitizable=False, - include_stderr=False, - ) - self.assertEqual(result, "std_out") - - def test_exec_ps_command_with_stderr(self): - self.conn.exec_command = mock.Mock() - self.conn.exec_command.return_value = "std_out\n\n", "stderr" - result = self.conn.exec_ps_command( - self.cmd, - include_stderr=True, - ) - self.conn.exec_command.assert_called_once_with( - "powershell.exe", - [ - "-EncodedCommand", - 'dABlAHMAdABfAGMAbQBkAA==', - '-NonInteractive', - '-ExecutionPolicy', - 'RemoteSigned', - ], - timeout=None, - sanitizable=False, - include_stderr=True, - ) - self.assertEqual(result, ("std_out", "stderr")) - - def test_test_path(self): - self.conn.exec_ps_command = mock.Mock() - self.conn.exec_ps_command.return_value = "True" - result = self.conn.test_path("test_path") - self.conn.exec_ps_command.assert_called_once_with( - "Test-Path -Path \"test_path\"" - ) - self.assertTrue(result) - - def test_download_file(self): - self.conn.exec_ps_command = mock.Mock() - self.conn.download_file(self.url, self.remote_path) - self.conn.exec_ps_command.assert_called_once() - - def test_download_file_exception(self): - self.conn.exec_ps_command = mock.Mock() - self.conn.exec_ps_command.side_effect = exception.CoriolisException - self.assertRaises( - exception.CoriolisException, - self.conn.download_file, - self.url, - self.remote_path, - ) - self.conn.exec_ps_command.assert_called_once() - - def test_write_file(self): - self.conn.exec_ps_command = mock.Mock() - self.conn.write_file(self.remote_path, b'file content') - self.conn.exec_ps_command.assert_called_once_with( - "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" - % (self.remote_path, 'ZmlsZSBjb250ZW50'), - ignore_stdout=True, - ) + def test_constructor_raises(self): + self.assertRaises(exception.InvalidInput, wsman.WSManConnection) diff --git a/coriolis/windows_ssh.py b/coriolis/windows_ssh.py new file mode 100644 index 000000000..865c02b2d --- /dev/null +++ b/coriolis/windows_ssh.py @@ -0,0 +1,634 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""SSH connection for Windows OS morphing minions. + +The Coriolis worker connects to the minion over OpenSSH. It does not open +WinRM. Morphing commands still run as powershell.exe, diskpart, reg.exe, +and DISM. + +Minion requirements +------------------- +The Windows morphing minion must provide all of the following: + +* Windows Server 2019 or later. +* OpenSSH Server listening on TCP port 22. A custom SSH port is allowed + when connection_info['port'] is that port. +* Windows PowerShell 5.1. The executable must be powershell.exe on PATH + for the SSH user. +* An SSH login that can run elevated morphing commands. Providers usually + send Administrator. Auth is a password or an SSH private key. +* The SSH user must start powershell.exe with -Command -. The process + reads commands from stdin. PowerShell 7 (pwsh) is not required. +* diskpart.exe, reg.exe, and DISM must be available. These tools are + present on Windows Server. +* The Coriolis worker must reach the minion IP on the SSH port. + +Not required +------------ +* WinRM, HTTPS port 5986, or a WinRM listener. +* PowerShell 7, pwsh.exe, or an OpenSSH Subsystem powershell line. +* PSRP remoting (Enter-PSSession -HostName, pypsrp SSH). +""" + +import base64 +import socket +import time +import uuid + +import paramiko +from oslo_log import log as logging +from oslo_utils import strutils + +from coriolis import exception, utils + +LOG = logging.getLogger(__name__) + +DEFAULT_TIMEOUT = 3600 +SSH_PORT = 22 +PS_ALIVE_CHECK_INTERVAL = 30 +PS_PING_TIMEOUT = 15 +PS_START_TIMEOUT = 60 +SSH_KEEPALIVE_INTERVAL = 30 +_PS_READY_MARKER = "CORIOLIS_PS_READY" +_PS_START_COMMAND = ( + "powershell.exe -NoLogo -NoProfile -NonInteractive " + "-ExecutionPolicy RemoteSigned -OutputFormat Text -Command -" +) +_PS_BOOTSTRAP = ( + "$ProgressPreference = 'SilentlyContinue'; " + "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false; " + "[Console]::InputEncoding = New-Object System.Text.UTF8Encoding $false; " + "$OutputEncoding = [Console]::OutputEncoding; " + "$ErrorActionPreference = 'Continue'; " + "Write-Output '%s'\r\n" % _PS_READY_MARKER +) + + +def _is_reg_exe(cmd): + base = str(cmd).replace("\\", "/").rsplit("/", 1)[-1].lower() + return base in ("reg", "reg.exe") + + +_CMD_QUOTE_CHARS = (" ", "\t", '"', "&", "|", "(", ")", "<", ">", "^", "%") + + +def _quote_cmd_arg(part): + part_str = str(part) + if (not part_str) or any(ch in part_str for ch in _CMD_QUOTE_CHARS): + return '"%s"' % part_str.replace('"', '""') + return part_str + + +def _format_windows_command(cmd, args): + return " ".join(_quote_cmd_arg(p) for p in [cmd] + list(args or [])) + + +def _escape_trailing_backslash_for_ssh(command): + """Keep a trailing backslash from eating the SSH quote. + + Windows OpenSSH wraps the exec string in double quotes. An odd number + of trailing backslashes escapes that quote. PowerShell then reports a + missing string terminator. DISM /image:F:\\ is the usual case. + """ + n = len(command) - len(command.rstrip("\\")) + if n % 2 == 1: + return command + "\\" + return command + + +def _wrap_native_command_for_ssh(command): + """Run native tools via cmd.exe. + + OpenSSH DefaultShell is often PowerShell. PowerShell parses (OI) in + icacls grants as a command. cmd.exe does not when the grant is quoted. + """ + command = _escape_trailing_backslash_for_ssh(command) + return 'cmd.exe /c "%s"' % command.replace('"', '""') + + +def _build_ssh_exec_command(cmd, args): + formatted = _format_windows_command(cmd, args) + wrapped = _wrap_native_command_for_ssh(formatted) + return _escape_trailing_backslash_for_ssh(wrapped) + + +def _strip_ps_output(stdout): + return (stdout or "").strip() + + +def _drain_ssh_channel(channel, stdout_buf, stderr_buf): + """Read available SSH bytes. Do not block on one stream. + + Cmdlets such as Expand-Archive write progress to stderr. A blocking + stdout read leaves that stderr unread. The SSH window fills and the + remote process waits forever. + """ + got = False + while channel.recv_stderr_ready(): + chunk = channel.recv_stderr(65536) + if not chunk: + break + stderr_buf.extend(chunk) + got = True + while channel.recv_ready(): + chunk = channel.recv(65536) + if not chunk: + break + stdout_buf.extend(chunk) + got = True + return got + + +def _split_on_marker_line(buf, marker): + """Return (before, line, after) when a full marker line is present.""" + marker_bytes = marker.encode("ascii") + idx = buf.find(marker_bytes) + if idx < 0: + return None + rest = buf[idx:] + nl = rest.find(b"\n") + if nl < 0: + return None + line = rest[:nl].rstrip(b"\r").decode("ascii", errors="replace") + before = buf[:idx] + after = rest[nl + 1 :] + return before, line, after + + +class WindowsSSHConnection(object): + """Windows morphing minion connection over SSH. + + See the module docstring for minion requirements. PowerShell cmdlets + reuse one remote powershell.exe process. Native commands still use one + SSH exec per call. + """ + + EOL = "\r\n" + + def __init__(self, timeout=None): + self._ssh = None + self._conn_timeout = int(timeout or DEFAULT_TIMEOUT) + self._host = None + self._port = SSH_PORT + self._username = None + self._password = None + self._pkey = None + self._ps_stdin = None + self._ps_channel = None + self._ps_stdout_buf = bytearray() + self._ps_last_alive = 0.0 + + @classmethod + def from_connection_info(cls, connection_info, timeout=DEFAULT_TIMEOUT): + """Return a WindowsSSHConnection for the provided conn info.""" + if not isinstance(connection_info, dict): + raise ValueError( + "Windows minion connection must be a dict. Got type '%s', " + "value: %s" % (type(connection_info), connection_info) + ) + + required_keys = ["ip", "username"] + missing = [key for key in required_keys if key not in connection_info] + if missing: + raise ValueError( + "The following keys were missing from Windows SSH connection " + "info %s. Got: %s" % (missing, connection_info) + ) + if not connection_info.get("password") and not connection_info.get("pkey"): + raise ValueError( + "Windows SSH connection info must include password or pkey. " + "Got: %s" % connection_info + ) + + host = connection_info["ip"] + port = connection_info.get("port") or SSH_PORT + username = connection_info["username"] + password = connection_info.get("password") + pkey = connection_info.get("pkey") + + LOG.info( + "Waiting for SSH connectivity on host: %(host)s:%(port)s", + {"host": host, "port": port}, + ) + utils.wait_for_port_connectivity(host, port) + + conn = cls(timeout) + conn.connect( + host=host, + port=port, + username=username, + password=password, + pkey=pkey, + ) + return conn + + def connect(self, host, port, username, password=None, pkey=None): + self._host = host + self._port = port + self._username = username + self._password = password + self._pkey = pkey + try: + self._open_ssh() + except exception.CoriolisException as ex: + if isinstance(ex.__cause__, paramiko.AuthenticationException): + raise exception.NotAuthorized( + message="The SSH connection credentials are invalid. " + "If you are using a template with a default " + "pre-baked username/password, please ensure " + "that you have passed the credentials to the " + "destination Coriolis plugin you have selected," + " either via the Target Environment parameters " + "set when creating the Migration/Replica, or " + "by setting it in the destination plugin's " + "dedicated section of the coriolis.conf " + "static configuration file." + ) from ex + raise + self._start_ps_session() + + def _open_ssh(self): + self._ssh = utils.connect_ssh( + hostname=self._host, + port=self._port, + username=self._username, + password=self._password, + pkey=self._pkey, + ) + self._ssh.set_log_channel("paramiko.morpher.%s.%s" % (self._host, self._port)) + self._configure_ssh_keepalive() + + def _configure_ssh_keepalive(self): + if not self._ssh: + return + transport = self._ssh.get_transport() + if transport: + transport.set_keepalive(SSH_KEEPALIVE_INTERVAL) + + def _ssh_is_alive(self): + if not self._ssh: + return False + transport = self._ssh.get_transport() + return bool(transport and transport.is_active()) + + def _ps_session_is_alive(self): + if not self._ssh_is_alive(): + return False + channel = self._ps_channel + if channel is None: + return False + if channel.closed: + return False + if channel.exit_status_ready(): + return False + return True + + def _should_probe_alive(self): + if not self._ps_last_alive: + return True + idle = time.monotonic() - self._ps_last_alive + return idle >= PS_ALIVE_CHECK_INTERVAL + + def _close_ps_session(self, wait=False, wait_timeout=10): + stdin = self._ps_stdin + channel = self._ps_channel + self._ps_stdin = None + self._ps_channel = None + self._ps_stdout_buf = bytearray() + if stdin is not None: + try: + stdin.write(b"exit\r\n") + stdin.flush() + except Exception: + pass + try: + stdin.close() + except Exception: + pass + if wait and channel is not None: + deadline = time.monotonic() + int(wait_timeout) + while time.monotonic() < deadline: + try: + if channel.exit_status_ready(): + break + except Exception: + break + time.sleep(0.05) + if channel is not None: + try: + channel.close() + except Exception: + pass + + def _reconnect_ssh(self): + if self._ssh: + try: + self._ssh.close() + except Exception: + pass + self._ssh = None + LOG.warning( + "Reconnecting SSH to Windows minion %(host)s:%(port)s", + {"host": self._host, "port": self._port}, + ) + utils.wait_for_port_connectivity(self._host, self._port) + self._open_ssh() + + def _restart_ps_session(self): + self._close_ps_session() + if not self._ssh_is_alive(): + self._reconnect_ssh() + self._start_ps_session() + + def _start_ps_session(self): + self._close_ps_session() + LOG.info( + "Starting persistent PowerShell session on %(host)s:%(port)s", + {"host": self._host, "port": self._port}, + ) + try: + stdin, stdout, _stderr = self._ssh.exec_command( + _PS_START_COMMAND, timeout=float(self._conn_timeout) + ) + self._ps_stdin = stdin + self._ps_channel = stdout.channel + self._ps_stdout_buf = bytearray() + self._write_ps_stdin(_PS_BOOTSTRAP.encode("utf-8")) + self._read_until_marker(_PS_READY_MARKER, timeout=PS_START_TIMEOUT) + self._ps_last_alive = time.monotonic() + except Exception: + self._close_ps_session() + raise + + def _write_ps_stdin(self, data): + try: + self._ps_stdin.write(data) + self._ps_stdin.flush() + except (OSError, EOFError, socket.timeout, paramiko.SSHException) as ex: + self._close_ps_session() + raise exception.CoriolisException( + "PowerShell SSH session write failed: %s" % ex + ) from ex + + def _read_until_marker(self, marker, timeout): + deadline = time.monotonic() + int(timeout) + stderr_buf = bytearray() + channel = self._ps_channel + if channel is None: + raise exception.CoriolisException( + "PowerShell SSH session is not connected." + ) + channel.settimeout(0.1) + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise exception.OSMorphingSSHOperationTimeout( + cmd=marker, timeout=timeout + ) + try: + _drain_ssh_channel(channel, self._ps_stdout_buf, stderr_buf) + except socket.timeout: + pass + + parsed = _split_on_marker_line(self._ps_stdout_buf, marker) + if parsed is not None: + before, line, after = parsed + self._ps_stdout_buf = bytearray(after) + stdout = before.decode("utf-8", errors="replace") + stderr = stderr_buf.decode("utf-8", errors="replace") + return stdout, stderr, line + + if channel.exit_status_ready() and not channel.recv_ready(): + leftover = self._ps_stdout_buf.decode("utf-8", errors="replace") + raise exception.CoriolisException( + "PowerShell SSH session closed while waiting for " + "marker '%s'. Output: %s" % (marker, leftover) + ) + time.sleep(0.05) + + def _ping_ps_session(self): + token = uuid.uuid4().hex + marker = "CORIOLIS_PS_ALIVE_%s" % token + try: + self._write_ps_stdin(("Write-Output '%s'\r\n" % marker).encode("utf-8")) + _stdout, _stderr, line = self._read_until_marker( + marker, timeout=PS_PING_TIMEOUT + ) + alive = line.startswith(marker) + if alive: + self._ps_last_alive = time.monotonic() + return alive + except Exception: + LOG.debug( + "PowerShell SSH alive check failed: %s", + utils.get_exception_details(), + ) + return False + + def _ensure_ps_session(self): + if self._ps_session_is_alive(): + if self._should_probe_alive() and not self._ping_ps_session(): + LOG.warning( + "PowerShell SSH session failed the alive check. " + "Starting a new session." + ) + self._restart_ps_session() + return + LOG.warning("PowerShell SSH session is not alive. Starting a new session.") + self._restart_ps_session() + + def _release_ps_registry_handles(self): + """Stop PowerShell so it does not hold loaded hive keys. + + Get-ItemProperty keeps RegistryKey objects in this process. + Garbage collection does not drop those handles. End the process + before reg.exe load or unload. + """ + self._close_ps_session(wait=True) + + def _build_ps_wrapper(self, cmd, token): + encoded_cmd = base64.b64encode(cmd.encode("utf-16le")).decode() + marker = "CORIOLIS_PS_DONE_%s" % token + return ( + "$ProgressPreference = 'SilentlyContinue'; " + "$__c = [System.Text.Encoding]::Unicode.GetString(" + "[Convert]::FromBase64String('%s')); " + "$__e = 0; " + "try { Invoke-Expression -Command $__c } " + "catch { Write-Error -ErrorRecord $_; $__e = 1 }; " + "Write-Output ('%s:' + $__e)\r\n" % (encoded_cmd, marker) + ) + + @utils.retry_on_error( + terminal_exceptions=[ + exception.NotAuthorized, + exception.OSMorphingSSHOperationTimeout, + ] + ) + def _invoke_persistent_ps(self, cmd, timeout=None): + timeout = int(timeout or self._conn_timeout) + self._ensure_ps_session() + token = uuid.uuid4().hex + marker = "CORIOLIS_PS_DONE_%s" % token + wrapper = self._build_ps_wrapper(cmd, token) + self._write_ps_stdin(wrapper.encode("utf-8")) + stdout, stderr, line = self._read_until_marker(marker, timeout=timeout) + self._ps_last_alive = time.monotonic() + prefix = "%s:" % marker + if not line.startswith(prefix): + raise exception.CoriolisException( + "PowerShell SSH session returned an invalid status line: %s" % line + ) + try: + exit_code = int(line[len(prefix) :].strip()) + except ValueError as ex: + raise exception.CoriolisException( + "PowerShell SSH session returned a non-numeric exit code: %s" % line + ) from ex + return stdout, stderr, exit_code + + def disconnect(self): + self._close_ps_session() + if self._ssh: + self._ssh.close() + self._ssh = None + + def set_timeout(self, timeout): + if timeout: + self._conn_timeout = int(timeout) + + def _read_ssh_exec_output(self, channel, timeout, sanitized_cmd): + """Read stdout and stderr until the SSH exec channel exits.""" + deadline = time.monotonic() + int(timeout) + stdout_buf = bytearray() + stderr_buf = bytearray() + channel.settimeout(0.1) + while True: + if time.monotonic() >= deadline: + raise exception.OSMorphingSSHOperationTimeout( + cmd=sanitized_cmd, timeout=timeout + ) + try: + _drain_ssh_channel(channel, stdout_buf, stderr_buf) + except socket.timeout: + pass + if channel.exit_status_ready(): + try: + _drain_ssh_channel(channel, stdout_buf, stderr_buf) + except socket.timeout: + pass + if not channel.recv_ready() and not channel.recv_stderr_ready(): + break + time.sleep(0.05) + exit_code = channel.recv_exit_status() + stdout_str = stdout_buf.decode("utf-8", errors="replace") + stderr_str = stderr_buf.decode("utf-8", errors="replace") + return stdout_str, stderr_str, exit_code + + @utils.retry_on_error( + terminal_exceptions=[ + exception.NotAuthorized, + exception.OSMorphingSSHOperationTimeout, + ] + ) + def _exec_command(self, cmd, args=[], timeout=None, sanitizable=True): + command = _build_ssh_exec_command(cmd, args) + if sanitizable: + sanitized_cmd = strutils.mask_password(command) + else: + sanitized_cmd = "***" + + timeout = int(timeout or self._conn_timeout) + LOG.debug("Executing SSH command: %s", sanitized_cmd) + try: + _, stdout, _stderr = self._ssh.exec_command(command, timeout=float(timeout)) + return self._read_ssh_exec_output(stdout.channel, timeout, sanitized_cmd) + except socket.timeout as ex: + raise exception.OSMorphingSSHOperationTimeout( + cmd=sanitized_cmd, timeout=timeout + ) from ex + + def exec_command( + self, + cmd, + args=[], + timeout=None, + sanitizable=True, + include_stderr=False, + ): + if sanitizable: + sanitized_cmd = strutils.mask_password(_build_ssh_exec_command(cmd, args)) + else: + sanitized_cmd = "***" + if _is_reg_exe(cmd): + self._release_ps_registry_handles() + LOG.debug("Executing Windows SSH command: %s", sanitized_cmd) + std_out, std_err, exit_code = self._exec_command( + cmd, args, timeout=timeout, sanitizable=sanitizable + ) + + if exit_code: + raise exception.CoriolisException( + "Command \"%s\" failed with exit code: %s\n" + "stdout: %s\nstd_err: %s" % (sanitized_cmd, exit_code, std_out, std_err) + ) + + if include_stderr: + return std_out, std_err + return std_out + + def exec_ps_command( + self, + cmd, + ignore_stdout=False, + timeout=None, + include_stderr=False, + ): + LOG.debug("Executing PS command: %s", strutils.mask_password(cmd)) + stdout, stderr, exit_code = self._invoke_persistent_ps(cmd, timeout=timeout) + if exit_code: + sanitized_cmd = strutils.mask_password(cmd) + raise exception.CoriolisException( + "Command \"%s\" failed with exit code: %s\n" + "stdout: %s\nstd_err: %s" % (sanitized_cmd, exit_code, stdout, stderr) + ) + if include_stderr: + return _strip_ps_output(stdout), stderr + return _strip_ps_output(stdout) + + def test_path(self, remote_path): + ret_val = self.exec_ps_command("Test-Path -Path \"%s\"" % remote_path) + return ret_val == "True" + + def download_file(self, url, remote_path): + LOG.debug( + "Downloading: \"%(url)s\" to \"%(path)s\"", + {"url": url, "path": remote_path}, + ) + try: + self.exec_ps_command( + "[Net.ServicePointManager]::SecurityProtocol = " + "[Net.SecurityProtocolType]::Tls12;" + "if(!([System.Management.Automation.PSTypeName]'" + "System.Net.Http.HttpClient').Type) {$assembly = " + "[System.Reflection.Assembly]::LoadWithPartialName(" + "'System.Net.Http')}; (new-object System.Net.Http.HttpClient)." + "GetStreamAsync('%(url)s').Result.CopyTo(" + "(New-Object IO.FileStream '%(outfile)s', Create, Write, " + "None), 1MB)" % {"url": url, "outfile": remote_path}, + ignore_stdout=True, + ) + except exception.CoriolisException as ex: + LOG.trace(utils.get_exception_details()) + raise exception.CoriolisException( + "Failed to download file from URL: %s to path: %s. Please " + "check logs for more details." % (url, remote_path) + ) from ex + + def write_file(self, remote_path, content): + self.exec_ps_command( + "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" + % (remote_path, base64.b64encode(content).decode()), + ignore_stdout=True, + ) diff --git a/coriolis/wsman.py b/coriolis/wsman.py index 27dd661a1..5fdab5b7e 100644 --- a/coriolis/wsman.py +++ b/coriolis/wsman.py @@ -1,248 +1,23 @@ -# Copyright 2016 Cloudbase Solutions Srl +# Copyright 2026 Cloudbase Solutions Srl # All Rights Reserved. -import base64 +"""Import shim for leftover providers that still import coriolis.wsman. -import requests -from oslo_log import log as logging -from oslo_utils import strutils -from winrm import exceptions as winrm_exceptions -from winrm import protocol +Windows morphing uses SSH. This module does not open WinRM. +Remove this module after the leftover providers stop importing it. +""" -from coriolis import exception, utils +from coriolis import exception -AUTH_BASIC = "basic" -AUTH_KERBEROS = "kerberos" -AUTH_CERTIFICATE = "certificate" - -CODEPAGE_UTF8 = 65001 -DEFAULT_TIMEOUT = 3600 - -LOG = logging.getLogger(__name__) +_WINRM_REMOVED_MSG = "Windows minion connections must use SSH. WinRM is not supported." class WSManConnection(object): - def __init__(self, timeout=None): - self._protocol = None - self._conn_timeout = int(timeout or DEFAULT_TIMEOUT) - - EOL = "\r\n" - - @utils.retry_on_error() - def connect( - self, url, username, auth=None, password=None, cert_pem=None, cert_key_pem=None - ): - if not auth: - if cert_pem: - auth = AUTH_CERTIFICATE - else: - auth = AUTH_BASIC - - auth_transport_map = { - AUTH_BASIC: 'plaintext', - AUTH_KERBEROS: 'kerberos', - AUTH_CERTIFICATE: 'ssl', - } + """Raise on use. Leftover providers import this class at load time.""" - self._protocol = protocol.Protocol( - endpoint=url, - transport=auth_transport_map[auth], - username=username, - password=password, - cert_pem=cert_pem, - cert_key_pem=cert_key_pem, - ) + def __init__(self, timeout=None): + raise exception.InvalidInput(_WINRM_REMOVED_MSG) @classmethod - def from_connection_info(cls, connection_info, timeout=DEFAULT_TIMEOUT): - """Returns a wsman.WSManConnection obj for the provided conn info.""" - if not isinstance(connection_info, dict): - raise ValueError( - "WSMan connection must be a dict. Got type '%s', value: %s" - % (type(connection_info), connection_info) - ) - - required_keys = ["ip", "username", "password"] - missing = [key for key in required_keys if key not in connection_info] - if missing: - raise ValueError( - "The following keys were missing from WSMan connection " - "info %s. Got: %s" % (missing, connection_info) - ) - - host = connection_info["ip"] - port = connection_info.get("port", 5986) - username = connection_info["username"] - password = connection_info.get("password") - cert_pem = connection_info.get("cert_pem") - cert_key_pem = connection_info.get("cert_key_pem") - url = "https://%s:%s/wsman" % (host, port) - - LOG.info( - "Waiting for connectivity on host: %(host)s:%(port)s", - {"host": host, "port": port}, - ) - utils.wait_for_port_connectivity(host, port) - - conn = cls(timeout) - conn.connect( - url=url, - username=username, - password=password, - cert_pem=cert_pem, - cert_key_pem=cert_key_pem, - ) - - return conn - - def disconnect(self): - self._protocol = None - - def set_timeout(self, timeout): - if timeout: - self._protocol.timeout = timeout - self._protocol.transport.timeout = timeout - - @utils.retry_on_error( - terminal_exceptions=[ - winrm_exceptions.InvalidCredentialsError, - exception.OSMorphingWinRMOperationTimeout, - ] - ) - def _exec_command(self, cmd, args=[], timeout=None, sanitizable=True): - if sanitizable: - sanitized_cmd = strutils.mask_password("%s %s" % (cmd, " ".join(args))) - else: - sanitized_cmd = "***" - - timeout = int(timeout or self._conn_timeout) - self.set_timeout(timeout) - shell_id = None - try: - shell_id = self._protocol.open_shell(codepage=CODEPAGE_UTF8) - command_id = self._protocol.run_command(shell_id, cmd, args) - try: - (std_out, std_err, exit_code) = self._protocol.get_command_output( - shell_id, command_id - ) - except requests.exceptions.ReadTimeout: - raise exception.OSMorphingWinRMOperationTimeout( - cmd=sanitized_cmd, timeout=timeout - ) - finally: - self._protocol.cleanup_command(shell_id, command_id) - - return (std_out, std_err, exit_code) - except winrm_exceptions.InvalidCredentialsError as ex: - raise exception.NotAuthorized( - message="The WinRM connection credentials are invalid. " - "If you are using a template with a default " - "pre-baked username/password, please ensure " - "that you have passed the credentials to the " - "destination Coriolis plugin you have selected," - " either via the Target Environment parameters " - "set when creating the Migration/Replica, or " - "by setting it in the destination plugin's " - "dedicated section of the coriolis.conf " - "static configuration file." - ) from ex - finally: - if shell_id: - self._protocol.close_shell(shell_id) - - def exec_command( - self, - cmd, - args=[], - timeout=None, - sanitizable=True, - include_stderr=False, - ): - # Our sanitization helpers do not work for base64 encoded commands, - # in which case we'll avoid logging it so that we won't leak - # sensitive information. - if sanitizable: - sanitized_cmd = strutils.mask_password("%s %s" % (cmd, " ".join(args))) - else: - sanitized_cmd = "***" - LOG.debug("Executing WSMAN command: %s", sanitized_cmd) - std_out, std_err, exit_code = self._exec_command( - cmd, args, timeout=timeout, sanitizable=sanitizable - ) - - if exit_code: - raise exception.CoriolisException( - "Command \"%s\" failed with exit code: %s\n" - "stdout: %s\nstd_err: %s" % (sanitized_cmd, exit_code, std_out, std_err) - ) - - if include_stderr: - return std_out, std_err - return std_out - - def exec_ps_command( - self, - cmd, - ignore_stdout=False, - timeout=None, - include_stderr=False, - ): - LOG.debug("Executing PS command: %s", strutils.mask_password(cmd)) - base64_cmd = base64.b64encode(cmd.encode('utf-16le')).decode() - ret = self.exec_command( - "powershell.exe", - [ - "-EncodedCommand", - base64_cmd, - "-NonInteractive", - "-ExecutionPolicy", - "RemoteSigned", - ], - timeout=timeout, - sanitizable=False, - include_stderr=include_stderr, - ) - if include_stderr: - stdout, stderr = ret - return stdout[:-2], stderr - else: - stdout = ret - return stdout[:-2] - - def test_path(self, remote_path): - ret_val = self.exec_ps_command("Test-Path -Path \"%s\"" % remote_path) - return ret_val == "True" - - def download_file(self, url, remote_path): - LOG.debug( - "Downloading: \"%(url)s\" to \"%(path)s\"", - {"url": url, "path": remote_path}, - ) - try: - # Nano Server does not have Invoke-WebRequest and additionally - # this is also faster - self.exec_ps_command( - "[Net.ServicePointManager]::SecurityProtocol = " - "[Net.SecurityProtocolType]::Tls12;" - "if(!([System.Management.Automation.PSTypeName]'" - "System.Net.Http.HttpClient').Type) {$assembly = " - "[System.Reflection.Assembly]::LoadWithPartialName(" - "'System.Net.Http')}; (new-object System.Net.Http.HttpClient)." - "GetStreamAsync('%(url)s').Result.CopyTo(" - "(New-Object IO.FileStream '%(outfile)s', Create, Write, " - "None), 1MB)" % {"url": url, "outfile": remote_path}, - ignore_stdout=True, - ) - except exception.CoriolisException as ex: - LOG.trace(utils.get_exception_details()) - raise exception.CoriolisException( - "Failed to download file from URL: %s to path: %s. Please " - "check logs for more details." % (url, remote_path) - ) from ex - - def write_file(self, remote_path, content): - self.exec_ps_command( - "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" - % (remote_path, base64.b64encode(content).decode()), - ignore_stdout=True, - ) + def from_connection_info(cls, connection_info, timeout=None): + raise exception.InvalidInput(_WINRM_REMOVED_MSG) diff --git a/requirements.txt b/requirements.txt index 89d38ee77..89af2d979 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,6 @@ python-keystoneclient python-memcached>=1.56 python-barbicanclient python-swiftclient>=3.2 -git+https://github.com/cloudbase/pywinrm.git@requests#egg=pywinrm PyYAML redis requests From 410335f9b737d9365796d9e00bc5dfaed7e66bec Mon Sep 17 00:00:00 2001 From: Cristian Matiut Date: Wed, 9 Sep 2026 14:48:44 +0300 Subject: [PATCH 2/2] Add back winrm as fallback --- coriolis/osmorphing/osmount/windows.py | 22 +- coriolis/osmorphing/windows.py | 12 +- coriolis/providers/provider_utils.py | 66 ++++- .../tests/osmorphing/osmount/test_windows.py | 12 +- coriolis/tests/osmorphing/test_windows.py | 10 +- .../tests/providers/test_provider_utils.py | 52 +++- coriolis/tests/test_windows_conn.py | 30 +++ coriolis/tests/test_windows_ssh.py | 103 +------- coriolis/tests/test_windows_ssh_cmd.py | 104 ++++++++ coriolis/tests/test_wsman.py | 238 ++++++++++++++++- coriolis/windows_conn.py | 30 +++ coriolis/windows_ssh.py | 93 ++----- coriolis/windows_ssh_cmd.py | 72 +++++ coriolis/wsman.py | 249 +++++++++++++++++- requirements.txt | 1 + 15 files changed, 863 insertions(+), 231 deletions(-) create mode 100644 coriolis/tests/test_windows_conn.py create mode 100644 coriolis/tests/test_windows_ssh_cmd.py create mode 100644 coriolis/windows_conn.py create mode 100644 coriolis/windows_ssh_cmd.py diff --git a/coriolis/osmorphing/osmount/windows.py b/coriolis/osmorphing/osmount/windows.py index 0a8b1a6e0..15f51322c 100644 --- a/coriolis/osmorphing/osmount/windows.py +++ b/coriolis/osmorphing/osmount/windows.py @@ -6,7 +6,7 @@ from oslo_log import log as logging -from coriolis import constants, exception, utils, windows_ssh +from coriolis import constants, exception, utils, windows_conn from coriolis.osmorphing.osmount import base LOG = logging.getLogger(__name__) @@ -21,13 +21,21 @@ def __init__(self, *args, **kwargs): self._unlocked_volumes: list[str] = [] def _connect(self): - host = self._connection_info["ip"] - self._event_manager.progress_update( - "Connecting through SSH to OSMorphing host on: %s" % host - ) + connection_info = self._connection_info + host = connection_info["ip"] + if windows_conn.uses_winrm(connection_info): + port = connection_info.get("port", windows_conn.WINRM_HTTPS_PORT) + self._event_manager.progress_update( + "Connecting to WinRM host: %(host)s:%(port)s" + % {"host": host, "port": port} + ) + else: + self._event_manager.progress_update( + "Connecting through SSH to OSMorphing host on: %s" % host + ) - self._conn = windows_ssh.WindowsSSHConnection.from_connection_info( - self._connection_info, self._osmount_operation_timeout + self._conn = windows_conn.from_connection_info( + connection_info, self._osmount_operation_timeout ) def get_connection(self): diff --git a/coriolis/osmorphing/windows.py b/coriolis/osmorphing/windows.py index 443a77106..1bafd766b 100644 --- a/coriolis/osmorphing/windows.py +++ b/coriolis/osmorphing/windows.py @@ -272,14 +272,12 @@ def _add_dism_driver(self, driver_path): LOG.info("Adding driver: %s" % driver_path) dism_path = self._get_dism_path() try: - # Pass a raw /driver: path. Nested quotes become part of the - # path after SSH wraps the command in cmd.exe /c. return self._conn.exec_command( dism_path, [ "/add-driver", "/image:%s" % self._os_root_dir, - "/driver:%s" % driver_path, + "/driver:\"%s\"" % driver_path, "/recurse", "/forceunsigned", ], @@ -306,7 +304,7 @@ def _add_dism_driver(self, driver_path): def _mount_disk_image(self, path): LOG.info("Mounting disk image: %s" % path) drive_letter, stderr = self._conn.exec_ps_command( - '"$((Mount-DiskImage \'%s\' -PassThru | Get-Volume).DriveLetter)"' % path, + "(Mount-DiskImage '%s' -PassThru | Get-Volume).DriveLetter" % path, include_stderr=True, ) if not drive_letter: @@ -343,7 +341,6 @@ def _expand_archive(self, path, destination, overwrite=True): self._conn.exec_ps_command("rm -recurse -force %s" % destination) self._conn.exec_ps_command( - "$ProgressPreference = 'SilentlyContinue'; " "Expand-Archive -LiteralPath '%(path)s' " "-DestinationPath '%(destination)s' -Force" % {"path": path, "destination": destination}, @@ -1027,9 +1024,8 @@ def _setup_qemu_agent_installation_local_script( ) else: self._conn.exec_ps_command( - "Copy-Item -Force '%s' -Destination '%s'" - % (msi_source_path, msi_dest_path), - ignore_stdout=True, + "Copy-Item '%s' -Destination '%s'" + % (msi_source_path, msi_dest_path) ) local_script = QEMU_GUEST_AGENT_INSTALL_SCRIPT_FORMAT % { "agent_msi_path": "%s\\%s" diff --git a/coriolis/providers/provider_utils.py b/coriolis/providers/provider_utils.py index f05637c00..009678519 100644 --- a/coriolis/providers/provider_utils.py +++ b/coriolis/providers/provider_utils.py @@ -6,7 +6,7 @@ import requests from oslo_log import log as logging -from coriolis import constants, exception, utils +from coriolis import constants, exception, utils, windows_conn, wsman LOG = logging.getLogger(__name__) @@ -205,9 +205,41 @@ def _poll_instance_until_reachable_ssh( ) +def _poll_instance_until_reachable_winrm( + connection_info: dict, + timeout: int = 600, + poll_interval: int = 10, +): + start = time.time() + while (time.time() - start) < timeout: + try: + conn = wsman.WSManConnection.from_connection_info(connection_info) + conn.exec_ps_command("whoami") + return + except Exception as ex: + LOG.debug( + f"Could not conect to Windows host: {str(ex)}. " + f"Retrying, time left: {timeout - (time.time() - start)}." + ) + time.sleep(poll_interval) + + raise exception.CoriolisException( + f"Operation timed out after waiting {timeout}s for Windows host to " + f"be accessible via WinRM." + ) + + +def _protocol_from_connection_info(connection_info, protocol): + if protocol: + return protocol + if windows_conn.uses_winrm(connection_info): + return constants.PROTOCOL_WINRM + return constants.PROTOCOL_SSH + + def poll_instance_until_reachable( connection_info: dict, - protocol: str = constants.PROTOCOL_SSH, + protocol: str = None, timeout: int = 600, poll_interval: int = 10, ) -> paramiko.SSHClient: @@ -219,19 +251,27 @@ def poll_instance_until_reachable( * username * password * pkey - Paramiko keypair - :param protocol: connection protocol. Only "ssh" is supported. + :param protocol: connection protocol, "ssh" or "winrm". If omitted, + port 5986 selects WinRM. Any other port selects SSH. :param timeout: the maximum amount of time to wait :param poll_interval: the amount of time to wait between retries """ - if protocol != constants.PROTOCOL_SSH: - raise exception.InvalidInput( - f"Unsupported instance connection protocol: {protocol}" + resolved = _protocol_from_connection_info(connection_info, protocol) + if resolved == constants.PROTOCOL_SSH: + ssh_connection_info = dict(connection_info) + if ssh_connection_info.get("port") is None: + ssh_connection_info["port"] = 22 + return _poll_instance_until_reachable_ssh( + connection_info=ssh_connection_info, + timeout=timeout, + poll_interval=poll_interval, + ) + if resolved == constants.PROTOCOL_WINRM: + return _poll_instance_until_reachable_winrm( + connection_info=connection_info, + timeout=timeout, + poll_interval=poll_interval, ) - ssh_connection_info = dict(connection_info) - if ssh_connection_info.get("port") is None: - ssh_connection_info["port"] = 22 - return _poll_instance_until_reachable_ssh( - connection_info=ssh_connection_info, - timeout=timeout, - poll_interval=poll_interval, + raise exception.InvalidInput( + f"Unsupported instance connection protocol: {resolved}" ) diff --git a/coriolis/tests/osmorphing/osmount/test_windows.py b/coriolis/tests/osmorphing/osmount/test_windows.py index 71fc00c72..7ac7f974b 100644 --- a/coriolis/tests/osmorphing/osmount/test_windows.py +++ b/coriolis/tests/osmorphing/osmount/test_windows.py @@ -24,7 +24,7 @@ class CoriolisTestException(Exception): class WindowsMountToolsTestCase(test_base.CoriolisBaseTestCase): """Test suite for the WindowsMountTools class.""" - @mock.patch.object(windows.windows_ssh.WindowsSSHConnection, 'from_connection_info') + @mock.patch.object(windows.windows_conn, 'from_connection_info') def setUp(self, mock_from_connection_info): super(WindowsMountToolsTestCase, self).setUp() self.event_manager = mock.MagicMock() @@ -45,19 +45,13 @@ def setUp(self, mock_from_connection_info): ) self.tools._conn = mock.MagicMock() - @mock.patch.object(windows.windows_ssh.WindowsSSHConnection, 'from_connection_info') + @mock.patch.object(windows.windows_conn, 'from_connection_info') def test__connect(self, mock_from_connection_info): result = self.tools._connect() self.assertIsNone(result) - expected_conn_info = { - "ip": "127.0.0.1", - "username": "random_username", - "password": "random_password", - "pkey": "random_pkey", - } mock_from_connection_info.assert_called_once_with( - expected_conn_info, mock.sentinel.operation_timeout + self.conn_info, mock.sentinel.operation_timeout ) def test_get_connection(self): diff --git a/coriolis/tests/osmorphing/test_windows.py b/coriolis/tests/osmorphing/test_windows.py index ce6c7fa58..220eff560 100644 --- a/coriolis/tests/osmorphing/test_windows.py +++ b/coriolis/tests/osmorphing/test_windows.py @@ -168,7 +168,7 @@ def test__add_dism_driver(self, mock_get_worker_os_drive_path): [ '/add-driver', '/image:%s' % self.os_root_dir, - '/driver:%s' % mock.sentinel.driver_path, + '/driver:"%s"' % mock.sentinel.driver_path, '/recurse', '/forceunsigned', ], @@ -209,7 +209,7 @@ def test__mount_disk_image(self): result = self.morphing_tools._mount_disk_image(mock.sentinel.path) self.conn.exec_ps_command.assert_called_once_with( - '"$((Mount-DiskImage \'%s\' -PassThru | Get-Volume).DriveLetter)"' + "(Mount-DiskImage '%s' -PassThru | Get-Volume).DriveLetter" % mock.sentinel.path, include_stderr=True, ) @@ -251,7 +251,6 @@ def test__expand_archive_remove_destination(self): [ mock.call("rm -recurse -force %s" % destination), mock.call( - "$ProgressPreference = 'SilentlyContinue'; " "Expand-Archive -LiteralPath '%(path)s' " "-DestinationPath '%(destination)s' -Force" % {"path": mock.sentinel.archive_path, "destination": destination}, @@ -1442,9 +1441,8 @@ def test_setup_qemu_agent_installation_local_script_from_path( exp_msi_dest_path = "C:\\Cloudbase-Init\\qemu-ga.msi" self.morphing_tools._conn.download_file.assert_not_called() self.morphing_tools._conn.exec_ps_command.assert_called_once_with( - "Copy-Item -Force '%s' -Destination '%s'" - % (fake_msi_source_path, exp_msi_dest_path), - ignore_stdout=True, + "Copy-Item '%s' -Destination '%s'" + % (fake_msi_source_path, exp_msi_dest_path) ) exp_script = windows.QEMU_GUEST_AGENT_INSTALL_SCRIPT_FORMAT % { diff --git a/coriolis/tests/providers/test_provider_utils.py b/coriolis/tests/providers/test_provider_utils.py index de6a6dd4f..72e5fc4a9 100644 --- a/coriolis/tests/providers/test_provider_utils.py +++ b/coriolis/tests/providers/test_provider_utils.py @@ -366,20 +366,54 @@ def test_poll_instance_missing_auth(self): poll_interval=5, ) - def test_poll_instance_winrm_rejected(self): - connection_info = { - "ip": "1.2.3.4", - "port": 5986, - "username": "Administrator", - "password": "pwned", - } + @mock.patch("coriolis.wsman.WSManConnection", new_callable=mock.Mock) + @mock.patch("time.sleep") + def test_poll_instance_winrm( + self, + mock_sleep, + mock_wsman, + ): + mock_conn = mock.Mock() + mock_conn.exec_ps_command.side_effect = [Exception, mock.sentinel.stdout] + mock_wsman.from_connection_info.return_value = mock_conn + poll_interval = 5 + + connection_info = self._get_mock_conn_info() + provider_utils.poll_instance_until_reachable( + connection_info=connection_info, + protocol=constants.PROTOCOL_WINRM, + timeout=30, + poll_interval=poll_interval, + ) + + mock_wsman.from_connection_info.assert_has_calls( + [mock.call(connection_info)] * 2, any_order=True + ) + mock_conn.exec_ps_command.assert_has_calls([mock.call("whoami")] * 2) + mock_sleep.assert_called_once_with(poll_interval) + + @mock.patch("coriolis.wsman.WSManConnection", new_callable=mock.Mock) + @mock.patch("time.sleep") + @mock.patch("time.time") + def test_poll_instance_winrm_timeout( + self, + mock_time, + mock_sleep, + mock_wsman, + ): + poll_interval = 5 + mock_time.side_effect = [x * 10 for x in range(20)] + mock_conn = mock.Mock() + mock_conn.exec_ps_command.side_effect = IOError + mock_wsman.from_connection_info.return_value = mock_conn + connection_info = self._get_mock_conn_info() self.assertRaises( - exception.InvalidInput, + exception.CoriolisException, provider_utils.poll_instance_until_reachable, connection_info=connection_info, protocol=constants.PROTOCOL_WINRM, timeout=30, - poll_interval=5, + poll_interval=poll_interval, ) def test_poll_instance_unsupported_protocol(self): diff --git a/coriolis/tests/test_windows_conn.py b/coriolis/tests/test_windows_conn.py new file mode 100644 index 000000000..a0856a03d --- /dev/null +++ b/coriolis/tests/test_windows_conn.py @@ -0,0 +1,30 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +from unittest import mock + +from coriolis import windows_conn, windows_ssh, wsman +from coriolis.tests import test_base + + +class WindowsConnTestCase(test_base.CoriolisBaseTestCase): + def test_uses_winrm_default_port(self): + self.assertTrue(windows_conn.uses_winrm({"ip": "10.0.0.1"})) + self.assertTrue(windows_conn.uses_winrm({"ip": "10.0.0.1", "port": 5986})) + self.assertFalse(windows_conn.uses_winrm({"ip": "10.0.0.1", "port": 22})) + + @mock.patch.object(wsman.WSManConnection, "from_connection_info") + @mock.patch.object(windows_ssh.WindowsSSHConnection, "from_connection_info") + def test_from_connection_info_winrm(self, mock_ssh, mock_winrm): + conn_info = {"ip": "10.0.0.1", "port": 5986, "username": "u", "password": "p"} + windows_conn.from_connection_info(conn_info, timeout=30) + mock_winrm.assert_called_once_with(conn_info, 30) + mock_ssh.assert_not_called() + + @mock.patch.object(wsman.WSManConnection, "from_connection_info") + @mock.patch.object(windows_ssh.WindowsSSHConnection, "from_connection_info") + def test_from_connection_info_ssh(self, mock_ssh, mock_winrm): + conn_info = {"ip": "10.0.0.1", "port": 22, "username": "u", "password": "p"} + windows_conn.from_connection_info(conn_info, timeout=30) + mock_ssh.assert_called_once_with(conn_info, 30) + mock_winrm.assert_not_called() diff --git a/coriolis/tests/test_windows_ssh.py b/coriolis/tests/test_windows_ssh.py index 3b66f0d31..233ac8826 100644 --- a/coriolis/tests/test_windows_ssh.py +++ b/coriolis/tests/test_windows_ssh.py @@ -255,42 +255,21 @@ def test_is_reg_exe(self): self.assertTrue(windows_ssh._is_reg_exe("C:\\Windows\\System32\\reg.exe")) self.assertFalse(windows_ssh._is_reg_exe("dism.exe")) - def test_exec_command_releases_ps_handles_for_reg(self): - self.conn._release_ps_registry_handles = mock.Mock() + def test_exec_command_closes_ps_session_for_reg(self): + self.conn._close_ps_session = mock.Mock() stdout = mock.Mock() stdout.channel = _fake_exec_channel() self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) self.conn.exec_command("reg.exe", ["unload", "HKLM\\x"]) - self.conn._release_ps_registry_handles.assert_called_once_with() + self.conn._close_ps_session.assert_called_once_with(wait=True) - def test_exec_command_skips_handle_release_for_other_cmds(self): - self.conn._release_ps_registry_handles = mock.Mock() + def test_exec_command_keeps_ps_session_for_other_cmds(self): + self.conn._close_ps_session = mock.Mock() stdout = mock.Mock() stdout.channel = _fake_exec_channel() self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) self.conn.exec_command("dism.exe", ["/Get-WimInfo"]) - self.conn._release_ps_registry_handles.assert_not_called() - - def test_release_ps_registry_handles_closes_session(self): - self.conn._close_ps_session = mock.Mock() - self.conn._release_ps_registry_handles() - self.conn._close_ps_session.assert_called_once_with(wait=True) - - def test_escape_trailing_backslash_for_ssh(self): - odd = "dism.exe /get-drivers /image:F:\\" - self.assertEqual( - windows_ssh._escape_trailing_backslash_for_ssh(odd), - "dism.exe /get-drivers /image:F:\\\\", - ) - self.assertEqual( - windows_ssh._escape_trailing_backslash_for_ssh("reg.exe unload HKLM\\x"), - "reg.exe unload HKLM\\x", - ) - even = "cmd /c dir C:\\\\" - self.assertEqual( - windows_ssh._escape_trailing_backslash_for_ssh(even), - even, - ) + self.conn._close_ps_session.assert_not_called() def test_exec_ps_command(self): self.conn._invoke_persistent_ps = mock.Mock() @@ -343,74 +322,6 @@ def test_write_file(self): ignore_stdout=True, ) - def test_format_windows_command_quotes_spaces(self): - result = windows_ssh._format_windows_command( - "reg.exe", ["load", "HKLM\\x", "C:\\Program Files\\hive"] - ) - self.assertEqual(result, 'reg.exe load HKLM\\x "C:\\Program Files\\hive"') - - def test_format_windows_command_quotes_icacls_grant(self): - grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" - result = windows_ssh._format_windows_command( - "icacls.exe", - [ - "F:\\Windows\\System32\\DriverStore\\FileRepository", - "/grant", - grant, - ], - ) - self.assertEqual( - result, - 'icacls.exe F:\\Windows\\System32\\DriverStore\\FileRepository ' - '/grant "%s"' % grant, - ) - - def test_build_ssh_exec_command_wraps_cmd_and_quotes_grant(self): - grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" - result = windows_ssh._build_ssh_exec_command( - "icacls.exe", - [ - "F:\\Windows\\System32\\DriverStore\\FileRepository", - "/grant", - grant, - ], - ) - self.assertEqual( - result, - 'cmd.exe /c "icacls.exe ' - 'F:\\Windows\\System32\\DriverStore\\FileRepository ' - '/grant ""%s"""' % grant, - ) - - def test_build_ssh_exec_command_dism_image_trailing_backslash(self): - result = windows_ssh._build_ssh_exec_command( - "dism.exe", ["/Get-Drivers", "/image:F:\\"] - ) - self.assertEqual( - result, - 'cmd.exe /c "dism.exe /Get-Drivers /image:F:\\\\"', - ) - - def test_build_ssh_exec_command_dism_add_driver_no_nested_quotes(self): - driver = "G:\\Balloon\\2k19\\amd64" - result = windows_ssh._build_ssh_exec_command( - "C:\\Windows\\System32\\dism.exe", - [ - "/add-driver", - "/image:F:\\", - "/driver:%s" % driver, - "/recurse", - "/forceunsigned", - ], - ) - self.assertEqual( - result, - 'cmd.exe /c "C:\\Windows\\System32\\dism.exe /add-driver ' - '/image:F:\\ /driver:G:\\Balloon\\2k19\\amd64 /recurse ' - '/forceunsigned"', - ) - self.assertNotIn("/driver:\"", result) - def test_split_on_marker_line(self): buf = b"True\r\nCORIOLIS_PS_DONE_abc:0\r\nleftover" parsed = windows_ssh._split_on_marker_line(buf, "CORIOLIS_PS_DONE_abc") @@ -427,6 +338,7 @@ def test_split_on_marker_line_incomplete(self): def test_strip_ps_output_trims_blank_lines(self): self.assertEqual(windows_ssh._strip_ps_output("\r\nG\r\n"), "G") self.assertEqual(windows_ssh._strip_ps_output("True\r\n"), "True") + self.assertEqual(windows_ssh._strip_ps_output("\n1\n\n2\n"), "1\r\n2") def test_ps_session_is_alive(self): channel = mock.Mock() @@ -487,6 +399,7 @@ def test_build_ps_wrapper_uses_encoded_command(self): wrapper = self.conn._build_ps_wrapper("Test-Path", "abc") self.assertIn("CORIOLIS_PS_DONE_abc", wrapper) self.assertIn("Invoke-Expression", wrapper) + self.assertIn("Out-String", wrapper) self.assertIn("$ProgressPreference = 'SilentlyContinue'", wrapper) self.assertIn("VABlAHMAdAAtAFAAYQB0AGgA", wrapper) diff --git a/coriolis/tests/test_windows_ssh_cmd.py b/coriolis/tests/test_windows_ssh_cmd.py new file mode 100644 index 000000000..fd833396c --- /dev/null +++ b/coriolis/tests/test_windows_ssh_cmd.py @@ -0,0 +1,104 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +from coriolis import windows_ssh_cmd +from coriolis.tests import test_base + + +class WinrmToSshCmdTestCase(test_base.CoriolisBaseTestCase): + def test_unwrap_winrm_driver_quotes(self): + self.assertEqual( + windows_ssh_cmd.unwrap_winrm_arg_quotes( + '/driver:"G:\\Balloon\\2k19\\amd64"' + ), + "/driver:G:\\Balloon\\2k19\\amd64", + ) + + def test_winrm_exec_to_ssh_dism_quotes_match_plain_path(self): + driver = "G:\\Balloon\\2k19\\amd64" + quoted = windows_ssh_cmd.winrm_exec_to_ssh( + "C:\\Windows\\System32\\dism.exe", + [ + "/add-driver", + "/image:F:\\", + '/driver:"%s"' % driver, + "/recurse", + "/forceunsigned", + ], + ) + plain = windows_ssh_cmd.winrm_exec_to_ssh( + "C:\\Windows\\System32\\dism.exe", + [ + "/add-driver", + "/image:F:\\", + "/driver:%s" % driver, + "/recurse", + "/forceunsigned", + ], + ) + self.assertEqual(quoted, plain) + self.assertNotIn('/driver:"', quoted) + + def test_winrm_exec_to_ssh_icacls_grant(self): + grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" + result = windows_ssh_cmd.winrm_exec_to_ssh( + "icacls.exe", + [ + "F:\\Windows\\System32\\DriverStore\\FileRepository", + "/grant", + grant, + ], + ) + self.assertEqual( + result, + 'cmd.exe /c "icacls.exe ' + 'F:\\Windows\\System32\\DriverStore\\FileRepository ' + '/grant ""%s"""' % grant, + ) + + def test_format_windows_command_quotes_spaces(self): + result = windows_ssh_cmd.format_windows_command( + "reg.exe", ["load", "HKLM\\x", "C:\\Program Files\\hive"] + ) + self.assertEqual(result, 'reg.exe load HKLM\\x "C:\\Program Files\\hive"') + + def test_format_windows_command_quotes_icacls_grant(self): + grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" + result = windows_ssh_cmd.format_windows_command( + "icacls.exe", + [ + "F:\\Windows\\System32\\DriverStore\\FileRepository", + "/grant", + grant, + ], + ) + self.assertEqual( + result, + 'icacls.exe F:\\Windows\\System32\\DriverStore\\FileRepository ' + '/grant "%s"' % grant, + ) + + def test_escape_trailing_backslash_for_ssh(self): + odd = "dism.exe /get-drivers /image:F:\\" + self.assertEqual( + windows_ssh_cmd.escape_trailing_backslash_for_ssh(odd), + "dism.exe /get-drivers /image:F:\\\\", + ) + self.assertEqual( + windows_ssh_cmd.escape_trailing_backslash_for_ssh("reg.exe unload HKLM\\x"), + "reg.exe unload HKLM\\x", + ) + even = "cmd /c dir C:\\\\" + self.assertEqual( + windows_ssh_cmd.escape_trailing_backslash_for_ssh(even), + even, + ) + + def test_winrm_exec_to_ssh_dism_image_trailing_backslash(self): + result = windows_ssh_cmd.winrm_exec_to_ssh( + "dism.exe", ["/Get-Drivers", "/image:F:\\"] + ) + self.assertEqual( + result, + 'cmd.exe /c "dism.exe /Get-Drivers /image:F:\\\\"', + ) diff --git a/coriolis/tests/test_wsman.py b/coriolis/tests/test_wsman.py index 6cbb5c410..93206e6b2 100644 --- a/coriolis/tests/test_wsman.py +++ b/coriolis/tests/test_wsman.py @@ -1,17 +1,239 @@ -# Copyright 2026 Cloudbase Solutions Srl +# Copyright 2023 Cloudbase Solutions Srl # All Rights Reserved. +import logging +from unittest import mock + +import requests +from winrm import protocol + from coriolis import exception, wsman from coriolis.tests import test_base -class WSManShimTestCase(test_base.CoriolisBaseTestCase): - def test_from_connection_info_raises(self): +class WSManConnectionTestCase(test_base.CoriolisBaseTestCase): + """Test suite for the Coriolis WSManConnection class.""" + + def setUp(self): + super(WSManConnectionTestCase, self).setUp() + self.conn = wsman.WSManConnection() + self.conn._protocol = mock.Mock() + self.conn._conn_timeout = 10 + self.cmd = "test_cmd" + self.args = ["-RecoveryPassword", "'ShouldNotBeLogged'"] + self.sanitized_cmd = "test_cmd -RecoveryPassword '***'" + self.url = "http://example.com/file" + self.remote_path = "/remote/path" + + def test__init__timeout(self): + self.connection = wsman.WSManConnection() + self.assertEqual(self.connection._conn_timeout, wsman.DEFAULT_TIMEOUT) + + def test__init__timeout_set(self): + self.connection = wsman.WSManConnection(timeout=100) + self.assertEqual(self.connection._conn_timeout, 100) + + @mock.patch.object(protocol, 'Protocol') + def test_connect(self, mock_protocol): + self.conn.connect('url', 'username', cert_pem='test_cert') + mock_protocol.assert_called_once_with( + endpoint='url', + transport='ssl', + username='username', + password=None, + cert_pem="test_cert", + cert_key_pem=None, + ) + + @mock.patch.object(protocol, 'Protocol') + def test_connect_no_auth(self, mock_protocol): + self.conn.connect('url', 'username') + mock_protocol.assert_called_once_with( + endpoint='url', + transport='plaintext', + username='username', + password=None, + cert_pem=None, + cert_key_pem=None, + ) + + @mock.patch.object(wsman.WSManConnection, 'connect') + @mock.patch('coriolis.utils.wait_for_port_connectivity') + def test_from_connection_info(self, mock_wait_for_port_connectivity, mock_connect): + connection_info = { + "ip": "127.0.0.1", + "username": "user", + "password": "pass", + } + result = self.conn.from_connection_info(connection_info) + mock_wait_for_port_connectivity.assert_called_once_with("127.0.0.1", 5986) + mock_connect.assert_called_once_with( + url="https://127.0.0.1:5986/wsman", + username="user", + password="pass", + cert_pem=None, + cert_key_pem=None, + ) + self.assertIsInstance(result, self.conn.__class__) + + def test_from_connection_info_missing_keys(self): + self.assertRaises( + ValueError, + self.conn.from_connection_info, + {"username": "user", "password": "pass"}, + ) + + def test_from_connection_info_invalid_type(self): + self.assertRaises( + ValueError, self.conn.from_connection_info, 'invalid-connection-type' + ) + + def test_disconnect(self): + self.conn.disconnect() + self.assertIsNone(self.conn._protocol) + + def test_set_timeout(self): + self.conn.set_timeout(self.conn._conn_timeout) + self.assertEqual(self.conn._protocol.transport.timeout, self.conn._conn_timeout) + self.assertEqual(self.conn._protocol.timeout, self.conn._conn_timeout) + + def test__exec_command(self): + self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 0) + std_out, std_err, exit_code = self.conn._exec_command(self.cmd, self.args) + self.assertEqual(std_out, "std_out") + self.assertEqual(std_err, "std_err") + self.assertEqual(exit_code, 0) + + self.conn._protocol.open_shell.assert_called_once_with( + codepage=wsman.CODEPAGE_UTF8 + ) + shell_id = self.conn._protocol.open_shell.return_value + self.conn._protocol.run_command.assert_called_once_with( + shell_id, self.cmd, self.args + ) + command_id = self.conn._protocol.run_command.return_value + self.conn._protocol.get_command_output.assert_called_once_with( + shell_id, command_id + ) + self.conn._protocol.cleanup_command.assert_called_once_with( + shell_id, command_id + ) + self.conn._protocol.close_shell.assert_called_once_with(shell_id) + + def test__exec_command_exception(self): + self.conn._protocol.get_command_output.side_effect = ( + requests.exceptions.ReadTimeout + ) + self.assertRaises( + exception.OSMorphingWinRMOperationTimeout, + self.conn._exec_command, + self.cmd, + self.args, + ) + self.conn._protocol.cleanup_command.assert_called_once_with(mock.ANY, mock.ANY) + self.conn._protocol.close_shell.assert_called_once_with(mock.ANY) + + @mock.patch("time.sleep") + def test__exec_command_invalid_credentials(self, mock_sleep): + self.conn._protocol.open_shell.side_effect = ( + wsman.winrm_exceptions.InvalidCredentialsError + ) + self.assertRaises( - exception.InvalidInput, - wsman.WSManConnection.from_connection_info, - {"ip": "10.0.0.1", "username": "admin", "password": "x"}, + exception.NotAuthorized, self.conn._exec_command, self.cmd, self.args ) + self.conn._protocol.close_shell.assert_not_called() - def test_constructor_raises(self): - self.assertRaises(exception.InvalidInput, wsman.WSManConnection) + def test_exec_command(self): + self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 0) + exp_sanitized_log = ( + "DEBUG:coriolis.wsman:Executing WSMAN command: %s" % self.sanitized_cmd + ) + with self.assertLogs("coriolis.wsman", level=logging.DEBUG) as log_cm: + std_out = self.conn.exec_command(self.cmd, self.args) + self.assertEqual(std_out, "std_out") + self.assertIn(exp_sanitized_log, log_cm.output) + + def test_exec_command_exception(self): + self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 1) + self.assertRaises( + exception.CoriolisException, self.conn.exec_command, self.cmd, self.args + ) + + def test_exec_ps_command(self): + self.conn.exec_command = mock.Mock() + self.conn.exec_command.return_value = "std_out\n\n" + result = self.conn.exec_ps_command( + self.cmd, + include_stderr=False, + ) + self.conn.exec_command.assert_called_once_with( + "powershell.exe", + [ + "-EncodedCommand", + 'dABlAHMAdABfAGMAbQBkAA==', + '-NonInteractive', + '-ExecutionPolicy', + 'RemoteSigned', + ], + timeout=None, + sanitizable=False, + include_stderr=False, + ) + self.assertEqual(result, "std_out") + + def test_exec_ps_command_with_stderr(self): + self.conn.exec_command = mock.Mock() + self.conn.exec_command.return_value = "std_out\n\n", "stderr" + result = self.conn.exec_ps_command( + self.cmd, + include_stderr=True, + ) + self.conn.exec_command.assert_called_once_with( + "powershell.exe", + [ + "-EncodedCommand", + 'dABlAHMAdABfAGMAbQBkAA==', + '-NonInteractive', + '-ExecutionPolicy', + 'RemoteSigned', + ], + timeout=None, + sanitizable=False, + include_stderr=True, + ) + self.assertEqual(result, ("std_out", "stderr")) + + def test_test_path(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.exec_ps_command.return_value = "True" + result = self.conn.test_path("test_path") + self.conn.exec_ps_command.assert_called_once_with( + "Test-Path -Path \"test_path\"" + ) + self.assertTrue(result) + + def test_download_file(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.download_file(self.url, self.remote_path) + self.conn.exec_ps_command.assert_called_once() + + def test_download_file_exception(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.exec_ps_command.side_effect = exception.CoriolisException + self.assertRaises( + exception.CoriolisException, + self.conn.download_file, + self.url, + self.remote_path, + ) + self.conn.exec_ps_command.assert_called_once() + + def test_write_file(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.write_file(self.remote_path, b'file content') + self.conn.exec_ps_command.assert_called_once_with( + "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" + % (self.remote_path, 'ZmlsZSBjb250ZW50'), + ignore_stdout=True, + ) diff --git a/coriolis/windows_conn.py b/coriolis/windows_conn.py new file mode 100644 index 000000000..568ad7fdf --- /dev/null +++ b/coriolis/windows_conn.py @@ -0,0 +1,30 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Pick a Windows minion connection from connection_info port. + +Port 5986 uses WinRM. Any other port, including 22, uses SSH. +A missing port uses WinRM, which matches the old os-mount default. +""" + +from coriolis import windows_ssh, wsman + +WINRM_HTTPS_PORT = 5986 + + +def uses_winrm(connection_info): + port = (connection_info or {}).get("port") + if port is None or port == "": + return True + return int(port) == WINRM_HTTPS_PORT + + +def from_connection_info(connection_info, timeout=None): + """Return WSManConnection or WindowsSSHConnection.""" + if uses_winrm(connection_info): + conn_cls = wsman.WSManConnection + else: + conn_cls = windows_ssh.WindowsSSHConnection + if timeout is None: + return conn_cls.from_connection_info(connection_info) + return conn_cls.from_connection_info(connection_info, timeout) diff --git a/coriolis/windows_ssh.py b/coriolis/windows_ssh.py index 865c02b2d..0c247d0f6 100644 --- a/coriolis/windows_ssh.py +++ b/coriolis/windows_ssh.py @@ -3,9 +3,11 @@ """SSH connection for Windows OS morphing minions. -The Coriolis worker connects to the minion over OpenSSH. It does not open -WinRM. Morphing commands still run as powershell.exe, diskpart, reg.exe, -and DISM. +The Coriolis worker connects to the minion over OpenSSH when +connection_info port is not 5986. Port 5986 still uses WinRM. + +Morphing commands stay in WinRM argv form. Native commands are converted +for SSH in coriolis.windows_ssh_cmd. Minion requirements ------------------- @@ -24,9 +26,9 @@ present on Windows Server. * The Coriolis worker must reach the minion IP on the SSH port. -Not required ------------- -* WinRM, HTTPS port 5986, or a WinRM listener. +Not required for the SSH path +----------------------------- +* WinRM, HTTPS port 5986, or a WinRM listener (used only when port is 5986). * PowerShell 7, pwsh.exe, or an OpenSSH Subsystem powershell line. * PSRP remoting (Enter-PSSession -HostName, pypsrp SSH). """ @@ -40,7 +42,7 @@ from oslo_log import log as logging from oslo_utils import strutils -from coriolis import exception, utils +from coriolis import exception, utils, windows_ssh_cmd LOG = logging.getLogger(__name__) @@ -70,51 +72,14 @@ def _is_reg_exe(cmd): return base in ("reg", "reg.exe") -_CMD_QUOTE_CHARS = (" ", "\t", '"', "&", "|", "(", ")", "<", ">", "^", "%") - - -def _quote_cmd_arg(part): - part_str = str(part) - if (not part_str) or any(ch in part_str for ch in _CMD_QUOTE_CHARS): - return '"%s"' % part_str.replace('"', '""') - return part_str - - -def _format_windows_command(cmd, args): - return " ".join(_quote_cmd_arg(p) for p in [cmd] + list(args or [])) - - -def _escape_trailing_backslash_for_ssh(command): - """Keep a trailing backslash from eating the SSH quote. - - Windows OpenSSH wraps the exec string in double quotes. An odd number - of trailing backslashes escapes that quote. PowerShell then reports a - missing string terminator. DISM /image:F:\\ is the usual case. - """ - n = len(command) - len(command.rstrip("\\")) - if n % 2 == 1: - return command + "\\" - return command - - -def _wrap_native_command_for_ssh(command): - """Run native tools via cmd.exe. - - OpenSSH DefaultShell is often PowerShell. PowerShell parses (OI) in - icacls grants as a command. cmd.exe does not when the grant is quoted. - """ - command = _escape_trailing_backslash_for_ssh(command) - return 'cmd.exe /c "%s"' % command.replace('"', '""') - - -def _build_ssh_exec_command(cmd, args): - formatted = _format_windows_command(cmd, args) - wrapped = _wrap_native_command_for_ssh(formatted) - return _escape_trailing_backslash_for_ssh(wrapped) - - def _strip_ps_output(stdout): - return (stdout or "").strip() + """Drop blank lines that persistent PowerShell Out-Default inserts.""" + lines = [ + line.strip() + for line in (stdout or "").replace("\r\n", "\n").split("\n") + if line.strip() + ] + return "\r\n".join(lines) def _drain_ssh_channel(channel, stdout_buf, stderr_buf): @@ -438,15 +403,6 @@ def _ensure_ps_session(self): LOG.warning("PowerShell SSH session is not alive. Starting a new session.") self._restart_ps_session() - def _release_ps_registry_handles(self): - """Stop PowerShell so it does not hold loaded hive keys. - - Get-ItemProperty keeps RegistryKey objects in this process. - Garbage collection does not drop those handles. End the process - before reg.exe load or unload. - """ - self._close_ps_session(wait=True) - def _build_ps_wrapper(self, cmd, token): encoded_cmd = base64.b64encode(cmd.encode("utf-16le")).decode() marker = "CORIOLIS_PS_DONE_%s" % token @@ -455,7 +411,12 @@ def _build_ps_wrapper(self, cmd, token): "$__c = [System.Text.Encoding]::Unicode.GetString(" "[Convert]::FromBase64String('%s')); " "$__e = 0; " - "try { Invoke-Expression -Command $__c } " + "try { " + "Invoke-Expression -Command $__c | " + "Out-String -Width 4096 -Stream | " + "Where-Object { $_.Trim() -ne '' } | " + "ForEach-Object { Write-Output $_.Trim() } " + "} " "catch { Write-Error -ErrorRecord $_; $__e = 1 }; " "Write-Output ('%s:' + $__e)\r\n" % (encoded_cmd, marker) ) @@ -533,7 +494,7 @@ def _read_ssh_exec_output(self, channel, timeout, sanitized_cmd): ] ) def _exec_command(self, cmd, args=[], timeout=None, sanitizable=True): - command = _build_ssh_exec_command(cmd, args) + command = windows_ssh_cmd.winrm_exec_to_ssh(cmd, args) if sanitizable: sanitized_cmd = strutils.mask_password(command) else: @@ -558,11 +519,15 @@ def exec_command( include_stderr=False, ): if sanitizable: - sanitized_cmd = strutils.mask_password(_build_ssh_exec_command(cmd, args)) + sanitized_cmd = strutils.mask_password( + windows_ssh_cmd.winrm_exec_to_ssh(cmd, args) + ) else: sanitized_cmd = "***" if _is_reg_exe(cmd): - self._release_ps_registry_handles() + # Get-ItemProperty keeps hive handles in this powershell.exe. + # Close that process before reg.exe load or unload. + self._close_ps_session(wait=True) LOG.debug("Executing Windows SSH command: %s", sanitized_cmd) std_out, std_err, exit_code = self._exec_command( cmd, args, timeout=timeout, sanitizable=sanitizable diff --git a/coriolis/windows_ssh_cmd.py b/coriolis/windows_ssh_cmd.py new file mode 100644 index 000000000..5647303f8 --- /dev/null +++ b/coriolis/windows_ssh_cmd.py @@ -0,0 +1,72 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Convert WinRM native (cmd, args) pairs to an OpenSSH command line. + +Osmorphing builds native tool argv for WinRM. WinRM runs argv as-is. +SSH sends one string. OpenSSH DefaultShell is often PowerShell, so the +converter wraps cmd.exe /c and quotes for cmd.exe. + +WinRM arguments may contain quotes that were only argv delimiters, such +as /driver:"G:\\path". Those quotes must not stay in the DISM path. +""" + +_CMD_QUOTE_CHARS = (" ", "\t", '"', "&", "|", "(", ")", "<", ">", "^", "%") + + +def unwrap_winrm_arg_quotes(part): + """Remove WinRM argv quotes from one native argument.""" + text = str(part).strip() + changed = True + while changed and text: + changed = False + if len(text) >= 2 and text[0] == '"' and text[-1] == '"': + text = text[1:-1] + changed = True + continue + idx = text.find(':"') + if idx >= 0 and text.endswith('"'): + text = text[: idx + 1] + text[idx + 2 : -1] + changed = True + return text + + +def quote_cmd_arg(part): + part_str = unwrap_winrm_arg_quotes(part) + if (not part_str) or any(ch in part_str for ch in _CMD_QUOTE_CHARS): + return '"%s"' % part_str.replace('"', '""') + return part_str + + +def format_windows_command(cmd, args): + return " ".join(quote_cmd_arg(p) for p in [cmd] + list(args or [])) + + +def escape_trailing_backslash_for_ssh(command): + """Keep a trailing backslash from eating the SSH quote. + + Windows OpenSSH wraps the exec string in double quotes. An odd number + of trailing backslashes escapes that quote. PowerShell then reports a + missing string terminator. DISM /image:F:\\ is the usual case. + """ + n = len(command) - len(command.rstrip("\\")) + if n % 2 == 1: + return command + "\\" + return command + + +def wrap_native_command_for_ssh(command): + """Run native tools via cmd.exe. + + OpenSSH DefaultShell is often PowerShell. PowerShell parses (OI) in + icacls grants as a command. cmd.exe does not when the grant is quoted. + """ + command = escape_trailing_backslash_for_ssh(command) + return 'cmd.exe /c "%s"' % command.replace('"', '""') + + +def winrm_exec_to_ssh(cmd, args=None): + """Return the SSH exec string for a WinRM (cmd, args) pair.""" + formatted = format_windows_command(cmd, args) + wrapped = wrap_native_command_for_ssh(formatted) + return escape_trailing_backslash_for_ssh(wrapped) diff --git a/coriolis/wsman.py b/coriolis/wsman.py index 5fdab5b7e..27dd661a1 100644 --- a/coriolis/wsman.py +++ b/coriolis/wsman.py @@ -1,23 +1,248 @@ -# Copyright 2026 Cloudbase Solutions Srl +# Copyright 2016 Cloudbase Solutions Srl # All Rights Reserved. -"""Import shim for leftover providers that still import coriolis.wsman. +import base64 -Windows morphing uses SSH. This module does not open WinRM. -Remove this module after the leftover providers stop importing it. -""" +import requests +from oslo_log import log as logging +from oslo_utils import strutils +from winrm import exceptions as winrm_exceptions +from winrm import protocol -from coriolis import exception +from coriolis import exception, utils -_WINRM_REMOVED_MSG = "Windows minion connections must use SSH. WinRM is not supported." +AUTH_BASIC = "basic" +AUTH_KERBEROS = "kerberos" +AUTH_CERTIFICATE = "certificate" +CODEPAGE_UTF8 = 65001 +DEFAULT_TIMEOUT = 3600 + +LOG = logging.getLogger(__name__) -class WSManConnection(object): - """Raise on use. Leftover providers import this class at load time.""" +class WSManConnection(object): def __init__(self, timeout=None): - raise exception.InvalidInput(_WINRM_REMOVED_MSG) + self._protocol = None + self._conn_timeout = int(timeout or DEFAULT_TIMEOUT) + + EOL = "\r\n" + + @utils.retry_on_error() + def connect( + self, url, username, auth=None, password=None, cert_pem=None, cert_key_pem=None + ): + if not auth: + if cert_pem: + auth = AUTH_CERTIFICATE + else: + auth = AUTH_BASIC + + auth_transport_map = { + AUTH_BASIC: 'plaintext', + AUTH_KERBEROS: 'kerberos', + AUTH_CERTIFICATE: 'ssl', + } + + self._protocol = protocol.Protocol( + endpoint=url, + transport=auth_transport_map[auth], + username=username, + password=password, + cert_pem=cert_pem, + cert_key_pem=cert_key_pem, + ) @classmethod - def from_connection_info(cls, connection_info, timeout=None): - raise exception.InvalidInput(_WINRM_REMOVED_MSG) + def from_connection_info(cls, connection_info, timeout=DEFAULT_TIMEOUT): + """Returns a wsman.WSManConnection obj for the provided conn info.""" + if not isinstance(connection_info, dict): + raise ValueError( + "WSMan connection must be a dict. Got type '%s', value: %s" + % (type(connection_info), connection_info) + ) + + required_keys = ["ip", "username", "password"] + missing = [key for key in required_keys if key not in connection_info] + if missing: + raise ValueError( + "The following keys were missing from WSMan connection " + "info %s. Got: %s" % (missing, connection_info) + ) + + host = connection_info["ip"] + port = connection_info.get("port", 5986) + username = connection_info["username"] + password = connection_info.get("password") + cert_pem = connection_info.get("cert_pem") + cert_key_pem = connection_info.get("cert_key_pem") + url = "https://%s:%s/wsman" % (host, port) + + LOG.info( + "Waiting for connectivity on host: %(host)s:%(port)s", + {"host": host, "port": port}, + ) + utils.wait_for_port_connectivity(host, port) + + conn = cls(timeout) + conn.connect( + url=url, + username=username, + password=password, + cert_pem=cert_pem, + cert_key_pem=cert_key_pem, + ) + + return conn + + def disconnect(self): + self._protocol = None + + def set_timeout(self, timeout): + if timeout: + self._protocol.timeout = timeout + self._protocol.transport.timeout = timeout + + @utils.retry_on_error( + terminal_exceptions=[ + winrm_exceptions.InvalidCredentialsError, + exception.OSMorphingWinRMOperationTimeout, + ] + ) + def _exec_command(self, cmd, args=[], timeout=None, sanitizable=True): + if sanitizable: + sanitized_cmd = strutils.mask_password("%s %s" % (cmd, " ".join(args))) + else: + sanitized_cmd = "***" + + timeout = int(timeout or self._conn_timeout) + self.set_timeout(timeout) + shell_id = None + try: + shell_id = self._protocol.open_shell(codepage=CODEPAGE_UTF8) + command_id = self._protocol.run_command(shell_id, cmd, args) + try: + (std_out, std_err, exit_code) = self._protocol.get_command_output( + shell_id, command_id + ) + except requests.exceptions.ReadTimeout: + raise exception.OSMorphingWinRMOperationTimeout( + cmd=sanitized_cmd, timeout=timeout + ) + finally: + self._protocol.cleanup_command(shell_id, command_id) + + return (std_out, std_err, exit_code) + except winrm_exceptions.InvalidCredentialsError as ex: + raise exception.NotAuthorized( + message="The WinRM connection credentials are invalid. " + "If you are using a template with a default " + "pre-baked username/password, please ensure " + "that you have passed the credentials to the " + "destination Coriolis plugin you have selected," + " either via the Target Environment parameters " + "set when creating the Migration/Replica, or " + "by setting it in the destination plugin's " + "dedicated section of the coriolis.conf " + "static configuration file." + ) from ex + finally: + if shell_id: + self._protocol.close_shell(shell_id) + + def exec_command( + self, + cmd, + args=[], + timeout=None, + sanitizable=True, + include_stderr=False, + ): + # Our sanitization helpers do not work for base64 encoded commands, + # in which case we'll avoid logging it so that we won't leak + # sensitive information. + if sanitizable: + sanitized_cmd = strutils.mask_password("%s %s" % (cmd, " ".join(args))) + else: + sanitized_cmd = "***" + LOG.debug("Executing WSMAN command: %s", sanitized_cmd) + std_out, std_err, exit_code = self._exec_command( + cmd, args, timeout=timeout, sanitizable=sanitizable + ) + + if exit_code: + raise exception.CoriolisException( + "Command \"%s\" failed with exit code: %s\n" + "stdout: %s\nstd_err: %s" % (sanitized_cmd, exit_code, std_out, std_err) + ) + + if include_stderr: + return std_out, std_err + return std_out + + def exec_ps_command( + self, + cmd, + ignore_stdout=False, + timeout=None, + include_stderr=False, + ): + LOG.debug("Executing PS command: %s", strutils.mask_password(cmd)) + base64_cmd = base64.b64encode(cmd.encode('utf-16le')).decode() + ret = self.exec_command( + "powershell.exe", + [ + "-EncodedCommand", + base64_cmd, + "-NonInteractive", + "-ExecutionPolicy", + "RemoteSigned", + ], + timeout=timeout, + sanitizable=False, + include_stderr=include_stderr, + ) + if include_stderr: + stdout, stderr = ret + return stdout[:-2], stderr + else: + stdout = ret + return stdout[:-2] + + def test_path(self, remote_path): + ret_val = self.exec_ps_command("Test-Path -Path \"%s\"" % remote_path) + return ret_val == "True" + + def download_file(self, url, remote_path): + LOG.debug( + "Downloading: \"%(url)s\" to \"%(path)s\"", + {"url": url, "path": remote_path}, + ) + try: + # Nano Server does not have Invoke-WebRequest and additionally + # this is also faster + self.exec_ps_command( + "[Net.ServicePointManager]::SecurityProtocol = " + "[Net.SecurityProtocolType]::Tls12;" + "if(!([System.Management.Automation.PSTypeName]'" + "System.Net.Http.HttpClient').Type) {$assembly = " + "[System.Reflection.Assembly]::LoadWithPartialName(" + "'System.Net.Http')}; (new-object System.Net.Http.HttpClient)." + "GetStreamAsync('%(url)s').Result.CopyTo(" + "(New-Object IO.FileStream '%(outfile)s', Create, Write, " + "None), 1MB)" % {"url": url, "outfile": remote_path}, + ignore_stdout=True, + ) + except exception.CoriolisException as ex: + LOG.trace(utils.get_exception_details()) + raise exception.CoriolisException( + "Failed to download file from URL: %s to path: %s. Please " + "check logs for more details." % (url, remote_path) + ) from ex + + def write_file(self, remote_path, content): + self.exec_ps_command( + "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" + % (remote_path, base64.b64encode(content).decode()), + ignore_stdout=True, + ) diff --git a/requirements.txt b/requirements.txt index 89af2d979..ad91a41a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ oslo.service>=1.12.0 oslo.versionedobjects oslo.reports paramiko>=2.1.0 +git+https://github.com/cloudbase/pywinrm.git@requests#egg=pywinrm paste pbr psutil