nac-test-pyats-common 0.1.0__py3-none-any.whl → 0.2.0__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 +11 -1
- nac_test_pyats_common/aci/__init__.py +3 -0
- nac_test_pyats_common/aci/auth.py +8 -2
- nac_test_pyats_common/aci/test_base.py +12 -3
- nac_test_pyats_common/catc/__init__.py +30 -9
- nac_test_pyats_common/catc/{test_base.py → api_test_base.py} +12 -3
- nac_test_pyats_common/catc/auth.py +18 -6
- nac_test_pyats_common/catc/device_resolver.py +164 -0
- nac_test_pyats_common/catc/ssh_test_base.py +115 -0
- nac_test_pyats_common/common/__init__.py +3 -0
- nac_test_pyats_common/common/base_device_resolver.py +83 -176
- nac_test_pyats_common/iosxe/__init__.py +3 -0
- nac_test_pyats_common/iosxe/iosxe_resolver.py +3 -0
- nac_test_pyats_common/iosxe/registry.py +15 -5
- nac_test_pyats_common/iosxe/test_base.py +12 -1
- nac_test_pyats_common/sdwan/__init__.py +5 -1
- nac_test_pyats_common/sdwan/api_test_base.py +25 -15
- nac_test_pyats_common/sdwan/auth.py +17 -6
- nac_test_pyats_common/sdwan/device_resolver.py +59 -22
- nac_test_pyats_common/sdwan/ssh_test_base.py +20 -11
- {nac_test_pyats_common-0.1.0.dist-info → nac_test_pyats_common-0.2.0.dist-info}/METADATA +2 -1
- nac_test_pyats_common-0.2.0.dist-info/RECORD +25 -0
- nac_test_pyats_common/iosxe/catc_resolver.py +0 -64
- nac_test_pyats_common-0.1.0.dist-info/RECORD +0 -24
- {nac_test_pyats_common-0.1.0.dist-info → nac_test_pyats_common-0.2.0.dist-info}/WHEEL +0 -0
- {nac_test_pyats_common-0.1.0.dist-info → nac_test_pyats_common-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2025 Daniel Schmidt
|
|
3
|
+
|
|
1
4
|
"""Base device resolver for SSH/D2D testing.
|
|
2
5
|
|
|
3
6
|
Provides the Template Method pattern for device inventory resolution.
|
|
@@ -5,14 +8,12 @@ Architecture-specific resolvers extend this class and implement the
|
|
|
5
8
|
abstract methods for schema navigation and credential retrieval.
|
|
6
9
|
"""
|
|
7
10
|
|
|
11
|
+
import ipaddress
|
|
8
12
|
import logging
|
|
9
13
|
import os
|
|
10
14
|
from abc import ABC, abstractmethod
|
|
11
15
|
from typing import Any
|
|
12
16
|
|
|
13
|
-
import yaml
|
|
14
|
-
from nac_test.utils.file_discovery import find_data_file
|
|
15
|
-
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
17
18
|
|
|
18
19
|
|
|
@@ -20,28 +21,27 @@ class BaseDeviceResolver(ABC):
|
|
|
20
21
|
"""Abstract base class for architecture-specific device resolvers.
|
|
21
22
|
|
|
22
23
|
This class implements the Template Method pattern for device inventory
|
|
23
|
-
resolution. It handles common logic (
|
|
24
|
-
|
|
25
|
-
work to abstract methods.
|
|
24
|
+
resolution. It handles common logic (credential injection, device dict
|
|
25
|
+
construction) while delegating schema-specific work to abstract methods.
|
|
26
26
|
|
|
27
27
|
Subclasses MUST implement:
|
|
28
28
|
- get_architecture_name(): Return architecture identifier (e.g., "sdwan")
|
|
29
29
|
- get_schema_root_key(): Return the root key in data model (e.g., "sdwan")
|
|
30
30
|
- navigate_to_devices(): Navigate schema to get iterable of device data
|
|
31
|
-
- extract_device_id(): Extract unique device identifier from device data
|
|
32
31
|
- extract_hostname(): Extract hostname from device data
|
|
33
32
|
- extract_host_ip(): Extract management IP from device data
|
|
34
33
|
- extract_os_type(): Extract OS type from device data
|
|
35
34
|
- get_credential_env_vars(): Return (username_env_var, password_env_var)
|
|
36
35
|
|
|
37
36
|
Subclasses MAY override:
|
|
38
|
-
-
|
|
37
|
+
- extract_device_id(): Extract device identifier (default: uses hostname)
|
|
39
38
|
- build_device_dict(): Customize device dict construction
|
|
40
|
-
-
|
|
39
|
+
- validate_device_data(): Pre-extraction validation hook
|
|
41
40
|
|
|
42
41
|
Attributes:
|
|
43
42
|
data_model: The merged NAC data model dictionary.
|
|
44
|
-
|
|
43
|
+
skipped_devices: List of dicts with device_id and reason for devices
|
|
44
|
+
that failed resolution. Populated after get_resolved_inventory().
|
|
45
45
|
|
|
46
46
|
Example:
|
|
47
47
|
>>> class SDWANDeviceResolver(BaseDeviceResolver):
|
|
@@ -57,33 +57,24 @@ class BaseDeviceResolver(ABC):
|
|
|
57
57
|
>>> devices = resolver.get_resolved_inventory()
|
|
58
58
|
"""
|
|
59
59
|
|
|
60
|
-
def __init__(
|
|
61
|
-
self,
|
|
62
|
-
data_model: dict[str, Any],
|
|
63
|
-
test_inventory: dict[str, Any] | None = None,
|
|
64
|
-
) -> None:
|
|
60
|
+
def __init__(self, data_model: dict[str, Any]) -> None:
|
|
65
61
|
"""Initialize the device resolver.
|
|
66
62
|
|
|
67
63
|
Args:
|
|
68
64
|
data_model: The merged NAC data model containing all architecture
|
|
69
65
|
data with resolved variables.
|
|
70
|
-
test_inventory: Optional test inventory specifying which devices
|
|
71
|
-
to test. If not provided, will attempt to load from file.
|
|
72
66
|
"""
|
|
73
67
|
self.data_model = data_model
|
|
74
|
-
self.
|
|
75
|
-
logger.debug(
|
|
76
|
-
f"Initialized {self.get_architecture_name()} resolver with "
|
|
77
|
-
f"{len(self.test_inventory.get('devices', []))} devices in inventory"
|
|
78
|
-
)
|
|
68
|
+
self.skipped_devices: list[dict[str, str]] = []
|
|
69
|
+
logger.debug(f"Initialized {self.get_architecture_name()} resolver")
|
|
79
70
|
|
|
80
71
|
def get_resolved_inventory(self) -> list[dict[str, Any]]:
|
|
81
72
|
"""Get resolved device inventory ready for SSH connection.
|
|
82
73
|
|
|
83
74
|
This is the main entry point. It:
|
|
84
75
|
1. Navigates the data model to find device data
|
|
85
|
-
2.
|
|
86
|
-
3.
|
|
76
|
+
2. Extracts hostname and management IP from each device
|
|
77
|
+
3. Sets OS type (architecture-specific, e.g., hardcoded to 'iosxe' for SD-WAN)
|
|
87
78
|
4. Injects SSH credentials from environment variables
|
|
88
79
|
5. Returns list of device dicts ready for nac-test
|
|
89
80
|
|
|
@@ -102,10 +93,15 @@ class BaseDeviceResolver(ABC):
|
|
|
102
93
|
logger.info(f"Resolving device inventory for {self.get_architecture_name()}")
|
|
103
94
|
|
|
104
95
|
resolved_devices: list[dict[str, Any]] = []
|
|
105
|
-
|
|
96
|
+
self.skipped_devices = [] # Reset for each resolution
|
|
97
|
+
all_devices = list(self.navigate_to_devices())
|
|
98
|
+
logger.debug(f"Found {len(all_devices)} devices in data model")
|
|
106
99
|
|
|
107
|
-
for device_data in
|
|
100
|
+
for device_data in all_devices:
|
|
108
101
|
try:
|
|
102
|
+
# Validate device data before extraction (optional hook)
|
|
103
|
+
self.validate_device_data(device_data)
|
|
104
|
+
|
|
109
105
|
device_dict = self.build_device_dict(device_data)
|
|
110
106
|
|
|
111
107
|
# Validate extracted fields
|
|
@@ -125,18 +121,53 @@ class BaseDeviceResolver(ABC):
|
|
|
125
121
|
)
|
|
126
122
|
except (KeyError, ValueError) as e:
|
|
127
123
|
device_id = self._safe_extract_device_id(device_data)
|
|
128
|
-
logger.
|
|
124
|
+
logger.debug(f"Skipping device {device_id}: {e}")
|
|
125
|
+
self.skipped_devices.append(
|
|
126
|
+
{
|
|
127
|
+
"device_id": device_id,
|
|
128
|
+
"reason": str(e),
|
|
129
|
+
}
|
|
130
|
+
)
|
|
129
131
|
continue
|
|
130
132
|
|
|
131
133
|
# Inject credentials (fail fast if missing)
|
|
132
134
|
self._inject_credentials(resolved_devices)
|
|
133
135
|
|
|
136
|
+
skipped_msg = (
|
|
137
|
+
f", skipped {len(self.skipped_devices)}" if self.skipped_devices else ""
|
|
138
|
+
)
|
|
134
139
|
logger.info(
|
|
135
140
|
f"Resolved {len(resolved_devices)} devices for "
|
|
136
|
-
f"{self.get_architecture_name()} D2D testing"
|
|
141
|
+
f"{self.get_architecture_name()} D2D testing{skipped_msg}"
|
|
137
142
|
)
|
|
138
143
|
return resolved_devices
|
|
139
144
|
|
|
145
|
+
def validate_device_data(self, _device_data: dict[str, Any]) -> None: # noqa: B027
|
|
146
|
+
"""Validate device data before extraction (optional hook).
|
|
147
|
+
|
|
148
|
+
Override this method to perform architecture-specific validation
|
|
149
|
+
before device field extraction. This is useful for filtering devices
|
|
150
|
+
based on state, type, or other criteria.
|
|
151
|
+
|
|
152
|
+
The default implementation does nothing - all devices pass validation.
|
|
153
|
+
Subclasses can override this to implement custom validation logic.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
_device_data: Raw device data from the data model (unused in base class).
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: If the device should be skipped. The error message
|
|
160
|
+
will be logged and included in skipped_devices tracking.
|
|
161
|
+
|
|
162
|
+
Example (Catalyst Center - skip devices in INIT/PNP states):
|
|
163
|
+
>>> def validate_device_data(self, device_data):
|
|
164
|
+
... state = device_data.get("state", "").upper()
|
|
165
|
+
... if state in ("INIT", "PNP"):
|
|
166
|
+
... raise ValueError(f"Device has unsupported state '{state}'")
|
|
167
|
+
"""
|
|
168
|
+
# Default implementation does nothing - subclasses can override
|
|
169
|
+
pass
|
|
170
|
+
|
|
140
171
|
def build_device_dict(self, device_data: dict[str, Any]) -> dict[str, Any]:
|
|
141
172
|
"""Build a device dictionary from raw device data.
|
|
142
173
|
|
|
@@ -147,8 +178,7 @@ class BaseDeviceResolver(ABC):
|
|
|
147
178
|
device_data: Raw device data from the data model.
|
|
148
179
|
|
|
149
180
|
Returns:
|
|
150
|
-
Device dictionary with hostname, host, os, device_id fields
|
|
151
|
-
plus any test-relevant fields like connection_options.
|
|
181
|
+
Device dictionary with hostname, host, os, device_id fields.
|
|
152
182
|
Credentials are injected separately.
|
|
153
183
|
|
|
154
184
|
Raises:
|
|
@@ -164,146 +194,31 @@ class BaseDeviceResolver(ABC):
|
|
|
164
194
|
raise ValueError(f"Invalid hostname: {hostname!r}")
|
|
165
195
|
if not isinstance(host, str) or not host:
|
|
166
196
|
raise ValueError(f"Invalid host IP: {host!r}")
|
|
197
|
+
|
|
198
|
+
# Validate IP address format (after CIDR stripping done by subclass)
|
|
199
|
+
try:
|
|
200
|
+
ipaddress.ip_address(host)
|
|
201
|
+
except ValueError:
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"Invalid IP address format: '{host}'. "
|
|
204
|
+
"Ensure the field contains a valid IPv4 or IPv6 address."
|
|
205
|
+
) from None
|
|
167
206
|
if not isinstance(os_type, str) or not os_type:
|
|
168
207
|
raise ValueError(f"Invalid OS type: {os_type!r}")
|
|
169
208
|
if not isinstance(device_id, str) or not device_id:
|
|
170
209
|
raise ValueError(f"Invalid device ID: {device_id!r}")
|
|
171
210
|
|
|
172
|
-
|
|
173
|
-
result = {
|
|
211
|
+
return {
|
|
174
212
|
"hostname": hostname,
|
|
175
213
|
"host": host,
|
|
176
214
|
"os": os_type,
|
|
177
215
|
"device_id": device_id,
|
|
178
216
|
}
|
|
179
217
|
|
|
180
|
-
# Preserve connection_options from test_inventory if present
|
|
181
|
-
# This allows specifying custom SSH ports, protocols, etc.
|
|
182
|
-
if "connection_options" in device_data:
|
|
183
|
-
result["connection_options"] = device_data["connection_options"]
|
|
184
|
-
|
|
185
|
-
return result
|
|
186
|
-
|
|
187
218
|
# -------------------------------------------------------------------------
|
|
188
219
|
# Private helper methods
|
|
189
220
|
# -------------------------------------------------------------------------
|
|
190
221
|
|
|
191
|
-
def _load_inventory(self) -> dict[str, Any]:
|
|
192
|
-
"""Load test inventory from file.
|
|
193
|
-
|
|
194
|
-
Searches for the inventory file using the generic file discovery
|
|
195
|
-
utility. Subclasses can override this to customize loading behavior.
|
|
196
|
-
|
|
197
|
-
Returns:
|
|
198
|
-
Test inventory dictionary, or empty dict if not found.
|
|
199
|
-
"""
|
|
200
|
-
filename = self.get_inventory_filename()
|
|
201
|
-
inventory_path = find_data_file(filename)
|
|
202
|
-
|
|
203
|
-
if inventory_path is None:
|
|
204
|
-
logger.warning(
|
|
205
|
-
f"Test inventory file '{filename}' not found for "
|
|
206
|
-
f"{self.get_architecture_name()}. Using empty inventory."
|
|
207
|
-
)
|
|
208
|
-
return {}
|
|
209
|
-
|
|
210
|
-
logger.info(f"Loading test inventory from {inventory_path}")
|
|
211
|
-
try:
|
|
212
|
-
with open(inventory_path) as f:
|
|
213
|
-
raw_data = yaml.safe_load(f) or {}
|
|
214
|
-
|
|
215
|
-
# Support both nested and flat formats:
|
|
216
|
-
# Nested: {arch: {test_inventory: {...}}}
|
|
217
|
-
# Flat: {test_inventory: {...}}
|
|
218
|
-
arch_key = self.get_schema_root_key()
|
|
219
|
-
if arch_key in raw_data and "test_inventory" in raw_data[arch_key]:
|
|
220
|
-
return raw_data[arch_key]["test_inventory"] # type: ignore[no-any-return]
|
|
221
|
-
elif "test_inventory" in raw_data:
|
|
222
|
-
return raw_data["test_inventory"] # type: ignore[no-any-return]
|
|
223
|
-
else:
|
|
224
|
-
return raw_data
|
|
225
|
-
|
|
226
|
-
except yaml.YAMLError as e:
|
|
227
|
-
logger.error(f"Failed to parse test inventory YAML: {e}")
|
|
228
|
-
return {}
|
|
229
|
-
except OSError as e:
|
|
230
|
-
logger.error(f"Failed to read test inventory file: {e}")
|
|
231
|
-
return {}
|
|
232
|
-
|
|
233
|
-
def _get_devices_to_test(self) -> list[dict[str, Any]]:
|
|
234
|
-
"""Get the list of device data dicts to process.
|
|
235
|
-
|
|
236
|
-
If test_inventory specifies devices, filter to only those.
|
|
237
|
-
Otherwise, return all devices from the data model.
|
|
238
|
-
|
|
239
|
-
Returns:
|
|
240
|
-
List of device data dictionaries from the data model.
|
|
241
|
-
"""
|
|
242
|
-
all_devices = list(self.navigate_to_devices())
|
|
243
|
-
logger.debug(f"Found {len(all_devices)} total devices in data model")
|
|
244
|
-
|
|
245
|
-
# If no test inventory, test all devices
|
|
246
|
-
inventory_devices = self.test_inventory.get("devices", [])
|
|
247
|
-
if not inventory_devices:
|
|
248
|
-
logger.debug("No test inventory devices specified, testing all devices")
|
|
249
|
-
return all_devices
|
|
250
|
-
|
|
251
|
-
# Build index for efficient lookup
|
|
252
|
-
device_index = self._build_device_index(all_devices)
|
|
253
|
-
|
|
254
|
-
# Filter to devices in test inventory
|
|
255
|
-
devices_to_test: list[dict[str, Any]] = []
|
|
256
|
-
for inventory_entry in inventory_devices:
|
|
257
|
-
device_id = self._get_device_id_from_inventory(inventory_entry)
|
|
258
|
-
if device_id in device_index:
|
|
259
|
-
# Merge inventory entry data with device data
|
|
260
|
-
merged = {**device_index[device_id], **inventory_entry}
|
|
261
|
-
devices_to_test.append(merged)
|
|
262
|
-
logger.debug(f"Added device {device_id} from test inventory")
|
|
263
|
-
else:
|
|
264
|
-
logger.warning(
|
|
265
|
-
f"Device '{device_id}' from test_inventory not found in "
|
|
266
|
-
f"{self.get_architecture_name()} data model"
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
logger.debug(f"Filtered to {len(devices_to_test)} devices from test inventory")
|
|
270
|
-
return devices_to_test
|
|
271
|
-
|
|
272
|
-
def _build_device_index(self, devices: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
273
|
-
"""Build a lookup index of devices by their ID.
|
|
274
|
-
|
|
275
|
-
Args:
|
|
276
|
-
devices: List of device data dictionaries.
|
|
277
|
-
|
|
278
|
-
Returns:
|
|
279
|
-
Dictionary mapping device ID to device data.
|
|
280
|
-
"""
|
|
281
|
-
device_index: dict[str, dict[str, Any]] = {}
|
|
282
|
-
for device_data in devices:
|
|
283
|
-
device_id = self._safe_extract_device_id(device_data)
|
|
284
|
-
if device_id:
|
|
285
|
-
device_index[device_id] = device_data
|
|
286
|
-
return device_index
|
|
287
|
-
|
|
288
|
-
def _get_device_id_from_inventory(self, inventory_entry: dict[str, Any]) -> str:
|
|
289
|
-
"""Extract device ID from a test inventory entry.
|
|
290
|
-
|
|
291
|
-
Override this if your inventory uses a different field name.
|
|
292
|
-
|
|
293
|
-
Args:
|
|
294
|
-
inventory_entry: Entry from test_inventory.devices[]
|
|
295
|
-
|
|
296
|
-
Returns:
|
|
297
|
-
Device identifier string.
|
|
298
|
-
"""
|
|
299
|
-
# Common patterns across architectures
|
|
300
|
-
for key in ["chassis_id", "device_id", "node_id", "hostname", "name"]:
|
|
301
|
-
if key in inventory_entry:
|
|
302
|
-
return str(inventory_entry[key])
|
|
303
|
-
|
|
304
|
-
logger.warning(f"Could not extract device ID from inventory entry: {inventory_entry}")
|
|
305
|
-
return ""
|
|
306
|
-
|
|
307
222
|
def _safe_extract_device_id(self, device_data: dict[str, Any]) -> str:
|
|
308
223
|
"""Safely extract device ID, returning empty string on failure."""
|
|
309
224
|
try:
|
|
@@ -333,7 +248,8 @@ class BaseDeviceResolver(ABC):
|
|
|
333
248
|
|
|
334
249
|
if missing_vars:
|
|
335
250
|
raise ValueError(
|
|
336
|
-
f"Missing required credential environment variables:
|
|
251
|
+
f"Missing required credential environment variables: "
|
|
252
|
+
f"{', '.join(missing_vars)}. "
|
|
337
253
|
f"These are required for {self.get_architecture_name()} D2D testing."
|
|
338
254
|
)
|
|
339
255
|
|
|
@@ -361,7 +277,7 @@ class BaseDeviceResolver(ABC):
|
|
|
361
277
|
def get_schema_root_key(self) -> str:
|
|
362
278
|
"""Return the root key in the data model for this architecture.
|
|
363
279
|
|
|
364
|
-
Used when
|
|
280
|
+
Used when navigating the schema.
|
|
365
281
|
|
|
366
282
|
Returns:
|
|
367
283
|
Root key (e.g., "sdwan", "apic", "cc").
|
|
@@ -387,11 +303,14 @@ class BaseDeviceResolver(ABC):
|
|
|
387
303
|
"""
|
|
388
304
|
...
|
|
389
305
|
|
|
390
|
-
@abstractmethod
|
|
391
306
|
def extract_device_id(self, device_data: dict[str, Any]) -> str:
|
|
392
307
|
"""Extract unique device identifier from device data.
|
|
393
308
|
|
|
394
|
-
|
|
309
|
+
Default implementation delegates to extract_hostname(), which is
|
|
310
|
+
appropriate when device_id and hostname are the same field.
|
|
311
|
+
|
|
312
|
+
Override this method when your architecture has a distinct device
|
|
313
|
+
identifier (e.g., SD-WAN uses chassis_id, not hostname).
|
|
395
314
|
|
|
396
315
|
Args:
|
|
397
316
|
device_data: Device data dict from navigate_to_devices().
|
|
@@ -399,11 +318,11 @@ class BaseDeviceResolver(ABC):
|
|
|
399
318
|
Returns:
|
|
400
319
|
Unique device identifier string.
|
|
401
320
|
|
|
402
|
-
Example (SD-WAN):
|
|
321
|
+
Example (SD-WAN override):
|
|
403
322
|
>>> def extract_device_id(self, device_data):
|
|
404
323
|
... return device_data["chassis_id"]
|
|
405
324
|
"""
|
|
406
|
-
|
|
325
|
+
return self.extract_hostname(device_data)
|
|
407
326
|
|
|
408
327
|
@abstractmethod
|
|
409
328
|
def extract_hostname(self, device_data: dict[str, Any]) -> str:
|
|
@@ -426,6 +345,8 @@ class BaseDeviceResolver(ABC):
|
|
|
426
345
|
"""Extract management IP address from device data.
|
|
427
346
|
|
|
428
347
|
Should handle any IP formatting (e.g., strip CIDR notation).
|
|
348
|
+
The returned value will be validated by the base class to ensure
|
|
349
|
+
it is a valid IPv4 or IPv6 address.
|
|
429
350
|
|
|
430
351
|
Args:
|
|
431
352
|
device_data: Device data dict from navigate_to_devices().
|
|
@@ -435,7 +356,7 @@ class BaseDeviceResolver(ABC):
|
|
|
435
356
|
|
|
436
357
|
Example (SD-WAN):
|
|
437
358
|
>>> def extract_host_ip(self, device_data):
|
|
438
|
-
... ip_var = device_data.get("management_ip_variable"
|
|
359
|
+
... ip_var = device_data.get("management_ip_variable")
|
|
439
360
|
... ip = device_data["device_variables"].get(ip_var, "")
|
|
440
361
|
... return ip.split("/")[0] if "/" in ip else ip
|
|
441
362
|
"""
|
|
@@ -476,17 +397,3 @@ class BaseDeviceResolver(ABC):
|
|
|
476
397
|
... return ("NXOS_SSH_USERNAME", "NXOS_SSH_PASSWORD")
|
|
477
398
|
"""
|
|
478
399
|
...
|
|
479
|
-
|
|
480
|
-
# -------------------------------------------------------------------------
|
|
481
|
-
# Optional overrides
|
|
482
|
-
# -------------------------------------------------------------------------
|
|
483
|
-
|
|
484
|
-
def get_inventory_filename(self) -> str:
|
|
485
|
-
"""Return the test inventory filename.
|
|
486
|
-
|
|
487
|
-
Override to use a different filename.
|
|
488
|
-
|
|
489
|
-
Returns:
|
|
490
|
-
Filename (default: "test_inventory.yaml").
|
|
491
|
-
"""
|
|
492
|
-
return "test_inventory.yaml"
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2025 Daniel Schmidt
|
|
3
|
+
|
|
1
4
|
"""Registry for IOS-XE device resolvers.
|
|
2
5
|
|
|
3
6
|
This module implements a plugin architecture for registering and retrieving
|
|
@@ -79,20 +82,24 @@ def register_iosxe_resolver(controller_type: str) -> Callable[[type[T]], type[T]
|
|
|
79
82
|
"""
|
|
80
83
|
# Validate the class extends BaseDeviceResolver
|
|
81
84
|
if not issubclass(cls, BaseDeviceResolver):
|
|
82
|
-
raise TypeError(
|
|
85
|
+
raise TypeError(
|
|
86
|
+
f"Resolver class {cls.__name__} must extend BaseDeviceResolver"
|
|
87
|
+
)
|
|
83
88
|
|
|
84
89
|
# Check for duplicate registration
|
|
85
90
|
if controller_type in _IOSXE_RESOLVER_REGISTRY:
|
|
86
91
|
existing_class = _IOSXE_RESOLVER_REGISTRY[controller_type]
|
|
87
92
|
raise ValueError(
|
|
88
|
-
f"A resolver is already registered for controller type
|
|
93
|
+
f"A resolver is already registered for controller type "
|
|
94
|
+
f"'{controller_type}': "
|
|
89
95
|
f"{existing_class.__module__}.{existing_class.__name__}"
|
|
90
96
|
)
|
|
91
97
|
|
|
92
98
|
# Register the resolver
|
|
93
99
|
_IOSXE_RESOLVER_REGISTRY[controller_type] = cls
|
|
94
100
|
logger.debug(
|
|
95
|
-
f"Registered IOS-XE resolver {cls.__name__} for controller type
|
|
101
|
+
f"Registered IOS-XE resolver {cls.__name__} for controller type "
|
|
102
|
+
f"'{controller_type}'"
|
|
96
103
|
)
|
|
97
104
|
|
|
98
105
|
return cls
|
|
@@ -100,7 +107,9 @@ def register_iosxe_resolver(controller_type: str) -> Callable[[type[T]], type[T]
|
|
|
100
107
|
return decorator
|
|
101
108
|
|
|
102
109
|
|
|
103
|
-
def get_resolver_for_controller(
|
|
110
|
+
def get_resolver_for_controller(
|
|
111
|
+
controller_type: str,
|
|
112
|
+
) -> type[BaseDeviceResolver] | None:
|
|
104
113
|
"""Get the device resolver class for a specific controller type.
|
|
105
114
|
|
|
106
115
|
Retrieves the registered resolver class for the specified controller type.
|
|
@@ -124,7 +133,8 @@ def get_resolver_for_controller(controller_type: str) -> type[BaseDeviceResolver
|
|
|
124
133
|
|
|
125
134
|
if resolver_class:
|
|
126
135
|
logger.debug(
|
|
127
|
-
f"Found resolver {resolver_class.__name__} for controller type
|
|
136
|
+
f"Found resolver {resolver_class.__name__} for controller type "
|
|
137
|
+
f"'{controller_type}'"
|
|
128
138
|
)
|
|
129
139
|
else:
|
|
130
140
|
logger.debug(
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2025 Daniel Schmidt
|
|
3
|
+
|
|
1
4
|
"""IOS-XE test base class for SSH/D2D testing."""
|
|
2
5
|
|
|
3
6
|
import os
|
|
@@ -18,8 +21,15 @@ class IOSXETestBase(SSHTestBase): # type: ignore[misc]
|
|
|
18
21
|
- IOS-XE (direct device access via IOSXE_URL)
|
|
19
22
|
"""
|
|
20
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
|
+
|
|
21
29
|
@classmethod
|
|
22
|
-
def get_ssh_device_inventory(
|
|
30
|
+
def get_ssh_device_inventory(
|
|
31
|
+
cls, data_model: dict[str, Any]
|
|
32
|
+
) -> list[dict[str, Any]]:
|
|
23
33
|
"""Get the SSH device inventory for IOS-XE devices.
|
|
24
34
|
|
|
25
35
|
Main entry point that detects the architecture and returns
|
|
@@ -58,6 +68,7 @@ class IOSXETestBase(SSHTestBase): # type: ignore[misc]
|
|
|
58
68
|
f"No device resolver registered for controller type '{controller_type}'"
|
|
59
69
|
)
|
|
60
70
|
resolver = resolver_class(data_model)
|
|
71
|
+
cls._last_resolver = resolver # Store for skipped_devices access
|
|
61
72
|
|
|
62
73
|
# Inline validation: Check data model has expected root key
|
|
63
74
|
expected_keys = {
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2025 Daniel Schmidt
|
|
3
|
+
|
|
1
4
|
"""SD-WAN (SDWAN Manager) adapter module for NAC PyATS testing.
|
|
2
5
|
|
|
3
6
|
This module provides SD-WAN-specific authentication, test base classes, and device
|
|
@@ -5,7 +8,8 @@ resolver implementations for use with the nac-test framework. It includes suppor
|
|
|
5
8
|
for both SDWAN Manager API testing and SSH-based device-to-device (D2D) testing.
|
|
6
9
|
|
|
7
10
|
Classes:
|
|
8
|
-
SDWANManagerAuth: SDWAN Manager authentication with JSESSIONID and XSRF token
|
|
11
|
+
SDWANManagerAuth: SDWAN Manager authentication with JSESSIONID and XSRF token
|
|
12
|
+
management.
|
|
9
13
|
SDWANManagerTestBase: Base class for SDWAN Manager API tests with tracking.
|
|
10
14
|
SDWANTestBase: Base class for SD-WAN SSH/D2D tests with device inventory.
|
|
11
15
|
SDWANDeviceResolver: Resolves device information from the SD-WAN data model.
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2025 Daniel Schmidt
|
|
3
|
+
|
|
1
4
|
"""SDWAN Manager-specific base test class for SD-WAN API testing.
|
|
2
5
|
|
|
3
|
-
This module provides the SDWANManagerTestBase class, which extends the generic
|
|
4
|
-
to add SDWAN Manager-specific functionality for testing SD-WAN
|
|
5
|
-
session management (JSESSIONID and XSRF token), client
|
|
6
|
-
a standardized interface for running asynchronous
|
|
6
|
+
This module provides the SDWANManagerTestBase class, which extends the generic
|
|
7
|
+
NACTestBase to add SDWAN Manager-specific functionality for testing SD-WAN
|
|
8
|
+
controllers. It handles session management (JSESSIONID and XSRF token), client
|
|
9
|
+
configuration, and provides a standardized interface for running asynchronous
|
|
10
|
+
verification tests against SDWAN Manager.
|
|
7
11
|
|
|
8
12
|
The class integrates with PyATS/Genie test frameworks and provides automatic
|
|
9
13
|
API call tracking for enhanced HTML reporting.
|
|
@@ -13,7 +17,9 @@ import asyncio
|
|
|
13
17
|
from typing import Any
|
|
14
18
|
|
|
15
19
|
import httpx
|
|
16
|
-
from nac_test.pyats_core.common.base_test import
|
|
20
|
+
from nac_test.pyats_core.common.base_test import (
|
|
21
|
+
NACTestBase, # type: ignore[import-untyped]
|
|
22
|
+
)
|
|
17
23
|
from pyats import aetest # type: ignore[import-untyped]
|
|
18
24
|
|
|
19
25
|
from .auth import SDWANManagerAuth
|
|
@@ -43,7 +49,8 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
43
49
|
|
|
44
50
|
Methods:
|
|
45
51
|
setup(): Initialize SDWAN Manager authentication and client.
|
|
46
|
-
get_sdwan_manager_client(): Create and configure an SDWAN Manager-specific
|
|
52
|
+
get_sdwan_manager_client(): Create and configure an SDWAN Manager-specific
|
|
53
|
+
HTTP client.
|
|
47
54
|
run_async_verification_test(): Execute async verification tests with PyATS.
|
|
48
55
|
|
|
49
56
|
Example:
|
|
@@ -68,7 +75,8 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
68
75
|
|
|
69
76
|
Initializes the SDWAN Manager test environment by:
|
|
70
77
|
1. Calling the parent class setup method
|
|
71
|
-
2. Obtaining SDWAN Manager session data (jsessionid, xsrf_token) using
|
|
78
|
+
2. Obtaining SDWAN Manager session data (jsessionid, xsrf_token) using
|
|
79
|
+
cached auth
|
|
72
80
|
3. Creating and storing an SDWAN Manager client for use in verification methods
|
|
73
81
|
|
|
74
82
|
The session data is obtained through the SDWANManagerAuth utility which
|
|
@@ -84,12 +92,14 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
84
92
|
self.client = self.get_sdwan_manager_client()
|
|
85
93
|
|
|
86
94
|
def get_sdwan_manager_client(self) -> httpx.AsyncClient:
|
|
87
|
-
"""Get an httpx async client configured for SDWAN Manager
|
|
95
|
+
"""Get an httpx async client configured for SDWAN Manager.
|
|
96
|
+
|
|
97
|
+
Configured with response tracking.
|
|
88
98
|
|
|
89
|
-
Creates an HTTP client specifically configured for SDWAN Manager API
|
|
90
|
-
with session headers, base URL, and automatic response
|
|
91
|
-
report generation. The client is wrapped to capture all
|
|
92
|
-
for detailed test reporting.
|
|
99
|
+
Creates an HTTP client specifically configured for SDWAN Manager API
|
|
100
|
+
communication with session headers, base URL, and automatic response
|
|
101
|
+
tracking for HTML report generation. The client is wrapped to capture all
|
|
102
|
+
API interactions for detailed test reporting.
|
|
93
103
|
|
|
94
104
|
The client includes:
|
|
95
105
|
- JSESSIONID cookie in all requests (via Cookie header)
|
|
@@ -98,9 +108,9 @@ class SDWANManagerTestBase(NACTestBase): # type: ignore[misc]
|
|
|
98
108
|
- Automatic API call tracking for reporting
|
|
99
109
|
|
|
100
110
|
Returns:
|
|
101
|
-
httpx.AsyncClient: Configured client with SDWAN Manager session data,
|
|
102
|
-
and wrapped for automatic API call tracking. The client
|
|
103
|
-
verification disabled for lab environment compatibility.
|
|
111
|
+
httpx.AsyncClient: Configured client with SDWAN Manager session data,
|
|
112
|
+
base URL, and wrapped for automatic API call tracking. The client
|
|
113
|
+
has SSL verification disabled for lab environment compatibility.
|
|
104
114
|
|
|
105
115
|
Note:
|
|
106
116
|
SSL verification is disabled (verify=False) to support lab environments
|