diff --git a/doc/changelog.d/237.miscellaneous.md b/doc/changelog.d/237.miscellaneous.md new file mode 100644 index 0000000..fde4d41 --- /dev/null +++ b/doc/changelog.d/237.miscellaneous.md @@ -0,0 +1 @@ +remote-connection server API changed. \ No newline at end of file diff --git a/doc/changelog.d/254.miscellaneous.md b/doc/changelog.d/254.miscellaneous.md new file mode 100644 index 0000000..a035c01 --- /dev/null +++ b/doc/changelog.d/254.miscellaneous.md @@ -0,0 +1 @@ +Fli/handle api change for remote connection diff --git a/doc/changelog.d/260.miscellaneous.md b/doc/changelog.d/260.miscellaneous.md new file mode 100644 index 0000000..a035c01 --- /dev/null +++ b/doc/changelog.d/260.miscellaneous.md @@ -0,0 +1 @@ +Fli/handle api change for remote connection diff --git a/doc/changelog.d/269.miscellaneous.md b/doc/changelog.d/269.miscellaneous.md new file mode 100644 index 0000000..a035c01 --- /dev/null +++ b/doc/changelog.d/269.miscellaneous.md @@ -0,0 +1 @@ +Fli/handle api change for remote connection diff --git a/doc/changelog.d/277.maintenance.md b/doc/changelog.d/277.maintenance.md new file mode 100644 index 0000000..a035c01 --- /dev/null +++ b/doc/changelog.d/277.maintenance.md @@ -0,0 +1 @@ +Fli/handle api change for remote connection diff --git a/pyproject.toml b/pyproject.toml index 227bda5..9fb1678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "ansys-platform-instancemanagement>=1.0.1", "ansys-pythonnet>=3.1.0rc1", "ansys-tools-path>=0.3.1", + "ansys-tools-common>=0.3.0", "tqdm>=4.65.0", "WMI>=1.4.9; platform_system=='Windows'", ] diff --git a/src/ansys/workbench/core/public_api.py b/src/ansys/workbench/core/public_api.py index c4413f0..2245761 100644 --- a/src/ansys/workbench/core/public_api.py +++ b/src/ansys/workbench/core/public_api.py @@ -22,6 +22,7 @@ """Module for public API on PyWorkbench.""" +import atexit import logging import tempfile @@ -40,15 +41,18 @@ class ClientWrapper(WorkbenchClient): Path to a writable directory on the client computer. host : str, default: None Server computer's name or IP address. + security : str, default: 'mtls' + Transport mode used for connection security. + Options are: "insecure", "uds", "wnua", "mtls" """ - def __init__(self, port, client_workdir=None, host=None): + def __init__(self, port, client_workdir=None, host=None, security="mtls"): """Create a PyWorkbench client that connects to a Workbench server.""" if host is None: host = "localhost" if client_workdir is None: client_workdir = tempfile.gettempdir() - super().__init__(client_workdir, host, port) + super().__init__(client_workdir, host, port, security) super()._connect() def exit(self): @@ -73,6 +77,8 @@ class LaunchWorkbench(ClientWrapper): server_workdir : str, None Path to a writable directory on the server computer. The default is ``None``, in which case the user preference for the Workbench temporary file folder is used. + use_insecure_connection : bool, default: False + whether to use insecure connection between the server and clients host : str, None Server computer's name or IP address. The default is ``None`` for launching on the local computer. @@ -102,6 +108,7 @@ def __init__( version=None, client_workdir=None, server_workdir=None, + use_insecure_connection=False, host=None, username=None, password=None, @@ -110,10 +117,19 @@ def __init__( version = "252" self._launcher = Launcher() - port = self._launcher.launch(version, show_gui, server_workdir, host, username, password) + port, security = self._launcher.launch( + version, show_gui, server_workdir, use_insecure_connection, host, username, password + ) if port is None or port <= 0: raise Exception("Failed to launch Ansys Workbench service.") - super().__init__(port, client_workdir, host) + if use_insecure_connection: + print( + "Using insecure connection is not recommended. " + "Please see the documentation for your installed " + "product for additional information." + ) + super().__init__(port, client_workdir, host, security) + atexit.register(self.exit) self._exited = False def exit(self): @@ -134,6 +150,7 @@ def launch_workbench( version=None, client_workdir=None, server_workdir=None, + use_insecure_connection=False, host=None, username=None, password=None, @@ -155,6 +172,8 @@ def launch_workbench( server_workdir : str, None Path to a writable directory on the server computer. The default is ``None``, in which case the user preference for the Workbench temporary file folder is used. + use_insecure_connection : bool, default: False + whether to use insecure connection between the server and clients host : str, None Server computer's name or IP address. The default is ``None`` for launching on the local computer. @@ -179,11 +198,18 @@ def launch_workbench( """ return LaunchWorkbench( - show_gui, version, client_workdir, server_workdir, host, username, password + show_gui, + version, + client_workdir, + server_workdir, + use_insecure_connection, + host, + username, + password, ) -def connect_workbench(port, client_workdir=None, host=None): +def connect_workbench(port, client_workdir=None, host=None, security="mtls"): """Create a PyWorkbench client that connects to an already running Workbench server. Parameters @@ -195,6 +221,8 @@ def connect_workbench(port, client_workdir=None, host=None): in which case the system temp directory is used. host : str, default: None Server computer's name or IP address. The default is ``None`` for the local computer. + security : str among 'mtls', 'wnua', 'insecure', default: 'mtls' + Transport mode used for connection security. The default is `mtls`. Returns ------- @@ -209,7 +237,7 @@ def connect_workbench(port, client_workdir=None, host=None): >>> from ansys.workbench.core import connect_workbench >>> wb = connect_workbench(port = 32588) """ - return ClientWrapper(port, client_workdir, host) + return ClientWrapper(port, client_workdir, host, security) __all__ = ["launch_workbench", "connect_workbench"] diff --git a/src/ansys/workbench/core/workbench_client.py b/src/ansys/workbench/core/workbench_client.py index 334204b..34ecf63 100644 --- a/src/ansys/workbench/core/workbench_client.py +++ b/src/ansys/workbench/core/workbench_client.py @@ -31,11 +31,11 @@ import re import time -import grpc import tqdm from ansys.api.workbench.v0 import workbench_pb2 as wb from ansys.api.workbench.v0.workbench_pb2_grpc import WorkbenchServiceStub +from ansys.tools.common.cyberchannel import create_channel from ansys.workbench.core.example_data import ExampleData @@ -50,13 +50,17 @@ class WorkbenchClient: Hostname or IP address of the server. server_port : int Port number of the server. + server_security : string + Security mode of the server. + Options are: "insecure", "uds", "wnua", "mtls" """ - def __init__(self, local_workdir, server_host, server_port): + def __init__(self, local_workdir, server_host, server_port, server_security): """Create a Workbench client.""" self.workdir = local_workdir self._server_host = server_host self._server_port = server_port + self._server_security = server_security self._server_version = -1 self.__init_logging() @@ -81,10 +85,18 @@ def __exit__(self, exc_type, exc_value, traceback): def _connect(self): """Connect to the server.""" - hnp = self._server_host + ":" + str(self._server_port) - self.channel = grpc.insecure_channel(hnp) + self.channel = create_channel( + host=self._server_host, + port=self._server_port, + transport_mode=self._server_security, + certs_dir=None, + grpc_options=None, + ) self.stub = WorkbenchServiceStub(self.channel) - logging.info(f"connected to the WB server at {hnp}") + logging.info( + f"connected to the WB server at {self._server_host}:{self._server_port} " + "using {self._server_security} connection" + ) def _disconnect(self): """Disconnect from the server.""" diff --git a/src/ansys/workbench/core/workbench_launcher.py b/src/ansys/workbench/core/workbench_launcher.py index 606f9c9..2822110 100644 --- a/src/ansys/workbench/core/workbench_launcher.py +++ b/src/ansys/workbench/core/workbench_launcher.py @@ -76,6 +76,7 @@ def launch( version, show_gui=True, server_workdir=None, + use_insecure_connection=False, host=None, username=None, password=None, @@ -91,6 +92,8 @@ def launch( server_workdir : str, default: None Path to a writable directory on the server. The default is ``None``, in which case the user preference for the Workbench temporary file folder is used. + use_insecure_connection : bool, default: False + whether to use insecure connection between the server and clients host : str, default: None Name or IP address of the server. The default is ``None``, which launches Workbench on the local computer. @@ -125,12 +128,21 @@ def launch( "Launching PyWorkbench on a remote machine from Linux is not supported." ) + if not host and not self._wmi and int(version) < 252: + raise Exception("Launching PyWorkbench 25.1 or below on Linux is not supported.") + if host and (not username or not password): raise Exception( - "Username and passwork must be specified " + "Username and password must be specified " "to launch PyWorkbench on a remote machine." ) + security = "mtls" + if use_insecure_connection: + security = "insecure" + elif not host and self._wmi: + security = "wnua" + if self._wmi: try: if not host: @@ -167,17 +179,44 @@ def launch( args.append("--start-and-wait") args.append("-nowindow") args.append("-E") + + # create command string prefix = uuid.uuid4().hex - cmd = "StartServer(EnvironmentPrefix='" - cmd += prefix + "'" + cmd1 = "StartServer(EnvironmentPrefix='" + cmd1 += prefix + "'" if server_workdir is not None: # use forward slash only to avoid escaping as command line argument server_workdir = server_workdir.replace("\\", "/") - cmd += ",WorkingDirectory='" + server_workdir + "'" - cmd += ")" + cmd1 += ",WorkingDirectory='" + server_workdir + "'" + cmd2 = str(cmd1) + cmd1 += ")" + cmd2 += ",Security='" + security + "'" + if host is not None: + cmd2 += ",AllowRemoteConnection=True" + cmd2 += ")" + + if self._wmi: + quote_or_not = '"' # quotes needed when constructing command line + else: + quote_or_not = "" + cmd = ( + quote_or_not + + cmd2 + + """ if __scriptingEngine__.CommandContext.AddinManager.GetAddin(""" + + """'Ansys.RemoteWB.Addin').Version.Major > 1 else """ + + cmd1 + + quote_or_not + ) args.append(cmd) + command_line = " ".join(args) + # security precaution statement + if host is not None: + print("""The server started will allow remote access connections to be +established, possibly permitting control of the machine and any data which resides on it. +It is highly recommended to only utilize these features on a trusted, secure network.""") + successful = False process = None if self._wmi: @@ -206,35 +245,37 @@ def launch( logging.info(f"Workbench is launched successfully with process ID {self._process_id}.") else: logging.error("Workbench failed to launch on the host.") - return 0 + return 0, security # retrieve server port once WB is fully up running port = None timeout = 60 * 8 # set 8 minutes as upper limit for WB startup start_time = time.time() - while True: - if self._wmi: + if self._wmi: + while True: port = self.__getenv("ANSYS_FRAMEWORK_SERVER_PORT") - else: - for line in process.stdout: - line = line.rstrip() - if line.startswith("ANSYS_FRAMEWORK_SERVER_PORT="): - port = line[28:] + if port and port.startswith(prefix): + port = port[len(prefix) :] + break + else: + port = None + if time.time() - start_time > timeout: + logging.error("Failed to start Workbench service within reasonable timeout.") + break + time.sleep(10) + else: + for line in process.stdout: + line = line.rstrip() + if line.startswith("ANSYS_FRAMEWORK_SERVER_PORT="): + port = line[28:] + if port.startswith(prefix): + port = port[len(prefix) :] break - if port and port.startswith(prefix): - port = port[len(prefix) :] - break - else: - port = None - if time.time() - start_time > timeout: - logging.error("Failed to start Workbench service within reasonable timeout.") - break - time.sleep(10) if not port or int(port) <= 0: logging.error("Failed to retrieve the port used by Workbench service.") - return 0 + return 0, security logging.info("Workbench service uses port: " + port) - return int(port) + return int(port), security def __getenv(self, key): value = None diff --git a/tests/test_workbench_client.py b/tests/test_workbench_client.py index fec0ef7..14a689c 100644 --- a/tests/test_workbench_client.py +++ b/tests/test_workbench_client.py @@ -36,7 +36,7 @@ @pytest.fixture def mock_grpc(): """Mock the insecure_channel method.""" - with patch("ansys.workbench.core.workbench_client.grpc.insecure_channel") as mock_channel: + with patch("grpc.insecure_channel") as mock_channel: yield mock_channel @@ -60,22 +60,28 @@ def mock_wb(): def test_connect(mock_grpc, mock_workbench_service_stub): """Test the connect method.""" - client = WorkbenchClient(local_workdir="/tmp", server_host="localhost", server_port=5000) + client = WorkbenchClient( + local_workdir="/tmp", server_host="localhost", server_port=5000, server_security="insecure" + ) client._connect() - mock_grpc.assert_called_once_with("localhost:5000") + mock_grpc.assert_called_once_with("localhost:5000", options=None) mock_workbench_service_stub.assert_called_once() def test_connect_workbench(): """Test the connect_workbench method.""" - client = connect_workbench(port=5000, client_workdir="/tmp", host="localhost") + client = connect_workbench( + port=5000, client_workdir="/tmp", host="localhost", security="insecure" + ) assert isinstance(client, WorkbenchClient) client.exit() def test_disconnect(): """Test the disconnect method.""" - client = WorkbenchClient(local_workdir="/tmp", server_host="localhost", server_port=5000) + client = WorkbenchClient( + local_workdir="/tmp", server_host="localhost", server_port=5000, server_security="insecure" + ) client._connect() client._disconnect() assert client.channel is None @@ -84,7 +90,9 @@ def test_disconnect(): def test_is_connected(): """Test the is_connected method.""" - client = WorkbenchClient(local_workdir="/tmp", server_host="localhost", server_port=5000) + client = WorkbenchClient( + local_workdir="/tmp", server_host="localhost", server_port=5000, server_security="insecure" + ) client._connect() assert client._is_connected() client._disconnect() @@ -92,14 +100,21 @@ def test_is_connected(): # def test_set_console_log_level(mock_wb): -# client = WorkbenchClient(local_workdir="/tmp", server_host="localhost", server_port=5000) +# client = WorkbenchClient( +# local_workdir="/tmp", +# server_host="localhost", +# server_port=5000, +# server_security="insecure", +# ) # client.set_console_log_level("warning") # assert client.__log_console_handler.level == logging.DEBUG def test_run_script_string(mock_workbench_service_stub): """Test the run_script_string method.""" - client = WorkbenchClient(local_workdir="/tmp", server_host="localhost", server_port=5000) + client = WorkbenchClient( + local_workdir="/tmp", server_host="localhost", server_port=5000, server_security="insecure" + ) client._connect() mock_stub = mock_workbench_service_stub.return_value mock_response = MagicMock() @@ -110,7 +125,9 @@ def test_run_script_string(mock_workbench_service_stub): def test_log_file(mock_wb, mock_workbench_service_stub): """Test the log file functionality.""" - client = WorkbenchClient(local_workdir="/tmp", server_host="localhost", server_port=5000) + client = WorkbenchClient( + local_workdir="/tmp", server_host="localhost", server_port=5000, server_security="insecure" + ) client._connect() mock_stub = mock_workbench_service_stub.return_value mock_response = MagicMock() @@ -143,7 +160,12 @@ def test_run_script_file(mock_workbench_service_stub): """Test the run_script_file method.""" local_workdir = workdir = pathlib.Path(__file__).parent script_dir = workdir / "scripts" - client = WorkbenchClient(local_workdir=local_workdir, server_host="localhost", server_port=5000) + client = WorkbenchClient( + local_workdir=local_workdir, + server_host="localhost", + server_port=5000, + server_security="insecure", + ) client._connect() mock_stub = mock_workbench_service_stub.return_value mock_response = MagicMock() @@ -159,7 +181,9 @@ def test_run_script_file(mock_workbench_service_stub): def test_upload_file(mock_workbench_service_stub): """Test the upload_file method.""" - client = WorkbenchClient(local_workdir="/tmp", server_host="localhost", server_port=5000) + client = WorkbenchClient( + local_workdir="/tmp", server_host="localhost", server_port=5000, server_security="insecure" + ) client._connect() mock_stub = mock_workbench_service_stub.return_value mock_response = MagicMock() @@ -177,7 +201,9 @@ def test_upload_file(mock_workbench_service_stub): def test_upload_file_from_example_repo(mock_workbench_service_stub): """Test the upload_file_from_example_repo method.""" - client = WorkbenchClient(local_workdir="/tmp", server_host="localhost", server_port=5000) + client = WorkbenchClient( + local_workdir="/tmp", server_host="localhost", server_port=5000, server_security="insecure" + ) client._connect() mock_stub = mock_workbench_service_stub.return_value mock_response = MagicMock() @@ -203,7 +229,12 @@ def test_upload_iterator(): try: # Create a WorkbenchClient instance - client = WorkbenchClient(local_workdir="/tmp", server_host="localhost", server_port=5000) + client = WorkbenchClient( + local_workdir="/tmp", + server_host="localhost", + server_port=5000, + server_security="insecure", + ) client._connect() # Get the temporary file path @@ -225,7 +256,9 @@ def test_upload_iterator(): def test_download_file(mock_workbench_service_stub): """Test the download_file method.""" - client = WorkbenchClient(local_workdir="/tmp", server_host="localhost", server_port=5000) + client = WorkbenchClient( + local_workdir="/tmp", server_host="localhost", server_port=5000, server_security="insecure" + ) client._connect() mock_stub = mock_workbench_service_stub.return_value client.stub = mock_stub