nac-test-pyats-common 0.1.1__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.
@@ -8,14 +8,12 @@ Architecture-specific resolvers extend this class and implement the
8
8
  abstract methods for schema navigation and credential retrieval.
9
9
  """
10
10
 
11
+ import ipaddress
11
12
  import logging
12
13
  import os
13
14
  from abc import ABC, abstractmethod
14
15
  from typing import Any
15
16
 
16
- import yaml
17
- from nac_test.utils.file_discovery import find_data_file
18
-
19
17
  logger = logging.getLogger(__name__)
20
18
 
21
19
 
@@ -23,29 +21,31 @@ class BaseDeviceResolver(ABC):
23
21
  """Abstract base class for architecture-specific device resolvers.
24
22
 
25
23
  This class implements the Template Method pattern for device inventory
26
- resolution. It handles common logic (inventory loading, credential
27
- injection, device dict construction) while delegating schema-specific
28
- work to abstract methods.
24
+ resolution. It handles common logic (credential injection, device dict
25
+ construction) while delegating schema-specific work to abstract methods.
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.
29
30
 
30
31
  Subclasses MUST implement:
31
32
  - get_architecture_name(): Return architecture identifier (e.g., "sdwan")
32
33
  - get_schema_root_key(): Return the root key in data model (e.g., "sdwan")
33
34
  - navigate_to_devices(): Navigate schema to get iterable of device data
34
- - extract_device_id(): Extract unique device identifier from device data
35
35
  - extract_hostname(): Extract hostname from device data
36
36
  - extract_host_ip(): Extract management IP from device data
37
- - extract_os_type(): Extract OS type from device data
37
+ - extract_os_platform_type(): Extract OS and platform info from device data
38
38
  - get_credential_env_vars(): Return (username_env_var, password_env_var)
39
39
 
40
40
  Subclasses MAY override:
41
- - get_inventory_filename(): Return inventory filename
42
- (default: "test_inventory.yaml")
41
+ - extract_device_id(): Extract device identifier (default: uses hostname)
43
42
  - build_device_dict(): Customize device dict construction
44
- - _load_inventory(): Customize inventory loading
43
+ - validate_device_data(): Pre-extraction validation hook
45
44
 
46
45
  Attributes:
47
46
  data_model: The merged NAC data model dictionary.
48
- test_inventory: The test inventory dictionary (devices to test).
47
+ skipped_devices: List of dicts with device_id and reason for devices
48
+ that failed resolution. Populated after get_resolved_inventory().
49
49
 
50
50
  Example:
51
51
  >>> class SDWANDeviceResolver(BaseDeviceResolver):
@@ -59,36 +59,29 @@ class BaseDeviceResolver(ABC):
59
59
  >>>
60
60
  >>> resolver = SDWANDeviceResolver(data_model)
61
61
  >>> devices = resolver.get_resolved_inventory()
62
+ >>> # devices[0]["username"] == "%ENV{IOSXE_USERNAME}"
63
+ >>> # devices[0]["password"] == "%ENV{IOSXE_PASSWORD}"
62
64
  """
63
65
 
64
- def __init__(
65
- self,
66
- data_model: dict[str, Any],
67
- test_inventory: dict[str, Any] | None = None,
68
- ) -> None:
66
+ def __init__(self, data_model: dict[str, Any]) -> None:
69
67
  """Initialize the device resolver.
70
68
 
71
69
  Args:
72
70
  data_model: The merged NAC data model containing all architecture
73
71
  data with resolved variables.
74
- test_inventory: Optional test inventory specifying which devices
75
- to test. If not provided, will attempt to load from file.
76
72
  """
77
73
  self.data_model = data_model
78
- self.test_inventory = test_inventory or self._load_inventory()
79
- logger.debug(
80
- f"Initialized {self.get_architecture_name()} resolver with "
81
- f"{len(self.test_inventory.get('devices', []))} devices in inventory"
82
- )
74
+ self.skipped_devices: list[dict[str, str]] = []
75
+ logger.debug(f"Initialized {self.get_architecture_name()} resolver")
83
76
 
84
77
  def get_resolved_inventory(self) -> list[dict[str, Any]]:
85
78
  """Get resolved device inventory ready for SSH connection.
86
79
 
87
80
  This is the main entry point. It:
88
81
  1. Navigates the data model to find device data
