nac-test-pyats-common 0.1.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 +49 -0
- nac_test_pyats_common/aci/__init__.py +40 -0
- nac_test_pyats_common/aci/auth.py +157 -0
- nac_test_pyats_common/aci/test_base.py +138 -0
- nac_test_pyats_common/catc/__init__.py +35 -0
- nac_test_pyats_common/catc/auth.py +205 -0
- nac_test_pyats_common/catc/test_base.py +168 -0
- nac_test_pyats_common/common/__init__.py +29 -0
- nac_test_pyats_common/common/base_device_resolver.py +492 -0
- nac_test_pyats_common/iosxe/__init__.py +58 -0
- nac_test_pyats_common/iosxe/catc_resolver.py +64 -0
- nac_test_pyats_common/iosxe/iosxe_resolver.py +62 -0
- nac_test_pyats_common/iosxe/registry.py +153 -0
- nac_test_pyats_common/iosxe/test_base.py +116 -0
- nac_test_pyats_common/py.typed +1 -0
- nac_test_pyats_common/sdwan/__init__.py +47 -0
- nac_test_pyats_common/sdwan/api_test_base.py +164 -0
- nac_test_pyats_common/sdwan/auth.py +215 -0
- nac_test_pyats_common/sdwan/device_resolver.py +179 -0
- nac_test_pyats_common/sdwan/ssh_test_base.py +101 -0
- nac_test_pyats_common-0.1.0.dist-info/METADATA +304 -0
- nac_test_pyats_common-0.1.0.dist-info/RECORD +24 -0
- nac_test_pyats_common-0.1.0.dist-info/WHEEL +4 -0
- nac_test_pyats_common-0.1.0.dist-info/licenses/LICENSE +385 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Catalyst Center-specific base test class for API testing.
|
|
2
|
+
|
|
3
|
+
This module provides the CatalystCenterTestBase class, which extends the generic
|
|
4
|
+
NACTestBase to add Catalyst Center-specific functionality for testing enterprise
|
|
5
|
+
network controllers. It handles token-based authentication, client configuration,
|
|
6
|
+
and provides a standardized interface for running asynchronous verification tests.
|
|
7
|
+
|
|
8
|
+
The class integrates with PyATS/Genie test frameworks and provides automatic
|
|
9
|
+
API call tracking for enhanced HTML reporting.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from nac_test.pyats_core.common.base_test import NACTestBase # type: ignore[import-untyped]
|
|
18
|
+
from pyats import aetest # type: ignore[import-untyped]
|
|
19
|
+
|
|
20
|
+
from .auth import CatalystCenterAuth
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
|
|
24
|
+
"""Base class for Catalyst Center API tests with enhanced reporting.
|
|
25
|
+
|
|
26
|
+
This class extends the generic NACTestBase to provide Catalyst Center-specific
|
|
27
|
+
functionality including token-based authentication (X-Auth-Token header),
|
|
28
|
+
API call tracking for HTML reports, and wrapped HTTP client for automatic
|
|
29
|
+
response capture. It serves as the foundation for all Catalyst Center-specific
|
|
30
|
+
API test classes.
|
|
31
|
+
|
|
32
|
+
The class follows the same pattern as APICTestBase and VManageTestBase for
|
|
33
|
+
consistency across NAC architecture adapters. Token refresh is handled
|
|
34
|
+
automatically by the AuthCache TTL mechanism.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
auth_data (dict): Catalyst Center authentication data containing the
|
|
38
|
+
token obtained during setup.
|
|
39
|
+
client (httpx.AsyncClient): Wrapped async HTTP client configured for
|
|
40
|
+
Catalyst Center.
|
|
41
|
+
controller_url (str): Base URL of the Catalyst Center controller.
|
|
42
|
+
verify_ssl (bool): Whether SSL verification is enabled.
|
|
43
|
+
|
|
44
|
+
Methods:
|
|
45
|
+
setup(): Initialize Catalyst Center authentication and client.
|
|
46
|
+
get_catc_client(): Create and configure a Catalyst Center-specific HTTP client.
|
|
47
|
+
run_async_verification_test(): Execute async verification tests with PyATS.
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
class MyDeviceTest(CatalystCenterTestBase):
|
|
51
|
+
async def get_items_to_verify(self):
|
|
52
|
+
return ['device1', 'device2']
|
|
53
|
+
|
|
54
|
+
async def verify_item(self, item):
|
|
55
|
+
response = await self.client.get(f"/dna/intent/api/v1/network-device/{item}")
|
|
56
|
+
return response.status_code == 200
|
|
57
|
+
|
|
58
|
+
@aetest.test
|
|
59
|
+
def verify_devices(self, steps):
|
|
60
|
+
self.run_async_verification_test(steps)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
@aetest.setup # type: ignore[misc, untyped-decorator]
|
|
64
|
+
def setup(self) -> None:
|
|
65
|
+
"""Setup method that extends the generic base class setup.
|
|
66
|
+
|
|
67
|
+
Initializes the Catalyst Center test environment by:
|
|
68
|
+
1. Calling the parent class setup method
|
|
69
|
+
2. Obtaining Catalyst Center authentication token using cached auth
|
|
70
|
+
3. Configuring SSL verification from environment
|
|
71
|
+
4. Creating and storing a Catalyst Center client for use in verification methods
|
|
72
|
+
|
|
73
|
+
The authentication token is obtained through the CatalystCenterAuth utility
|
|
74
|
+
which manages token lifecycle and prevents duplicate authentication requests
|
|
75
|
+
across parallel test execution.
|
|
76
|
+
"""
|
|
77
|
+
super().setup()
|
|
78
|
+
|
|
79
|
+
# Get Catalyst Center auth data (token)
|
|
80
|
+
self.auth_data = CatalystCenterAuth.get_auth()
|
|
81
|
+
|
|
82
|
+
# Get controller URL from environment
|
|
83
|
+
self.controller_url = os.environ.get("CC_URL", "").rstrip("/")
|
|
84
|
+
|
|
85
|
+
# Determine SSL verification setting
|
|
86
|
+
insecure = os.environ.get("CC_INSECURE", "True").lower() in ("true", "1", "yes")
|
|
87
|
+
self.verify_ssl = not insecure
|
|
88
|
+
|
|
89
|
+
# Store the Catalyst Center client for use in verification methods
|
|
90
|
+
self.client = self.get_catc_client()
|
|
91
|
+
|
|
92
|
+
def get_catc_client(self) -> httpx.AsyncClient:
|
|
93
|
+
"""Get an httpx async client configured for Catalyst Center with response tracking.
|
|
94
|
+
|
|
95
|
+
Creates an HTTP client specifically configured for Catalyst Center API
|
|
96
|
+
communication with authentication headers, base URL, and automatic response
|
|
97
|
+
tracking for HTML report generation. The client is wrapped to capture all
|
|
98
|
+
API interactions for detailed test reporting.
|
|
99
|
+
|
|
100
|
+
The client includes:
|
|
101
|
+
- X-Auth-Token header for authentication
|
|
102
|
+
- Content-Type: application/json header
|
|
103
|
+
- Accept: application/json header
|
|
104
|
+
- Automatic API call tracking for reporting
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
httpx.AsyncClient: Configured client with Catalyst Center authentication,
|
|
108
|
+
base URL, and wrapped for automatic API call tracking. SSL verification
|
|
109
|
+
is controlled by the CC_INSECURE environment variable.
|
|
110
|
+
|
|
111
|
+
Note:
|
|
112
|
+
SSL verification can be disabled via CC_INSECURE=True to support lab
|
|
113
|
+
environments with self-signed certificates. For production environments,
|
|
114
|
+
consider enabling SSL verification with proper certificate management.
|
|
115
|
+
"""
|
|
116
|
+
headers = {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
"Accept": "application/json",
|
|
119
|
+
"X-Auth-Token": self.auth_data["token"],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Get base client from pool with SSL verification setting
|
|
123
|
+
base_client = self.pool.get_client(
|
|
124
|
+
base_url=self.controller_url,
|
|
125
|
+
headers=headers,
|
|
126
|
+
verify=self.verify_ssl,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Use the generic tracking wrapper from base class
|
|
130
|
+
return self.wrap_client_for_tracking(base_client, device_name="CatalystCenter") # type: ignore[no-any-return]
|
|
131
|
+
|
|
132
|
+
def run_async_verification_test(self, steps: Any) -> None:
|
|
133
|
+
"""Execute asynchronous verification tests with PyATS step tracking.
|
|
134
|
+
|
|
135
|
+
Simple entry point that uses base class orchestration to run async
|
|
136
|
+
verification tests. This thin wrapper:
|
|
137
|
+
1. Creates and manages an event loop for async operations
|
|
138
|
+
2. Calls NACTestBase.run_verification_async() to execute tests
|
|
139
|
+
3. Passes results to NACTestBase.process_results_smart() for reporting
|
|
140
|
+
4. Ensures proper cleanup of async resources
|
|
141
|
+
|
|
142
|
+
The actual verification logic is handled by:
|
|
143
|
+
- get_items_to_verify() - must be implemented by the test class
|
|
144
|
+
- verify_item() - must be implemented by the test class
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
steps: PyATS steps object for test reporting and step management.
|
|
148
|
+
Each verification item will be executed as a separate step
|
|
149
|
+
with automatic pass/fail tracking.
|
|
150
|
+
|
|
151
|
+
Note:
|
|
152
|
+
This method creates its own event loop to ensure compatibility
|
|
153
|
+
with PyATS synchronous test execution model. The loop and client
|
|
154
|
+
connections are properly closed after test completion.
|
|
155
|
+
"""
|
|
156
|
+
loop = asyncio.new_event_loop()
|
|
157
|
+
asyncio.set_event_loop(loop)
|
|
158
|
+
try:
|
|
159
|
+
# Call the base class generic orchestration
|
|
160
|
+
results = loop.run_until_complete(self.run_verification_async())
|
|
161
|
+
|
|
162
|
+
# Process results using smart configuration-driven processing
|
|
163
|
+
self.process_results_smart(results, steps)
|
|
164
|
+
finally:
|
|
165
|
+
# Clean up the Catalyst Center client connection
|
|
166
|
+
if hasattr(self, "client"):
|
|
167
|
+
loop.run_until_complete(self.client.aclose())
|
|
168
|
+
loop.close()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Common base classes for nac-test-pyats-common.
|
|
2
|
+
|
|
3
|
+
This module provides architecture-agnostic base classes that can be extended
|
|
4
|
+
by architecture-specific implementations (ACI, SD-WAN, Catalyst Center, etc.).
|
|
5
|
+
|
|
6
|
+
Key Components:
|
|
7
|
+
BaseDeviceResolver: Abstract base class for device inventory resolution
|
|
8
|
+
using the Template Method pattern. Each architecture implements
|
|
9
|
+
schema-specific navigation while the base class handles common
|
|
10
|
+
logic like credential injection and inventory filtering.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
from nac_test_pyats_common.common import BaseDeviceResolver
|
|
14
|
+
|
|
15
|
+
class ACIDeviceResolver(BaseDeviceResolver):
|
|
16
|
+
def get_architecture_name(self) -> str:
|
|
17
|
+
return "aci"
|
|
18
|
+
|
|
19
|
+
def navigate_to_devices(self) -> list[dict[str, Any]]:
|
|
20
|
+
return self.data_model.get("apic", {}).get("nodes", [])
|
|
21
|
+
|
|
22
|
+
# ... implement other abstract methods
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from nac_test_pyats_common.common.base_device_resolver import BaseDeviceResolver
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"BaseDeviceResolver",
|
|
29
|
+
]
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""Base device resolver for SSH/D2D testing.
|
|
2
|
+
|
|
3
|
+
Provides the Template Method pattern for device inventory resolution.
|
|
4
|
+
Architecture-specific resolvers extend this class and implement the
|
|
5
|
+
abstract methods for schema navigation and credential retrieval.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from nac_test.utils.file_discovery import find_data_file
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseDeviceResolver(ABC):
|
|
20
|
+
"""Abstract base class for architecture-specific device resolvers.
|
|
21
|
+
|
|
22
|
+
This class implements the Template Method pattern for device inventory
|
|
23
|
+
resolution. It handles common logic (inventory loading, credential
|
|
24
|
+
injection, device dict construction) while delegating schema-specific
|
|
25
|
+
work to abstract methods.
|
|
26
|
+
|
|
27
|
+
Subclasses MUST implement:
|
|
28
|
+
- get_architecture_name(): Return architecture identifier (e.g., "sdwan")
|
|
29
|
+
- get_schema_root_key(): Return the root key in data model (e.g., "sdwan")
|
|
30
|
+
- navigate_to_devices(): Navigate schema to get iterable of device data
|
|
31
|
+
- extract_device_id(): Extract unique device identifier from device data
|
|
32
|
+
- extract_hostname(): Extract hostname from device data
|
|
33
|
+
- extract_host_ip(): Extract management IP from device data
|
|
34
|
+
- extract_os_type(): Extract OS type from device data
|
|
35
|
+
- get_credential_env_vars(): Return (username_env_var, password_env_var)
|
|
36
|
+
|
|
37
|
+
Subclasses MAY override:
|
|
38
|
+
- get_inventory_filename(): Return inventory filename (default: "test_inventory.yaml")
|
|
39
|
+
- build_device_dict(): Customize device dict construction
|
|
40
|
+
- _load_inventory(): Customize inventory loading
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
data_model: The merged NAC data model dictionary.
|
|
44
|
+
test_inventory: The test inventory dictionary (devices to test).
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> class SDWANDeviceResolver(BaseDeviceResolver):
|
|
48
|
+
... def get_architecture_name(self) -> str:
|
|
49
|
+
... return "sdwan"
|
|
50
|
+
...
|
|
51
|
+
... def get_schema_root_key(self) -> str:
|
|
52
|
+
... return "sdwan"
|
|
53
|
+
...
|
|
54
|
+
... # ... implement other abstract methods ...
|
|
55
|
+
>>>
|
|
56
|
+
>>> resolver = SDWANDeviceResolver(data_model)
|
|
57
|
+
>>> devices = resolver.get_resolved_inventory()
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
data_model: dict[str, Any],
|
|
63
|
+
test_inventory: dict[str, Any] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Initialize the device resolver.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
data_model: The merged NAC data model containing all architecture
|
|
69
|
+
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
|
+
"""
|
|
73
|
+
self.data_model = data_model
|
|
74
|
+
self.test_inventory = test_inventory or self._load_inventory()
|
|
75
|
+
logger.debug(
|
|
76
|
+
f"Initialized {self.get_architecture_name()} resolver with "
|
|
77
|
+
f"{len(self.test_inventory.get('devices', []))} devices in inventory"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def get_resolved_inventory(self) -> list[dict[str, Any]]:
|
|
81
|
+
"""Get resolved device inventory ready for SSH connection.
|
|
82
|
+
|
|
83
|
+
This is the main entry point. It:
|
|
84
|
+
1. Navigates the data model to find device data
|
|
85
|
+
2. Matches devices against test inventory (if provided)
|
|
86
|
+
3. Extracts hostname, IP, OS from each device
|
|
87
|
+
4. Injects SSH credentials from environment variables
|
|
88
|
+
5. Returns list of device dicts ready for nac-test
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
List of device dictionaries with all required fields:
|
|
92
|
+
- hostname (str)
|
|
93
|
+
- host (str)
|
|
94
|
+
- os (str)
|
|
95
|
+
- username (str)
|
|
96
|
+
- password (str)
|
|
97
|
+
- Plus any architecture-specific fields
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ValueError: If credential environment variables are not set.
|
|
101
|
+
"""
|
|
102
|
+
logger.info(f"Resolving device inventory for {self.get_architecture_name()}")
|
|
103
|
+
|
|
104
|
+
resolved_devices: list[dict[str, Any]] = []
|
|
105
|
+
devices_to_test = self._get_devices_to_test()
|
|
106
|
+
|
|
107
|
+
for device_data in devices_to_test:
|
|
108
|
+
try:
|
|
109
|
+
device_dict = self.build_device_dict(device_data)
|
|
110
|
+
|
|
111
|
+
# Validate extracted fields
|
|
112
|
+
if not device_dict.get("hostname"):
|
|
113
|
+
raise ValueError("hostname is empty or missing")
|
|
114
|
+
if not device_dict.get("host"):
|
|
115
|
+
raise ValueError("host (IP address) is empty or missing")
|
|
116
|
+
if not device_dict.get("os"):
|
|
117
|
+
raise ValueError("os type is empty or missing")
|
|
118
|
+
if not device_dict.get("device_id"):
|
|
119
|
+
raise ValueError("device_id is empty or missing")
|
|
120
|
+
|
|
121
|
+
resolved_devices.append(device_dict)
|
|
122
|
+
logger.debug(
|
|
123
|
+
f"Resolved device: {device_dict['hostname']} "
|
|
124
|
+
f"({device_dict['host']}, {device_dict['os']})"
|
|
125
|
+
)
|
|
126
|
+
except (KeyError, ValueError) as e:
|
|
127
|
+
device_id = self._safe_extract_device_id(device_data)
|
|
128
|
+
logger.warning(f"Skipping device {device_id}: {e}")
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Inject credentials (fail fast if missing)
|
|
132
|
+
self._inject_credentials(resolved_devices)
|
|
133
|
+
|
|
134
|
+
logger.info(
|
|
135
|
+
f"Resolved {len(resolved_devices)} devices for "
|
|
136
|
+
f"{self.get_architecture_name()} D2D testing"
|
|
137
|
+
)
|
|
138
|
+
return resolved_devices
|
|
139
|
+
|
|
140
|
+
def build_device_dict(self, device_data: dict[str, Any]) -> dict[str, Any]:
|
|
141
|
+
"""Build a device dictionary from raw device data.
|
|
142
|
+
|
|
143
|
+
Override this method to customize device dict construction
|
|
144
|
+
for your architecture.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
device_data: Raw device data from the data model.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Device dictionary with hostname, host, os, device_id fields,
|
|
151
|
+
plus any test-relevant fields like connection_options.
|
|
152
|
+
Credentials are injected separately.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
ValueError: If any required field extraction fails.
|
|
156
|
+
"""
|
|
157
|
+
hostname = self.extract_hostname(device_data)
|
|
158
|
+
host = self.extract_host_ip(device_data)
|
|
159
|
+
os_type = self.extract_os_type(device_data)
|
|
160
|
+
device_id = self.extract_device_id(device_data)
|
|
161
|
+
|
|
162
|
+
# Validate all extracted values are non-empty strings
|
|
163
|
+
if not isinstance(hostname, str) or not hostname:
|
|
164
|
+
raise ValueError(f"Invalid hostname: {hostname!r}")
|
|
165
|
+
if not isinstance(host, str) or not host:
|
|
166
|
+
raise ValueError(f"Invalid host IP: {host!r}")
|
|
167
|
+
if not isinstance(os_type, str) or not os_type:
|
|
168
|
+
raise ValueError(f"Invalid OS type: {os_type!r}")
|
|
169
|
+
if not isinstance(device_id, str) or not device_id:
|
|
170
|
+
raise ValueError(f"Invalid device ID: {device_id!r}")
|
|
171
|
+
|
|
172
|
+
# Start with required fields
|
|
173
|
+
result = {
|
|
174
|
+
"hostname": hostname,
|
|
175
|
+
"host": host,
|
|
176
|
+
"os": os_type,
|
|
177
|
+
"device_id": device_id,
|
|
178
|
+
}
|
|
179
|
+
|
|
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
|
+
# -------------------------------------------------------------------------
|
|
188
|
+
# Private helper methods
|
|
189
|
+
# -------------------------------------------------------------------------
|
|
190
|
+
|
|
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
|
+
def _safe_extract_device_id(self, device_data: dict[str, Any]) -> str:
|
|
308
|
+
"""Safely extract device ID, returning empty string on failure."""
|
|
309
|
+
try:
|
|
310
|
+
return self.extract_device_id(device_data)
|
|
311
|
+
except (KeyError, ValueError):
|
|
312
|
+
return "<unknown>"
|
|
313
|
+
|
|
314
|
+
def _inject_credentials(self, devices: list[dict[str, Any]]) -> None:
|
|
315
|
+
"""Inject SSH credentials from environment variables.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
devices: List of device dicts to update in place.
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
ValueError: If required credential environment variables are not set.
|
|
322
|
+
"""
|
|
323
|
+
username_var, password_var = self.get_credential_env_vars()
|
|
324
|
+
username = os.environ.get(username_var)
|
|
325
|
+
password = os.environ.get(password_var)
|
|
326
|
+
|
|
327
|
+
# FAIL FAST - raise error if credentials missing
|
|
328
|
+
missing_vars: list[str] = []
|
|
329
|
+
if not username:
|
|
330
|
+
missing_vars.append(username_var)
|
|
331
|
+
if not password:
|
|
332
|
+
missing_vars.append(password_var)
|
|
333
|
+
|
|
334
|
+
if missing_vars:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"Missing required credential environment variables: {', '.join(missing_vars)}. "
|
|
337
|
+
f"These are required for {self.get_architecture_name()} D2D testing."
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
logger.debug(f"Injecting credentials from {username_var} and {password_var}")
|
|
341
|
+
for device in devices:
|
|
342
|
+
device["username"] = username
|
|
343
|
+
device["password"] = password
|
|
344
|
+
|
|
345
|
+
# -------------------------------------------------------------------------
|
|
346
|
+
# Abstract methods - MUST be implemented by subclasses
|
|
347
|
+
# -------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
@abstractmethod
|
|
350
|
+
def get_architecture_name(self) -> str:
|
|
351
|
+
"""Return the architecture identifier.
|
|
352
|
+
|
|
353
|
+
Used for logging and error messages.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Architecture name (e.g., "sdwan", "aci", "catc").
|
|
357
|
+
"""
|
|
358
|
+
...
|
|
359
|
+
|
|
360
|
+
@abstractmethod
|
|
361
|
+
def get_schema_root_key(self) -> str:
|
|
362
|
+
"""Return the root key in the data model for this architecture.
|
|
363
|
+
|
|
364
|
+
Used when loading test inventory and navigating the schema.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Root key (e.g., "sdwan", "apic", "cc").
|
|
368
|
+
"""
|
|
369
|
+
...
|
|
370
|
+
|
|
371
|
+
@abstractmethod
|
|
372
|
+
def navigate_to_devices(self) -> list[dict[str, Any]]:
|
|
373
|
+
"""Navigate the data model to find all devices.
|
|
374
|
+
|
|
375
|
+
This is where architecture-specific schema navigation happens.
|
|
376
|
+
Implement this to traverse your NAC schema structure.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Iterable of device data dictionaries from the data model.
|
|
380
|
+
|
|
381
|
+
Example (SD-WAN):
|
|
382
|
+
>>> def navigate_to_devices(self):
|
|
383
|
+
... devices = []
|
|
384
|
+
... for site in self.data_model.get("sdwan", {}).get("sites", []):
|
|
385
|
+
... devices.extend(site.get("routers", []))
|
|
386
|
+
... return devices
|
|
387
|
+
"""
|
|
388
|
+
...
|
|
389
|
+
|
|
390
|
+
@abstractmethod
|
|
391
|
+
def extract_device_id(self, device_data: dict[str, Any]) -> str:
|
|
392
|
+
"""Extract unique device identifier from device data.
|
|
393
|
+
|
|
394
|
+
This ID is used to match test_inventory entries with data model devices.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
device_data: Device data dict from navigate_to_devices().
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Unique device identifier string.
|
|
401
|
+
|
|
402
|
+
Example (SD-WAN):
|
|
403
|
+
>>> def extract_device_id(self, device_data):
|
|
404
|
+
... return device_data["chassis_id"]
|
|
405
|
+
"""
|
|
406
|
+
...
|
|
407
|
+
|
|
408
|
+
@abstractmethod
|
|
409
|
+
def extract_hostname(self, device_data: dict[str, Any]) -> str:
|
|
410
|
+
"""Extract device hostname from device data.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
device_data: Device data dict from navigate_to_devices().
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
Device hostname string.
|
|
417
|
+
|
|
418
|
+
Example (SD-WAN):
|
|
419
|
+
>>> def extract_hostname(self, device_data):
|
|
420
|
+
... return device_data["device_variables"]["system_hostname"]
|
|
421
|
+
"""
|
|
422
|
+
...
|
|
423
|
+
|
|
424
|
+
@abstractmethod
|
|
425
|
+
def extract_host_ip(self, device_data: dict[str, Any]) -> str:
|
|
426
|
+
"""Extract management IP address from device data.
|
|
427
|
+
|
|
428
|
+
Should handle any IP formatting (e.g., strip CIDR notation).
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
device_data: Device data dict from navigate_to_devices().
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
IP address string (e.g., "10.1.1.100").
|
|
435
|
+
|
|
436
|
+
Example (SD-WAN):
|
|
437
|
+
>>> def extract_host_ip(self, device_data):
|
|
438
|
+
... ip_var = device_data.get("management_ip_variable", "mgmt_ip")
|
|
439
|
+
... ip = device_data["device_variables"].get(ip_var, "")
|
|
440
|
+
... return ip.split("/")[0] if "/" in ip else ip
|
|
441
|
+
"""
|
|
442
|
+
...
|
|
443
|
+
|
|
444
|
+
@abstractmethod
|
|
445
|
+
def extract_os_type(self, device_data: dict[str, Any]) -> str:
|
|
446
|
+
"""Extract operating system type from device data.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
device_data: Device data dict from navigate_to_devices().
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
OS type string (e.g., "iosxe", "nxos", "iosxr").
|
|
453
|
+
|
|
454
|
+
Example (SD-WAN):
|
|
455
|
+
>>> def extract_os_type(self, device_data):
|
|
456
|
+
... return device_data.get("os", "iosxe")
|
|
457
|
+
"""
|
|
458
|
+
...
|
|
459
|
+
|
|
460
|
+
@abstractmethod
|
|
461
|
+
def get_credential_env_vars(self) -> tuple[str, str]:
|
|
462
|
+
"""Return environment variable names for SSH credentials.
|
|
463
|
+
|
|
464
|
+
Each architecture uses different env vars for device credentials.
|
|
465
|
+
These are separate from controller credentials.
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
Tuple of (username_env_var, password_env_var).
|
|
469
|
+
|
|
470
|
+
Example (SD-WAN D2D uses IOS-XE devices):
|
|
471
|
+
>>> def get_credential_env_vars(self):
|
|
472
|
+
... return ("IOSXE_USERNAME", "IOSXE_PASSWORD")
|
|
473
|
+
|
|
474
|
+
Example (ACI D2D uses NX-OS switches):
|
|
475
|
+
>>> def get_credential_env_vars(self):
|
|
476
|
+
... return ("NXOS_SSH_USERNAME", "NXOS_SSH_PASSWORD")
|
|
477
|
+
"""
|
|
478
|
+
...
|
|
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"
|