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,14 +14,21 @@ 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 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
25
|
import os
|
|
20
26
|
from typing import Any
|
|
21
27
|
|
|
22
|
-
import
|
|
23
|
-
from nac_test.pyats_core.common.
|
|
24
|
-
|
|
28
|
+
from nac_test.pyats_core.common.auth_cache import AuthCache
|
|
29
|
+
from nac_test.pyats_core.common.subprocess_auth import (
|
|
30
|
+
SubprocessAuthError, # noqa: F401 - re-exported for callers to catch
|
|
31
|
+
execute_auth_subprocess,
|
|
25
32
|
)
|
|
26
33
|
|
|
27
34
|
# Default token lifetime for Catalyst Center authentication in seconds
|
|
@@ -74,6 +81,10 @@ class CatalystCenterAuth:
|
|
|
74
81
|
using Basic Auth. It tries the modern auth endpoint first, then falls back
|
|
75
82
|
to the legacy endpoint if needed for backward compatibility.
|
|
76
83
|
|
|
84
|
+
Note: On macOS, SSL operations in forked processes crash due to OpenSSL
|
|
85
|
+
threading issues. This method uses subprocess with spawn context to perform
|
|
86
|
+
authentication in a fresh process, avoiding the fork+SSL crash.
|
|
87
|
+
|
|
77
88
|
Args:
|
|
78
89
|
url: Base URL of the Catalyst Center (e.g., "https://catc.example.com").
|
|
79
90
|
Should not include trailing slashes or API paths.
|
|
@@ -90,56 +101,110 @@ class CatalystCenterAuth:
|
|
|
90
101
|
- expires_in (int): Token lifetime in seconds (typically 3600).
|
|
91
102
|
|
|
92
103
|
Raises:
|
|
93
|
-
|
|
94
|
-
on all
|
|
95
|
-
|
|
96
|
-
ValueError: If the token is not received in the response from any endpoint.
|
|
104
|
+
SubprocessAuthError: If authentication subprocess fails or authentication
|
|
105
|
+
fails on all available endpoints.
|
|
106
|
+
ValueError: If the authentication response is malformed.
|
|
97
107
|
|
|
98
108
|
Note:
|
|
99
109
|
SSL verification can be disabled via the verify_ssl parameter to handle
|
|
100
110
|
self-signed certificates commonly used in lab deployments. In production
|
|
101
111
|
environments, proper certificate validation should be enabled.
|
|
102
112
|
"""
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
113
|
+
# Build auth parameters for subprocess
|
|
114
|
+
auth_params = {
|
|
115
|
+
"url": url,
|
|
116
|
+
"username": username,
|
|
117
|
+
"password": password,
|
|
118
|
+
"timeout": AUTH_REQUEST_TIMEOUT_SECONDS,
|
|
119
|
+
"verify_ssl": verify_ssl,
|
|
120
|
+
"endpoints": AUTH_ENDPOINTS,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Catalyst Center-specific authentication logic
|
|
124
|
+
# This script assumes `params` dict is already loaded by execute_auth_subprocess
|
|
125
|
+
auth_script_body = """
|
|
126
|
+
import base64
|
|
127
|
+
import json
|
|
128
|
+
import ssl
|
|
129
|
+
import urllib.request
|
|
130
|
+
import urllib.error
|
|
131
|
+
|
|
132
|
+
url = params["url"]
|
|
133
|
+
username = params["username"]
|
|
134
|
+
password = params["password"]
|
|
135
|
+
timeout = params["timeout"]
|
|
136
|
+
verify_ssl = params["verify_ssl"]
|
|
137
|
+
endpoints = params["endpoints"]
|
|
138
|
+
|
|
139
|
+
# Create SSL context
|
|
140
|
+
ssl_context = ssl.create_default_context()
|
|
141
|
+
if not verify_ssl:
|
|
142
|
+
ssl_context.check_hostname = False
|
|
143
|
+
ssl_context.verify_mode = ssl.CERT_NONE
|
|
144
|
+
|
|
145
|
+
# Create Basic Auth header
|
|
146
|
+
credentials = f"{username}:{password}"
|
|
147
|
+
b64_credentials = base64.b64encode(credentials.encode("utf-8")).decode("utf-8")
|
|
148
|
+
auth_header = f"Basic {b64_credentials}"
|
|
149
|
+
|
|
150
|
+
# Create HTTPS handler with SSL context
|
|
151
|
+
https_handler = urllib.request.HTTPSHandler(context=ssl_context)
|
|
152
|
+
opener = urllib.request.build_opener(https_handler)
|
|
153
|
+
|
|
154
|
+
last_error = None
|
|
155
|
+
|
|
156
|
+
for endpoint in endpoints:
|
|
157
|
+
try:
|
|
158
|
+
# Create request with Basic Auth and proper headers
|
|
159
|
+
request = urllib.request.Request(
|
|
160
|
+
f"{url}{endpoint}",
|
|
161
|
+
data=None,
|
|
162
|
+
headers={
|
|
163
|
+
"Content-Type": "application/json",
|
|
164
|
+
"Accept": "application/json",
|
|
165
|
+
"Authorization": auth_header,
|
|
166
|
+
},
|
|
167
|
+
method="POST"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Execute authentication request
|
|
171
|
+
response = opener.open(request, timeout=timeout)
|
|
172
|
+
response_body = response.read().decode("utf-8")
|
|
173
|
+
response_data = json.loads(response_body)
|
|
174
|
+
|
|
175
|
+
# Extract token from response
|
|
176
|
+
token = response_data.get("Token")
|
|
177
|
+
if not token:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"No 'Token' field in auth response from {endpoint}. "
|
|
180
|
+
f"Response keys: {list(response_data.keys())}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
result = {"token": str(token)}
|
|
184
|
+
break # Success - exit loop
|
|
185
|
+
|
|
186
|
+
except urllib.error.HTTPError as e:
|
|
187
|
+
error_body = e.read().decode("utf-8") if e.fp else ""
|
|
188
|
+
err_snippet = error_body[:200]
|
|
189
|
+
last_error = (
|
|
190
|
+
f"HTTP {e.code} on {endpoint}: {e.reason}. {err_snippet}"
|
|
191
|
+
)
|
|
192
|
+
continue
|
|
193
|
+
except ValueError as e:
|
|
194
|
+
last_error = str(e)
|
|
195
|
+
continue
|
|
196
|
+
except Exception as e:
|
|
197
|
+
last_error = f"{endpoint}: {str(e)}"
|
|
198
|
+
continue
|
|
199
|
+
else:
|
|
200
|
+
# Loop completed without break - all endpoints failed
|
|
201
|
+
result = {"error": f"All endpoints failed. Last error: {last_error}"}
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
# Execute authentication in subprocess (fork-safe on macOS)
|
|
205
|
+
auth_result = execute_auth_subprocess(auth_params, auth_script_body)
|
|
206
|
+
|
|
207
|
+
return {"token": auth_result["token"]}, CATALYST_CENTER_TOKEN_LIFETIME_SECONDS
|
|
143
208
|
|
|
144
209
|
@classmethod
|
|
145
210
|
def get_auth(cls) -> dict[str, Any]:
|
|
@@ -168,8 +233,10 @@ class CatalystCenterAuth:
|
|
|
168
233
|
Raises:
|
|
169
234
|
ValueError: If any required environment variables (CC_URL, CC_USERNAME,
|
|
170
235
|
CC_PASSWORD) are not set.
|
|
171
|
-
|
|
172
|
-
|
|
236
|
+
SubprocessAuthError: If authentication fails due to invalid credentials,
|
|
237
|
+
network issues, connection timeouts, or Catalyst Center server errors.
|
|
238
|
+
The error message will contain details from the authentication
|
|
239
|
+
subprocess.
|
|
173
240
|
|
|
174
241
|
Example:
|
|
175
242
|
>>> # Set environment variables first
|
|
@@ -141,16 +141,23 @@ class CatalystCenterDeviceResolver(BaseDeviceResolver):
|
|
|
141
141
|
|
|
142
142
|
return ip_value
|
|
143
143
|
|
|
144
|
-
def
|
|
145
|
-
"""Return
|
|
144
|
+
def extract_os_platform_type(self, device_data: dict[str, Any]) -> dict[str, str]:
|
|
145
|
+
"""Return PyATS abstraction info for Catalyst Center managed devices.
|
|
146
|
+
|
|
147
|
+
All managed devices are currently IOS-XE based. Platform and model
|
|
148
|
+
could potentially be extracted from the 'pid' field in the future.
|
|
146
149
|
|
|
147
150
|
Args:
|
|
148
|
-
device_data: Device data dictionary
|
|
151
|
+
device_data: Device data dictionary from the data model.
|
|
149
152
|
|
|
150
153
|
Returns:
|
|
151
|
-
|
|
154
|
+
Dictionary with 'os' key (and potentially platform/model in future).
|
|
152
155
|
"""
|
|
153
|
-
return
|
|
156
|
+
return {
|
|
157
|
+
"os": "iosxe",
|
|
158
|
+
# Future enhancement: extract platform/model from pid field
|
|
159
|
+
# e.g., "C9300-24P" -> platform="cat9k", model="c9300"
|
|
160
|
+
}
|
|
154
161
|
|
|
155
162
|
def get_credential_env_vars(self) -> tuple[str, str]:
|
|
156
163
|
"""Return IOS-XE credential env vars for managed devices.
|
|
@@ -24,13 +24,17 @@ class BaseDeviceResolver(ABC):
|
|
|
24
24
|
resolution. It handles common logic (credential injection, device dict
|
|
25
25
|
construction) while delegating schema-specific work to abstract methods.
|
|
26
26
|
|
|
27
|
+
Credentials are injected as environment variable references (%ENV{VARNAME})
|
|
28
|
+
rather than cleartext values for security. PyATS testbed loader will resolve
|
|
29
|
+
these references at runtime.
|
|
30
|
+
|
|
27
31
|
Subclasses MUST implement:
|
|
28
32
|
- get_architecture_name(): Return architecture identifier (e.g., "sdwan")
|
|
29
33
|
- get_schema_root_key(): Return the root key in data model (e.g., "sdwan")
|
|
30
34
|
- navigate_to_devices(): Navigate schema to get iterable of device data
|
|
31
35
|
- extract_hostname(): Extract hostname from device data
|
|
32
36
|
- extract_host_ip(): Extract management IP from device data
|
|
33
|
-
-
|
|
37
|
+
- extract_os_platform_type(): Extract OS and platform info from device data
|
|
34
38
|
- get_credential_env_vars(): Return (username_env_var, password_env_var)
|
|
35
39
|
|
|
36
40
|
Subclasses MAY override:
|
|
@@ -55,6 +59,8 @@ class BaseDeviceResolver(ABC):
|
|
|
55
59
|
>>>
|
|
56
60
|
>>> resolver = SDWANDeviceResolver(data_model)
|
|
57
61
|
>>> devices = resolver.get_resolved_inventory()
|
|
62
|
+
>>> # devices[0]["username"] == "%ENV{IOSXE_USERNAME}"
|
|
63
|
+
>>> # devices[0]["password"] == "%ENV{IOSXE_PASSWORD}"
|
|
58
64
|
"""
|
|
59
65
|
|
|
60
66
|
def __init__(self, data_model: dict[str, Any]) -> None:
|
|
@@ -75,7 +81,7 @@ class BaseDeviceResolver(ABC):
|
|
|
75
81
|
1. Navigates the data model to find device data
|
|
76
82
|
2. Extracts hostname and management IP from each device
|
|
77
83
|
3. Sets OS type (architecture-specific, e.g., hardcoded to 'iosxe' for SD-WAN)
|
|
78
|
-
4. Injects SSH
|
|
84
|
+
4. Injects SSH credential environment variable references
|
|
79
85
|
5. Returns list of device dicts ready for nac-test
|
|
80
86
|
|
|
81
87
|
Returns:
|
|
@@ -83,8 +89,8 @@ class BaseDeviceResolver(ABC):
|
|
|
83
89
|
- hostname (str)
|
|
84
90
|
- host (str)
|
|
85
91
|
- os (str)
|
|
86
|
-
- username (str)
|
|
87
|
-
- password (str)
|
|
92
|
+
- username (str): Environment variable reference in %ENV{VARNAME} format
|
|
93
|
+
- password (str): Environment variable reference in %ENV{VARNAME} format
|
|
88
94
|
- Plus any architecture-specific fields
|
|
89
95
|
|
|
90
96
|
Raises:
|
|
@@ -178,15 +184,16 @@ class BaseDeviceResolver(ABC):
|
|
|
178
184
|
device_data: Raw device data from the data model.
|
|
179
185
|
|
|
180
186
|
Returns:
|
|
181
|
-
Device dictionary with hostname, host, os, device_id fields
|
|
182
|
-
|
|
187
|
+
Device dictionary with hostname, host, os, device_id fields,
|
|
188
|
+
plus optional platform, model, series fields if provided by
|
|
189
|
+
extract_os_platform_type(). Credentials are injected separately.
|
|
183
190
|
|
|
184
191
|
Raises:
|
|
185
192
|
ValueError: If any required field extraction fails.
|
|
186
193
|
"""
|
|
187
194
|
hostname = self.extract_hostname(device_data)
|
|
188
195
|
host = self.extract_host_ip(device_data)
|
|
189
|
-
|
|
196
|
+
os_platform_info = self.extract_os_platform_type(device_data)
|
|
190
197
|
device_id = self.extract_device_id(device_data)
|
|
191
198
|
|
|
192
199
|
# Validate all extracted values are non-empty strings
|
|
@@ -203,18 +210,38 @@ class BaseDeviceResolver(ABC):
|
|
|
203
210
|
f"Invalid IP address format: '{host}'. "
|
|
204
211
|
"Ensure the field contains a valid IPv4 or IPv6 address."
|
|
205
212
|
) from None
|
|
213
|
+
|
|
214
|
+
# Validate os_platform_info structure
|
|
215
|
+
# Runtime check for dict type (defensive programming for non-mypy users)
|
|
216
|
+
if not isinstance(os_platform_info, dict):
|
|
217
|
+
type_name = type(os_platform_info).__name__ # type: ignore[unreachable]
|
|
218
|
+
raise ValueError( # type: ignore[unreachable]
|
|
219
|
+
f"extract_os_platform_type must return a dict, got {type_name}"
|
|
220
|
+
)
|
|
221
|
+
if "os" not in os_platform_info:
|
|
222
|
+
raise ValueError("extract_os_platform_type must return dict with 'os' key")
|
|
223
|
+
|
|
224
|
+
os_type = os_platform_info["os"]
|
|
206
225
|
if not isinstance(os_type, str) or not os_type:
|
|
207
226
|
raise ValueError(f"Invalid OS type: {os_type!r}")
|
|
208
227
|
if not isinstance(device_id, str) or not device_id:
|
|
209
228
|
raise ValueError(f"Invalid device ID: {device_id!r}")
|
|
210
229
|
|
|
211
|
-
|
|
230
|
+
# Build base device dict with required fields
|
|
231
|
+
device_dict = {
|
|
212
232
|
"hostname": hostname,
|
|
213
233
|
"host": host,
|
|
214
234
|
"os": os_type,
|
|
215
235
|
"device_id": device_id,
|
|
216
236
|
}
|
|
217
237
|
|
|
238
|
+
# Add optional PyATS abstraction fields if present
|
|
239
|
+
for field in ["platform", "model", "series"]:
|
|
240
|
+
if field in os_platform_info:
|
|
241
|
+
device_dict[field] = os_platform_info[field]
|
|
242
|
+
|
|
243
|
+
return device_dict
|
|
244
|
+
|
|
218
245
|
# -------------------------------------------------------------------------
|
|
219
246
|
# Private helper methods
|
|
220
247
|
# -------------------------------------------------------------------------
|
|
@@ -227,7 +254,15 @@ class BaseDeviceResolver(ABC):
|
|
|
227
254
|
return "<unknown>"
|
|
228
255
|
|
|
229
256
|
def _inject_credentials(self, devices: list[dict[str, Any]]) -> None:
|
|
230
|
-
"""Inject SSH
|
|
257
|
+
"""Inject SSH credential environment variable references.
|
|
258
|
+
|
|
259
|
+
Injects credentials as %ENV{VARNAME} references instead of cleartext
|
|
260
|
+
values for security. PyATS testbed loader will resolve these at runtime.
|
|
261
|
+
|
|
262
|
+
This prevents credentials from being written to disk in testbed.yaml files.
|
|
263
|
+
|
|
264
|
+
If consumers need cleartext values, they can detect the %ENV{} pattern
|
|
265
|
+
and resolve via os.environ.get() themselves.
|
|
231
266
|
|
|
232
267
|
Args:
|
|
233
268
|
devices: List of device dicts to update in place.
|
|
@@ -236,14 +271,12 @@ class BaseDeviceResolver(ABC):
|
|
|
236
271
|
ValueError: If required credential environment variables are not set.
|
|
237
272
|
"""
|
|
238
273
|
username_var, password_var = self.get_credential_env_vars()
|
|
239
|
-
username = os.environ.get(username_var)
|
|
240
|
-
password = os.environ.get(password_var)
|
|
241
274
|
|
|
242
|
-
# FAIL FAST -
|
|
275
|
+
# FAIL FAST - validate env vars are set (without reading values)
|
|
243
276
|
missing_vars: list[str] = []
|
|
244
|
-
if not
|
|
277
|
+
if username_var not in os.environ:
|
|
245
278
|
missing_vars.append(username_var)
|
|
246
|
-
if not
|
|
279
|
+
if password_var not in os.environ:
|
|
247
280
|
missing_vars.append(password_var)
|
|
248
281
|
|
|
249
282
|
if missing_vars:
|
|
@@ -253,10 +286,13 @@ class BaseDeviceResolver(ABC):
|
|
|
253
286
|
f"These are required for {self.get_architecture_name()} D2D testing."
|
|
254
287
|
)
|
|
255
288
|
|
|
256
|
-
|
|
289
|
+
# Inject environment variable references (not cleartext values)
|
|
290
|
+
logger.debug(
|
|
291
|
+
f"Injecting credential references for {username_var} and {password_var}"
|
|
292
|
+
)
|
|
257
293
|
for device in devices:
|
|
258
|
-
device["username"] =
|
|
259
|
-
device["password"] =
|
|
294
|
+
device["username"] = f"%ENV{{{username_var}}}"
|
|
295
|
+
device["password"] = f"%ENV{{{password_var}}}"
|
|
260
296
|
|
|
261
297
|
# -------------------------------------------------------------------------
|
|
262
298
|
# Abstract methods - MUST be implemented by subclasses
|
|
@@ -363,18 +399,29 @@ class BaseDeviceResolver(ABC):
|
|
|
363
399
|
...
|
|
364
400
|
|
|
365
401
|
@abstractmethod
|
|
366
|
-
def
|
|
367
|
-
"""Extract
|
|
402
|
+
def extract_os_platform_type(self, device_data: dict[str, Any]) -> dict[str, str]:
|
|
403
|
+
"""Extract PyATS device abstraction fields from device data.
|
|
404
|
+
|
|
405
|
+
Returns a dictionary with device abstraction information for PyATS:
|
|
406
|
+
- os (required): Operating system type (e.g., "iosxe", "nxos")
|
|
407
|
+
- platform (optional): Device platform (e.g., "sdwan", "cat9k")
|
|
408
|
+
- model (optional): Device model (e.g., "c8000v", "c9300")
|
|
409
|
+
- series (optional): Device series (e.g., "catalyst", "nexus")
|
|
368
410
|
|
|
369
411
|
Args:
|
|
370
412
|
device_data: Device data dict from navigate_to_devices().
|
|
371
413
|
|
|
372
414
|
Returns:
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
415
|
+
Dictionary containing at minimum the 'os' key, with optional
|
|
416
|
+
'platform', 'model', and 'series' keys.
|
|
417
|
+
|
|
418
|
+
Example:
|
|
419
|
+
>>> def extract_os_platform_type(self, device_data):
|
|
420
|
+
... return {
|
|
421
|
+
... "os": "iosxe",
|
|
422
|
+
... "platform": "sdwan",
|
|
423
|
+
... "model": "c8000v" # if extractable
|
|
424
|
+
... }
|
|
378
425
|
"""
|
|
379
426
|
...
|
|
380
427
|
|
|
@@ -56,8 +56,8 @@ class IOSXEResolver(BaseDeviceResolver):
|
|
|
56
56
|
"""Extract management IP."""
|
|
57
57
|
raise NotImplementedError("IOSXEResolver is not yet implemented")
|
|
58
58
|
|
|
59
|
-
def
|
|
60
|
-
"""Extract OS
|
|
59
|
+
def extract_os_platform_type(self, device_data: dict[str, Any]) -> dict[str, str]:
|
|
60
|
+
"""Extract OS and platform info."""
|
|
61
61
|
raise NotImplementedError("IOSXEResolver is not yet implemented")
|
|
62
62
|
|
|
63
63
|
def get_credential_env_vars(self) -> tuple[str, str]:
|
|
@@ -68,6 +68,7 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
68
68
|
"""
|
|
69
69
|
|
|
70
70
|
client: httpx.AsyncClient | None = None # MUST declare at class level
|
|
71
|
+
auth_data: dict[str, Any] # Declared at class level for type checker compatibility
|
|
71
72
|
|
|
72
73
|
@aetest.setup # type: ignore[misc, untyped-decorator]
|
|
73
74
|
def setup(self) -> None:
|
|
@@ -77,7 +78,11 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
77
78
|
1. Calling the parent class setup method
|
|
78
79
|
2. Obtaining SDWAN Manager session data (jsessionid, xsrf_token) using
|
|
79
80
|
cached auth
|
|
80
|
-
|
|
81
|
+
|
|
82
|
+
Note: Client creation is deferred to run_async_verification_test() to avoid
|
|
83
|
+
macOS fork() issues with httpx/SSL. Creating httpx.AsyncClient in a forked
|
|
84
|
+
process before entering an async context can cause crashes on macOS due to
|
|
85
|
+
OpenSSL threading primitives that are not fork-safe.
|
|
81
86
|
|
|
82
87
|
The session data is obtained through the SDWANManagerAuth utility which
|
|
83
88
|
manages session lifecycle and prevents duplicate authentication requests
|
|
@@ -86,10 +91,18 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
86
91
|
super().setup()
|
|
87
92
|
|
|
88
93
|
# Get shared SDWAN Manager auth data (jsessionid, xsrf_token)
|
|
89
|
-
|
|
94
|
+
# This reads from file cache - no httpx client creation here
|
|
95
|
+
try:
|
|
96
|
+
self.auth_data = SDWANManagerAuth.get_auth()
|
|
97
|
+
except (RuntimeError, ValueError) as e:
|
|
98
|
+
# Convert auth failures to FAILED (not ERRORED) - auth issues are
|
|
99
|
+
# expected failure conditions, not infrastructure errors
|
|
100
|
+
self.auth_data = {} # Ensure attribute exists for cleanup code
|
|
101
|
+
self.failed(f"Authentication failed: {e}")
|
|
102
|
+
return
|
|
90
103
|
|
|
91
|
-
#
|
|
92
|
-
|
|
104
|
+
# NOTE: Client creation is deferred to run_async_verification_test()
|
|
105
|
+
# to avoid macOS fork() + httpx/SSL crash issues
|
|
93
106
|
|
|
94
107
|
def get_sdwan_manager_client(self) -> httpx.AsyncClient:
|
|
95
108
|
"""Get an httpx async client configured for SDWAN Manager.
|
|
@@ -141,9 +154,10 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
141
154
|
Simple entry point that uses base class orchestration to run async
|
|
142
155
|
verification tests. This thin wrapper:
|
|
143
156
|
1. Creates and manages an event loop for async operations
|
|
144
|
-
2.
|
|
145
|
-
3.
|
|
146
|
-
4.
|
|
157
|
+
2. Creates the SDWAN Manager client (deferred from setup for fork safety)
|
|
158
|
+
3. Calls NACTestBase.run_verification_async() to execute tests
|
|
159
|
+
4. Passes results to NACTestBase.process_results_smart() for reporting
|
|
160
|
+
5. Ensures proper cleanup of async resources
|
|
147
161
|
|
|
148
162
|
The actual verification logic is handled by:
|
|
149
163
|
- get_items_to_verify() - must be implemented by the test class
|
|
@@ -158,10 +172,17 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
158
172
|
This method creates its own event loop to ensure compatibility
|
|
159
173
|
with PyATS synchronous test execution model. The loop and client
|
|
160
174
|
connections are properly closed after test completion.
|
|
175
|
+
|
|
176
|
+
Client creation is done HERE (not in setup) to avoid macOS fork()
|
|
177
|
+
issues with httpx/SSL. Creating httpx.AsyncClient after fork() but
|
|
178
|
+
before entering an async context can crash on macOS.
|
|
161
179
|
"""
|
|
162
180
|
loop = asyncio.new_event_loop()
|
|
163
181
|
asyncio.set_event_loop(loop)
|
|
164
182
|
try:
|
|
183
|
+
# Create client INSIDE the event loop context to avoid macOS fork+SSL crash
|
|
184
|
+
self.client = self.get_sdwan_manager_client()
|
|
185
|
+
|
|
165
186
|
# Call the base class generic orchestration
|
|
166
187
|
results = loop.run_until_complete(self.run_verification_async())
|
|
167
188
|
|