89
- 2. Matches devices against test inventory (if provided)
90
- 3. Extracts hostname, IP, OS from each device
91
- 4. Injects SSH credentials from environment variables
82
+ 2. Extracts hostname and management IP from each device
83
+ 3. Sets OS type (architecture-specific, e.g., hardcoded to 'iosxe' for SD-WAN)
84
+ 4. Injects SSH credential environment variable references
92
85
  5. Returns list of device dicts ready for nac-test
93
86
 
94
87
  Returns:
@@ -96,8 +89,8 @@ class BaseDeviceResolver(ABC):
96
89
  - hostname (str)
97
90
  - host (str)
98
91
  - os (str)
99
- - username (str)
100
- - password (str)
92
+ - username (str): Environment variable reference in %ENV{VARNAME} format
93
+ - password (str): Environment variable reference in %ENV{VARNAME} format
101
94
  - Plus any architecture-specific fields
102
95
 
103
96
  Raises:
@@ -106,10 +99,15 @@ class BaseDeviceResolver(ABC):
106
99
  logger.info(f"Resolving device inventory for {self.get_architecture_name()}")
107
100
 
108
101
  resolved_devices: list[dict[str, Any]] = []
109
- devices_to_test = self._get_devices_to_test()
102
+ self.skipped_devices = [] # Reset for each resolution
103
+ all_devices = list(self.navigate_to_devices())
104
+ logger.debug(f"Found {len(all_devices)} devices in data model")
110
105
 
111
- for device_data in devices_to_test:
106
+ for device_data in all_devices:
112
107
  try:
108
+ # Validate device data before extraction (optional hook)
109
+ self.validate_device_data(device_data)
110
+
113
111
  device_dict = self.build_device_dict(device_data)
114
112
 
115
113
  # Validate extracted fields
@@ -129,18 +127,53 @@ class BaseDeviceResolver(ABC):
129
127
  )
130
128
  except (KeyError, ValueError) as e:
131
129
  device_id = self._safe_extract_device_id(device_data)
132
- logger.warning(f"Skipping device {device_id}: {e}")
130
+ logger.debug(f"Skipping device {device_id}: {e}")
131
+ self.skipped_devices.append(
132
+ {
133
+ "device_id": device_id,
134
+ "reason": str(e),
135
+ }
136
+ )
133
137
  continue
134
138
 
135
139
  # Inject credentials (fail fast if missing)
136
140
  self._inject_credentials(resolved_devices)
137
141
 
142
+ skipped_msg = (
143
+ f", skipped {len(self.skipped_devices)}" if self.skipped_devices else ""
144
+ )
138
145
  logger.info(
139
146
  f"Resolved {len(resolved_devices)} devices for "
140
- f"{self.get_architecture_name()} D2D testing"
147
+ f"{self.get_architecture_name()} D2D testing{skipped_msg}"
141
148
  )
142
149
  return resolved_devices
143
150
 
151
+ def validate_device_data(self, _device_data: dict[str, Any]) -> None: # noqa: B027
152
+ """Validate device data before extraction (optional hook).
153
+
154
+ Override this method to perform architecture-specific validation
155
+ before device field extraction. This is useful for filtering devices
156
+ based on state, type, or other criteria.
157
+
158
+ The default implementation does nothing - all devices pass validation.
159
+ Subclasses can override this to implement custom validation logic.
160
+
161
+ Args:
162
+ _device_data: Raw device data from the data model (unused in base class).
163
+
164
+ Raises:
165
+ ValueError: If the device should be skipped. The error message
166
+ will be logged and included in skipped_devices tracking.
167
+
168
+ Example (Catalyst Center - skip devices in INIT/PNP states):
169
+ >>> def validate_device_data(self, device_data):
170
+ ... state = device_data.get("state", "").upper()
171
+ ... if state in ("INIT", "PNP"):
172
+ ... raise ValueError(f"Device has unsupported state '{state}'")
173
+ """
174
+ # Default implementation does nothing - subclasses can override
175
+ pass
176
+
144
177
  def build_device_dict(self, device_data: dict[str, Any]) -> dict[str, Any]:
145
178
  """Build a device dictionary from raw device data.
146
179
 
@@ -152,15 +185,15 @@ class BaseDeviceResolver(ABC):
152
185
 
153
186
  Returns:
154
187
  Device dictionary with hostname, host, os, device_id fields,
155
- plus any test-relevant fields like connection_options.
156
- Credentials are injected separately.
188
+ plus optional platform, model, series fields if provided by
189
+ extract_os_platform_type(). Credentials are injected separately.
157
190
 
158
191
  Raises:
159
192
  ValueError: If any required field extraction fails.
160
193
  """
