ansys-saf-testing 0.11.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. ansys/saf/testing/__init__.py +22 -0
  2. ansys/saf/testing/_common/__init__.py +14 -0
  3. ansys/saf/testing/_common/common.py +46 -0
  4. ansys/saf/testing/_common/directories.py +56 -0
  5. ansys/saf/testing/_common/grpc_certificates.py +391 -0
  6. ansys/saf/testing/_common/network.py +114 -0
  7. ansys/saf/testing/_common/py.typed +0 -0
  8. ansys/saf/testing/_database/__init__.py +14 -0
  9. ansys/saf/testing/_database/postgresql.py +136 -0
  10. ansys/saf/testing/_database/psql_container_manager.py +139 -0
  11. ansys/saf/testing/_database/py.typed +0 -0
  12. ansys/saf/testing/_database/sqlite.py +29 -0
  13. ansys/saf/testing/_docker/__init__.py +14 -0
  14. ansys/saf/testing/_docker/docker.py +55 -0
  15. ansys/saf/testing/_docker/py.typed +0 -0
  16. ansys/saf/testing/_frontend/__init__.py +14 -0
  17. ansys/saf/testing/_frontend/py.typed +0 -0
  18. ansys/saf/testing/_frontend/selenium.py +219 -0
  19. ansys/saf/testing/_hps/__init__.py +14 -0
  20. ansys/saf/testing/_hps/hps.py +226 -0
  21. ansys/saf/testing/_hps/hps_client.py +127 -0
  22. ansys/saf/testing/_hps/process/__init__.py +14 -0
  23. ansys/saf/testing/_hps/process/hps_deployment_process.py +72 -0
  24. ansys/saf/testing/_hps/process/hps_scaler_process.py +122 -0
  25. ansys/saf/testing/_hps/process/py.typed +0 -0
  26. ansys/saf/testing/_hps/py.typed +0 -0
  27. ansys/saf/testing/_hps/scripts/__init__.py +14 -0
  28. ansys/saf/testing/_hps/scripts/hps_installer.py +576 -0
  29. ansys/saf/testing/_hps/scripts/keycloak/.env +24 -0
  30. ansys/saf/testing/_hps/scripts/keycloak/Dockerfile +14 -0
  31. ansys/saf/testing/_hps/scripts/keycloak/job-action-token-1.2.0.jar +0 -0
  32. ansys/saf/testing/_hps/scripts/keycloak/service.yaml +50 -0
  33. ansys/saf/testing/_hps/scripts/launch_hps_stack.py +61 -0
  34. ansys/saf/testing/_hps/scripts/py.typed +0 -0
  35. ansys/saf/testing/_pim/__init__.py +14 -0
  36. ansys/saf/testing/_pim/pim.py +163 -0
  37. ansys/saf/testing/_pim/process/__init__.py +14 -0
  38. ansys/saf/testing/_pim/process/pim_process.py +256 -0
  39. ansys/saf/testing/_pim/process/py.typed +0 -0
  40. ansys/saf/testing/_pim/py.typed +0 -0
  41. ansys/saf/testing/_pim/scripts/__init__.py +14 -0
  42. ansys/saf/testing/_pim/scripts/launch_pim.py +36 -0
  43. ansys/saf/testing/_pim/scripts/py.typed +0 -0
  44. ansys/saf/testing/_pytest/__init__.py +14 -0
  45. ansys/saf/testing/_pytest/docstring_with_test_case.py +36 -0
  46. ansys/saf/testing/_pytest/mocking.py +30 -0
  47. ansys/saf/testing/_pytest/platform_specific.py +70 -0
  48. ansys/saf/testing/_pytest/py.typed +0 -0
  49. ansys/saf/testing/_pytest/pytest.py +47 -0
  50. ansys/saf/testing/_solution/__init__.py +14 -0
  51. ansys/saf/testing/_solution/common.py +63 -0
  52. ansys/saf/testing/_solution/const.py +32 -0
  53. ansys/saf/testing/_solution/end_to_end/__init__.py +14 -0
  54. ansys/saf/testing/_solution/end_to_end/container/Dockerfile +52 -0
  55. ansys/saf/testing/_solution/end_to_end/container/docker-compose.yaml +93 -0
  56. ansys/saf/testing/_solution/end_to_end/glow_client.py +189 -0
  57. ansys/saf/testing/_solution/end_to_end/glow_execution_configurations.py +1363 -0
  58. ansys/saf/testing/_solution/end_to_end/glow_process.py +1509 -0
  59. ansys/saf/testing/_solution/end_to_end/glow_server.py +750 -0
  60. ansys/saf/testing/_solution/end_to_end/log_manager.py +127 -0
  61. ansys/saf/testing/_solution/end_to_end/project_context.py +152 -0
  62. ansys/saf/testing/_solution/end_to_end/py.typed +0 -0
  63. ansys/saf/testing/_solution/py.typed +0 -0
  64. ansys/saf/testing/_solution/solution.py +407 -0
  65. ansys/saf/testing/_system/__init__.py +14 -0
  66. ansys/saf/testing/_system/process.py +242 -0
  67. ansys/saf/testing/_system/py.typed +0 -0
  68. ansys/saf/testing/common/__init__.py +18 -0
  69. ansys/saf/testing/common/py.typed +0 -0
  70. ansys/saf/testing/database/__init__.py +35 -0
  71. ansys/saf/testing/database/py.typed +0 -0
  72. ansys/saf/testing/directories/__init__.py +23 -0
  73. ansys/saf/testing/directories/py.typed +0 -0
  74. ansys/saf/testing/docker/__init__.py +28 -0
  75. ansys/saf/testing/docker/py.typed +0 -0
  76. ansys/saf/testing/docstring_with_test_case/__init__.py +18 -0
  77. ansys/saf/testing/docstring_with_test_case/py.typed +0 -0
  78. ansys/saf/testing/hps/__init__.py +55 -0
  79. ansys/saf/testing/hps/process/__init__.py +22 -0
  80. ansys/saf/testing/hps/process/py.typed +0 -0
  81. ansys/saf/testing/hps/py.typed +0 -0
  82. ansys/saf/testing/hps/scripts/__init__.py +15 -0
  83. ansys/saf/testing/hps/scripts/hps_installer.py +26 -0
  84. ansys/saf/testing/hps/scripts/launch_hps_stack.py +19 -0
  85. ansys/saf/testing/hps/scripts/py.typed +0 -0
  86. ansys/saf/testing/mocking/__init__.py +18 -0
  87. ansys/saf/testing/mocking/py.typed +0 -0
  88. ansys/saf/testing/network/__init__.py +32 -0
  89. ansys/saf/testing/network/py.typed +0 -0
  90. ansys/saf/testing/pim/__init__.py +46 -0
  91. ansys/saf/testing/pim/process/__init__.py +21 -0
  92. ansys/saf/testing/pim/process/py.typed +0 -0
  93. ansys/saf/testing/pim/py.typed +0 -0
  94. ansys/saf/testing/pim/scripts/__init__.py +15 -0
  95. ansys/saf/testing/pim/scripts/launch_pim.py +20 -0
  96. ansys/saf/testing/pim/scripts/py.typed +0 -0
  97. ansys/saf/testing/platform_specific/__init__.py +42 -0
  98. ansys/saf/testing/platform_specific/py.typed +0 -0
  99. ansys/saf/testing/process/__init__.py +18 -0
  100. ansys/saf/testing/process/py.typed +0 -0
  101. ansys/saf/testing/py.typed +0 -0
  102. ansys/saf/testing/pytest/__init__.py +22 -0
  103. ansys/saf/testing/pytest/py.typed +0 -0
  104. ansys/saf/testing/selenium/__init__.py +58 -0
  105. ansys/saf/testing/selenium/py.typed +0 -0
  106. ansys/saf/testing/solution/__init__.py +58 -0
  107. ansys/saf/testing/solution/const/__init__.py +18 -0
  108. ansys/saf/testing/solution/const/py.typed +0 -0
  109. ansys/saf/testing/solution/end_to_end/__init__.py +263 -0
  110. ansys/saf/testing/solution/end_to_end/py.typed +0 -0
  111. ansys/saf/testing/solution/py.typed +0 -0
  112. ansys_saf_testing-0.11.dev0.dist-info/METADATA +230 -0
  113. ansys_saf_testing-0.11.dev0.dist-info/RECORD +117 -0
  114. ansys_saf_testing-0.11.dev0.dist-info/WHEEL +4 -0
  115. ansys_saf_testing-0.11.dev0.dist-info/entry_points.txt +17 -0
  116. ansys_saf_testing-0.11.dev0.dist-info/licenses/AUTHORS +12 -0
  117. ansys_saf_testing-0.11.dev0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,22 @@
