Skip to content

Commit 77fc09b

Browse files
committed
feat: secure grpc connection
1 parent 3a535c7 commit 77fc09b

File tree

7 files changed

+514
-21
lines changed

7 files changed

+514
-21
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,7 @@ docker/*-binaries.zip
167167
# Ignore scripts folder
168168
scripts
169169

170+
# Ignore certs folder
171+
certs
172+
170173
# End of https://www.toptal.com/developers/gitignore/api/python

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ classifiers = [
2727

2828
dependencies = [
2929
"ansys-api-discovery==1.0.9",
30-
"ansys-tools-common>=0.2,<1",
30+
"ansys-tools-common>=0.3,<1",
3131
"beartype>=0.11.0,<0.23",
3232
"geomdl>=5,<6",
3333
"grpcio>=1.35.0,<2",
@@ -97,7 +97,7 @@ tests = [
9797
]
9898
general-all = [
9999
"ansys-platform-instancemanagement==1.1.2",
100-
"ansys-tools-common==0.2.1",
100+
"ansys-tools-common==0.3.0",
101101
"ansys-tools-visualization-interface==0.12.1",
102102
"beartype==0.22.5",
103103
"docker==7.1.0",

src/ansys/geometry/core/connection/client.py

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,35 @@
4848
pass
4949

5050

51-
def _create_geometry_channel(target: str) -> grpc.Channel:
51+
def _create_geometry_channel(
52+
target: str,
53+
transport_mode: str | None = None,
54+
uds_dir: Path | str | None = None,
55+
uds_id: str | None = None,
56+
certs_dir: Path | str | None = None,
57+
) -> grpc.Channel:
5258
"""Create a Geometry service gRPC channel.
5359
5460
Parameters
5561
----------
5662
target : str
5763
Target of the channel. This is usually a string in the form of
5864
``host:port``.
65+
transport_mode : str | None
66+
Transport mode selected, by default `None` and thus it will be selected
67+
for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls"
68+
uds_dir : Path | str | None
69+
Directory to use for Unix Domain Sockets (UDS) transport mode.
70+
By default `None` and thus it will use the "~/.conn" folder.
71+
uds_id : str | None
72+
Optional ID to use for the UDS socket filename.
73+
By default `None` and thus it will use "aposdas_socket.sock".
74+
Otherwise, the socket filename will be "aposdas_socket-<uds_id>.sock".
75+
certs_dir : Path | str | None
76+
Directory to use for TLS certificates.
77+
By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable.
78+
If not found, it will use the "certs" folder assuming it is in the current working
79+
directory.
5980
6081
Returns
6182
-------
@@ -66,16 +87,38 @@ def _create_geometry_channel(target: str) -> grpc.Channel:
6687
-----
6788
Contains specific options for the Geometry service.
6889
"""
69-
return grpc.insecure_channel(
70-
target,
71-
options=[
72-
("grpc.max_receive_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH),
73-
("grpc.max_send_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH),
74-
],
90+
from ansys.tools.common.cyberchannel import create_channel
91+
92+
# Split target into host and port
93+
host, port = target.split(":")
94+
95+
# Add specific gRPC options for the Geometry service
96+
grpc_options = [
97+
("grpc.max_receive_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH),
98+
("grpc.max_send_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH),
99+
]
100+
101+
# Create the channel accordingly
102+
return create_channel(
103+
host=host,
104+
port=port,
105+
transport_mode=transport_mode,
106+
uds_service="aposdas_socket",
107+
uds_dir=uds_dir,
108+
uds_id=uds_id,
109+
certs_dir=certs_dir,
110+
grpc_options=grpc_options,
75111
)
76112

77113

78-
def wait_until_healthy(channel: grpc.Channel | str, timeout: float) -> grpc.Channel:
114+
def wait_until_healthy(
115+
channel: grpc.Channel | str,
116+
timeout: float,
117+
transport_mode: str | None = None,
118+
uds_dir: Path | str | None = None,
119+
uds_id: str | None = None,
120+
certs_dir: Path | str | None = None,
121+
) -> grpc.Channel:
79122
"""Wait until a channel is healthy before returning.
80123
81124
Parameters
@@ -93,6 +136,21 @@ def wait_until_healthy(channel: grpc.Channel | str, timeout: float) -> grpc.Chan
93136
is made with the remaining time.
94137
* If the total elapsed time exceeds the value for the ``timeout`` parameter,
95138
a ``TimeoutError`` is raised.
139+
transport_mode : str | None
140+
Transport mode selected, by default `None` and thus it will be selected
141+
for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls"
142+
uds_dir : Path | str | None
143+
Directory to use for Unix Domain Sockets (UDS) transport mode.
144+
By default `None` and thus it will use the "~/.conn" folder.
145+
uds_id : str | None
146+
Optional ID to use for the UDS socket filename.
147+
By default `None` and thus it will use "aposdas_socket.sock".
148+
Otherwise, the socket filename will be "aposdas_socket-<uds_id>.sock".
149+
certs_dir : Path | str | None
150+
Directory to use for TLS certificates.
151+
By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable.
152+
If not found, it will use the "certs" folder assuming it is in the current working
153+
directory.
96154
97155
Returns
98156
-------
@@ -115,7 +173,15 @@ def wait_until_healthy(channel: grpc.Channel | str, timeout: float) -> grpc.Chan
115173
while time.time() < t_max:
116174
try:
117175
tmp_channel = (
118-
_create_geometry_channel(channel) if channel_creation_required else channel
176+
_create_geometry_channel(
177+
channel,
178+
transport_mode=transport_mode,
179+
uds_dir=uds_dir,
180+
uds_id=uds_id,
181+
certs_dir=certs_dir,
182+
)
183+
if channel_creation_required
184+
else channel
119185
)
120186
health_stub = health_pb2_grpc.HealthStub(tmp_channel)
121187
request = health_pb2.HealthCheckRequest(service="")
@@ -179,6 +245,21 @@ class GrpcClient:
179245
proto_version : str | None, default: None
180246
Protocol version to use for communication with the server. If None, v0 is used.
181247
Available versions are "v0", "v1", etc.
248+
transport_mode : str | None
249+
Transport mode selected, by default `None` and thus it will be selected
250+
for you based on the connection criteria. Options are: "insecure", "uds", "wnua", "mtls"
251+
uds_dir : Path | str | None
252+
Directory to use for Unix Domain Sockets (UDS) transport mode.
253+
By default `None` and thus it will use the "~/.conn" folder.
254+
uds_id : str | None
255+
Optional ID to use for the UDS socket filename.
256+
By default `None` and thus it will use "aposdas_socket.sock".
257+
Otherwise, the socket filename will be "aposdas_socket-<uds_id>.sock".
258+
certs_dir : Path | str | None
259+
Directory to use for TLS certificates.
260+
By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable.
261+
If not found, it will use the "certs" folder assuming it is in the current working
262+
directory.
182263
"""
183264

184265
@check_input_types
@@ -194,6 +275,10 @@ def __init__(
194275
logging_level: int = logging.INFO,
195276
logging_file: Path | str | None = None,
196277
proto_version: str | None = None,
278+
transport_mode: str | None = None,
279+
uds_dir: Path | str | None = None,
280+
uds_id: str | None = None,
281+
certs_dir: Path | str | None = None,
197282
):
198283
"""Initialize the ``GrpcClient`` object."""
199284
self._closed = False
@@ -208,7 +293,19 @@ def __init__(
208293
self._channel = wait_until_healthy(channel, self._grpc_health_timeout)
209294
else:
210295
self._target = f"{host}:{port}"
211-
self._channel = wait_until_healthy(self._target, self._grpc_health_timeout)
296+
self._channel = wait_until_healthy(
297+
self._target,
298+
self._grpc_health_timeout,
299+
transport_mode=transport_mode,
300+
uds_dir=uds_dir,
301+
uds_id=uds_id,
302+
certs_dir=certs_dir,
303+
)
304+
305+
# HACK: If we are using UDS, the target needs to be updated to reflect
306+
# the actual socket file being used.
307+
if transport_mode == "uds":
308+
self._target = self._channel._channel.target().decode()
212309

213310
# Initialize the gRPC services
214311
self._services = _GRPCServices(self._channel, version=proto_version)

src/ansys/geometry/core/connection/docker_instance.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from enum import Enum
2525
from functools import wraps
2626
import os
27+
from pathlib import Path
2728
from typing import Optional
2829

2930
from beartype import beartype as check_input_types
@@ -37,6 +38,7 @@
3738
except ModuleNotFoundError: # pragma: no cover
3839
_HAS_DOCKER = False
3940

41+
from ansys.tools.common.cyberchannel import verify_transport_mode
4042
import ansys.geometry.core.connection.defaults as pygeom_defaults
4143
from ansys.geometry.core.logger import LOG
4244

@@ -106,6 +108,14 @@ class LocalDockerInstance:
106108
in which case the ``LocalDockerInstance`` class identifies the OS of your
107109
Docker engine and deploys the latest version of the Geometry service for that
108110
OS.
111+
transport_mode : str | None
112+
Transport mode selected, by default `None` and thus it will be selected
113+
for you based on the connection criteria. Options are: "insecure", "mtls"
114+
certs_dir : Path | str | None
115+
Directory to use for TLS certificates.
116+
By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable.
117+
If not found, it will use the "certs" folder assuming it is in the current working
118+
directory.
109119
"""
110120

111121
__DOCKER_CLIENT__: "DockerClient" = None
@@ -164,6 +174,8 @@ def __init__(
164174
restart_if_existing_service: bool = False,
165175
name: str | None = None,
166176
image: GeometryContainers | None = None,
177+
transport_mode: str | None = None,
178+
certs_dir: Path | str | None = None,
167179
) -> None:
168180
"""``LocalDockerInstance`` constructor."""
169181
# Initialize instance variables
@@ -190,7 +202,13 @@ def __init__(
190202
#
191203
# First, check if the port is available... otherwise raise error
192204
if port_available:
193-
self._deploy_container(port=port, name=name, image=image)
205+
self._deploy_container(
206+
port=port,
207+
name=name,
208+
image=image,
209+
transport_mode=transport_mode,
210+
certs_dir=certs_dir,
211+
)
194212
else:
195213
raise RuntimeError(f"Geometry service cannot be deployed on port {port}")
196214

@@ -245,7 +263,14 @@ def _is_cont_geom_service(self, cont: "Container") -> bool:
245263
# If you have reached this point, the image is not a Geometry service
246264
return False # pragma: no cover
247265

248-
def _deploy_container(self, port: int, name: str | None, image: GeometryContainers | None):
266+
def _deploy_container(
267+
self,
268+
port: int,
269+
name: str | None,
270+
image: GeometryContainers | None,
271+
transport_mode: str | None,
272+
certs_dir: Path | str | None,
273+
) -> None:
249274
"""Handle the deployment of a Geometry service.
250275
251276
Parameters
@@ -258,6 +283,14 @@ def _deploy_container(self, port: int, name: str | None, image: GeometryContaine
258283
image : GeometryContainers or None
259284
Geometry service Docker container image to be used. If ``None``, the
260285
latest container version matching
286+
transport_mode : str | None
287+
Transport mode selected, by default `None` and thus it will be selected
288+
for you based on the connection criteria. Options are: "insecure", "mtls"
289+
certs_dir : Path | str | None
290+
Directory to use for TLS certificates.
291+
By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment
292+
variable. If not found, it will use the "certs" folder assuming it is in the
293+
current working directory.
261294
262295
Raises
263296
------
@@ -292,6 +325,37 @@ def _deploy_container(self, port: int, name: str | None, image: GeometryContaine
292325
"No license server provided... Store its value under the following env variable: ANSRV_GEO_LICENSE_SERVER." # noqa: E501
293326
)
294327

328+
# Verify the transport mode
329+
if transport_mode:
330+
verify_transport_mode(transport_mode, "remote")
331+
else:
332+
# Default to "mtls" if possible
333+
transport_mode = "mtls"
334+
LOG.info(f"No transport mode provided. Defaulting to '{transport_mode}'")
335+
336+
# Create the shared volume if mtls is selected
337+
volumes = None
338+
if transport_mode == "mtls":
339+
# Share the certificates directory if needed
340+
if certs_dir is None:
341+
certs_dir_env = os.getenv("ANSYS_GRPC_CERTIFICATES", None)
342+
if certs_dir_env is not None:
343+
certs_dir = certs_dir_env
344+
else:
345+
certs_dir = Path.cwd() / "certs"
346+
347+
if not Path(certs_dir).is_dir(): # pragma: no cover
348+
raise RuntimeError(
349+
"Transport mode 'mtls' was selected, but the expected"
350+
f" certificates directory does not exist: {certs_dir}"
351+
)
352+
volumes = {
353+
str(Path(certs_dir).resolve()): {
354+
"bind": "/certs" if image.value[1] == "linux" else "C:/certs",
355+
"mode": "ro",
356+
}
357+
}
358+
295359
# Try to deploy it
296360
try:
297361
container: Container = self.docker_client().containers.run(
@@ -300,12 +364,17 @@ def _deploy_container(self, port: int, name: str | None, image: GeometryContaine
300364
auto_remove=True,
301365
name=name,
302366
ports={"50051/tcp": port},
367+
volumes=volumes,
303368
environment={
304369
"LICENSE_SERVER": license_server,
305370
"LOG_LEVEL": os.getenv("ANSRV_GEO_LOG_LEVEL", 2),
306371
"ENABLE_TRACE": os.getenv("ANSRV_GEO_ENABLE_TRACE", 0),
307372
"USE_DEBUG_MODE": os.getenv("ANSRV_GEO_USE_DEBUG_MODE", 0),
373+
"ANSYS_GRPC_CERTIFICATES": "/certs"
374+
if image.value[1] == "linux"
375+
else "C:/certs",
308376
},
377+
command=f"--transport-mode={transport_mode}",
309378
)
310379
except ImageNotFound: # pragma: no cover
311380
raise RuntimeError(

0 commit comments

Comments
 (0)