161
194
  hostname = self.extract_hostname(device_data)
162
195
  host = self.extract_host_ip(device_data)
163
- os_type = self.extract_os_type(device_data)
196
+ os_platform_info = self.extract_os_platform_type(device_data)
164
197
  device_id = self.extract_device_id(device_data)
165
198
 
166
199
  # Validate all extracted values are non-empty strings
@@ -168,150 +201,51 @@ class BaseDeviceResolver(ABC):
168
201
  raise ValueError(f"Invalid hostname: {hostname!r}")
169
202
  if not isinstance(host, str) or not host:
170
203
  raise ValueError(f"Invalid host IP: {host!r}")
204
+
205
+ # Validate IP address format (after CIDR stripping done by subclass)
206
+ try:
207
+ ipaddress.ip_address(host)
208
+ except ValueError:
209
+ raise ValueError(
210
+ f"Invalid IP address format: '{host}'. "
211
+ "Ensure the field contains a valid IPv4 or IPv6 address."
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"]
171
225
  if not isinstance(os_type, str) or not os_type:
172
226
  raise ValueError(f"Invalid OS type: {os_type!r}")
173
227
  if not isinstance(device_id, str) or not device_id:
174
228
  raise ValueError(f"Invalid device ID: {device_id!r}")
175
229
 
176
- # Start with required fields
177
- result = {
230
+ # Build base device dict with required fields
231
+ device_dict = {
178
232
  "hostname": hostname,
179
233
  "host": host,
180
234
  "os": os_type,
181
235
  "device_id": device_id,
182
236
  }
183
237
 
184
- # Preserve connection_options from test_inventory if present
185
- # This allows specifying custom SSH ports, protocols, etc.
186
- if "connection_options" in device_data:
187
- result["connection_options"] = device_data["connection_options"]
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]
188
242
 
189
- return result
243
+ return device_dict
190
244
 
191
245
  # -------------------------------------------------------------------------
192
246
  # Private helper methods
193
247
  # -------------------------------------------------------------------------
194
248
 
195
- def _load_inventory(self) -> dict[str, Any]:
196
- """Load test inventory from file.
197
-
198
- Searches for the inventory file using the generic file discovery
199
- utility. Subclasses can override this to customize loading behavior.
200
-
201
- Returns:
202
- Test inventory dictionary, or empty dict if not found.
203
- """
204
- filename = self.get_inventory_filename()
205
- inventory_path = find_data_file(filename)
206
-
207
- if inventory_path is None:
208
- logger.warning(
209
- f"Test inventory file '{filename}' not found for "
210
- f"{self.get_architecture_name()}. Using empty inventory."
211
- )
212
- return {}
213
-
214
- logger.info(f"Loading test inventory from {inventory_path}")
215
- try:
216
- with open(inventory_path) as f:
217
- raw_data = yaml.safe_load(f) or {}
218
-
219
- # Support both nested and flat formats:
220
- # Nested: {arch: {test_inventory: {...}}}
221
- # Flat: {test_inventory: {...}}
222
- arch_key = self.get_schema_root_key()
223
- if arch_key in raw_data and "test_inventory" in raw_data[arch_key]:
224
- return raw_data[arch_key]["test_inventory"] # type: ignore[no-any-return]
225
- elif "test_inventory" in raw_data:
226
- return raw_data["test_inventory"] # type: ignore[no-any-return]
227
- else:
228
- return raw_data
229
-
230
- except yaml.YAMLError as e:
231
- logger.error(f"Failed to parse test inventory YAML: {e}")
232
- return {}
233
- except OSError as e:
234
- logger.error(f"Failed to read test inventory file: {e}")
235
- return {}
236
-
237
- def _get_devices_to_test(self) -> list[dict[str, Any]]:
238
- """Get the list of device data dicts to process.
239
-
240
- If test_inventory specifies devices, filter to only those.
241
- Otherwise, return all devices from the data model.
242
-
243
- Returns:
244
- List of device data dictionaries from the data model.
245
- """
246
- all_devices = list(self.navigate_to_devices())
247
- logger.debug(f"Found {len(all_devices)} total devices in data model")
248
-
249
- # If no test inventory, test all devices
250
- inventory_devices = self.test_inventory.get("devices", [])
251
- if not inventory_devices:
252
- logger.debug("No test inventory devices specified, testing all devices")
253
- return all_devices
254
-
255
- # Build index for efficient lookup
256
- device_index = self._build_device_index(all_devices)
257
-
258
- # Filter to devices in test inventory
259
- devices_to_test: list[dict[str, Any]] = []
260
- for inventory_entry in inventory_devices:
261
- device_id = self._get_device_id_from_inventory(inventory_entry)
262
- if device_id in device_index:
263
- # Merge inventory entry data with device data
264
- merged = {**device_index[device_id], **inventory_entry}
265
- devices_to_test.append(merged)
266
- logger.debug(f"Added device {device_id} from test inventory")
267
- else:
268
- logger.warning(
269
- f"Device '{device_id}' from test_inventory not found in "
270
- f"{self.get_architecture_name()} data model"
271
- )
272
-
273
- logger.debug(f"Filtered to {len(devices_to_test)} devices from test inventory")
274
- return devices_to_test
275
-
276
- def _build_device_index(
277
- self, devices: list[dict[str, Any]]
278
- ) -> dict[str, dict[str, Any]]:
279
- """Build a lookup index of devices by their ID.
280
-
281
- Args:
282
- devices: List of device data dictionaries.
283
-
284
- Returns:
285
- Dictionary mapping device ID to device data.
286
- """
287
- device_index: dict[str, dict[str, Any]] = {}
288
- for device_data in devices:
289
- device_id = self._safe_extract_device_id(device_data)
290
- if device_id:
291
- device_index[device_id] = device_data
292
- return device_index
293
-
294
- def _get_device_id_from_inventory(self, inventory_entry: dict[str, Any]) -> str:
295
- """Extract device ID from a test inventory entry.
296
-
297
- Override this if your inventory uses a different field name.
298
-
299
- Args:
300
- inventory_entry: Entry from test_inventory.devices[]
301
-
302
- Returns:
303
- Device identifier string.
304
- """
305
- # Common patterns across architectures
306
- for key in ["chassis_id", "device_id", "node_id", "hostname", "name"]:
307
- if key in inventory_entry:
308
- return str(inventory_entry[key])
309
-
310
- logger.warning(
311
- f"Could not extract device ID from inventory entry: {inventory_entry}"
312
- )
313
- return ""
314
-
315
249
  def _safe_extract_device_id(self, device_data: dict[str, Any]) -> str:
316
250
  """Safely extract device ID, returning empty string on failure."""
317
251
  try:
@@ -320,7 +254,15 @@ class BaseDeviceResolver(ABC):
320
254
  return "<unknown>"
321
255
 
322
256
  def _inject_credentials(self, devices: list[dict[str, Any]]) -> None:
323
- """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.
324
266
 
325
267
  Args:
326
268
  devices: List of device dicts to update in place.
@@ -329,14 +271,12 @@ class BaseDeviceResolver(ABC):
329
271
  ValueError: If required credential environment variables are not set.
330
272
  """
331
273
  username_var, password_var = self.get_credential_env_vars()
332
- username = os.environ.get(username_var)
333
- password = os.environ.get(password_var)
334
274
 
335
- # FAIL FAST - raise error if credentials missing
275
+ # FAIL FAST - validate env vars are set (without reading values)
336
276
  missing_vars: list[str] = []
337
- if not username:
277
+ if username_var not in os.environ:
338
278
  missing_vars.append(username_var)
339
- if not password:
279
+ if password_var not in os.environ:
340
280
  missing_vars.append(password_var)
341
281
 
342
282
  if missing_vars:
@@ -346,10 +286,13 @@ class BaseDeviceResolver(ABC):
346
286
  f"These are required for {self.get_architecture_name()} D2D testing."
347
287
  )