1
+ # Copyright (C) 2026 ANSYS, Inc. and/or its affiliates.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ try:
18
+ import importlib.metadata as importlib_metadata
19
+ except ModuleNotFoundError:
20
+ import importlib_metadata # type: ignore
21
+
22
+ __version__ = importlib_metadata.version(f"{__name__.replace('.', '-')}") # type: ignore
@@ -0,0 +1,14 @@
1
+ # Copyright (C) 2026 ANSYS, Inc. and/or its affiliates.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
@@ -0,0 +1,46 @@
1
+ # Copyright (C) 2026 ANSYS, Inc. and/or its affiliates.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ from collections.abc import Generator
17
+ from importlib.util import find_spec
18
+ from pathlib import Path
19
+ import platform
20
+ from typing import TypeVar
21
+
22
+ F = TypeVar("F")
23
+ YieldFixture = Generator[F, None, None]
24
+
25
+
26
+ def find_exec_in_venv(venv_parent_dir: Path, exec_name: str, verify: bool = True) -> Path:
27
+ if platform.system() == "Windows":
28
+ exec_path = venv_parent_dir / ".venv" / "Scripts" / f"{exec_name}.exe"
29
+ if verify and not exec_path.is_file():
30
+ exec_path = venv_parent_dir / ".venv" / "Scripts" / f"{exec_name}.cmd"
31
+ else:
32
+ exec_path = venv_parent_dir / ".venv" / "bin" / exec_name
33
+ if verify and not exec_path.is_file():
34
+ raise FileNotFoundError(f"Executable {exec_name} not found in {venv_parent_dir}")
35
+ return exec_path
36
+
37
+
38
+ def has_modules_installed(modules: list[str]) -> bool:
39
+ """Return True if all modules in the list are importable via find_spec."""
40
+ for module in modules:
41
+ try:
42
+ if find_spec(module) is None:
43
+ return False
44
+ except (ImportError, ModuleNotFoundError):
45
+ return False
46
+ return True
@@ -0,0 +1,56 @@
1
+ # Copyright (C) 2026 ANSYS, Inc. and/or its affiliates.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ import os
17
+ from pathlib import Path
18
+
19
+ import pytest
20
+
21
+ from ansys.saf.testing._common.common import YieldFixture
22
+
23
+
24
+ @pytest.fixture
25
+ def mock_appdata(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
26
+ appdata = tmp_path / "appdata"
27
+ appdata.mkdir(exist_ok=True, parents=True)
28
+ monkeypatch.setenv("APPDATA", str(appdata))
29
+ monkeypatch.setenv("XDG_DATA_HOME", str(appdata))
30
+ return appdata
31
+
32
+
33
+ @pytest.fixture(scope="module")
34
+ def mock_module_appdata(tmp_path_factory: pytest.TempPathFactory, monkeysession: pytest.MonkeyPatch) -> Path:
35
+ appdata = tmp_path_factory.mktemp("appdata")
36
+ monkeysession.setenv("APPDATA", str(appdata))
37
+ monkeysession.setenv("XDG_DATA_HOME", str(appdata))
38
+ return appdata
39
+
40
+
41
+ @pytest.fixture(scope="session")
42
+ def mock_session_appdata(tmp_path_factory: pytest.TempPathFactory, monkeysession: pytest.MonkeyPatch) -> Path:
43
+ appdata = tmp_path_factory.mktemp("appdata")
44
+ monkeysession.setenv("APPDATA", str(appdata))
45
+ monkeysession.setenv("XDG_DATA_HOME", str(appdata))
46
+ return appdata
47
+
48
+
49
+ @pytest.fixture
50
+ def tmp_path_as_working_dir(tmp_path: Path, request: pytest.FixtureRequest) -> YieldFixture[Path]:
51
+ new_cwd = tmp_path if not hasattr(request, "param") else tmp_path / request.param
52
+ new_cwd.mkdir(exist_ok=True, parents=True)
53
+ original_path = Path.cwd()
54
+ os.chdir(new_cwd)
55
+ yield new_cwd
56
+ os.chdir(original_path)
@@ -0,0 +1,391 @@
1
+ # Copyright (C) 2026 ANSYS, Inc. and/or its affiliates.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """
17
+ Create certificates for gRPC mutual TLS testing.
18
+ This module can generate a complete set of certificates including:
19
+ - Certificate Authority (CA) key and certificate
20
+ - Server key and certificate(s) (signed by CA)
21
+ - Client key and certificate (signed by CA)
22
+ """
23
+
24
+ from datetime import datetime, timedelta, timezone
25
+ import ipaddress
26
+ import logging
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from cryptography import x509
31
+ from cryptography.hazmat.primitives import hashes, serialization
32
+ from cryptography.hazmat.primitives.asymmetric import rsa
33
+ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
34
+
35
+ _logger = logging.getLogger(__name__)
36
+
37
+
38
+ def generate_private_key(key_size: int = 4096) -> rsa.RSAPrivateKey:
39
+ """
40
+ Generate an RSA private key.
41
+ Parameters
42
+ ----------
43
+ key_size : int, optional
44
+ Size of the RSA key in bits, by default 4096
45
+ Returns
46
+ -------
47
+ rsa.RSAPrivateKey
48
+ Generated RSA private key
49
+ """
50
+ return rsa.generate_private_key(
51
+ public_exponent=65537,
52
+ key_size=key_size,
53
+ )
54
+
55
+
56
+ def save_private_key(key: rsa.RSAPrivateKey, filename: Path) -> None:
57
+ """
58
+ Save a private key to a PEM file.
59
+ Parameters
60
+ ----------
61
+ key : rsa.RSAPrivateKey
62
+ The private key to save
63
+ filename : str
64
+ Path to the output file
65
+ """
66
+ filename.write_bytes(
67
+ key.private_bytes(
68
+ encoding=serialization.Encoding.PEM,
69
+ format=serialization.PrivateFormat.PKCS8,
70
+ encryption_algorithm=serialization.NoEncryption(),
71
+ ),
72
+ )
73
+
74
+
75
+ def save_certificate(cert: x509.Certificate, filename: Path) -> None:
76
+ """
77
+ Save a certificate to a PEM file.
78
+ Parameters
79
+ ----------
80
+ cert : x509.Certificate
81
+ The certificate to save
82
+ filename : str
83
+ Path to the output file
84
+ """
85
+ filename.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
86
+
87
+
88
+ def create_ca_certificate(ca_key: rsa.RSAPrivateKey, validity_days: int) -> x509.Certificate:
89
+ """
90
+ Create a self-signed CA certificate.
91
+ Parameters
92
+ ----------
93
+ ca_key : rsa.RSAPrivateKey
94
+ The private key for the CA certificate
95
+ validity_days : int
96
+ Number of days the certificate should be valid
97
+ Returns
98
+ -------
99
+ x509.Certificate
100
+ Self-signed CA certificate with appropriate extensions for certificate signing
101
+ """
102
+ subject = issuer = x509.Name(
103
+ [
104
+ x509.NameAttribute(NameOID.COMMON_NAME, "Test CA"),
105
+ ],
106
+ )
107
+
108
+ cert = (
109
+ x509.CertificateBuilder()
110
+ .subject_name(subject)
111
+ .issuer_name(issuer)
112
+ .public_key(ca_key.public_key())
113
+ .serial_number(x509.random_serial_number())
114
+ .not_valid_before(datetime.now(timezone.utc))
115
+ .not_valid_after(datetime.now(timezone.utc) + timedelta(days=validity_days))
116
+ .add_extension(
117
+ x509.BasicConstraints(ca=True, path_length=None),
118
+ critical=True,
119
+ )
120
+ .add_extension(
121
+ x509.KeyUsage(
122
+ digital_signature=True,
123
+ key_cert_sign=True,
124
+ crl_sign=True,
125
+ key_encipherment=False,
126
+ data_encipherment=False,
127
+ key_agreement=False,
128
+ content_commitment=False,
129
+ encipher_only=False,
130
+ decipher_only=False,
131
+ ),
132
+ critical=True,
133
+ )
134
+ .sign(ca_key, hashes.SHA256())
135
+ )
136
+
137
+ return cert
138
+
139
+
140
+ def create_server_certificate(
141
+ server_key: rsa.RSAPrivateKey,
142
+ ca_cert: x509.Certificate,
143
+ ca_key: rsa.RSAPrivateKey,
144
+ server_common_name: str,
145
+ validity_days: int,
146
+ san_names: list[str] | None = None,
147
+ ) -> x509.Certificate:
148
+ """
149
+ Create a server certificate signed by the CA with optional Subject Alternative Names.
150
+ Parameters
151
+ ----------
152
+ server_key : rsa.RSAPrivateKey
153
+ The private key for the server certificate
154
+ ca_cert : x509.Certificate
155
+ The CA certificate to use as issuer
156
+ ca_key : rsa.RSAPrivateKey
157
+ The CA private key to sign the certificate
158
+ server_common_name : str
159
+ The common name for the server certificate (will be used as CN and primary SAN)
160
+ validity_days : int
161
+ Number of days the certificate should be valid
162
+ san_names : list, optional
163
+ Additional Subject Alternative Names to include, by default None
164
+ Returns
165
+ -------
166
+ x509.Certificate
167
+ Server certificate signed by the CA with SERVER_AUTH extended key usage
168
+ """
169
+ subject = x509.Name(
170
+ [
171
+ x509.NameAttribute(NameOID.COMMON_NAME, server_common_name),
172
+ ],
173
+ )
174
+
175
+ # Build SAN list - always include the CN, plus any additional names
176
+ san_list = [x509.DNSName(server_common_name)]
177
+ if san_names:
178
+ for name in san_names:
179
+ # Skip if it's the same as CN to avoid duplicates
180
+ if name != server_common_name:
181
+ try:
182
+ # Try to parse as IP address
183
+ ip_addr = ipaddress.ip_address(name)
184
+ san_list.append(x509.IPAddress(ip_addr)) # type: ignore
185
+ _logger.info(" Added IP SAN: %s", name)
186
+ except ValueError:
187
+ # Not an IP, treat as DNS name
188
+ san_list.append(x509.DNSName(name))
189
+ _logger.info(" Added DNS SAN: %s", name)
190
+
191
+ cert = (
192
+ x509.CertificateBuilder()
193
+ .subject_name(subject)
194
+ .issuer_name(ca_cert.subject)
195
+ .public_key(server_key.public_key())
196
+ .serial_number(x509.random_serial_number())
197
+ .not_valid_before(datetime.now(timezone.utc))
198
+ .not_valid_after(datetime.now(timezone.utc) + timedelta(days=validity_days))
199
+ .add_extension(
200
+ x509.SubjectAlternativeName(san_list),
201
+ critical=False,
202
+ )
203
+ .add_extension(
204
+ x509.KeyUsage(
205
+ digital_signature=True,
206
+ key_encipherment=True,
207
+ key_cert_sign=False,
208
+ crl_sign=False,
209
+ data_encipherment=False,
210
+ key_agreement=False,
211
+ content_commitment=False,
212
+ encipher_only=False,
213
+ decipher_only=False,
214
+ ),
215
+ critical=True,
216
+ )
217
+ .add_extension(
218
+ x509.ExtendedKeyUsage(
219
+ [
220
+ ExtendedKeyUsageOID.SERVER_AUTH,
221
+ ],
222
+ ),
223
+ critical=False,
224
+ )
225
+ .sign(ca_key, hashes.SHA256())
226
+ )
227
+
228
+ return cert
229
+
230
+
231
+ def create_client_certificate(
232
+ client_key: rsa.RSAPrivateKey,
233
+ ca_cert: x509.Certificate,
234
+ ca_key: rsa.RSAPrivateKey,
235
+ client_common_name: str,
236
+ validity_days: int,
237
+ ) -> x509.Certificate:
238
+ """
239
+ Create a client certificate signed by the CA.
240
+ Parameters
241
+ ----------
242
+ client_key : rsa.RSAPrivateKey
243
+ The private key for the client certificate
244
+ ca_cert : x509.Certificate
245
+ The CA certificate to use as issuer
246
+ ca_key : rsa.RSAPrivateKey
247
+ The CA private key to sign the certificate
248
+ client_common_name : str
249
+ The common name for the client certificate
250
+ validity_days : int
251
+ Number of days the certificate should be valid
252
+ Returns
253
+ -------
254
+ x509.Certificate
255
+ Client certificate signed by the CA with CLIENT_AUTH extended key usage
256
+ """
257
+ subject = x509.Name(
258
+ [
259
+ x509.NameAttribute(NameOID.COMMON_NAME, client_common_name),
260
+ ],
261
+ )
262
+
263
+ cert = (
264
+ x509.CertificateBuilder()
265
+ .subject_name(subject)
266
+ .issuer_name(ca_cert.subject)
267
+ .public_key(client_key.public_key())
268
+ .serial_number(x509.random_serial_number())
269
+ .not_valid_before(datetime.now(timezone.utc))
270
+ .not_valid_after(datetime.now(timezone.utc) + timedelta(days=validity_days))
271
+ .add_extension(
272
+ x509.SubjectAlternativeName(
273
+ [
274
+ x509.DNSName(client_common_name),
275
+ ],
276
+ ),
277
+ critical=False,
278
+ )
279
+ .add_extension(
280
+ x509.KeyUsage(
281
+ digital_signature=True,
282
+ key_encipherment=True,
283
+ key_cert_sign=False,
284
+ crl_sign=False,
285
+ data_encipherment=False,
286
+ key_agreement=False,
287
+ content_commitment=False,
288
+ encipher_only=False,
289
+ decipher_only=False,
290
+ ),
291
+ critical=True,
292
+ )
293
+ .add_extension(
294
+ x509.ExtendedKeyUsage(
295
+ [
296
+ ExtendedKeyUsageOID.CLIENT_AUTH,
297
+ ],
298
+ ),
299
+ critical=False,
300
+ )
301
+ .sign(ca_key, hashes.SHA256())
302
+ )
303
+
304
+ return cert
305
+
306
+
307
+ def parse_server_spec(server_spec: str) -> tuple[str, list[Any]]:
308
+ """
309
+ Parse a server specification string into primary hostname and SAN list.
310
+ Parameters
311
+ ----------
312
+ server_spec : str
313
+ A comma-separated string like "node01,192.0.2.1" or just "node01"
314
+ Returns
315
+ -------
316
+ tuple[str, list]
317
+ Tuple containing (primary_hostname, [additional_san_names])
318
+ Raises
319
+ ------
320
+ ValueError
321
+ If the server specification is empty or invalid
322
+ """
323
+ names = [name.strip() for name in server_spec.split(",") if name.strip()]
324
+ if not names:
325
+ raise ValueError("Server specification cannot be empty")
326
+
327
+ primary_hostname = names[0]
328
+ additional_sans = names[1:] if len(names) > 1 else []
329
+
330
+ return primary_hostname, additional_sans
331
+
332
+
333
+ def generate_server_certificates(
334
+ ca_cert: x509.Certificate,
335
+ ca_key: rsa.RSAPrivateKey,
336
+ server_specs: list[Any],
337
+ validity_days: int,
338
+ directory: str = ".",
339
+ ) -> list[str]:
340
+ """
341
+ Generate multiple server certificates based on server specifications.
342
+ Parameters
343
+ ----------
344
+ ca_cert : x509.Certificate
345
+ The CA certificate to sign with
346
+ ca_key : rsa.RSAPrivateKey
347
+ The CA private key to sign with
348
+ server_specs : list
349
+ List of server specification strings in format "hostname[,san1,san2,...]"
350
+ validity_days : int
351
+ Number of days the certificates should be valid
352
+ directory : str, optional
353
+ Directory to save the generated certificates and keys, by default "."
354
+ Returns
355
+ -------
356
+ list
357
+ List of generated certificate filenames
358
+ """
359
+ generated_files: list[str] = []
360
+
361
+ for spec in server_specs:
362
+ primary_hostname, additional_sans = parse_server_spec(spec)
363
+
364
+ # If only one server is specified, use 'server' as generic name
365
+ filename = "server" if len(server_specs) == 1 else primary_hostname
366
+
367
+ _logger.info("Generating server certificate for %s", primary_hostname)
368
+ if additional_sans:
369
+ _logger.info(" Additional SAN names: %s", ", ".join(additional_sans))
370
+
371
+ # Generate server key and certificate
372
+ server_key = generate_private_key()
373
+ server_cert = create_server_certificate(
374
+ server_key,
375
+ ca_cert,
376
+ ca_key,
377
+ primary_hostname,
378
+ validity_days,
379
+ additional_sans,
380
+ )
381
+
382
+ # Save with primary hostname as filename
383
+ key_filename = Path(directory) / f"{filename}.key"
384
+ cert_filename = Path(directory) / f"{filename}.crt"
385
+
386
+ save_private_key(server_key, key_filename)
387
+ save_certificate(server_cert, cert_filename)
388
+
389
+ generated_files.extend([key_filename.as_posix(), cert_filename.as_posix()])
390
+
391
+ return generated_files
@@ -0,0 +1,114 @@
1
+ # Copyright (C) 2026 ANSYS, Inc. and/or its affiliates.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ from pathlib import Path
17
+ import platform
18
+ import socket
19
+ import subprocess
20
+
21
+ import pytest
22
+
23
+ from ansys.saf.testing._common.grpc_certificates import (
24
+ create_ca_certificate,
25
+ create_client_certificate,
26
+ generate_private_key,
27
+ generate_server_certificates,
28
+ save_certificate,
29
+ save_private_key,
30
+ )
31
+ from ansys.saf.testing._solution.const import TestDeployment
32
+
33
+
34
+ def get_random_free_port() -> int:
35
+ """Return a free socket port. Handled by the OS."""
36
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
37
+ try:
38
+ sock.bind(("", 0))
39
+ return sock.getsockname()[1]
40
+ except OSError as err:
41
+ raise OSError("no free ports") from err
42
+
43
+
44
+ def get_local_ip() -> str:
45
+ """Return the unique ipv4 address of this node."""
46
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
47
+ s.connect(("8.8.8.8", 80))
48
+ ip = s.getsockname()[0]
49
+ s.close()
50
+ return ip
51
+
52
+
53
+ def get_docker_gateway_ip() -> str:
54
+ p = subprocess.run("ip route | awk '/docker0/ {print $9}'", capture_output=True, text=True, shell=True)
55
+ if p.returncode != 0:
56
+ raise Exception("Can't get default docker gateway IP")
57
+ return p.stdout.strip()
58
+
59
+
60
+ @pytest.fixture(scope="session")
61
+ def docker_gateway_ip(deployment_type: TestDeployment) -> str:
62
+ if deployment_type == TestDeployment.DockerCompose and platform.system() == "Linux":
63
+ return get_docker_gateway_ip()
64
+ else:
65
+ return "127.0.0.1"
66
+
67
+
68
+ @pytest.fixture(scope="session")
69
+ def local_ip() -> str:
70
+ return get_local_ip()
71
+
72
+
73
+ @pytest.fixture(scope="session")
74
+ def certificates_directory(
75
+ tmp_path_factory: pytest.TempPathFactory,
76
+ local_ip: str,
77
+ docker_gateway_ip: str,
78
+ hps_host: str,
79
+ ) -> Path:
80
+ """Create localhost server and client certificates signed by a local CA. The validity is one day."""
81
+
82
+ output_dir = tmp_path_factory.mktemp("certs-")
83
+
84
+ servers = [
85
+ f"localhost,127.0.0.1,{local_ip}",
86
+ ]
87
+ if docker_gateway_ip not in servers[0]:
88
+ servers[0] += f",{docker_gateway_ip},host.docker.internal"
89
+ if hps_host not in servers[0]:
90
+ servers[0] += f",{hps_host}"
91
+
92
+ days = 1
93
+ client_common_name = "client"
94
+
95
+ output_dir.mkdir(exist_ok=True)
96
+
97
+ # Generate CA key and certificate for self-signing
98
+ ca_key = generate_private_key()
99
+ ca_cert = create_ca_certificate(ca_key, days)
100
+
101
+ save_private_key(ca_key, output_dir / "ca.key")
102
+ save_certificate(ca_cert, output_dir / "ca.crt")
103
+
104
+ # Generate server certificates
105
+ generate_server_certificates(ca_cert, ca_key, servers, days, output_dir.as_posix())
106
+
107
+ # Generate client key and certificate
108
+ client_key = generate_private_key()
109
+ client_cert = create_client_certificate(client_key, ca_cert, ca_key, client_common_name, days)
110
+
111
+ save_private_key(client_key, output_dir / "client.key")
112
+ save_certificate(client_cert, output_dir / "client.crt")
113
+
114
+ return output_dir
File without changes
@@ -0,0 +1,14 @@
1
+ # Copyright (C) 2026 ANSYS, Inc. and/or its affiliates.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.