diff --git a/coriolis/osmorphing/osmount/windows.py b/coriolis/osmorphing/osmount/windows.py index 9dfea037d..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, wsman +from coriolis import constants, exception, utils, windows_conn from coriolis.osmorphing.osmount import base LOG = logging.getLogger(__name__) @@ -22,14 +22,19 @@ def __init__(self, *args, **kwargs): def _connect(self): connection_info = self._connection_info - host = connection_info["ip"] - port = connection_info.get("port", 5986) - self._event_manager.progress_update( - "Connecting to WinRM host: %(host)s:%(port)s" % {"host": host, "port": port} - ) + 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 = wsman.WSManConnection.from_connection_info( + self._conn = windows_conn.from_connection_info( connection_info, self._osmount_operation_timeout ) diff --git a/coriolis/providers/provider_utils.py b/coriolis/providers/provider_utils.py index e13be2723..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, wsman +from coriolis import constants, exception, utils, windows_conn, wsman 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. @@ -225,9 +229,17 @@ def _poll_instance_until_reachable_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: @@ -239,22 +251,27 @@ def poll_instance_until_reachable( * username * password * pkey - Paramiko keypair - :param protocol: connection protocol, "ssh" or "winrm" + :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 """ - # 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: - 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, ) - return helper( - connection_info=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 3d2ffe222..7ac7f974b 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_conn, 'from_connection_info') + def setUp(self, mock_from_connection_info): super(WindowsMountToolsTestCase, self).setUp() self.event_manager = mock.MagicMock() self.ssh = mock.MagicMock() @@ -43,9 +43,9 @@ 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_conn, 'from_connection_info') def test__connect(self, mock_from_connection_info): result = self.tools._connect() self.assertIsNone(result) diff --git a/coriolis/tests/providers/test_provider_utils.py b/coriolis/tests/providers/test_provider_utils.py index 7884019ba..72e5fc4a9 100644 --- a/coriolis/tests/providers/test_provider_utils.py +++ b/coriolis/tests/providers/test_provider_utils.py @@ -351,6 +351,21 @@ def test_poll_instance_ssh_timeout( poll_interval=poll_interval, ) + 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, + ) + @mock.patch("coriolis.wsman.WSManConnection", new_callable=mock.Mock) @mock.patch("time.sleep") def test_poll_instance_winrm( @@ -400,3 +415,12 @@ def test_poll_instance_winrm_timeout( timeout=30, poll_interval=poll_interval, ) + + def test_poll_instance_unsupported_protocol(self): + connection_info = self._get_mock_conn_info() + self.assertRaises( + exception.InvalidInput, + provider_utils.poll_instance_until_reachable, + connection_info=connection_info, + protocol="ftp", + ) diff --git a/coriolis/tests/test_windows_conn.py b/coriolis/tests/test_windows_conn.py new file mode 100644 index 000000000..8b76caecc --- /dev/null +++ b/coriolis/tests/test_windows_conn.py @@ -0,0 +1,50 @@ +# 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() + + def test_minion_connection_default_is_ssh(self): + windows_conn.CONF.clear_override("connection_type", group="windows_minion") + self.assertEqual("ssh", windows_conn.minion_connection()) + self.assertTrue(windows_conn.minion_uses_ssh()) + self.assertEqual(22, windows_conn.minion_port()) + + def test_minion_connection_winrm(self): + windows_conn.CONF.set_override( + "connection_type", "winrm", group="windows_minion" + ) + self.addCleanup( + windows_conn.CONF.clear_override, + "connection_type", + group="windows_minion", + ) + self.assertEqual("winrm", windows_conn.minion_connection()) + self.assertFalse(windows_conn.minion_uses_ssh()) + self.assertEqual(5986, windows_conn.minion_port()) + self.assertFalse(windows_conn.uses_winrm({"port": 22})) diff --git a/coriolis/tests/test_windows_minion.py b/coriolis/tests/test_windows_minion.py new file mode 100644 index 000000000..b5f0e7096 --- /dev/null +++ b/coriolis/tests/test_windows_minion.py @@ -0,0 +1,53 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +import base64 + +from coriolis import exception, windows_minion +from coriolis.tests import test_base + + +class WindowsMinionTestCase(test_base.CoriolisBaseTestCase): + def test_script_opens_ssh_on_every_profile(self): + script = windows_minion.build_windows_openssh_script("ssh-rsa AAAAB3 key") + + self.assertNotIn("#ps1_sysnative", script) + self.assertNotIn("net.exe user", script) + self.assertIn("Set-NetFirewallRule", script) + self.assertIn("-Profile Any", script) + self.assertIn("-EdgeTraversalPolicy Allow", script) + self.assertIn("ssh-rsa AAAAB3 key", script) + self.assertNotIn("__CORIOLIS_PUBLIC_KEY__", script) + self.assertNotIn("__CORIOLIS_ACCOUNT_LINE__", script) + + def test_cloudbase_init_script_sets_password(self): + script = windows_minion.build_windows_openssh_script( + "ssh-rsa AAAAB3 key", + username="Administrator", + password="generated-pass", + cloudbase_init=True, + ) + + self.assertTrue(script.startswith("#ps1_sysnative\n")) + self.assertIn("net.exe user Administrator generated-pass", script) + self.assertIn("ssh-rsa AAAAB3 key", script) + + def test_script_rejects_partial_account(self): + self.assertRaises( + exception.InvalidInput, + windows_minion.build_windows_openssh_script, + "ssh-rsa AAAAB3 key", + username="Administrator", + ) + + def test_agent_command_decodes_to_script(self): + command = windows_minion.windows_openssh_agent_command("ssh-rsa AAAAB3 key") + + prefix = "powershell.exe -NoProfile -NonInteractive -EncodedCommand " + self.assertTrue(command.startswith(prefix)) + encoded = command[len(prefix) :] + script = base64.b64decode(encoded).decode("utf-16le") + self.assertEqual( + windows_minion.build_windows_openssh_script("ssh-rsa AAAAB3 key"), + script, + ) diff --git a/coriolis/tests/test_windows_ssh.py b/coriolis/tests/test_windows_ssh.py new file mode 100644 index 000000000..ba8c5fd7b --- /dev/null +++ b/coriolis/tests/test_windows_ssh.py @@ -0,0 +1,447 @@ +# 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 + + +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): + self.conn._invoke_persistent_ps = mock.Mock( + return_value=("std_out", "std_err", 0) + ) + std_out, std_err, exit_code = self.conn._exec_command(self.cmd, self.args) + script = self.conn._invoke_persistent_ps.call_args[0][0] + self.assertEqual(std_out, "std_out") + self.assertEqual(std_err, "std_err") + self.assertEqual(exit_code, 0) + self.assertIn("& 'test_cmd' '-RecoveryPassword'", script) + self.assertIn("ShouldNotBeLogged", script) + self.assertNotRegex(script, r"(?:^|[;{])\s*exit\b") + + def test_exec_command(self): + self.conn._invoke_persistent_ps = mock.Mock(return_value=("std_out", "", 0)) + exp_log = ( + "DEBUG:coriolis.windows_ssh:Executing Windows SSH command: " + "test_cmd -RecoveryPassword '***'" + ) + 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) + script = self.conn._invoke_persistent_ps.call_args[0][0] + self.assertIn("& 'test_cmd'", script) + + def test_exec_command_exception(self): + self.conn._invoke_persistent_ps = mock.Mock( + return_value=("std_out", "bad arg", 1) + ) + 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_closes_ps_session_for_reg(self): + self.conn._close_ps_session = mock.Mock() + self.conn._invoke_persistent_ps = mock.Mock(return_value=("", "", 0)) + self.conn.exec_command("reg.exe", ["unload", "HKLM\\x"]) + self.conn._close_ps_session.assert_called_once_with(wait=True) + + def test_exec_command_keeps_ps_session_for_other_cmds(self): + self.conn._close_ps_session = mock.Mock() + self.conn._invoke_persistent_ps = mock.Mock(return_value=("", "", 0)) + self.conn.exec_command("dism.exe", ["/Get-WimInfo"]) + self.conn._close_ps_session.assert_not_called() + + 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) + cmd = self.conn.exec_ps_command.call_args[0][0] + self.assertIn(self.url, cmd) + self.assertIn(self.remote_path, cmd) + self.assertIn("$outFile.Dispose()", cmd) + self.assertIn("$client.Dispose()", cmd) + + 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_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") + self.assertEqual(windows_ssh._strip_ps_output("\n1\n\n2\n"), "1\r\n2") + + 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("Out-String", 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/windows_conn.py b/coriolis/windows_conn.py new file mode 100644 index 000000000..027f5e487 --- /dev/null +++ b/coriolis/windows_conn.py @@ -0,0 +1,73 @@ +# 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. + +New Windows minions use [windows_minion] connection_type. ssh is port 22. +winrm is port 5986. A minion that is already deployed keeps the port +saved in connection_info. +""" + +from oslo_config import cfg + +from coriolis import windows_ssh, wsman + +SSH_PORT = 22 +WINRM_HTTPS_PORT = 5986 +CONNECTION_SSH = "ssh" +CONNECTION_WINRM = "winrm" + +windows_minion_opts = [ + cfg.StrOpt( + "connection_type", + default=CONNECTION_SSH, + choices=[CONNECTION_SSH, CONNECTION_WINRM], + help=( + "Connection used for a new Windows minion. ssh starts OpenSSH " + "and uses port 22. winrm uses port 5986. The Windows template " + "must already accept WinRM when this is winrm. A minion that " + "is already deployed keeps the port in its connection_info." + ), + ), +] + +CONF = cfg.CONF +CONF.register_opts(windows_minion_opts, "windows_minion") + + +def minion_connection(): + """Return ssh or winrm for a new Windows minion.""" + return CONF.windows_minion.connection_type + + +def minion_uses_ssh(): + """Return True when a new Windows minion uses SSH.""" + return minion_connection() == CONNECTION_SSH + + +def minion_port(): + """Return 22 for ssh and 5986 for winrm.""" + if minion_uses_ssh(): + return SSH_PORT + return WINRM_HTTPS_PORT + + +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_minion.py b/coriolis/windows_minion.py new file mode 100644 index 000000000..a64ba4d04 --- /dev/null +++ b/coriolis/windows_minion.py @@ -0,0 +1,158 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Shared Windows minion OpenSSH setup. + +The stock OpenSSH firewall rule is Private and blocks edge traversal. +An unidentified NIC does not use the Private profile. +A NAT address needs edge traversal Allow. +The rule must use every profile. + +This module only returns the guest script. +The provider must run the script. +The provider must open TCP 22 on the platform firewall. +Core uses SSH when connection_info port is not 5986. +Port 5986, or a missing port, still uses WinRM. + +Providers that call this module +------------------------------- +OpenStack. +Nova user data. +cloudbase-init runs the script because it starts with #ps1_sysnative. +The image needs UserDataPlugin. + +CloudStack. +deployVirtualMachine user data, base64-encoded by the provider. +Same #ps1_sysnative path as OpenStack. +The script also sets the Administrator password with net user. +The provider opens TCP 22 on the minion public IP. + +Proxmox. +QEMU guest agent. +windows_openssh_agent_command() sends powershell.exe -EncodedCommand. +The script has no #ps1_sysnative header. +The template needs the guest agent. +powershell.exe must be on the agent PATH. +The provider sets the password with a separate net user command. + +Providers that can call this module +------------------------------------ +LXD. +The worker already sets cloud-init user data. +The current text is #cloud-config with ssh_authorized_keys. +That text does not start sshd. +That text does not change the firewall profile. +Replace that text with this script. + +libvirt. +The minion config drive already carries user data. +The current text is #cloud-config. +The connection port is 5986. +Send this script and set the port to 22. + +STACKIT. +Cloud-init user data already runs a PowerShell script. +That script configures a WinRM listener. +The connection port is 5986. +Run this script instead, and set the port to 22. + +AWS. +EC2 runs user data that starts with . +It does not run a #ps1_sysnative script. +Use the script body with an header. +The current Windows user data opens WinRM on port 5986. + +oVirt. +Use this script only when the Windows template runs cloudbase-init. +The provider already allows that initialization path. +windows_openssh_agent_command() does not apply. +The oVirt guest agent reports the IP. +It does not run a QEMU guest-exec command. +A template with only a preset WinRM password has no channel for this script. + +VMware vSphere. +OS morphing clones the Windows template from migr_template_map. +The connection is TCP port 22. +The username and password come from the template maps. +The clone spec does not set user data. +The clone spec does not run a guest command. +The template must already accept that login on port 22. +VMware Tools can run the script with StartProgramInGuest. +The provider does not call that API today. +The backup writer still accepts only a Linux minion. +That check is for disk copy, not for the OS morphing minion. +""" + +import base64 + +from coriolis import exception + +# Placeholders are replaced by build_windows_openssh_script. +_OPENSSH_SCRIPT = """$ErrorActionPreference = 'Stop' +__CORIOLIS_ACCOUNT_LINE__ +$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue +if (-not $svc) { + $cap = Get-WindowsCapability -Online | + Where-Object { $_.Name -like 'OpenSSH.Server*' } | + Select-Object -First 1 + if (-not $cap) { throw 'OpenSSH Server capability is not available' } + Add-WindowsCapability -Online -Name $cap.Name | Out-Null +} +Set-Service -Name sshd -StartupType Automatic +Start-Service -Name sshd +$ruleName = 'OpenSSH-Server-In-TCP' +$rule = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue +if (-not $rule) { + New-NetFirewallRule -Name $ruleName ` + -DisplayName 'OpenSSH Server (sshd)' -Enabled True ` + -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | + Out-Null +} +Set-NetFirewallRule -Name $ruleName -Enabled True -Profile Any ` + -EdgeTraversalPolicy Allow +$keyDir = Join-Path $env:ProgramData 'ssh' +$keyFile = Join-Path $keyDir 'administrators_authorized_keys' +New-Item -ItemType Directory -Force -Path $keyDir | Out-Null +$key = @' +__CORIOLIS_PUBLIC_KEY__ +'@ +$existing = '' +if (Test-Path $keyFile) { $existing = Get-Content -Raw -Path $keyFile } +if ($existing -notlike ('*' + $key + '*')) { + Add-Content -Path $keyFile -Value $key +} +icacls.exe $keyFile /inheritance:r /grant 'Administrators:F' /grant 'SYSTEM:F' | + Out-Null +""" + + +def build_windows_openssh_script( + public_key, username=None, password=None, cloudbase_init=False +): + """Return the PowerShell script that starts sshd and opens TCP 22. + + Set both username and password to add a ``net user`` line. + Set cloudbase_init to prefix ``#ps1_sysnative``. + The password must not contain spaces. + """ + if (username is None) != (password is None): + raise exception.InvalidInput( + "Windows OpenSSH script needs both username and password, or neither." + ) + account_line = "" + if username is not None: + account_line = "net.exe user %s %s" % (username, password) + script = _OPENSSH_SCRIPT.replace( + "__CORIOLIS_ACCOUNT_LINE__\n", + (account_line + "\n") if account_line else "", + ).replace("__CORIOLIS_PUBLIC_KEY__", public_key) + if cloudbase_init: + script = "#ps1_sysnative\n" + script + return script + + +def windows_openssh_agent_command(public_key): + """Return a guest-agent command that starts Windows OpenSSH.""" + script = build_windows_openssh_script(public_key) + encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii") + return "powershell.exe -NoProfile -NonInteractive -EncodedCommand " + encoded diff --git a/coriolis/windows_ssh.py b/coriolis/windows_ssh.py new file mode 100644 index 000000000..ad126907f --- /dev/null +++ b/coriolis/windows_ssh.py @@ -0,0 +1,576 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""SSH connection for Windows OS morphing minions. + +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. SSH runs them in one open +PowerShell process. The process is started with -Command -. + +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 later 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 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). +""" + +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 _ps_single_quote(text): + return "'" + str(text).replace("'", "''") + "'" + + +def _native_ps_script(cmd, args): + """Return a PowerShell script that runs one program. + + The script runs inside the open PowerShell process. It must not call + exit. exit would stop that process. + """ + parts = [_ps_single_quote(cmd)] + parts.extend(_ps_single_quote(part) for part in (args or [])) + return ( + "& %s; if ($null -eq $LASTEXITCODE -or $LASTEXITCODE -ne 0) { " + "throw ('native exit ' + $LASTEXITCODE) }" % " ".join(parts) + ) + + +def _is_reg_exe(cmd): + base = str(cmd).replace("\\", "/").rsplit("/", 1)[-1].lower() + return base in ("reg", "reg.exe") + + +def _strip_ps_output(stdout): + """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): + """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 + and native programs reuse one remote powershell.exe process. + """ + + 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 _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 | " + "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) + ) + + @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 _exec_command(self, cmd, args=[], timeout=None): + return self._invoke_persistent_ps(_native_ps_script(cmd, args), timeout=timeout) + + def exec_command( + self, + cmd, + args=[], + timeout=None, + sanitizable=True, + include_stderr=False, + ): + if sanitizable: + sanitized_cmd = strutils.mask_password( + "%s %s" % (cmd, " ".join(str(part) for part in (args or []))) + ) + else: + sanitized_cmd = "***" + if _is_reg_exe(cmd): + # 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) + + 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}, + ) + # This PowerShell process stays open for later commands. + # Close the zip file here. Expand-Archive must open that path next. + try: + self.exec_ps_command( + "[Net.ServicePointManager]::SecurityProtocol = " + "[Net.SecurityProtocolType]::Tls12; " + "if (!([System.Management.Automation.PSTypeName]" + "'System.Net.Http.HttpClient').Type) { " + "[System.Reflection.Assembly]::LoadWithPartialName(" + "'System.Net.Http') | Out-Null }; " + "$client = New-Object System.Net.Http.HttpClient; " + "$inStream = $null; $outFile = $null; " + "try { " + "$inStream = $client.GetStreamAsync('%(url)s').Result; " + "$outFile = New-Object IO.FileStream '%(outfile)s', " + "Create, Write, None; " + "$inStream.CopyTo($outFile, 1MB) " + "} finally { " + "if ($outFile) { $outFile.Dispose() }; " + "if ($inStream) { $inStream.Dispose() }; " + "$client.Dispose() }" % {"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 89d38ee77..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 @@ -34,7 +35,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