348
288
 
349
- 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
+ )
350
293
  for device in devices:
351
- device["username"] = username
352
- device["password"] = password
294
+ device["username"] = f"%ENV{{{username_var}}}"
295
+ device["password"] = f"%ENV{{{password_var}}}"
353
296
 
354
297
  # -------------------------------------------------------------------------
355
298
  # Abstract methods - MUST be implemented by subclasses
@@ -370,7 +313,7 @@ class BaseDeviceResolver(ABC):
370
313
  def get_schema_root_key(self) -> str:
371
314
  """Return the root key in the data model for this architecture.
372
315
 
373
- Used when loading test inventory and navigating the schema.
316
+ Used when navigating the schema.
374
317
 
375
318
  Returns:
376
319
  Root key (e.g., "sdwan", "apic", "cc").
@@ -396,11 +339,14 @@ class BaseDeviceResolver(ABC):
396
339
  """
397
340
  ...
398
341
 
399
- @abstractmethod
400
342
  def extract_device_id(self, device_data: dict[str, Any]) -> str:
401
343
  """Extract unique device identifier from device data.
402
344
 
403
- This ID is used to match test_inventory entries with data model devices.
345
+ Default implementation delegates to extract_hostname(), which is
346
+ appropriate when device_id and hostname are the same field.
347
+
348
+ Override this method when your architecture has a distinct device
349
+ identifier (e.g., SD-WAN uses chassis_id, not hostname).
404
350
 
405
351
  Args:
406
352
  device_data: Device data dict from navigate_to_devices().
@@ -408,11 +354,11 @@ class BaseDeviceResolver(ABC):
408
354
  Returns:
409
355
  Unique device identifier string.
410
356
 
411
- Example (SD-WAN):
357
+ Example (SD-WAN override):
412
358
  >>> def extract_device_id(self, device_data):
413
359
  ... return device_data["chassis_id"]
414
360
  """
