diff --git a/.github/workflows/nightly_docker_test.yml b/.github/workflows/nightly_docker_test.yml index 4da041bb85..624796cdad 100644 --- a/.github/workflows/nightly_docker_test.yml +++ b/.github/workflows/nightly_docker_test.yml @@ -27,6 +27,7 @@ env: ANSRV_GEO_PORT: 710 ANSRV_GEO_LICENSE_SERVER: ${{ secrets.LICENSE_SERVER }} GEO_CONT_NAME: ans_geo_nightly + IS_WORKFLOW_RUNNING: true concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/v1_testing.yml b/.github/workflows/v1_testing.yml index 939ad9d164..04ddfa7ec4 100644 --- a/.github/workflows/v1_testing.yml +++ b/.github/workflows/v1_testing.yml @@ -9,6 +9,7 @@ env: ANSRV_GEO_LICENSE_SERVER: ${{ secrets.LICENSE_SERVER }} GEO_CONT_NAME: ans_geo RESET_IMAGE_CACHE: 0 + IS_WORKFLOW_RUNNING: true permissions: contents: read diff --git a/.gitignore b/.gitignore index d77863c878..b0069690ab 100644 --- a/.gitignore +++ b/.gitignore @@ -167,4 +167,7 @@ docker/*-binaries.zip # Ignore scripts folder scripts +# Ignore certs folder +certs + # End of https://www.toptal.com/developers/gitignore/api/python diff --git a/doc/changelog.d/2411.added.md b/doc/changelog.d/2411.added.md new file mode 100644 index 0000000000..c75c3de235 --- /dev/null +++ b/doc/changelog.d/2411.added.md @@ -0,0 +1 @@ +Secure grpc channels diff --git a/doc/source/cheatsheet/cheat_sheet.qmd b/doc/source/cheatsheet/cheat_sheet.qmd index 3a1afb7d75..7bb7951a3f 100644 --- a/doc/source/cheatsheet/cheat_sheet.qmd +++ b/doc/source/cheatsheet/cheat_sheet.qmd @@ -113,6 +113,7 @@ modeler = launch_modeler(mode='geometry_service') ```{python} #| output: false +#| eval: false from ansys.geometry.core import Modeler modeler = Modeler() print(modeler) diff --git a/doc/source/user_guide/connection.rst b/doc/source/user_guide/connection.rst new file mode 100644 index 0000000000..14135b2f8b --- /dev/null +++ b/doc/source/user_guide/connection.rst @@ -0,0 +1,151 @@ +Launching and connecting to a Modeler instance +********************************************** + +PyAnsys Geometry provides the ability to launch and connect to a Modeler instance +either locally or remotely. A Modeler instance is a running instance of a +CAD service that the PyAnsys Geometry client can communicate with. + +CAD services that are supported include: + +- Ansys Geometry Service +- Ansys Discovery +- Ansys SpaceClaim + +Connecting to a Modeler instance +-------------------------------- + +To connect to a Modeler instance, you can use the :class:`Modeler() ` class, +which provides methods to connect to the desired CAD service. You can specify the connection parameters such +as the host, port, and authentication details. + +.. code:: python + + from ansys.geometry.core import Modeler + + # Connect to a local Modeler instance + modeler = Modeler(host="localhost", port=12345) + +Connection types +---------------- + +PyAnsys Geometry supports different connection types to connect to a Modeler instance, which +differ in the environment in which the transport takes place: + +- **Local connection**: The Modeler instance is running on the same machine as the PyAnsys + Geometry client. This is typically used for development and testing purposes. +- **Remote connection**: The service instance is running on a different machine, and the + Modeler client connects to it over the network. This is useful for accessing + Modeler instances hosted on remote servers or cloud environments. + +Securing connections +-------------------- + +When connecting to a remote Modeler instance, it is important to ensure that the connection +is secure, not only to protect sensitive data but also to comply with organizational +security policies. These secure connections can be established using various methods, such as: + +- **mTLS**: Mutual Transport Layer Security (mTLS) is a security protocol that ensures both the client + and server authenticate each other using digital certificates. This provides a high level of + security for the connection. PyAnsys Geometry supports mTLS connections to Modeler instances. In that + case, you need to provide the necessary certificates and keys when establishing the connection. More + information on the certificates needed can be seen in + `Generating certificates for mTLS `_. + Make sure that the names of the files are in line with the previous link. An example of how to set up + an mTLS connection is shown below: + + .. note:: + + mTLS is the default transport mode when connecting to remote Modeler instances. + + .. code:: python + + from ansys.geometry.core import Modeler, launch_modeler + + # OPTION 1: Connect to a Modeler instance using mTLS + modeler = Modeler( + host="remote_host", + port=12345, + transport_mode="mtls", + certs_dir="path/to/certs_directory", + ) + + # OPTION 2: Launch the Modeler instance locally using mTLS + modeler = launch_modeler( + transport_mode="mtls", + certs_dir="path/to/certs_directory", + ) + +- **UDS**: Unix Domain Sockets (UDS) provide a way to establish secure connections between + processes on the same machine. UDS connections are faster and more secure than traditional + network connections, as they do not require network protocols. PyAnsys Geometry supports UDS + connections to local Modeler instances (**only on Linux-based services**). An example of how + to set up a UDS connection is shown below: + + .. note:: + + UDS is only supported when connecting to local Modeler instances on Linux-based services. + It is also the default transport mode in such cases. + + .. code:: python + + from ansys.geometry.core import Modeler, launch_modeler + + # OPTION 1: Connect to a local Modeler instance using UDS + modeler = Modeler(host="localhost", port=12345, transport_mode="uds") + + # OPTION 2: Launch the Modeler instance locally using UDS and specific directory and id for + # the UDS socket + modeler = Modeler(host="localhost", port=12345, transport_mode="uds", uds_dir="/path/to/uds_directory", uds_id="unique_id") + + # OPTION 3: Launch the Modeler instance locally using UDS + modeler = launch_modeler(transport_mode="uds") + + # OPTION 4: Launch the Modeler instance locally using UDS and specific directory and id for + # the UDS socket + modeler = launch_modeler(transport_mode="uds", uds_dir="/path/to/uds_directory", uds_id="unique_id") + +- **WNUA**: Windows Named User Authentication (WNUA) provides a way to establish secure connections + between processes on the same Windows machine. WNUA connections use a built-in mechanism to verify + that the owner of the service running is also the owner of the client connection established (similar + to UDS). PyAnsys Geometry supports WNUA connections to local Modeler instances + (**only on Windows-based services**). An example of how to set up a WNUA connection is shown below: + + .. note:: + + WNUA is only supported when connecting to local Modeler instances on Windows-based services. + It is also the default transport mode in such cases. + + .. code:: python + + from ansys.geometry.core import Modeler, launch_modeler + + # OPTION 1: Connect to a local Modeler instance using WNUA + modeler = Modeler(host="localhost", port=12345, transport_mode="wnua") + + # OPTION 2: Launch the Modeler instance locally using WNUA + modeler = launch_modeler(transport_mode="wnua") + +- **Insecure**: Insecure connections do not provide any security measures to protect the data + transmitted between the client and server. This mode is not recommended for production use, + as it exposes the connection to potential security risks. However, it can be useful for + development and testing purposes in trusted environments. An example of how to set up an + insecure connection is shown below: + + .. code:: python + + from ansys.geometry.core import Modeler, launch_modeler + + # OPTION 1: Connect to a Modeler instance using an insecure connection + modeler = Modeler( + host="remote_host", + port=12345, + transport_mode="insecure", + ) + + # OPTION 2: Launch the Modeler instance locally using an insecure connection + modeler = launch_modeler( + transport_mode="insecure", + ) + +For more information on secure connections and transport modes, see +`Securing gRPC connections `_. diff --git a/doc/source/user_guide/index.rst b/doc/source/user_guide/index.rst index 0b9393bbe2..6253cd9673 100644 --- a/doc/source/user_guide/index.rst +++ b/doc/source/user_guide/index.rst @@ -5,13 +5,14 @@ User guide ========== This section provides an overview of the PyAnsys Geometry library, -explaining key concepts and approaches for primitives, +explaining key concepts and approaches for connection, primitives, sketches (2D basic shape elements), and model designs. .. toctree:: :maxdepth: 1 :hidden: + connection primitives shapes designer diff --git a/pyproject.toml b/pyproject.toml index 25330e9c37..0745cc34ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dependencies = [ "ansys-api-discovery==1.0.10", - "ansys-tools-common>=0.2,<1", + "ansys-tools-common>=0.3,<1", "beartype>=0.11.0,<0.23", "geomdl>=5,<6", "grpcio>=1.35.0,<2", @@ -97,7 +97,7 @@ tests = [ ] general-all = [ "ansys-platform-instancemanagement==1.1.2", - "ansys-tools-common==0.2.1", + "ansys-tools-common==0.3.0", "ansys-tools-visualization-interface==0.12.1", "beartype==0.22.6", "docker==7.1.0", diff --git a/src/ansys/geometry/core/connection/client.py b/src/ansys/geometry/core/connection/client.py index aad71cf2c2..38c250b316 100644 --- a/src/ansys/geometry/core/connection/client.py +++ b/src/ansys/geometry/core/connection/client.py @@ -23,9 +23,11 @@ import atexit import logging +import os from pathlib import Path import time from typing import Optional +import warnings from beartype import beartype as check_input_types import grpc @@ -48,7 +50,13 @@ pass -def _create_geometry_channel(target: str) -> grpc.Channel: +def _create_geometry_channel( + target: str, + transport_mode: str | None = None, + uds_dir: Path | str | None = None, + uds_id: str | None = None, + certs_dir: Path | str | None = None, +) -> grpc.Channel: """Create a Geometry service gRPC channel. Parameters @@ -56,6 +64,21 @@ def _create_geometry_channel(target: str) -> grpc.Channel: target : str Target of the channel. This is usually a string in the form of ``host:port``. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls" + uds_dir : Path | str | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use "aposdas_socket.sock". + Otherwise, the socket filename will be "aposdas_socket-.sock". + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. Returns ------- @@ -66,16 +89,38 @@ def _create_geometry_channel(target: str) -> grpc.Channel: ----- Contains specific options for the Geometry service. """ - return grpc.insecure_channel( - target, - options=[ - ("grpc.max_receive_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH), - ("grpc.max_send_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH), - ], + from ansys.tools.common.cyberchannel import create_channel + + # Split target into host and port + host, port = target.split(":") + + # Add specific gRPC options for the Geometry service + grpc_options = [ + ("grpc.max_receive_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH), + ("grpc.max_send_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH), + ] + + # Create the channel accordingly + return create_channel( + host=host, + port=port, + transport_mode=transport_mode, + uds_service="aposdas_socket", + uds_dir=uds_dir, + uds_id=uds_id, + certs_dir=certs_dir, + grpc_options=grpc_options, ) -def wait_until_healthy(channel: grpc.Channel | str, timeout: float) -> grpc.Channel: +def wait_until_healthy( + channel: grpc.Channel | str, + timeout: float, + transport_mode: str | None = None, + uds_dir: Path | str | None = None, + uds_id: str | None = None, + certs_dir: Path | str | None = None, +) -> grpc.Channel: """Wait until a channel is healthy before returning. Parameters @@ -93,6 +138,21 @@ def wait_until_healthy(channel: grpc.Channel | str, timeout: float) -> grpc.Chan is made with the remaining time. * If the total elapsed time exceeds the value for the ``timeout`` parameter, a ``TimeoutError`` is raised. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls" + uds_dir : Path | str | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use "aposdas_socket.sock". + Otherwise, the socket filename will be "aposdas_socket-.sock". + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. Returns ------- @@ -108,6 +168,20 @@ def wait_until_healthy(channel: grpc.Channel | str, timeout: float) -> grpc.Chan t_max = time.time() + timeout t_out = 0.1 + # If transport mode is not specified, default to insecure when running in CI + if transport_mode is None: + if os.getenv("IS_WORKFLOW_RUNNING") is not None: + warnings.warn( + "Transport mode forced to 'insecure' when running in CI workflows.", + ) + transport_mode = "insecure" + else: + raise ValueError( + "Transport mode must be specified when not running in CI workflows." + " Use 'transport_mode' parameter with one of the possible options." + " Options are: 'insecure', 'uds', 'wnua', 'mtls'." + ) + # If the channel is a string, create a channel using the default insecure channel channel_creation_required = True if isinstance(channel, str) else False tmp_channel = None @@ -115,7 +189,15 @@ def wait_until_healthy(channel: grpc.Channel | str, timeout: float) -> grpc.Chan while time.time() < t_max: try: tmp_channel = ( - _create_geometry_channel(channel) if channel_creation_required else channel + _create_geometry_channel( + channel, + transport_mode=transport_mode, + uds_dir=uds_dir, + uds_id=uds_id, + certs_dir=certs_dir, + ) + if channel_creation_required + else channel ) health_stub = health_pb2_grpc.HealthStub(tmp_channel) request = health_pb2.HealthCheckRequest(service="") @@ -179,6 +261,21 @@ class GrpcClient: proto_version : str | None, default: None Protocol version to use for communication with the server. If None, v0 is used. Available versions are "v0", "v1", etc. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls" + uds_dir : Path | str | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use "aposdas_socket.sock". + Otherwise, the socket filename will be "aposdas_socket-.sock". + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. """ @check_input_types @@ -194,6 +291,10 @@ def __init__( logging_level: int = logging.INFO, logging_file: Path | str | None = None, proto_version: str | None = None, + transport_mode: str | None = None, + uds_dir: Path | str | None = None, + uds_id: str | None = None, + certs_dir: Path | str | None = None, ): """Initialize the ``GrpcClient`` object.""" self._closed = False @@ -208,7 +309,19 @@ def __init__( self._channel = wait_until_healthy(channel, self._grpc_health_timeout) else: self._target = f"{host}:{port}" - self._channel = wait_until_healthy(self._target, self._grpc_health_timeout) + self._channel = wait_until_healthy( + self._target, + self._grpc_health_timeout, + transport_mode=transport_mode, + uds_dir=uds_dir, + uds_id=uds_id, + certs_dir=certs_dir, + ) + + # HACK: If we are using UDS, the target needs to be updated to reflect + # the actual socket file being used. + if transport_mode == "uds": + self._target = self._channel._channel.target().decode() # Initialize the gRPC services self._services = _GRPCServices(self._channel, version=proto_version) diff --git a/src/ansys/geometry/core/connection/docker_instance.py b/src/ansys/geometry/core/connection/docker_instance.py index 92cdcd7caf..b0d9fb8ebb 100644 --- a/src/ansys/geometry/core/connection/docker_instance.py +++ b/src/ansys/geometry/core/connection/docker_instance.py @@ -24,6 +24,7 @@ from enum import Enum from functools import wraps import os +from pathlib import Path from typing import Optional from beartype import beartype as check_input_types @@ -37,6 +38,8 @@ except ModuleNotFoundError: # pragma: no cover _HAS_DOCKER = False +from ansys.tools.common.cyberchannel import verify_transport_mode + import ansys.geometry.core.connection.defaults as pygeom_defaults from ansys.geometry.core.logger import LOG @@ -106,6 +109,14 @@ class LocalDockerInstance: in which case the ``LocalDockerInstance`` class identifies the OS of your Docker engine and deploys the latest version of the Geometry service for that OS. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "mtls" + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. """ __DOCKER_CLIENT__: "DockerClient" = None @@ -164,6 +175,8 @@ def __init__( restart_if_existing_service: bool = False, name: str | None = None, image: GeometryContainers | None = None, + transport_mode: str | None = None, + certs_dir: Path | str | None = None, ) -> None: """``LocalDockerInstance`` constructor.""" # Initialize instance variables @@ -190,7 +203,13 @@ def __init__( # # First, check if the port is available... otherwise raise error if port_available: - self._deploy_container(port=port, name=name, image=image) + self._deploy_container( + port=port, + name=name, + image=image, + transport_mode=transport_mode, + certs_dir=certs_dir, + ) else: raise RuntimeError(f"Geometry service cannot be deployed on port {port}") @@ -245,7 +264,14 @@ def _is_cont_geom_service(self, cont: "Container") -> bool: # If you have reached this point, the image is not a Geometry service return False # pragma: no cover - def _deploy_container(self, port: int, name: str | None, image: GeometryContainers | None): + def _deploy_container( + self, + port: int, + name: str | None, + image: GeometryContainers | None, + transport_mode: str | None, + certs_dir: Path | str | None, + ) -> None: """Handle the deployment of a Geometry service. Parameters @@ -258,6 +284,14 @@ def _deploy_container(self, port: int, name: str | None, image: GeometryContaine image : GeometryContainers or None Geometry service Docker container image to be used. If ``None``, the latest container version matching + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "mtls" + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment + variable. If not found, it will use the "certs" folder assuming it is in the + current working directory. Raises ------ @@ -292,6 +326,37 @@ def _deploy_container(self, port: int, name: str | None, image: GeometryContaine "No license server provided... Store its value under the following env variable: ANSRV_GEO_LICENSE_SERVER." # noqa: E501 ) + # Verify the transport mode + if transport_mode: + verify_transport_mode(transport_mode, "remote") + else: + # Default to "mtls" if possible + transport_mode = "mtls" + LOG.info(f"No transport mode provided. Defaulting to '{transport_mode}'") + + # Create the shared volume if mtls is selected + volumes = None + if transport_mode == "mtls": + # Share the certificates directory if needed + if certs_dir is None: + certs_dir_env = os.getenv("ANSYS_GRPC_CERTIFICATES", None) + if certs_dir_env is not None: + certs_dir = certs_dir_env + else: + certs_dir = Path.cwd() / "certs" + + if not Path(certs_dir).is_dir(): # pragma: no cover + raise RuntimeError( + "Transport mode 'mtls' was selected, but the expected" + f" certificates directory does not exist: {certs_dir}" + ) + volumes = { + str(Path(certs_dir).resolve()): { + "bind": "/certs" if image.value[1] == "linux" else "C:/certs", + "mode": "ro", + } + } + # Try to deploy it try: container: Container = self.docker_client().containers.run( @@ -300,12 +365,17 @@ def _deploy_container(self, port: int, name: str | None, image: GeometryContaine auto_remove=True, name=name, ports={"50051/tcp": port}, + volumes=volumes, environment={ "LICENSE_SERVER": license_server, "LOG_LEVEL": os.getenv("ANSRV_GEO_LOG_LEVEL", 2), "ENABLE_TRACE": os.getenv("ANSRV_GEO_ENABLE_TRACE", 0), "USE_DEBUG_MODE": os.getenv("ANSRV_GEO_USE_DEBUG_MODE", 0), + "ANSYS_GRPC_CERTIFICATES": "/certs" + if image.value[1] == "linux" + else "C:/certs", }, + command=f"--transport-mode={transport_mode}", ) except ImageNotFound: # pragma: no cover raise RuntimeError( diff --git a/src/ansys/geometry/core/connection/launcher.py b/src/ansys/geometry/core/connection/launcher.py index 3d2e1688f3..2fccd3a5e0 100644 --- a/src/ansys/geometry/core/connection/launcher.py +++ b/src/ansys/geometry/core/connection/launcher.py @@ -25,6 +25,7 @@ import os from pathlib import Path from typing import TYPE_CHECKING +import warnings from ansys.geometry.core.connection.backend import ApiVersions, BackendType import ansys.geometry.core.connection.defaults as pygeom_defaults @@ -301,6 +302,8 @@ def launch_docker_modeler( image: GeometryContainers | None = None, client_log_level: int = logging.INFO, client_log_file: str | None = None, + transport_mode: str | None = None, + certs_dir: Path | str | None = None, **kwargs: dict | None, ) -> "Modeler": """Start the Geometry service locally using Docker. @@ -335,6 +338,14 @@ def launch_docker_modeler( client_log_file : str, default: None Path to the log file for the client. The default is ``None``, in which case the client logs to the console. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "mtls" + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. **kwargs : dict, default: None Placeholder to prevent errors when passing additional arguments that are not compatible with this method. @@ -349,6 +360,12 @@ def launch_docker_modeler( if not _HAS_DOCKER: # pragma: no cover raise ModuleNotFoundError("The package 'docker' is required to use this function.") + if os.getenv("IS_WORKFLOW_RUNNING") is not None: + warnings.warn( + "Transport mode forced to 'insecure' when running in CI workflows.", + ) + transport_mode = "insecure" + # Call the LocalDockerInstance ctor. docker_instance = LocalDockerInstance( port=port, @@ -356,6 +373,8 @@ def launch_docker_modeler( restart_if_existing_service=restart_if_existing_service, name=name, image=image, + transport_mode=transport_mode, + certs_dir=certs_dir, ) # Once the local Docker instance is ready... return the Modeler @@ -365,6 +384,8 @@ def launch_docker_modeler( docker_instance=docker_instance, logging_level=client_log_level, logging_file=client_log_file, + transport_mode=transport_mode if transport_mode else "mtls", + certs_dir=certs_dir, ) @@ -510,6 +531,10 @@ def launch_modeler_with_geometry_service( server_logs_folder: str = None, client_log_file: str = None, server_working_dir: str | Path | None = None, + transport_mode: str | None = None, + uds_dir: Path | str | None = None, + uds_id: str | None = None, + certs_dir: Path | str | None = None, product_version: int = None, # DEPRECATED: use `version` instead **kwargs: dict | None, ) -> "Modeler": @@ -563,6 +588,21 @@ def launch_modeler_with_geometry_service( server_working_dir : str | Path, optional Sets the working directory for the product instance. If nothing is defined, the working directory will be inherited from the parent process. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls" + uds_dir : Path | str | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use "aposdas_socket.sock". + Otherwise, the socket filename will be "aposdas_socket-.sock". + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. product_version: int, optional The product version to be started. Deprecated, use `version` instead. **kwargs : dict, default: None @@ -621,6 +661,10 @@ def launch_modeler_with_geometry_service( server_logs_folder=server_logs_folder, client_log_file=client_log_file, server_working_dir=server_working_dir, + transport_mode=transport_mode, + uds_dir=uds_dir, + uds_id=uds_id, + certs_dir=certs_dir, product_version=product_version, ) @@ -639,6 +683,10 @@ def launch_modeler_with_discovery( client_log_level: int = logging.INFO, client_log_file: str = None, server_working_dir: str | Path | None = None, + transport_mode: str | None = None, + uds_dir: Path | str | None = None, + uds_id: str | None = None, + certs_dir: Path | str | None = None, product_version: int = None, # DEPRECATED: use `version` instead **kwargs: dict | None, ): @@ -694,6 +742,21 @@ def launch_modeler_with_discovery( server_working_dir : str | Path, optional Sets the working directory for the product instance. If nothing is defined, the working directory will be inherited from the parent process. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls" + uds_dir : Path | str | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use "aposdas_socket.sock". + Otherwise, the socket filename will be "aposdas_socket-.sock". + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. product_version: int, optional The product version to be started. Deprecated, use `version` instead. **kwargs : dict, default: None @@ -747,6 +810,10 @@ def launch_modeler_with_discovery( client_log_level=client_log_level, client_log_file=client_log_file, server_working_dir=server_working_dir, + transport_mode=transport_mode, + uds_dir=uds_dir, + uds_id=uds_id, + certs_dir=certs_dir, product_version=product_version, ) @@ -765,6 +832,10 @@ def launch_modeler_with_spaceclaim( client_log_level: int = logging.INFO, client_log_file: str = None, server_working_dir: str | Path | None = None, + transport_mode: str | None = None, + uds_dir: Path | str | None = None, + uds_id: str | None = None, + certs_dir: Path | str | None = None, product_version: int = None, # DEPRECATED: use `version` instead **kwargs: dict | None, ): @@ -820,6 +891,21 @@ def launch_modeler_with_spaceclaim( server_working_dir : str | Path, optional Sets the working directory for the product instance. If nothing is defined, the working directory will be inherited from the parent process. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls" + uds_dir : Path | str | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use "aposdas_socket.sock". + Otherwise, the socket filename will be "aposdas_socket-.sock". + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. product_version: int, optional The product version to be started. Deprecated, use `version` instead. **kwargs : dict, default: None @@ -873,6 +959,10 @@ def launch_modeler_with_spaceclaim( client_log_level=client_log_level, client_log_file=client_log_file, server_working_dir=server_working_dir, + transport_mode=transport_mode, + uds_dir=uds_dir, + uds_id=uds_id, + certs_dir=certs_dir, product_version=product_version, ) @@ -890,6 +980,10 @@ def launch_modeler_with_core_service( server_logs_folder: str = None, client_log_file: str = None, server_working_dir: str | Path | None = None, + transport_mode: str | None = None, + uds_dir: Path | str | None = None, + uds_id: str | None = None, + certs_dir: Path | str | None = None, product_version: int = None, # DEPRECATED: use `version` instead **kwargs: dict | None, ) -> "Modeler": @@ -943,6 +1037,21 @@ def launch_modeler_with_core_service( server_working_dir : str | Path, optional Sets the working directory for the product instance. If nothing is defined, the working directory will be inherited from the parent process. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls" + uds_dir : Path | str | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use "aposdas_socket.sock". + Otherwise, the socket filename will be "aposdas_socket-.sock". + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. product_version: int, optional The product version to be started. Deprecated, use `version` instead. **kwargs : dict, default: None @@ -1001,6 +1110,10 @@ def launch_modeler_with_core_service( server_logs_folder=server_logs_folder, client_log_file=client_log_file, server_working_dir=server_working_dir, + transport_mode=transport_mode, + uds_dir=uds_dir, + uds_id=uds_id, + certs_dir=certs_dir, specific_minimum_version=252, product_version=product_version, ) diff --git a/src/ansys/geometry/core/connection/product_instance.py b/src/ansys/geometry/core/connection/product_instance.py index 13f33a6bde..2cd1b34d25 100644 --- a/src/ansys/geometry/core/connection/product_instance.py +++ b/src/ansys/geometry/core/connection/product_instance.py @@ -32,6 +32,7 @@ import subprocess # nosec B404 from typing import TYPE_CHECKING +from ansys.tools.common.cyberchannel import verify_transport_mode, verify_uds_socket from ansys.tools.common.path import get_available_ansys_installations, get_latest_ansys_installation from ansys.geometry.core.connection.backend import ApiVersions, BackendType @@ -189,6 +190,10 @@ def prepare_and_start_backend( client_log_level: int = logging.INFO, server_logs_folder: str = None, client_log_file: str = None, + transport_mode: str | None = None, + uds_dir: Path | str | None = None, + uds_id: str | None = None, + certs_dir: Path | str | None = None, specific_minimum_version: int = None, server_working_dir: str | Path | None = None, product_version: int | None = None, # Deprecated, use `version` instead. @@ -249,6 +254,21 @@ def prepare_and_start_backend( server_working_dir : str | Path, optional Sets the working directory for the product instance. If nothing is defined, the working directory will be inherited from the parent process. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls" + uds_dir : Path | str | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use "aposdas_socket.sock". + Otherwise, the socket filename will be "aposdas_socket-.sock". + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. product_version: ``int``, optional The product version to be started. Deprecated, use `version` instead. @@ -510,18 +530,41 @@ def prepare_and_start_backend( f"Cannot connect to backend {backend_type.name} using ``prepare_and_start_backend()``" ) + # Handling transport mode conditions + exe_args, transport_values = _handle_transport_mode( + host=host, + transport_mode=transport_mode, + uds_dir=uds_dir, + uds_id=uds_id, + certs_dir=certs_dir, + ) + # HACK: This is a temporary hack to pass in the certs dir consistently. + # Versions 252 and before were not handling the --certs-dir argument properly, + # so we need to set it explicitly here as an environment variable. + # This can be removed in future versions. + # + # Assign environment variables as needed + if transport_values["certs_dir"]: + env_copy["ANSYS_GRPC_CERTIFICATES"] = certs_dir + + # On SpaceClaim and Discovery, we need to change the "--" to "/" + if backend_type in (BackendType.DISCOVERY, BackendType.SPACECLAIM): + exe_args = [arg.replace("--", "/") for arg in exe_args] + LOG.info(f"Launching ProductInstance for {backend_type.name}") LOG.debug(f"Args: {args}") + LOG.debug(f"Exe args: {exe_args}") + LOG.debug(f"Transport mode values: {transport_values}") LOG.debug(f"Environment variables: {env_copy}") instance = ProductInstance( - __start_program(args, env_copy, server_working_dir=server_working_dir).pid + __start_program(args, exe_args, env_copy, server_working_dir=server_working_dir).pid ) # Verify that the backend is ready to accept connections # before returning the Modeler instance. LOG.info("Waiting for backend to be ready...") - _wait_for_backend(host, port, timeout) + _wait_for_backend(host, port, timeout, transport_values) return Modeler( host=host, @@ -530,6 +573,10 @@ def prepare_and_start_backend( product_instance=instance, logging_level=client_log_level, logging_file=client_log_file, + transport_mode=transport_values["transport_mode"], + uds_id=transport_values["uds_id"], + uds_dir=transport_values["uds_dir"], + certs_dir=transport_values["certs_dir"], ) @@ -548,7 +595,7 @@ def get_available_port() -> int: return port -def _wait_for_backend(host: str, port: int, timeout: int): +def _wait_for_backend(host: str, port: int, timeout: int, transport_values: dict[str, str]) -> None: """Check if the backend is ready to accept connections. Parameters @@ -559,18 +606,33 @@ def _wait_for_backend(host: str, port: int, timeout: int): The backend's port number. timeout : int The timeout in seconds. + transport_values : dict[str, str] + The transport mode values dictionary. """ import time start_time = time.time() while time.time() - start_time < timeout: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - if s.connect_ex((host, port)) == 0: + if transport_values["transport_mode"] == "uds": + # For UDS, we just check if the socket file exists. + if verify_uds_socket( + "aposdas_socket", transport_values["uds_dir"], transport_values["uds_id"] + ): LOG.debug("Backend is ready to accept connections.") return else: LOG.debug("Still waiting for backend to be ready... Retrying in 5 seconds.") time.sleep(5) + continue + # For other transport modes, we check if the port is open. + else: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex((host, port)) == 0: + LOG.debug("Backend is ready to accept connections.") + return + else: + LOG.debug("Still waiting for backend to be ready... Retrying in 5 seconds.") + time.sleep(5) raise ConnectionError("Timeout while waiting for backend to be ready.") @@ -622,6 +684,7 @@ def _manifest_path_provider( def __start_program( args: list[str], + exe_args: list[str], local_env: dict[str, str], server_working_dir: str | os.PathLike | None = None, ) -> subprocess.Popen: @@ -632,6 +695,8 @@ def __start_program( args : list[str] List of arguments to be passed to the program. The first list's item shall be the program path. + exe_args : list[str] + Additional command line arguments to be passed to the program. local_env : dict[str,str] Environment variables to be passed to the program. server_working_dir : str | Path, optional @@ -649,7 +714,7 @@ def __start_program( """ # private method and controlled input by library - excluding bandit check. return subprocess.Popen( # nosec B603 - args, + args + exe_args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -732,3 +797,130 @@ def _get_common_env( env_copy[BACKEND_LOG_LEVEL_VARIABLE] = "0" return env_copy + + +def _handle_transport_mode( + host: str, + transport_mode: str | None = None, + uds_dir: Path | str | None = None, + uds_id: str | None = None, + certs_dir: Path | str | None = None, +) -> tuple[list[str], dict[str, str], str]: + """Handle transport mode conditions. + + Parameters + ---------- + host : str + The backend's ip address. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls" + uds_dir : Path | str | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use "aposdas_socket.sock". + Otherwise, the socket filename will be "aposdas_socket-.sock". + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. + + Returns + ------- + tuple[str, dict[str, str]] + + """ + # Localhost addresses + loopback_localhosts = ("localhost", "127.0.0.1") + + # Command line arguments to be passed to the backend + exe_args = [] + + # If the transport mode is selected, simply use it... with caution. + if transport_mode is not None: + verify_transport_mode(transport_mode) + else: + # Select the transport mode based on the connection criteria. + # 1. if host is localhost.. either wnua or uds depending on the OS + if host in loopback_localhosts: + transport_mode = "wnua" if os.name == "nt" else "uds" + else: + # 2. if host is not localhost.. always default to mtls + transport_mode = "mtls" + + LOG.info( + f"Transport mode not specified. Selected '{transport_mode}'" + " based on connection criteria." + ) + + # If mtls is selected -- verify certs_dir + if transport_mode == "mtls": + # Share the certificates directory if needed + if certs_dir is None: + certs_dir_env = os.getenv("ANSYS_GRPC_CERTIFICATES", None) + if certs_dir_env is not None: + certs_dir = certs_dir_env + else: + certs_dir = Path.cwd() / "certs" + + if not Path(certs_dir).is_dir(): # pragma: no cover + raise RuntimeError( + "Transport mode 'mtls' was selected, but the expected" + f" certificates directory does not exist: {certs_dir}" + ) + LOG.info(f"Using certificates directory: {Path(certs_dir).resolve().as_posix()}") + + # Determine args to be passed to the backend + exe_args.append(f"--transport-mode={transport_mode}") + exe_args.append(f"--certs-dir={Path(certs_dir).resolve().as_posix()}") + elif transport_mode == "uds": + # UDS is only available for localhost connections + if host not in loopback_localhosts: + raise RuntimeError("Transport mode 'uds' is only available for localhost connections.") + # Share the uds_dir if needed + if uds_dir is None: + uds_dir = Path.home() / ".conn" + + # If the folder does not exist, create it + uds_dir.mkdir(parents=True, exist_ok=True) + + # Verify that the UDS file doesn't already exist + if verify_uds_socket("aposdas_socket", uds_dir, uds_id) is True: + raise RuntimeError("UDS socket file already exists.") + + # Determine args to be passed to the backend + exe_args.append(f"--transport-mode={transport_mode}") + exe_args.append(f"--uds-dir={Path(uds_dir).resolve().as_posix()}") + if uds_id is not None: + exe_args.append(f"--uds-id={uds_id}") + + elif transport_mode == "wnua": + # WNUA is only available on Windows for localhost connections + if os.name != "nt": # pragma: no cover + raise RuntimeError("Transport mode 'wnua' is only available on Windows.") + if host not in loopback_localhosts: + raise RuntimeError("Transport mode 'wnua' is only available for localhost connections.") + + # Determine args to be passed to the backend + exe_args.append(f"--transport-mode={transport_mode}") + + elif transport_mode == "insecure": + # Determine args to be passed to the backend + exe_args.append(f"--transport-mode={transport_mode}") + + else: # pragma: no cover + raise RuntimeError(f"Transport mode '{transport_mode}' is not recognized.") + + # Store the final transport values + transport_values = { + "transport_mode": transport_mode, + "uds_dir": uds_dir, + "uds_id": uds_id, + "certs_dir": certs_dir, + } + + # Return the args to be passed to the backend, and the transport values + return exe_args, transport_values diff --git a/src/ansys/geometry/core/modeler.py b/src/ansys/geometry/core/modeler.py index a604935708..aa6847c209 100644 --- a/src/ansys/geometry/core/modeler.py +++ b/src/ansys/geometry/core/modeler.py @@ -86,6 +86,21 @@ class Modeler: proto_version : str | None, default: None Protocol version to use for communication with the server. If None, v0 is used. Available versions are "v0", "v1", etc. + transport_mode : str | None + Transport mode selected, by default `None` and thus it will be selected + for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls" + uds_dir : Path | str | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use "aposdas_socket.sock". + Otherwise, the socket filename will be "aposdas_socket-.sock". + certs_dir : Path | str | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. """ def __init__( @@ -100,6 +115,10 @@ def __init__( logging_level: int = logging.INFO, logging_file: Path | str | None = None, proto_version: str | None = None, + transport_mode: str | None = None, + uds_dir: Path | str | None = None, + uds_id: str | None = None, + certs_dir: Path | str | None = None, ): """Initialize the ``Modeler`` class.""" from ansys.geometry.core.designer.geometry_commands import GeometryCommands @@ -115,6 +134,10 @@ def __init__( logging_level=logging_level, logging_file=logging_file, proto_version=proto_version, + transport_mode=transport_mode, + uds_dir=uds_dir, + uds_id=uds_id, + certs_dir=certs_dir, ) # Single design for the Modeler diff --git a/tests/conftest.py b/tests/conftest.py index ce5add55cf..52453fc41d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,6 +76,14 @@ def pytest_addoption(parser): help=("Specify the backend version to use for the tests. By default, 'latest'."), ) + parser.addoption( + "--transport-mode", + action="store", + default="default", + help=("Specify the transport mode to use for the tests. By default, 'default'."), + choices=("default", "insecure", "uds", "wnua", "mtls"), + ) + def pytest_configure(config): """Configure pytest with custom options.""" @@ -172,6 +180,16 @@ def proto_version(request): return value.lower() +@pytest.fixture(scope="session") +def transport_mode(request): + """Fixture to determine transport mode to be used.""" + + value: str = request.config.getoption("--transport-mode", default="default") + mode = None if value.lower() == "default" else value.lower() + + return mode + + @pytest.fixture def fake_record(): def inner_fake_record( diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index bb2f159fac..70de8a273c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -98,7 +98,7 @@ def skip_if_discovery(modeler: Modeler, test_name: str, element_not_available: s @pytest.fixture(scope="session") -def docker_instance(use_existing_service): +def docker_instance(use_existing_service, transport_mode): # This will only have a value in case that: # # 0) The "--use-existing-service=yes" option is not used @@ -152,13 +152,14 @@ def docker_instance(use_existing_service): connect_to_existing_service=True, restart_if_existing_service=True, image=is_image_available_cont, + transport_mode=transport_mode, ) return docker_instance @pytest.fixture(scope="session") -def session_modeler(docker_instance, proto_version): +def session_modeler(docker_instance, proto_version, transport_mode): # Log to file - accepts str or Path objects, Path is passed for testing/coverage purposes. log_file_path = Path(__file__).absolute().parent / "logs" / "integration_tests_logs.txt" @@ -172,6 +173,7 @@ def session_modeler(docker_instance, proto_version): logging_level=logging.DEBUG, logging_file=log_file_path, proto_version=proto_version, + transport_mode=transport_mode, ) yield modeler diff --git a/tests/integration/test_launcher_local.py b/tests/integration/test_launcher_local.py index 240fd921a8..68247e3f81 100644 --- a/tests/integration/test_launcher_local.py +++ b/tests/integration/test_launcher_local.py @@ -78,7 +78,10 @@ def test_if_docker_is_installed(): @pytest.mark.skipif(SKIP_DOCKER_TESTS_CONDITION[0], reason=SKIP_DOCKER_TESTS_CONDITION[1]) def test_local_launcher_connect( - modeler: Modeler, caplog: pytest.LogCaptureFixture, docker_instance: LocalDockerInstance + modeler: Modeler, + caplog: pytest.LogCaptureFixture, + docker_instance: LocalDockerInstance, + transport_mode: str, ): """Checking connection to existing service using launch modeler.""" if not docker_instance: @@ -94,7 +97,10 @@ def test_local_launcher_connect( # Connect to the existing target... this will throw a warning local_modeler = launch_docker_modeler( - port=port, connect_to_existing_service=True, restart_if_existing_service=False + port=port, + connect_to_existing_service=True, + restart_if_existing_service=False, + transport_mode=transport_mode, ) assert _check_service_already_running(port, caplog.text) is True caplog.clear() @@ -108,7 +114,10 @@ def test_local_launcher_connect( # @pytest.mark.skipif(SKIP_DOCKER_TESTS_CONDITION[0], reason=SKIP_DOCKER_TESTS_CONDITION[1]) @pytest.mark.skip(reason="Temporary skip due to transport mode requirements") def test_local_launcher_connect_with_restart( - modeler: Modeler, caplog: pytest.LogCaptureFixture, docker_instance: LocalDockerInstance + modeler: Modeler, + caplog: pytest.LogCaptureFixture, + docker_instance: LocalDockerInstance, + transport_mode: str, ): """Checking connection to existing service using launch modeler and restarting existing service. @@ -130,6 +139,7 @@ def test_local_launcher_connect_with_restart( connect_to_existing_service=True, restart_if_existing_service=False, image=image, + transport_mode=transport_mode, ) # Check that the warning is NOT raised @@ -142,6 +152,7 @@ def test_local_launcher_connect_with_restart( connect_to_existing_service=True, restart_if_existing_service=True, image=image, + transport_mode=transport_mode, ) # Check that the warnings are raised