nac-test-pyats-common 0.2.0__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.
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/PKG-INFO +3 -2
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/pyproject.toml +9 -3
- nac_test_pyats_common-0.2.1/src/nac_test_pyats_common/aci/auth.py +287 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/aci/test_base.py +35 -12
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/catc/api_test_base.py +35 -11
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/catc/auth.py +116 -49
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/catc/device_resolver.py +12 -5
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/common/base_device_resolver.py +71 -24
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/iosxe/iosxe_resolver.py +2 -2
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/sdwan/api_test_base.py +28 -7
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/sdwan/auth.py +125 -64
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/sdwan/device_resolver.py +23 -8
- nac_test_pyats_common-0.2.1/tests/unit/aci/__init__.py +2 -0
- nac_test_pyats_common-0.2.1/tests/unit/aci/test_auth.py +164 -0
- nac_test_pyats_common-0.2.1/tests/unit/catc/test_auth.py +112 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/tests/unit/catc/test_device_resolver.py +10 -9
- nac_test_pyats_common-0.2.1/tests/unit/sdwan/__init__.py +0 -0
- nac_test_pyats_common-0.2.1/tests/unit/sdwan/test_auth.py +107 -0
- nac_test_pyats_common-0.2.1/tests/unit/sdwan/test_device_resolver.py +614 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/tests/unit/test_base_device_resolver.py +18 -14
- nac_test_pyats_common-0.2.0/src/nac_test_pyats_common/aci/auth.py +0 -163
- nac_test_pyats_common-0.2.0/tests/unit/catc/test_auth.py +0 -344
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/.gitignore +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/LICENSE +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/README.md +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/__init__.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/aci/__init__.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/catc/__init__.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/catc/ssh_test_base.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/common/__init__.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/iosxe/__init__.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/iosxe/registry.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/iosxe/test_base.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/py.typed +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/sdwan/__init__.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/src/nac_test_pyats_common/sdwan/ssh_test_base.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/tests/__init__.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/tests/unit/__init__.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/tests/unit/catc/__init__.py +0 -0
- {nac_test_pyats_common-0.2.0 → nac_test_pyats_common-0.2.1}/tests/unit/iosxe/__init__.py +0 -0
- {nac_test_pyats_common-0.2.0 → 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.2.
|
|
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.
|
|
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.2.
|
|
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
|
-
"
|
|
31
|
-
"
|
|
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
|
+
]
|
|
@@ -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
|
|
37
|
+
client (httpx.AsyncClient | None): Wrapped async HTTP client configured
|
|
38
|
+
for APIC. Initialized to None, set during run_async_verification_test().
|
|
38
39
|
controller_url (str): Base URL of the APIC controller (inherited).
|
|
39
40
|
username (str): APIC username for authentication (inherited).
|
|
40
41
|
password (str): APIC password for authentication (inherited).
|
|
@@ -58,6 +59,8 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
58
59
|
self.run_async_verification_test(steps)
|
|
59
60
|
"""
|
|
60
61
|
|
|
62
|
+
client: httpx.AsyncClient | None = None # MUST declare at class level
|
|
63
|
+
|
|
61
64
|
@aetest.setup # type: ignore[misc, untyped-decorator]
|
|
62
65
|
def setup(self) -> None:
|
|
63
66
|
"""Setup method that extends the generic base class setup.
|
|
@@ -65,7 +68,11 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
65
68
|
Initializes the APIC test environment by:
|
|
66
69
|
1. Calling the parent class setup method
|
|
67
70
|
2. Obtaining an APIC authentication token using file-based locking
|
|
68
|
-
|
|
71
|
+
|
|
72
|
+
Note: Client creation is deferred to run_async_verification_test() to avoid
|
|
73
|
+
macOS fork() issues with httpx/SSL. Creating httpx.AsyncClient in a forked
|
|
74
|
+
process before entering an async context can cause crashes on macOS due to
|
|
75
|
+
OpenSSL threading primitives that are not fork-safe.
|
|
69
76
|
|
|
70
77
|
The authentication token is obtained through the APICAuth utility which
|
|
71
78
|
manages token lifecycle and prevents duplicate authentication requests
|
|
@@ -74,12 +81,20 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
74
81
|
super().setup()
|
|
75
82
|
|
|
76
83
|
# Get shared APIC token using file-based locking
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
# This reads from file cache - no httpx client creation here
|
|
85
|
+
try:
|
|
86
|
+
self.token = APICAuth.get_token(
|
|
87
|
+
self.controller_url, self.username, self.password
|
|
88
|
+
)
|
|
89
|
+
except (RuntimeError, ValueError) as e:
|
|
90
|
+
# Convert auth failures to FAILED (not ERRORED) - auth issues are
|
|
91
|
+
# expected failure conditions, not infrastructure errors
|
|
92
|
+
self.token = "" # Ensure attribute exists for cleanup code
|
|
93
|
+
self.failed(f"Authentication failed: {e}")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
# NOTE: Client creation is deferred to run_async_verification_test()
|
|
97
|
+
# to avoid macOS fork() + httpx/SSL crash issues
|
|
83
98
|
|
|
84
99
|
def get_apic_client(self) -> httpx.AsyncClient:
|
|
85
100
|
"""Get an httpx async client configured for APIC with response tracking.
|
|
@@ -114,9 +129,10 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
114
129
|
Simple entry point that uses base class orchestration to run async
|
|
115
130
|
verification tests. This thin wrapper:
|
|
116
131
|
1. Creates and manages an event loop for async operations
|
|
117
|
-
2.
|
|
118
|
-
3.
|
|
119
|
-
4.
|
|
132
|
+
2. Creates the APIC client (deferred from setup for fork safety)
|
|
133
|
+
3. Calls NACTestBase.run_verification_async() to execute tests
|
|
134
|
+
4. Passes results to NACTestBase.process_results_smart() for reporting
|
|
135
|
+
5. Ensures proper cleanup of async resources
|
|
120
136
|
|
|
121
137
|
The actual verification logic is handled by:
|
|
122
138
|
- get_items_to_verify() - must be implemented by the test class
|
|
@@ -131,10 +147,17 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
131
147
|
This method creates its own event loop to ensure compatibility
|
|
132
148
|
with PyATS synchronous test execution model. The loop and client
|
|
133
149
|
connections are properly closed after test completion.
|
|
150
|
+
|
|
151
|
+
Client creation is done HERE (not in setup) to avoid macOS fork()
|
|
152
|
+
issues with httpx/SSL. Creating httpx.AsyncClient after fork() but
|
|
153
|
+
before entering an async context can crash on macOS.
|
|
134
154
|
"""
|
|
135
155
|
loop = asyncio.new_event_loop()
|
|
136
156
|
asyncio.set_event_loop(loop)
|
|
137
157
|
try:
|
|
158
|
+
# Create client INSIDE the event loop context to avoid macOS fork+SSL crash
|
|
159
|
+
self.client = self.get_apic_client()
|
|
160
|
+
|
|
138
161
|
# Call the base class generic orchestration
|
|
139
162
|
results = loop.run_until_complete(self.run_verification_async())
|
|
140
163
|
|
|
@@ -142,6 +165,6 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
|
|
|
142
165
|
self.process_results_smart(results, steps)
|
|
143
166
|
finally:
|
|
144
167
|
# Clean up the APIC client connection
|
|
145
|
-
if hasattr(
|
|
168
|
+
if self.client is not None: # MANDATORY: never use hasattr()
|
|
146
169
|
loop.run_until_complete(self.client.aclose())
|
|
147
170
|
loop.close()
|
|
@@ -34,15 +34,16 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
34
34
|
response capture. It serves as the foundation for all Catalyst Center-specific
|
|
35
35
|
API test classes.
|
|
36
36
|
|
|
37
|
-
The class follows the same pattern as APICTestBase and
|
|
37
|
+
The class follows the same pattern as APICTestBase and SDWANManagerTestBase for
|
|
38
38
|
consistency across NAC architecture adapters. Token refresh is handled
|
|
39
39
|
automatically by the AuthCache TTL mechanism.
|
|
40
40
|
|
|
41
41
|
Attributes:
|
|
42
42
|
auth_data (dict): Catalyst Center authentication data containing the
|
|
43
43
|
token obtained during setup.
|
|
44
|
-
client (httpx.AsyncClient): Wrapped async HTTP client configured for
|
|
45
|
-
Catalyst Center.
|
|
44
|
+
client (httpx.AsyncClient | None): Wrapped async HTTP client configured for
|
|
45
|
+
Catalyst Center. Initialized to None, set during
|
|
46
|
+
run_async_verification_test().
|
|
46
47
|
controller_url (str): Base URL of the Catalyst Center controller.
|
|
47
48
|
verify_ssl (bool): Whether SSL verification is enabled.
|
|
48
49
|
|
|
@@ -67,6 +68,9 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
67
68
|
self.run_async_verification_test(steps)
|
|
68
69
|
"""
|
|
69
70
|
|
|
71
|
+
client: httpx.AsyncClient | None = None # MUST declare at class level
|
|
72
|
+
auth_data: dict[str, Any] # Declared at class level for type checker compatibility
|
|
73
|
+
|
|
70
74
|
@aetest.setup # type: ignore[misc, untyped-decorator]
|
|
71
75
|
def setup(self) -> None:
|
|
72
76
|
"""Setup method that extends the generic base class setup.
|
|
@@ -75,7 +79,11 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
75
79
|
1. Calling the parent class setup method
|
|
76
80
|
2. Obtaining Catalyst Center authentication token using cached auth
|
|
77
81
|
3. Configuring SSL verification from environment
|
|
78
|
-
|
|
82
|
+
|
|
83
|
+
Note: Client creation is deferred to run_async_verification_test() to avoid
|
|
84
|
+
macOS fork() issues with httpx/SSL. Creating httpx.AsyncClient in a forked
|
|
85
|
+
process before entering an async context can cause crashes on macOS due to
|
|
86
|
+
OpenSSL threading primitives that are not fork-safe.
|
|
79
87
|
|
|
80
88
|
The authentication token is obtained through the CatalystCenterAuth utility
|
|
81
89
|
which manages token lifecycle and prevents duplicate authentication requests
|
|
@@ -84,7 +92,15 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
84
92
|
super().setup()
|
|
85
93
|
|
|
86
94
|
# Get Catalyst Center auth data (token)
|
|
87
|
-
|
|
95
|
+
# This reads from file cache - no httpx client creation here
|
|
96
|
+
try:
|
|
97
|
+
self.auth_data = CatalystCenterAuth.get_auth()
|
|
98
|
+
except (RuntimeError, ValueError) as e:
|
|
99
|
+
# Convert auth failures to FAILED (not ERRORED) - auth issues are
|
|
100
|
+
# expected failure conditions, not infrastructure errors
|
|
101
|
+
self.auth_data = {} # Ensure attribute exists for cleanup code
|
|
102
|
+
self.failed(f"Authentication failed: {e}")
|
|
103
|
+
return
|
|
88
104
|
|
|
89
105
|
# Get controller URL from environment
|
|
90
106
|
self.controller_url = os.environ.get("CC_URL", "").rstrip("/")
|
|
@@ -93,8 +109,8 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
93
109
|
insecure = os.environ.get("CC_INSECURE", "True").lower() in ("true", "1", "yes")
|
|
94
110
|
self.verify_ssl = not insecure
|
|
95
111
|
|
|
96
|
-
#
|
|
97
|
-
|
|
112
|
+
# NOTE: Client creation is deferred to run_async_verification_test()
|
|
113
|
+
# to avoid macOS fork() + httpx/SSL crash issues
|
|
98
114
|
|
|
99
115
|
def get_catc_client(self) -> httpx.AsyncClient:
|
|
100
116
|
"""Get an httpx async client configured for Catalyst Center.
|
|
@@ -144,9 +160,10 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
144
160
|
Simple entry point that uses base class orchestration to run async
|
|
145
161
|
verification tests. This thin wrapper:
|
|
146
162
|
1. Creates and manages an event loop for async operations
|
|
147
|
-
2.
|
|
148
|
-
3.
|
|
149
|
-
4.
|
|
163
|
+
2. Creates the Catalyst Center client (deferred from setup for fork safety)
|
|
164
|
+
3. Calls NACTestBase.run_verification_async() to execute tests
|
|
165
|
+
4. Passes results to NACTestBase.process_results_smart() for reporting
|
|
166
|
+
5. Ensures proper cleanup of async resources
|
|
150
167
|
|
|
151
168
|
The actual verification logic is handled by:
|
|
152
169
|
- get_items_to_verify() - must be implemented by the test class
|
|
@@ -161,10 +178,17 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
161
178
|
This method creates its own event loop to ensure compatibility
|
|
162
179
|
with PyATS synchronous test execution model. The loop and client
|
|
163
180
|
connections are properly closed after test completion.
|
|
181
|
+
|
|
182
|
+
Client creation is done HERE (not in setup) to avoid macOS fork()
|
|
183
|
+
issues with httpx/SSL. Creating httpx.AsyncClient after fork() but
|
|
184
|
+
before entering an async context can crash on macOS.
|
|
164
185
|
"""
|
|
165
186
|
loop = asyncio.new_event_loop()
|
|
166
187
|
asyncio.set_event_loop(loop)
|
|
167
188
|
try:
|
|
189
|
+
# Create client INSIDE the event loop context to avoid macOS fork+SSL crash
|
|
190
|
+
self.client = self.get_catc_client()
|
|
191
|
+
|
|
168
192
|
# Call the base class generic orchestration
|
|
169
193
|
results = loop.run_until_complete(self.run_verification_async())
|
|
170
194
|
|
|
@@ -172,6 +196,6 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
|
172
196
|
self.process_results_smart(results, steps)
|
|
173
197
|
finally:
|
|
174
198
|
# Clean up the Catalyst Center client connection
|
|
175
|
-
if hasattr(
|
|
199
|
+
if self.client is not None: # MANDATORY: never use hasattr()
|
|
176
200
|
loop.run_until_complete(self.client.aclose())
|
|
177
201
|
loop.close()
|