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.
- ansys/saf/testing/__init__.py +22 -0
- ansys/saf/testing/_common/__init__.py +14 -0
- ansys/saf/testing/_common/common.py +46 -0
- ansys/saf/testing/_common/directories.py +56 -0
- ansys/saf/testing/_common/grpc_certificates.py +391 -0
- ansys/saf/testing/_common/network.py +114 -0
- ansys/saf/testing/_common/py.typed +0 -0
- ansys/saf/testing/_database/__init__.py +14 -0
- ansys/saf/testing/_database/postgresql.py +136 -0
- ansys/saf/testing/_database/psql_container_manager.py +139 -0
- ansys/saf/testing/_database/py.typed +0 -0
- ansys/saf/testing/_database/sqlite.py +29 -0
- ansys/saf/testing/_docker/__init__.py +14 -0
- ansys/saf/testing/_docker/docker.py +55 -0
- ansys/saf/testing/_docker/py.typed +0 -0
- ansys/saf/testing/_frontend/__init__.py +14 -0
- ansys/saf/testing/_frontend/py.typed +0 -0
- ansys/saf/testing/_frontend/selenium.py +219 -0
- ansys/saf/testing/_hps/__init__.py +14 -0
- ansys/saf/testing/_hps/hps.py +226 -0
- ansys/saf/testing/_hps/hps_client.py +127 -0
- ansys/saf/testing/_hps/process/__init__.py +14 -0
- ansys/saf/testing/_hps/process/hps_deployment_process.py +72 -0
- ansys/saf/testing/_hps/process/hps_scaler_process.py +122 -0
- ansys/saf/testing/_hps/process/py.typed +0 -0
- ansys/saf/testing/_hps/py.typed +0 -0
- ansys/saf/testing/_hps/scripts/__init__.py +14 -0
- ansys/saf/testing/_hps/scripts/hps_installer.py +576 -0
- ansys/saf/testing/_hps/scripts/keycloak/.env +24 -0
- ansys/saf/testing/_hps/scripts/keycloak/Dockerfile +14 -0
- ansys/saf/testing/_hps/scripts/keycloak/job-action-token-1.2.0.jar +0 -0
- ansys/saf/testing/_hps/scripts/keycloak/service.yaml +50 -0
- ansys/saf/testing/_hps/scripts/launch_hps_stack.py +61 -0
- ansys/saf/testing/_hps/scripts/py.typed +0 -0
- ansys/saf/testing/_pim/__init__.py +14 -0
- ansys/saf/testing/_pim/pim.py +163 -0
- ansys/saf/testing/_pim/process/__init__.py +14 -0
- ansys/saf/testing/_pim/process/pim_process.py +256 -0
- ansys/saf/testing/_pim/process/py.typed +0 -0
- ansys/saf/testing/_pim/py.typed +0 -0
- ansys/saf/testing/_pim/scripts/__init__.py +14 -0
- ansys/saf/testing/_pim/scripts/launch_pim.py +36 -0
- ansys/saf/testing/_pim/scripts/py.typed +0 -0
- ansys/saf/testing/_pytest/__init__.py +14 -0
- ansys/saf/testing/_pytest/docstring_with_test_case.py +36 -0
- ansys/saf/testing/_pytest/mocking.py +30 -0
- ansys/saf/testing/_pytest/platform_specific.py +70 -0
- ansys/saf/testing/_pytest/py.typed +0 -0
- ansys/saf/testing/_pytest/pytest.py +47 -0
- ansys/saf/testing/_solution/__init__.py +14 -0
- ansys/saf/testing/_solution/common.py +63 -0
- ansys/saf/testing/_solution/const.py +32 -0
- ansys/saf/testing/_solution/end_to_end/__init__.py +14 -0
- ansys/saf/testing/_solution/end_to_end/container/Dockerfile +52 -0
- ansys/saf/testing/_solution/end_to_end/container/docker-compose.yaml +93 -0
- ansys/saf/testing/_solution/end_to_end/glow_client.py +189 -0
- ansys/saf/testing/_solution/end_to_end/glow_execution_configurations.py +1363 -0
- ansys/saf/testing/_solution/end_to_end/glow_process.py +1509 -0
- ansys/saf/testing/_solution/end_to_end/glow_server.py +750 -0
- ansys/saf/testing/_solution/end_to_end/log_manager.py +127 -0
- ansys/saf/testing/_solution/end_to_end/project_context.py +152 -0
- ansys/saf/testing/_solution/end_to_end/py.typed +0 -0
- ansys/saf/testing/_solution/py.typed +0 -0
- ansys/saf/testing/_solution/solution.py +407 -0
- ansys/saf/testing/_system/__init__.py +14 -0
- ansys/saf/testing/_system/process.py +242 -0
- ansys/saf/testing/_system/py.typed +0 -0
- ansys/saf/testing/common/__init__.py +18 -0
- ansys/saf/testing/common/py.typed +0 -0
- ansys/saf/testing/database/__init__.py +35 -0
- ansys/saf/testing/database/py.typed +0 -0
- ansys/saf/testing/directories/__init__.py +23 -0
- ansys/saf/testing/directories/py.typed +0 -0
- ansys/saf/testing/docker/__init__.py +28 -0
- ansys/saf/testing/docker/py.typed +0 -0
- ansys/saf/testing/docstring_with_test_case/__init__.py +18 -0
- ansys/saf/testing/docstring_with_test_case/py.typed +0 -0
- ansys/saf/testing/hps/__init__.py +55 -0
- ansys/saf/testing/hps/process/__init__.py +22 -0
- ansys/saf/testing/hps/process/py.typed +0 -0
- ansys/saf/testing/hps/py.typed +0 -0
- ansys/saf/testing/hps/scripts/__init__.py +15 -0
- ansys/saf/testing/hps/scripts/hps_installer.py +26 -0
- ansys/saf/testing/hps/scripts/launch_hps_stack.py +19 -0
- ansys/saf/testing/hps/scripts/py.typed +0 -0
- ansys/saf/testing/mocking/__init__.py +18 -0
- ansys/saf/testing/mocking/py.typed +0 -0
- ansys/saf/testing/network/__init__.py +32 -0
- ansys/saf/testing/network/py.typed +0 -0
- ansys/saf/testing/pim/__init__.py +46 -0
- ansys/saf/testing/pim/process/__init__.py +21 -0
- ansys/saf/testing/pim/process/py.typed +0 -0
- ansys/saf/testing/pim/py.typed +0 -0
- ansys/saf/testing/pim/scripts/__init__.py +15 -0
- ansys/saf/testing/pim/scripts/launch_pim.py +20 -0
- ansys/saf/testing/pim/scripts/py.typed +0 -0
- ansys/saf/testing/platform_specific/__init__.py +42 -0
- ansys/saf/testing/platform_specific/py.typed +0 -0
- ansys/saf/testing/process/__init__.py +18 -0
- ansys/saf/testing/process/py.typed +0 -0
- ansys/saf/testing/py.typed +0 -0
- ansys/saf/testing/pytest/__init__.py +22 -0
- ansys/saf/testing/pytest/py.typed +0 -0
- ansys/saf/testing/selenium/__init__.py +58 -0
- ansys/saf/testing/selenium/py.typed +0 -0
- ansys/saf/testing/solution/__init__.py +58 -0
- ansys/saf/testing/solution/const/__init__.py +18 -0
- ansys/saf/testing/solution/const/py.typed +0 -0
- ansys/saf/testing/solution/end_to_end/__init__.py +263 -0
- ansys/saf/testing/solution/end_to_end/py.typed +0 -0
- ansys/saf/testing/solution/py.typed +0 -0
- ansys_saf_testing-0.11.dev0.dist-info/METADATA +230 -0
- ansys_saf_testing-0.11.dev0.dist-info/RECORD +117 -0
- ansys_saf_testing-0.11.dev0.dist-info/WHEEL +4 -0
- ansys_saf_testing-0.11.dev0.dist-info/entry_points.txt +17 -0
- ansys_saf_testing-0.11.dev0.dist-info/licenses/AUTHORS +12 -0
- 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.
|