nac-test-pyats-common 0.2.0__py3-none-any.whl → 0.2.1__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.
- nac_test_pyats_common/aci/auth.py +177 -53
- nac_test_pyats_common/aci/test_base.py +35 -12
- nac_test_pyats_common/catc/api_test_base.py +35 -11
- nac_test_pyats_common/catc/auth.py +116 -49
- nac_test_pyats_common/catc/device_resolver.py +12 -5
- nac_test_pyats_common/common/base_device_resolver.py +71 -24
- nac_test_pyats_common/iosxe/iosxe_resolver.py +2 -2
- nac_test_pyats_common/sdwan/api_test_base.py +28 -7
- nac_test_pyats_common/sdwan/auth.py +125 -64
- nac_test_pyats_common/sdwan/device_resolver.py +23 -8
- {nac_test_pyats_common-0.2.0.dist-info → nac_test_pyats_common-0.2.1.dist-info}/METADATA +3 -2
- nac_test_pyats_common-0.2.1.dist-info/RECORD +25 -0
- nac_test_pyats_common-0.2.0.dist-info/RECORD +0 -25
- {nac_test_pyats_common-0.2.0.dist-info → nac_test_pyats_common-0.2.1.dist-info}/WHEEL +0 -0
- {nac_test_pyats_common-0.2.0.dist-info → nac_test_pyats_common-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -14,17 +14,29 @@ The module implements a two-tier API design:
|
|
|
14
14
|
|
|
15
15
|
This design ensures efficient token management by reusing valid tokens and only
|
|
16
16
|
re-authenticating when necessary, reducing unnecessary API calls to the APIC controller.
|
|
17
|
+
|
|
18
|
+
Note on Fork Safety:
|
|
19
|
+
This module uses urllib instead of httpx for synchronous authentication requests.
|
|
20
|
+
httpx is NOT fork-safe on macOS - creating httpx.Client after fork() causes
|
|
21
|
+
silent crashes due to OpenSSL threading issues. urllib uses simpler primitives
|
|
22
|
+
that work correctly after fork().
|
|
17
23
|
"""
|
|
18
24
|
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
from nac_test.pyats_core.common.auth_cache import AuthCache
|
|
28
|
+
from nac_test.pyats_core.common.subprocess_auth import (
|
|
29
|
+
SubprocessAuthError, # noqa: F401 - re-exported for callers to catch
|
|
30
|
+
execute_auth_subprocess,
|
|
22
31
|
)
|
|
23
32
|
|
|
24
33
|
# Default token lifetime for APIC authentication tokens in seconds
|
|
25
34
|
# APIC tokens are typically valid for 10 minutes (600 seconds) by default
|
|
26
35
|
APIC_TOKEN_LIFETIME_SECONDS: int = 600
|
|
27
36
|
|
|
37
|
+
# HTTP timeout for authentication request
|
|
38
|
+
AUTH_REQUEST_TIMEOUT_SECONDS: float = 30.0
|
|
39
|
+
|
|
28
40
|
|
|
29
41
|
class APICAuth:
|
|
30
42
|
"""APIC-specific authentication implementation with token caching.
|
|
@@ -44,19 +56,27 @@ class APICAuth:
|
|
|
44
56
|
"""
|
|
45
57
|
|
|
46
58
|
@staticmethod
|
|
47
|
-
def authenticate(
|
|
59
|
+
def authenticate(
|
|
60
|
+
url: str, username: str, password: str, verify_ssl: bool = False
|
|
61
|
+
) -> tuple[str, int]:
|
|
48
62
|
"""Perform direct APIC authentication and obtain a session token.
|
|
49
63
|
|
|
50
64
|
This method performs a direct authentication request to the APIC controller
|
|
51
65
|
using the provided credentials. It returns both the token and its lifetime
|
|
52
66
|
for proper cache management.
|
|
53
67
|
|
|
68
|
+
Internally uses execute_auth_subprocess() to run authentication in a clean
|
|
69
|
+
subprocess, avoiding the macOS fork+SSL crash issue where SSL operations
|
|
70
|
+
crash after fork().
|
|
71
|
+
|
|
54
72
|
Args:
|
|
55
73
|
url: Base URL of the APIC controller (e.g., "https://apic.example.com").
|
|
56
74
|
Should not include trailing slashes or API paths.
|
|
57
75
|
username: APIC username for authentication. This should be a valid user
|
|
58
76
|
configured in the APIC with appropriate permissions.
|
|
59
77
|
password: Password for the specified APIC user account.
|
|
78
|
+
verify_ssl: Whether to verify SSL certificates. Defaults to False for
|
|
79
|
+
backward compatibility with lab environments using self-signed certs.
|
|
60
80
|
|
|
61
81
|
Returns:
|
|
62
82
|
A tuple containing:
|
|
@@ -65,50 +85,100 @@ class APICAuth:
|
|
|
65
85
|
- expires_in (int): Token lifetime in seconds (typically 600 seconds).
|
|
66
86
|
|
|
67
87
|
Raises:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
88
|
+
SubprocessAuthError: If authentication subprocess fails or returns an error.
|
|
89
|
+
ValueError: If the APIC response contains malformed JSON or unexpected
|
|
90
|
+
structure that cannot be properly parsed.
|
|
91
|
+
|
|
92
|
+
Note:
|
|
93
|
+
SSL verification defaults to disabled to handle self-signed certificates
|
|
94
|
+
commonly used in lab and development APIC deployments. Set verify_ssl=True
|
|
95
|
+
for production environments with proper certificate validation.
|
|
76
96
|
"""
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
97
|
+
# Build auth parameters for subprocess
|
|
98
|
+
auth_params = {
|
|
99
|
+
"url": url,
|
|
100
|
+
"username": username,
|
|
101
|
+
"password": password,
|
|
102
|
+
"timeout": AUTH_REQUEST_TIMEOUT_SECONDS,
|
|
103
|
+
"verify_ssl": verify_ssl,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# APIC-specific authentication logic
|
|
107
|
+
# This script assumes `params` dict is already loaded by execute_auth_subprocess
|
|
108
|
+
auth_script_body = """
|
|
109
|
+
import json
|
|
110
|
+
import ssl
|
|
111
|
+
import urllib.request
|
|
112
|
+
import urllib.error
|
|
113
|
+
|
|
114
|
+
url = params["url"]
|
|
115
|
+
username = params["username"]
|
|
116
|
+
password = params["password"]
|
|
117
|
+
timeout = params["timeout"]
|
|
118
|
+
verify_ssl = params["verify_ssl"]
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
# Create SSL context
|
|
122
|
+
ssl_context = ssl.create_default_context()
|
|
123
|
+
if not verify_ssl:
|
|
124
|
+
ssl_context.check_hostname = False
|
|
125
|
+
ssl_context.verify_mode = ssl.CERT_NONE
|
|
126
|
+
|
|
127
|
+
# Build JSON payload for APIC authentication
|
|
128
|
+
payload = json.dumps({
|
|
129
|
+
"aaaUser": {
|
|
130
|
+
"attributes": {
|
|
131
|
+
"name": username,
|
|
132
|
+
"pwd": password
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}).encode("utf-8")
|
|
136
|
+
|
|
137
|
+
# Create request with proper headers
|
|
138
|
+
request = urllib.request.Request(
|
|
139
|
+
f"{url}/api/aaaLogin.json",
|
|
140
|
+
data=payload,
|
|
141
|
+
headers={"Content-Type": "application/json"},
|
|
142
|
+
method="POST"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Create HTTPS handler with SSL context
|
|
146
|
+
https_handler = urllib.request.HTTPSHandler(context=ssl_context)
|
|
147
|
+
opener = urllib.request.build_opener(https_handler)
|
|
148
|
+
|
|
149
|
+
# Execute authentication request
|
|
150
|
+
response = opener.open(request, timeout=timeout)
|
|
151
|
+
response_body = response.read().decode("utf-8")
|
|
152
|
+
response_data = json.loads(response_body)
|
|
153
|
+
|
|
154
|
+
# Extract token from response
|
|
155
|
+
token = response_data["imdata"][0]["aaaLogin"]["attributes"]["token"]
|
|
156
|
+
result = {"token": token}
|
|
157
|
+
|
|
158
|
+
except urllib.error.HTTPError as e:
|
|
159
|
+
error_body = e.read().decode("utf-8") if e.fp else ""
|
|
160
|
+
result = {
|
|
161
|
+
"error": f"HTTP {e.code}: {e.reason}",
|
|
162
|
+
"response": error_body[:500]
|
|
163
|
+
}
|
|
164
|
+
except (KeyError, IndexError) as e:
|
|
165
|
+
result = {
|
|
166
|
+
"error": f"Unexpected response structure: {str(e)}",
|
|
167
|
+
"response": response_body[:500] if "response_body" in dir() else ""
|
|
168
|
+
}
|
|
169
|
+
except Exception as e:
|
|
170
|
+
result = {"error": str(e)}
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
# Execute authentication in subprocess
|
|
174
|
+
auth_data = execute_auth_subprocess(auth_params, auth_script_body)
|
|
175
|
+
|
|
176
|
+
return auth_data["token"], APIC_TOKEN_LIFETIME_SECONDS
|
|
109
177
|
|
|
110
178
|
@classmethod
|
|
111
|
-
def get_token(
|
|
179
|
+
def get_token(
|
|
180
|
+
cls, url: str, username: str, password: str, verify_ssl: bool = False
|
|
181
|
+
) -> str:
|
|
112
182
|
"""Get APIC token with automatic caching and renewal.
|
|
113
183
|
|
|
114
184
|
This is the primary method that consumers should use to obtain APIC tokens.
|
|
@@ -126,6 +196,8 @@ class APICAuth:
|
|
|
126
196
|
username: APIC username for authentication. This should be a valid user
|
|
127
197
|
configured in the APIC with appropriate permissions.
|
|
128
198
|
password: Password for the specified APIC user account.
|
|
199
|
+
verify_ssl: Whether to verify SSL certificates. Defaults to False for
|
|
200
|
+
backward compatibility with lab environments using self-signed certs.
|
|
129
201
|
|
|
130
202
|
Returns:
|
|
131
203
|
A valid APIC session token that can be used in API requests.
|
|
@@ -133,13 +205,9 @@ class APICAuth:
|
|
|
133
205
|
API calls to the APIC controller.
|
|
134
206
|
|
|
135
207
|
Raises:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
httpx.RequestError: If the request fails due to network issues,
|
|
140
|
-
connection timeouts, or other transport-level problems.
|
|
141
|
-
ValueError: If the APIC response contains malformed or unexpected JSON
|
|
142
|
-
structure that cannot be properly parsed.
|
|
208
|
+
SubprocessAuthError: If authentication fails due to invalid credentials,
|
|
209
|
+
network issues, connection timeouts, or APIC server errors. The error
|
|
210
|
+
message will contain details from the authentication subprocess.
|
|
143
211
|
|
|
144
212
|
Examples:
|
|
145
213
|
>>> # Get a token for APIC access
|
|
@@ -159,5 +227,61 @@ class APICAuth:
|
|
|
159
227
|
url=url,
|
|
160
228
|
username=username,
|
|
161
229
|
password=password,
|
|
162
|
-
auth_func=cls.authenticate,
|
|
230
|
+
auth_func=lambda u, un, pw: cls.authenticate(u, un, pw, verify_ssl),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def get_auth(cls) -> str:
|
|
235
|
+
"""Get APIC token with automatic caching, using environment variables.
|
|
236
|
+
|
|
237
|
+
This is the primary method that consumers should use to obtain APIC tokens
|
|
238
|
+
when using environment variable configuration. It leverages the AuthCache
|
|
239
|
+
to efficiently manage token lifecycle.
|
|
240
|
+
|
|
241
|
+
Environment Variables Required:
|
|
242
|
+
APIC_URL: Base URL of the APIC controller
|
|
243
|
+
APIC_USERNAME: APIC username for authentication
|
|
244
|
+
APIC_PASSWORD: APIC password for authentication
|
|
245
|
+
APIC_INSECURE: Optional. Set to "True" to disable SSL verification
|
|
246
|
+
(default: True for backward compatibility)
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
A valid APIC session token.
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
ValueError: If required environment variables are not set.
|
|
253
|
+
"""
|
|
254
|
+
url = os.environ.get("APIC_URL")
|
|
255
|
+
username = os.environ.get("APIC_USERNAME")
|
|
256
|
+
password = os.environ.get("APIC_PASSWORD")
|
|
257
|
+
insecure = os.environ.get("APIC_INSECURE", "True").lower() in (
|
|
258
|
+
"true",
|
|
259
|
+
"1",
|
|
260
|
+
"yes",
|
|
163
261
|
)
|
|
262
|
+
|
|
263
|
+
# Validate environment variables and collect missing ones
|
|
264
|
+
missing_vars: list[str] = []
|
|
265
|
+
if not url:
|
|
266
|
+
missing_vars.append("APIC_URL")
|
|
267
|
+
if not username:
|
|
268
|
+
missing_vars.append("APIC_USERNAME")
|
|
269
|
+
if not password:
|
|
270
|
+
missing_vars.append("APIC_PASSWORD")
|
|
271
|
+
|
|
272
|
+
if missing_vars:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
f"Missing required environment variables: {', '.join(missing_vars)}"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Type narrowing: url, username, password are guaranteed to be str
|
|
278
|
+
# We raised ValueError above if any were None/empty
|
|
279
|
+
assert url is not None
|
|
280
|
+
assert username is not None
|
|
281
|
+
assert password is not None
|
|
282
|
+
|
|
283
|
+
# Normalize URL by removing trailing slash
|
|
284
|
+
url = url.rstrip("/")
|
|
285
|
+
verify_ssl = not insecure # APIC_INSECURE=True means verify=False
|
|
286
|
+
|
|
287
|
+
return cls.get_token(url, username, password, verify_ssl)
|
|
@@ -34,7 +34,8 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
34
34
|
|
|
35
35
|
Attributes:
|
|
36
36
|
token (str): APIC authentication token obtained during setup.
|
|
37
|
-
client (httpx.AsyncClient): Wrapped async HTTP client configured
|
|
37
|
+
client (httpx.AsyncClient | None): Wrapped async HTTP client configured
|
|
38
|
+
for APIC. Initialized to None, set during run_async_verification_test().
|
|
38
39
|
controller_url (str): Base URL of the APIC controller (inherited).
|
|
39
40
|
username (str): APIC username for authentication (inherited).
|
|
40
41
|
password (str): APIC password for authentication (inherited).
|
|
@@ -58,6 +59,8 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
58
59
|
self.run_async_verification_test(steps)
|
|
59
60
|
"""
|
|
60
61
|
|
|
62
|
+
client: httpx.AsyncClient | None = None # MUST declare at class level
|
|
63
|
+
|
|
61
64
|
@aetest.setup # type: ignore[misc, untyped-decorator]
|
|
62
65
|
def setup(self) -> None:
|
|
63
66
|
"""Setup method that extends the generic base class setup.
|
|
@@ -65,7 +68,11 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
65
68
|
Initializes the APIC test environment by:
|
|
66
69
|
1. Calling the parent class setup method
|
|
67
70
|
2. Obtaining an APIC authentication token using file-based locking
|
|
68
|
-
|
|
71
|
+
|
|
72
|
+
Note: Client creation is deferred to run_async_verification_test() to avoid
|
|
73
|
+
macOS fork() issues with httpx/SSL. Creating httpx.AsyncClient in a forked
|
|
74
|
+
process before entering an async context can cause crashes on macOS due to
|
|
75
|
+
OpenSSL threading primitives that are not fork-safe.
|
|
69
76
|
|
|
70
77
|
The authentication token is obtained through the APICAuth utility which
|
|
71
78
|
manages token lifecycle and prevents duplicate authentication requests
|
|
@@ -74,12 +81,20 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
74
81
|
super().setup()
|
|
75
82
|
|
|
76
83
|
# Get shared APIC token using file-based locking
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
# This reads from file cache - no httpx client creation here
|
|
85
|
+
try:
|
|
86
|
+
self.token = APICAuth.get_token(
|
|
87
|
+
self.controller_url, self.username, self.password
|
|
88
|
+
)
|
|
89
|
+
except (RuntimeError, ValueError) as e:
|
|
90
|
+
# Convert auth failures to FAILED (not ERRORED) - auth issues are
|
|
91
|
+
# expected failure conditions, not infrastructure errors
|
|
92
|
+
self.token = "" # Ensure attribute exists for cleanup code
|
|
93
|
+
self.failed(f"Authentication failed: {e}")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
# NOTE: Client creation is deferred to run_async_verification_test()
|
|
97
|
+
# to avoid macOS fork() + httpx/SSL crash issues
|
|
83
98
|
|
|
84
99
|
def get_apic_client(self) -> httpx.AsyncClient:
|
|
85
100
|
"""Get an httpx async client configured for APIC with response tracking.
|
|
@@ -114,9 +129,10 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
114
129
|
Simple entry point that uses base class orchestration to run async
|
|
115
130
|
verification tests. This thin wrapper:
|
|
116
131
|
1. Creates and manages an event loop for async operations
|
|
117
|
-
2.
|
|
118
|
-
3.
|
|
119
|
-
4.
|
|
132
|
+
2. Creates the APIC client (deferred from setup for fork safety)
|
|
133
|
+
3. Calls NACTestBase.run_verification_async() to execute tests
|
|
134
|
+
4. Passes results to NACTestBase.process_results_smart() for reporting
|
|
135
|
+
5. Ensures proper cleanup of async resources
|
|
120
136
|
|
|
121
137
|
The actual verification logic is handled by:
|
|
122
138
|
- get_items_to_verify() - must be implemented by the test class
|
|
@@ -131,10 +147,17 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
131
147
|
This method creates its own event loop to ensure compatibility
|
|
132
148
|
with PyATS synchronous test execution model. The loop and client
|
|
133
149
|
connections are properly closed after test completion.
|
|
150
|
+
|
|
151
|
+
Client creation is done HERE (not in setup) to avoid macOS fork()
|
|
152
|
+
issues with httpx/SSL. Creating httpx.AsyncClient after fork() but
|
|
153
|
+
before entering an async context can crash on macOS.
|
|
134
154
|
"""
|
|
135
155
|
loop = asyncio.new_event_loop()
|
|
136
156
|
asyncio.set_event_loop(loop)
|
|
137
157
|
try:
|
|
158
|
+
# Create client INSIDE the event loop context to avoid macOS fork+SSL crash
|
|
159
|
+
self.client = self.get_apic_client()
|
|
160
|
+
|
|
138
161
|
# Call the base class generic orchestration
|
|
139
162
|
results = loop.run_until_complete(self.run_verification_async())
|
|
140
163
|
|
|
@@ -142,6 +165,6 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
142
165
|
self.process_results_smart(results, steps)
|
|
143
166
|
finally:
|
|
144
167
|
# Clean up the APIC client connection
|
|
145
|
-
if hasattr(
|
|
168
|
+
if self.client is not None: # MANDATORY: never use hasattr()
|
|
146
169
|
loop.run_until_complete(self.client.aclose())
|
|
147
170
|
loop.close()
|
|
@@ -34,15 +34,16 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
34
34
|
response capture. It serves as the foundation for all Catalyst Center-specific
|
|
35
35
|
API test classes.
|
|
36
36
|
|
|
37
|
-
The class follows the same pattern as APICTestBase and
|
|
37
|
+
The class follows the same pattern as APICTestBase and SDWANManagerTestBase for
|
|
38
38
|
consistency across NAC architecture adapters. Token refresh is handled
|
|
39
39
|
automatically by the AuthCache TTL mechanism.
|
|
40
40
|
|
|
41
41
|
Attributes:
|
|
42
42
|
auth_data (dict): Catalyst Center authentication data containing the
|
|
43
43
|
token obtained during setup.
|
|
44
|
-
client (httpx.AsyncClient): Wrapped async HTTP client configured for
|
|
45
|
-
Catalyst Center.
|
|
44
|
+
client (httpx.AsyncClient | None): Wrapped async HTTP client configured for
|
|
45
|
+
Catalyst Center. Initialized to None, set during
|
|
46
|
+
run_async_verification_test().
|
|
46
47
|
controller_url (str): Base URL of the Catalyst Center controller.
|
|
47
48
|
verify_ssl (bool): Whether SSL verification is enabled.
|
|
48
49
|
|
|
@@ -67,6 +68,9 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
67
68
|
self.run_async_verification_test(steps)
|
|
68
69
|
"""
|
|
69
70
|
|
|
71
|
+
client: httpx.AsyncClient | None = None # MUST declare at class level
|
|
72
|
+
auth_data: dict[str, Any] # Declared at class level for type checker compatibility
|
|
73
|
+
|
|
70
74
|
@aetest.setup # type: ignore[misc, untyped-decorator]
|
|
71
75
|
def setup(self) -> None:
|
|
72
76
|
"""Setup method that extends the generic base class setup.
|
|
@@ -75,7 +79,11 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
75
79
|
1. Calling the parent class setup method
|
|
76
80
|
2. Obtaining Catalyst Center authentication token using cached auth
|
|
77
81
|
3. Configuring SSL verification from environment
|
|
78
|
-
|
|
82
|
+
|
|
83
|
+
Note: Client creation is deferred to run_async_verification_test() to avoid
|
|
84
|
+
macOS fork() issues with httpx/SSL. Creating httpx.AsyncClient in a forked
|
|
85
|
+
process before entering an async context can cause crashes on macOS due to
|
|
86
|
+
OpenSSL threading primitives that are not fork-safe.
|
|
79
87
|
|
|
80
88
|
The authentication token is obtained through the CatalystCenterAuth utility
|
|
81
89
|
which manages token lifecycle and prevents duplicate authentication requests
|
|
@@ -84,7 +92,15 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
84
92
|
super().setup()
|
|
85
93
|
|
|
86
94
|
# Get Catalyst Center auth data (token)
|
|
87
|
-
|
|
95
|
+
# This reads from file cache - no httpx client creation here
|
|
96
|
+
try:
|
|
97
|
+
self.auth_data = CatalystCenterAuth.get_auth()
|
|
98
|
+
except (RuntimeError, ValueError) as e:
|
|
99
|
+
# Convert auth failures to FAILED (not ERRORED) - auth issues are
|
|
100
|
+
# expected failure conditions, not infrastructure errors
|
|
101
|
+
self.auth_data = {} # Ensure attribute exists for cleanup code
|
|
102
|
+
self.failed(f"Authentication failed: {e}")
|
|
103
|
+
return
|
|
88
104
|
|
|
89
105
|
# Get controller URL from environment
|
|
90
106
|
self.controller_url = os.environ.get("CC_URL", "").rstrip("/")
|
|
@@ -93,8 +109,8 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
93
109
|
insecure = os.environ.get("CC_INSECURE", "True").lower() in ("true", "1", "yes")
|
|
94
110
|
self.verify_ssl = not insecure
|
|
95
111
|
|
|
96
|
-
#
|
|
97
|
-
|
|
112
|
+
# NOTE: Client creation is deferred to run_async_verification_test()
|
|
113
|
+
# to avoid macOS fork() + httpx/SSL crash issues
|
|
98
114
|
|
|
99
115
|
def get_catc_client(self) -> httpx.AsyncClient:
|
|
100
116
|
"""Get an httpx async client configured for Catalyst Center.
|
|
@@ -144,9 +160,10 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
144
160
|
Simple entry point that uses base class orchestration to run async
|
|
145
161
|
verification tests. This thin wrapper:
|
|
146
162
|
1. Creates and manages an event loop for async operations
|
|
147
|
-
2.
|
|
148
|
-
3.
|
|
149
|
-
4.
|
|
163
|
+
2. Creates the Catalyst Center client (deferred from setup for fork safety)
|
|
164
|
+
3. Calls NACTestBase.run_verification_async() to execute tests
|
|
165
|
+
4. Passes results to NACTestBase.process_results_smart() for reporting
|
|
166
|
+
5. Ensures proper cleanup of async resources
|
|
150
167
|
|
|
151
168
|
The actual verification logic is handled by:
|
|
152
169
|
- get_items_to_verify() - must be implemented by the test class
|
|
@@ -161,10 +178,17 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
161
178
|
This method creates its own event loop to ensure compatibility
|
|
162
179
|
with PyATS synchronous test execution model. The loop and client
|
|
163
180
|
connections are properly closed after test completion.
|
|
181
|
+
|
|
182
|
+
Client creation is done HERE (not in setup) to avoid macOS fork()
|
|
183
|
+
issues with httpx/SSL. Creating httpx.AsyncClient after fork() but
|
|
184
|
+
before entering an async context can crash on macOS.
|
|
164
185
|
"""
|
|
165
186
|
loop = asyncio.new_event_loop()
|
|
166
187
|
asyncio.set_event_loop(loop)
|
|
167
188
|
try:
|
|
189
|
+
# Create client INSIDE the event loop context to avoid macOS fork+SSL crash
|
|
190
|
+
self.client = self.get_catc_client()
|
|
191
|
+
|
|
168
192
|
# Call the base class generic orchestration
|
|
169
193
|
results = loop.run_until_complete(self.run_verification_async())
|
|
170
194
|
|
|
@@ -172,6 +196,6 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
172
196
|
self.process_results_smart(results, steps)
|
|
173
197
|
finally:
|
|
174
198
|
# Clean up the Catalyst Center client connection
|
|
175
|
-
if hasattr(
|
|
199
|
+
if self.client is not None: # MANDATORY: never use hasattr()
|
|
176
200
|
loop.run_until_complete(self.client.aclose())
|
|
177
201
|
loop.close()
|