415
- ...
361
+ return self.extract_hostname(device_data)
416
362
 
417
363
  @abstractmethod
418
364
  def extract_hostname(self, device_data: dict[str, Any]) -> str:
@@ -435,6 +381,8 @@ class BaseDeviceResolver(ABC):
435
381
  """Extract management IP address from device data.
436
382
 
437
383
  Should handle any IP formatting (e.g., strip CIDR notation).
384
+ The returned value will be validated by the base class to ensure
385
+ it is a valid IPv4 or IPv6 address.
438
386
 
439
387
  Args:
440
388
  device_data: Device data dict from navigate_to_devices().
@@ -444,25 +392,36 @@ class BaseDeviceResolver(ABC):
444
392
 
445
393
  Example (SD-WAN):
446
394
  >>> def extract_host_ip(self, device_data):
447
- ... ip_var = device_data.get("management_ip_variable", "mgmt_ip")
395
+ ... ip_var = device_data.get("management_ip_variable")
448
396
  ... ip = device_data["device_variables"].get(ip_var, "")
449
397
  ... return ip.split("/")[0] if "/" in ip else ip
450
398
  """
451
399
  ...
452
400
 
453
401
  @abstractmethod
454
- def extract_os_type(self, device_data: dict[str, Any]) -> str:
455
- """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")
456
410
 
457
411
  Args:
458
412
  device_data: Device data dict from navigate_to_devices().
459
413
 
460
414
  Returns:
461
- OS type string (e.g., "iosxe", "nxos", "iosxr").
462
-
463
- Example (SD-WAN):
464
- >>> def extract_os_type(self, device_data):
465
- ... 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
+ ... }
466
425
  """
467
426
  ...
468
427
 
@@ -485,17 +444,3 @@ class BaseDeviceResolver(ABC):
485
444
  ... return ("NXOS_SSH_USERNAME", "NXOS_SSH_PASSWORD")
486
445
  """
487
446
  ...
488
-
489
- # -------------------------------------------------------------------------
490
- # Optional overrides
491
- # -------------------------------------------------------------------------
492
-
493
- def get_inventory_filename(self) -> str:
494
- """Return the test inventory filename.
495
-
496
- Override to use a different filename.
497
-
498
- Returns:
499
- Filename (default: "test_inventory.yaml").
500
- """
501
- return "test_inventory.yaml"
@@ -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]:
@@ -21,6 +21,11 @@ class IOSXETestBase(SSHTestBase): # type: ignore[misc]
21
21
  - IOS-XE (direct device access via IOSXE_URL)
22
22
  """
23
23
 
24
+ # Class-level storage for the last resolver instance
25
+ # This allows nac-test to access skipped_devices after calling
26
+ # get_ssh_device_inventory()
27
+ _last_resolver: Any = None
28
+
24
29
  @classmethod
25
30
  def get_ssh_device_inventory(
26
31
  cls, data_model: dict[str, Any]
@@ -63,6 +68,7 @@ class IOSXETestBase(SSHTestBase): # type: ignore[misc]
63
68
  f"No device resolver registered for controller type '{controller_type}'"
64
69
  )
65
70
  resolver = resolver_class(data_model)
71
+ cls._last_resolver = resolver # Store for skipped_devices access
66
72
 
67
73
  # Inline validation: Check data model has expected root key
68
74
  expected_keys = {
@@ -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