nac-test-pyats-common 0.1.1__tar.gz → 0.2.1__tar.gz

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.
Files changed (43) hide show
  1. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/PKG-INFO +3 -2
  2. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/pyproject.toml +9 -3
  3. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/__init__.py +8 -1
  4. nac_test_pyats_common-0.2.1/src/nac_test_pyats_common/aci/auth.py +287 -0
  5. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/aci/test_base.py +35 -12
  6. nac_test_pyats_common-0.2.1/src/nac_test_pyats_common/catc/__init__.py +56 -0
  7. nac_test_pyats_common-0.1.1/src/nac_test_pyats_common/catc/test_base.py → nac_test_pyats_common-0.2.1/src/nac_test_pyats_common/catc/api_test_base.py +35 -11
  8. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/catc/auth.py +116 -49
  9. nac_test_pyats_common-0.2.1/src/nac_test_pyats_common/catc/device_resolver.py +171 -0
  10. nac_test_pyats_common-0.2.1/src/nac_test_pyats_common/catc/ssh_test_base.py +115 -0
  11. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/common/base_device_resolver.py +144 -199
  12. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/iosxe/iosxe_resolver.py +2 -2
  13. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/iosxe/test_base.py +6 -0
  14. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/sdwan/api_test_base.py +28 -7
  15. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/sdwan/auth.py +125 -64
  16. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/sdwan/device_resolver.py +69 -22
  17. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/sdwan/ssh_test_base.py +11 -9
  18. nac_test_pyats_common-0.2.1/tests/unit/aci/__init__.py +2 -0
  19. nac_test_pyats_common-0.2.1/tests/unit/aci/test_auth.py +164 -0
  20. nac_test_pyats_common-0.2.1/tests/unit/catc/__init__.py +4 -0
  21. nac_test_pyats_common-0.2.1/tests/unit/catc/test_auth.py +112 -0
  22. nac_test_pyats_common-0.2.1/tests/unit/catc/test_device_resolver.py +534 -0
  23. nac_test_pyats_common-0.2.1/tests/unit/sdwan/__init__.py +0 -0
  24. nac_test_pyats_common-0.2.1/tests/unit/sdwan/test_auth.py +107 -0
  25. nac_test_pyats_common-0.2.1/tests/unit/sdwan/test_device_resolver.py +614 -0
  26. nac_test_pyats_common-0.2.1/tests/unit/test_base_device_resolver.py +1080 -0
  27. nac_test_pyats_common-0.1.1/src/nac_test_pyats_common/aci/auth.py +0 -163
  28. nac_test_pyats_common-0.1.1/src/nac_test_pyats_common/catc/__init__.py +0 -38
  29. nac_test_pyats_common-0.1.1/src/nac_test_pyats_common/iosxe/catc_resolver.py +0 -67
  30. nac_test_pyats_common-0.1.1/tests/unit/test_base_device_resolver.py +0 -906
  31. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/.gitignore +0 -0
  32. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/LICENSE +0 -0
  33. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/README.md +0 -0
  34. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/aci/__init__.py +0 -0
  35. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/common/__init__.py +0 -0
  36. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/iosxe/__init__.py +0 -0
  37. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/iosxe/registry.py +0 -0
  38. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/py.typed +0 -0
  39. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/sdwan/__init__.py +0 -0
  40. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/tests/__init__.py +0 -0
  41. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/tests/unit/__init__.py +0 -0
  42. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/tests/unit/iosxe/__init__.py +0 -0
  43. {nac_test_pyats_common-0.1.1 → nac_test_pyats_common-0.2.1}/tests/unit/iosxe/test_registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nac-test-pyats-common
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Summary: Architecture adapters for Network as Code (NaC) PyATS testing - auth classes, test base classes, and device resolvers
5
5
  Project-URL: Homepage, https://github.com/netascode/nac-test-pyats-common
