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.
- nac_test_pyats_common/__init__.py +8 -1
- nac_test_pyats_common/aci/auth.py +177 -53
- nac_test_pyats_common/aci/test_base.py +35 -12
- nac_test_pyats_common/catc/__init__.py +27 -9
- nac_test_pyats_common/catc/{test_base.py → api_test_base.py} +35 -11
- nac_test_pyats_common/catc/auth.py +116 -49
- nac_test_pyats_common/catc/device_resolver.py +171 -0
- nac_test_pyats_common/catc/ssh_test_base.py +115 -0
- nac_test_pyats_common/common/base_device_resolver.py +144 -199
- nac_test_pyats_common/iosxe/iosxe_resolver.py +2 -2
- nac_test_pyats_common/iosxe/test_base.py +6 -0
- nac_test_pyats_common/sdwan/api_test_base.py +28 -7
- nac_test_pyats_common/sdwan/auth.py +125 -64
- nac_test_pyats_common/sdwan/device_resolver.py +69 -22
- nac_test_pyats_common/sdwan/ssh_test_base.py +11 -9
- {nac_test_pyats_common-0.1.1.dist-info → nac_test_pyats_common-0.2.1.dist-info}/METADATA +3 -2
- nac_test_pyats_common-0.2.1.dist-info/RECORD +25 -0
- nac_test_pyats_common/iosxe/catc_resolver.py +0 -67
- nac_test_pyats_common-0.1.1.dist-info/RECORD +0 -24
- {nac_test_pyats_common-0.1.1.dist-info → nac_test_pyats_common-0.2.1.dist-info}/WHEEL +0 -0
- {nac_test_pyats_common-0.1.1.dist-info → nac_test_pyats_common-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -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 (
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
43
|
+
- validate_device_data(): Pre-extraction validation hook
|
|
45
44
|
|
|
46
45
|
Attributes:
|
|
47
46
|
data_model: The merged NAC data model dictionary.
|
|
48
|
-
|
|
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.
|
|
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.
|
|
90
|
-
3.
|
|
91
|
-
4. Injects SSH
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
177
|
-
|
|
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
|
-
#
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
|
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 -
|
|
275
|
+
# FAIL FAST - validate env vars are set (without reading values)
|
|
336
276
|
missing_vars: list[str] = []
|
|
337
|
-
if not
|
|
277
|
+
if username_var not in os.environ:
|
|
338
278
|
missing_vars.append(username_var)
|
|
339
|
-
if not
|
|
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
|
-
|
|
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"] =
|
|
352
|
-
device["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
|
|
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
|
-
|
|
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"
|
|
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
|
|
455
|
-
"""Extract
|
|
402
|
+
def extract_os_platform_type(self, device_data: dict[str, Any]) -> dict[str, str]:
|
|
403
|
+
"""Extract PyATS device abstraction fields from device data.
|
|
404
|
+
|
|
405
|
+
Returns a dictionary with device abstraction information for PyATS:
|
|
406
|
+
- os (required): Operating system type (e.g., "iosxe", "nxos")
|
|
407
|
+
- platform (optional): Device platform (e.g., "sdwan", "cat9k")
|
|
408
|
+
- model (optional): Device model (e.g., "c8000v", "c9300")
|
|
409
|
+
- series (optional): Device series (e.g., "catalyst", "nexus")
|
|
456
410
|
|
|
457
411
|
Args:
|
|
458
412
|
device_data: Device data dict from navigate_to_devices().
|
|
459
413
|
|
|
460
414
|
Returns:
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
60
|
-
"""Extract OS
|
|
59
|
+
def extract_os_platform_type(self, device_data: dict[str, Any]) -> dict[str, str]:
|
|
60
|
+
"""Extract OS and platform info."""
|
|
61
61
|
raise NotImplementedError("IOSXEResolver is not yet implemented")
|
|
62
62
|
|
|
63
63
|
def get_credential_env_vars(self) -> tuple[str, str]:
|
|
@@ -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
|
-
|
|
81
|
+
|
|
82
|
+
Note: Client creation is deferred to run_async_verification_test() to avoid
|
|
83
|
+
macOS fork() issues with httpx/SSL. Creating httpx.AsyncClient in a forked
|
|
84
|
+
process before entering an async context can cause crashes on macOS due to
|
|
85
|
+
OpenSSL threading primitives that are not fork-safe.
|
|
81
86
|
|
|
82
87
|
The session data is obtained through the SDWANManagerAuth utility which
|
|
83
88
|
manages session lifecycle and prevents duplicate authentication requests
|
|
@@ -86,10 +91,18 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
86
91
|
super().setup()
|
|
87
92
|
|
|
88
93
|
# Get shared SDWAN Manager auth data (jsessionid, xsrf_token)
|
|
89
|
-
|
|
94
|
+
# This reads from file cache - no httpx client creation here
|
|
95
|
+
try:
|
|
96
|
+
self.auth_data = SDWANManagerAuth.get_auth()
|
|
97
|
+
except (RuntimeError, ValueError) as e:
|
|
98
|
+
# Convert auth failures to FAILED (not ERRORED) - auth issues are
|
|
99
|
+
# expected failure conditions, not infrastructure errors
|
|
100
|
+
self.auth_data = {} # Ensure attribute exists for cleanup code
|
|
101
|
+
self.failed(f"Authentication failed: {e}")
|
|
102
|
+
return
|
|
90
103
|
|
|
91
|
-
#
|
|
92
|
-
|
|
104
|
+
# NOTE: Client creation is deferred to run_async_verification_test()
|
|
105
|
+
# to avoid macOS fork() + httpx/SSL crash issues
|
|
93
106
|
|
|
94
107
|
def get_sdwan_manager_client(self) -> httpx.AsyncClient:
|
|
95
108
|
"""Get an httpx async client configured for SDWAN Manager.
|
|
@@ -141,9 +154,10 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
141
154
|
Simple entry point that uses base class orchestration to run async
|
|
142
155
|
verification tests. This thin wrapper:
|
|
143
156
|
1. Creates and manages an event loop for async operations
|
|
144
|
-
2.
|
|
145
|
-
3.
|
|
146
|
-
4.
|
|
157
|
+
2. Creates the SDWAN Manager client (deferred from setup for fork safety)
|
|
158
|
+
3. Calls NACTestBase.run_verification_async() to execute tests
|
|
159
|
+
4. Passes results to NACTestBase.process_results_smart() for reporting
|
|
160
|
+
5. Ensures proper cleanup of async resources
|
|
147
161
|
|
|
148
162
|
The actual verification logic is handled by:
|
|
149
163
|
- get_items_to_verify() - must be implemented by the test class
|
|
@@ -158,10 +172,17 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
158
172
|
This method creates its own event loop to ensure compatibility
|
|
159
173
|
with PyATS synchronous test execution model. The loop and client
|
|
160
174
|
connections are properly closed after test completion.
|
|
175
|
+
|
|
176
|
+
Client creation is done HERE (not in setup) to avoid macOS fork()
|
|
177
|
+
issues with httpx/SSL. Creating httpx.AsyncClient after fork() but
|
|
178
|
+
before entering an async context can crash on macOS.
|
|
161
179
|
"""
|
|
162
180
|
loop = asyncio.new_event_loop()
|
|
163
181
|
asyncio.set_event_loop(loop)
|
|
164
182
|
try:
|
|
183
|
+
# Create client INSIDE the event loop context to avoid macOS fork+SSL crash
|
|
184
|
+
self.client = self.get_sdwan_manager_client()
|
|
185
|
+
|
|
165
186
|
# Call the base class generic orchestration
|
|
166
187
|
results = loop.run_until_complete(self.run_verification_async())
|
|
167
188
|
|