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.
@@ -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 httpx
23
- from nac_test.pyats_core.common.auth_cache import (
24
- AuthCache, # type: ignore[import-untyped]
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
- httpx.HTTPStatusError: If Catalyst Center returns a non-2xx status code
94
- on all auth endpoints, typically indicating authentication failure.
95
- RuntimeError: If authentication fails on all available endpoints.
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
- last_error: Exception | None = None
104
-
105
- with httpx.Client(
106
- verify=verify_ssl, timeout=AUTH_REQUEST_TIMEOUT_SECONDS
107
- ) as client:
108
- for endpoint in AUTH_ENDPOINTS:
109
- try:
110
- auth_response = client.post(
111
- f"{url}{endpoint}",
112
- auth=(username, password),
113
- headers={
114
- "Content-Type": "application/json",
115
- "Accept": "application/json",
116
- },
117
- )
118
- auth_response.raise_for_status()
119
-
120
- # Extract token from response
121
- response_data = auth_response.json()
122
- token = response_data.get("Token")
123
-
124
- if not token:
125
- raise ValueError(
126
- f"No 'Token' field in auth response from {endpoint}. "
127
- f"Response keys: {list(response_data.keys())}"
128
- )
129
-
130
- # Return auth data with token lifetime
131
- return {"token": str(token)}, CATALYST_CENTER_TOKEN_LIFETIME_SECONDS
132
-
133
- except (httpx.HTTPError, ValueError) as e:
134
- last_error = e
135
- # Try next endpoint
136
- continue
137
-
138
- # All endpoints failed
139
- raise RuntimeError(
140
- f"Catalyst Center authentication failed on all endpoints. "
141
- f"Last error: {last_error}"
142
- ) from last_error
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
- RuntimeError: If authentication fails on all available endpoints.
172
- httpx.HTTPStatusError: If Catalyst Center returns authentication errors.
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 extract_os_type(self, device_data: dict[str, Any]) -> str:
145
- """Return 'iosxe' as all managed devices are IOS-XE based.
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 (unused, OS is hardcoded).
151
+ device_data: Device data dictionary from the data model.
149
152
 
150
153
  Returns:
151
- Always returns 'iosxe'.
154
+ Dictionary with 'os' key (and potentially platform/model in future).
152
155
  """
153
- return "iosxe"
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
- - extract_os_type(): Extract OS type from device data
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 credentials from environment variables
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
- Credentials are injected separately.
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
- os_type = self.extract_os_type(device_data)
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
- return {
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 credentials from environment variables.
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 - raise error if credentials missing
275
+ # FAIL FAST - validate env vars are set (without reading values)
243
276
  missing_vars: list[str] = []
244
- if not username:
277
+ if username_var not in os.environ:
245
278
  missing_vars.append(username_var)
246
- if not password:
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
- logger.debug(f"Injecting credentials from {username_var} and {password_var}")
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"] = username
259
- device["password"] = 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 extract_os_type(self, device_data: dict[str, Any]) -> str:
367
- """Extract operating system type from device data.
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
- OS type string (e.g., "iosxe", "nxos", "iosxr").
374
-
375
- Example (SD-WAN):
376
- >>> def extract_os_type(self, device_data):
377
- ... return device_data.get("os", "iosxe")
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 extract_os_type(self, device_data: dict[str, Any]) -> str:
60
- """Extract OS type."""
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
- 3. Creating and storing an SDWAN Manager client for use in verification methods
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
- self.auth_data = SDWANManagerAuth.get_auth()
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
- # Store the SDWAN Manager client for use in verification methods
92
- self.client = self.get_sdwan_manager_client()
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. Calls NACTestBase.run_verification_async() to execute tests
145
- 3. Passes results to NACTestBase.process_results_smart() for reporting
146
- 4. Ensures proper cleanup of async resources
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