6
6
  Project-URL: Documentation, https://github.com/netascode/nac-test-pyats-common
@@ -23,8 +23,9 @@ Classifier: Programming Language :: Python :: 3.13
23
23
  Classifier: Topic :: Software Development :: Testing
24
24
  Classifier: Topic :: System :: Networking
25
25
  Requires-Python: >=3.10
26
+ Requires-Dist: filelock>=3.20.1
26
27
  Requires-Dist: httpx>=0.28
27
- Requires-Dist: nac-test==1.1.0b2
28
+ Requires-Dist: nac-test==1.1.0b3
28
29
  Provides-Extra: dev
29
30
  Requires-Dist: bandit[toml]>=1.8.6; extra == 'dev'
30
31
  Requires-Dist: mypy>=1.10; extra == 'dev'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nac-test-pyats-common"
3
- version = "0.1.1"
3
+ version = "0.2.1"
4
4
  description = "Architecture adapters for Network as Code (NaC) PyATS testing - auth classes, test base classes, and device resolvers"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -27,8 +27,9 @@ classifiers = [
27
27
  keywords = ["cisco", "network", "testing", "pyats", "nac", "aci", "sdwan", "catalyst-center"]
28
28
 
29
29
  dependencies = [
30
- "httpx>=0.28", # HTTP client for auth
31
- "nac-test==1.1.0b2", # Pinned until nac-test stable release includes pyats functionality
30
+ "filelock>=3.20.1",
31
+ "httpx>=0.28", # HTTP client for auth
32
+ "nac-test==1.1.0b3", # Pinned until nac-test stable release includes pyats functionality
32
33
  ]
33
34
 
34
35
  [project.optional-dependencies]
@@ -145,3 +146,8 @@ exclude_lines = [
145
146
  "@abstract",
146
147
  ]
147
148
  omit = ["*/__main__.py"]
149
+
150
+ [dependency-groups]
151
+ dev = [
152
+ "ruff>=0.14.10",
153
+ ]
@@ -29,7 +29,12 @@ __version__ = "1.0.0"
29
29
 
30
30
  # Public API - Import from subpackages
31
31
  from nac_test_pyats_common.aci import APICAuth, APICTestBase
32
- from nac_test_pyats_common.catc import CatalystCenterAuth, CatalystCenterTestBase
32
+ from nac_test_pyats_common.catc import (
33
+ CatalystCenterAuth,
34
+ CatalystCenterDeviceResolver,
35
+ CatalystCenterSSHTestBase,
36
+ CatalystCenterTestBase,
37
+ )
33
38
  from nac_test_pyats_common.sdwan import (
34
39
  SDWANDeviceResolver,
35
40
  SDWANManagerAuth,
@@ -49,4 +54,6 @@ __all__ = [
49
54
  # Catalyst Center
50
55
  "CatalystCenterAuth",
51
56
  "CatalystCenterTestBase",
57
+ "CatalystCenterSSHTestBase",
58
+ "CatalystCenterDeviceResolver",
52
59
  ]
@@ -0,0 +1,287 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2025 Daniel Schmidt
3
+
4
+ """APIC authentication module for Cisco ACI (Application Centric Infrastructure).
5
+
6
+ This module provides authentication functionality for Cisco APIC (Application Policy
7
+ Infrastructure Controller), which is the central management and policy enforcement
8
+ point for ACI fabric. The authentication mechanism uses REST API calls to obtain
9
+ session tokens that are valid for a limited time period.
10
+
11
+ The module implements a two-tier API design:
12
+ 1. authenticate() - Low-level method that performs direct APIC authentication
13
+ 2. get_token() - High-level method that leverages caching for efficient token reuse
14
+
15
+ This design ensures efficient token management by reusing valid tokens and only
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().
23
+ """
24
+
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,
31
+ )
32
+
33
+ # Default token lifetime for APIC authentication tokens in seconds
34
+ # APIC tokens are typically valid for 10 minutes (600 seconds) by default
35
+ APIC_TOKEN_LIFETIME_SECONDS: int = 600
36
+
37
+ # HTTP timeout for authentication request
38
+ AUTH_REQUEST_TIMEOUT_SECONDS: float = 30.0
39
+
40
+
41
+ class APICAuth:
42
+ """APIC-specific authentication implementation with token caching.
43
+
44
+ This class provides a two-tier API for APIC authentication:
45
+
46
+ 1. Low-level authenticate() method: Directly authenticates with APIC and returns
47
+ a token along with its expiration time. This is typically used by the caching
48
+ layer and not called directly by consumers.
49
+
50
+ 2. High-level get_token() method: Provides cached token management, automatically
51
+ handling token renewal when expired. This is the primary method that consumers
52
+ should use for obtaining APIC tokens.
53
+
54
+ The two-tier design ensures efficient token reuse across multiple API calls while
55
+ maintaining clean separation between authentication logic and caching concerns.
56
+ """
57
+
58
+ @staticmethod
59
+ def authenticate(
60
+ url: str, username: str, password: str, verify_ssl: bool = False
61
+ ) -> tuple[str, int]:
62
+ """Perform direct APIC authentication and obtain a session token.
63
+
64
+ This method performs a direct authentication request to the APIC controller
65
+ using the provided credentials. It returns both the token and its lifetime
66
+ for proper cache management.
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
+
72
+ Args:
73
+ url: Base URL of the APIC controller (e.g., "https://apic.example.com").
74
+ Should not include trailing slashes or API paths.
75
+ username: APIC username for authentication. This should be a valid user
76
+ configured in the APIC with appropriate permissions.
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.
80
+
81
+ Returns:
82
+ A tuple containing:
83
+ - token (str): The APIC session token that should be included in
84
+ subsequent API requests as a cookie (APIC-cookie).
85
+ - expires_in (int): Token lifetime in seconds (typically 600 seconds).
86
+
87
+ Raises:
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.
96
+ """
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
177
+
178
+ @classmethod
179
+ def get_token(
180
+ cls, url: str, username: str, password: str, verify_ssl: bool = False
181
+ ) -> str:
182
+ """Get APIC token with automatic caching and renewal.
183
+
184
+ This is the primary method that consumers should use to obtain APIC tokens.
185
+ It leverages the AuthCache to efficiently manage token lifecycle, reusing
186
+ valid tokens and automatically renewing expired ones. This significantly
187
+ reduces the number of authentication requests to the APIC controller.
188
+
189
+ The method uses a cache key based on the controller type ("ACI"), URL,
190
+ and username to ensure proper token isolation between different APIC
191
+ instances and user accounts.
192
+
193
+ Args:
194
+ url: Base URL of the APIC controller (e.g., "https://apic.example.com").
195
+ Should not include trailing slashes or API paths.
196
+ username: APIC username for authentication. This should be a valid user
197
+ configured in the APIC with appropriate permissions.
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.
201
+
202
+ Returns:
203
+ A valid APIC session token that can be used in API requests.
204
+ The token should be included as a cookie (APIC-cookie) in subsequent
205
+ API calls to the APIC controller.
206
+
207
+ Raises:
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.
211
+
212
+ Examples:
213
+ >>> # Get a token for APIC access
214
+ >>> token = APICAuth.get_token(
215
+ ... url="https://apic.example.com",
216
+ ... username="admin",
217
+ ... password="password123"
218
+ ... )
219
+ >>> # Use the token in subsequent API calls
220
+ >>> headers = {"Cookie": f"APIC-cookie={token}"}
221
+ """
222
+ # AuthCache.get_or_create_token returns str, but mypy can't verify this
223
+ # because nac_test lacks py.typed marker. The return type is guaranteed
224
+ # by AuthCache's implementation which uses extract_token=True mode.
225
+ return AuthCache.get_or_create_token( # type: ignore[no-any-return]
226
+ controller_type="ACI",
227
+ url=url,
228
+ username=username,
229
+ password=password,
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",
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 for APIC.
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
- 3. Creating and storing an APIC client for use in verification methods
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
- self.token = APICAuth.get_token(
78
- self.controller_url, self.username, self.password
79
- )
80
-
81
- # Store the APIC client for use in verification methods
82
- self.client = self.get_apic_client()
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. Calls NACTestBase.run_verification_async() to execute tests
118
- 3. Passes results to NACTestBase.process_results_smart() for reporting
119
- 4. Ensures proper cleanup of async resources
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(self, "client"):
168
+ if self.client is not None: # MANDATORY: never use hasattr()
146
169
  loop.run_until_complete(self.client.aclose())
147
170
  loop.close()
@@ -0,0 +1,56 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2025 Daniel Schmidt
3
+
4
+ """Catalyst Center adapter module for NAC PyATS testing.
5
+
6
+ This module provides Catalyst Center-specific authentication, test base classes, and
7
+ device resolver implementations for use with the nac-test framework. It includes support
8
+ for both Catalyst Center API testing and SSH-based device-to-device (D2D) testing.
9
+
10
+ Classes:
11
+ CatalystCenterAuth: Token-based authentication with automatic endpoint
12
+ detection.
13
+ CatalystCenterTestBase: Base class for Catalyst Center API tests with
14
+ tracking.
15
+ CatalystCenterSSHTestBase: Base class for Catalyst Center SSH/D2D tests
16
+ with device inventory.
17
+ CatalystCenterDeviceResolver: Resolves device information from the
18
+ Catalyst Center data model.
19
+
20
+ Example:
21
+ For Catalyst Center API testing:
22
+
23
+ >>> from nac_test_pyats_common.catc import CatalystCenterTestBase
24
+ >>>
25
+ >>> class VerifyNetworkDevices(CatalystCenterTestBase):
26
+ ... async def get_items_to_verify(self):
27
+ ... return ['device-uuid-1', 'device-uuid-2']
28
+ ...
29
+ ... async def verify_item(self, item):
30
+ ... response = await self.client.get(
31
+ ... f"/dna/intent/api/v1/network-device/{item}"
32
+ ... )
33
+ ... return response.status_code == 200
34
+
35
+ For SSH/D2D testing:
36
+
37
+ >>> from nac_test_pyats_common.catc import CatalystCenterSSHTestBase
38
+ >>>
39
+ >>> class VerifyInterfaceStatus(CatalystCenterSSHTestBase):
40
+ ... @aetest.test
41
+ ... def verify_interfaces(self, steps, device):
42
+ ... # SSH-based verification
43
+ ... pass
44
+ """
45
+
46
+ from .api_test_base import CatalystCenterTestBase
47
+ from .auth import CatalystCenterAuth
48
+ from .device_resolver import CatalystCenterDeviceResolver
49
+ from .ssh_test_base import CatalystCenterSSHTestBase
50
+
51
+ __all__ = [
52
+ "CatalystCenterAuth",
53
+ "CatalystCenterTestBase",
54
+ "CatalystCenterSSHTestBase",
55
+ "CatalystCenterDeviceResolver",
56
+ ]
@@ -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 VManageTestBase for
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
- 4. Creating and storing a Catalyst Center client for use in verification methods
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
- self.auth_data = CatalystCenterAuth.get_auth()
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
- # Store the Catalyst Center client for use in verification methods
97
- self.client = self.get_catc_client()
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. Calls NACTestBase.run_verification_async() to execute tests
148
- 3. Passes results to NACTestBase.process_results_smart() for reporting
149
- 4. Ensures proper cleanup of async resources
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(self, "client"):
199
+ if self.client is not None: # MANDATORY: never use hasattr()
176
200
  loop.run_until_complete(self.client.aclose())
177
201
  loop.close()