nac-test-pyats-common 0.1.1__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.
@@ -29,7 +29,12 @@ __version__ = "1.0.0"
29
29
 
30
30
  # Public API - Import from subpackages
31
31
  from nac_test_pyats_common.aci import APICAuth, APICTestBase
32
- from nac_test_pyats_common.catc import CatalystCenterAuth, CatalystCenterTestBase
32
+ from nac_test_pyats_common.catc import (
33
+ CatalystCenterAuth,
34
+ CatalystCenterDeviceResolver,
35
+ CatalystCenterSSHTestBase,
36
+ CatalystCenterTestBase,
37
+ )
33
38
  from nac_test_pyats_common.sdwan import (
34
39
  SDWANDeviceResolver,
35
40
  SDWANManagerAuth,
@@ -49,4 +54,6 @@ __all__ = [
49
54
  # Catalyst Center
50
55
  "CatalystCenterAuth",
51
56
  "CatalystCenterTestBase",
57
+ "CatalystCenterSSHTestBase",
58
+ "CatalystCenterDeviceResolver",
52
59
  ]
@@ -3,15 +3,23 @@
3
3
 
4
4
  """Catalyst Center adapter module for NAC PyATS testing.
5
5
 
6
- This module provides Catalyst Center-specific authentication and test base class
7
- implementations for use with the nac-test framework. Catalyst Center (formerly
8
- DNA Center) is Cisco's enterprise network management platform.
6
+ This module provides Catalyst Center-specific authentication, test base classes, and
7
+ device resolver implementations for use with the nac-test framework. It includes support
8
+ for both Catalyst Center API testing and SSH-based device-to-device (D2D) testing.
9
9
 
10
10
  Classes:
11
- CatalystCenterAuth: Token-based authentication with automatic endpoint detection.
12
- CatalystCenterTestBase: Base class for Catalyst Center API tests with tracking.
11
+ CatalystCenterAuth: Token-based authentication with automatic endpoint
12
+ detection.
13
+ CatalystCenterTestBase: Base class for Catalyst Center API tests with
14
+ tracking.
15
+ CatalystCenterSSHTestBase: Base class for Catalyst Center SSH/D2D tests
16
+ with device inventory.
17
+ CatalystCenterDeviceResolver: Resolves device information from the
18
+ Catalyst Center data model.
13
19
 
14
20
  Example:
21
+ For Catalyst Center API testing:
22
+
15
23
  >>> from nac_test_pyats_common.catc import CatalystCenterTestBase
16
24
  >>>
17
25
  >>> class VerifyNetworkDevices(CatalystCenterTestBase):
@@ -23,16 +31,26 @@ Example:
23
31
  ... f"/dna/intent/api/v1/network-device/{item}"
24
32
  ... )
25
33
  ... return response.status_code == 200
26
- ...
34
+
35
+ For SSH/D2D testing:
36
+
37
+ >>> from nac_test_pyats_common.catc import CatalystCenterSSHTestBase
38
+ >>>
39
+ >>> class VerifyInterfaceStatus(CatalystCenterSSHTestBase):
27
40
  ... @aetest.test
28
- ... def verify_devices(self, steps):
29
- ... self.run_async_verification_test(steps)
41
+ ... def verify_interfaces(self, steps, device):
42
+ ... # SSH-based verification
43
+ ... pass
30
44
  """
31
45
 
46
+ from .api_test_base import CatalystCenterTestBase
32
47
  from .auth import CatalystCenterAuth
33
- from .test_base import CatalystCenterTestBase
48
+ from .device_resolver import CatalystCenterDeviceResolver
49
+ from .ssh_test_base import CatalystCenterSSHTestBase
34
50
 
35
51
  __all__ = [
36
52
  "CatalystCenterAuth",
37
53
  "CatalystCenterTestBase",
54
+ "CatalystCenterSSHTestBase",
55
+ "CatalystCenterDeviceResolver",
38
56
  ]
@@ -0,0 +1,164 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2025 Daniel Schmidt
3
+
4
+ """Catalyst Center-specific device resolver for parsing the NAC data model.
5
+
6
+ This module provides the CatalystCenterDeviceResolver class, which extends
7
+ BaseDeviceResolver to implement Catalyst Center schema navigation for D2D testing.
8
+ """
9
+
10
+ import logging
11
+ from typing import Any
12
+
13
+ from nac_test_pyats_common.common import BaseDeviceResolver
14
+ from nac_test_pyats_common.iosxe.registry import register_iosxe_resolver
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @register_iosxe_resolver("CC")
20
+ class CatalystCenterDeviceResolver(BaseDeviceResolver):
21
+ """Catalyst Center device resolver for D2D testing.
22
+
23
+ Navigates the Catalyst Center NAC schema (catalyst_center.inventory.devices[])
24
+ to extract device information for SSH testing.
25
+
26
+ Schema structure:
27
+ catalyst_center:
28
+ inventory:
29
+ devices:
30
+ - name: P3-BN1
31
+ fqdn_name: P3-BN1.cisco.eu
32
+ device_ip: 192.168.38.1
33
+ pid: C9300-24P
34
+ state: PROVISION
35
+ device_role: ACCESS
36
+ site: Global/MAX_AREA/MAX_BUILDING
37
+
38
+ Credentials:
39
+ Uses IOSXE_USERNAME and IOSXE_PASSWORD environment variables
40
+ for SSH access to managed IOS-XE devices.
41
+
42
+ Example:
43
+ >>> resolver = CatalystCenterDeviceResolver(data_model)
44
+ >>> devices = resolver.get_resolved_inventory()
45
+ >>> for device in devices:
46
+ ... print(f"{device['hostname']}: {device['host']}")
47
+ """
48
+
49
+ def get_architecture_name(self) -> str:
50
+ """Return 'catalyst_center' as the architecture identifier.
51
+
52
+ Returns:
53
+ Architecture name used in logging and error messages.
54
+ """
55
+ return "catalyst_center"
56
+
57
+ def get_schema_root_key(self) -> str:
58
+ """Return 'catalyst_center' as the root key in the data model.
59
+
60
+ Returns:
61
+ Root key used when navigating the schema.
62
+ """
63
+ return "catalyst_center"
64
+
65
+ def navigate_to_devices(self) -> list[dict[str, Any]]:
66
+ """Navigate Catalyst Center schema: catalyst_center.inventory.devices[].
67
+
68
+ Traverses the Catalyst Center data model structure to find all
69
+ managed devices in the inventory.
70
+
71
+ Returns:
72
+ List of device dictionaries from the inventory.
73
+ """
74
+ devices: list[dict[str, Any]] = []
75
+ cc_data = self.data_model.get("catalyst_center", {})
76
+ inventory = cc_data.get("inventory", {})
77
+ devices.extend(inventory.get("devices", []))
78
+ return devices
79
+
80
+ def validate_device_data(self, device_data: dict[str, Any]) -> None:
81
+ """Validate device state before extraction.
82
+
83
+ Skip devices with INIT or PNP states as they are not fully provisioned.
84
+
85
+ Args:
86
+ device_data: Device data dictionary from the data model.
87
+
88
+ Raises:
89
+ ValueError: If the device has an unsupported state (INIT, PNP).
90
+ """
91
+ state = device_data.get("state", "").upper()
92
+ if state in ("INIT", "PNP"):
93
+ raise ValueError(
94
+ f"Device has unsupported state '{state}' "
95
+ "(devices in INIT or PNP state are not fully provisioned)"
96
+ )
97
+
98
+ def extract_hostname(self, device_data: dict[str, Any]) -> str:
99
+ """Extract hostname from the 'name' field.
100
+
101
+ Uses the device name as the hostname for SSH connections.
102
+
103
+ Args:
104
+ device_data: Device data dictionary from the data model.
105
+
106
+ Returns:
107
+ Device hostname string.
108
+ """
109
+ name = device_data.get("name")
110
+ if not name:
111
+ raise ValueError("Device missing 'name' field")
112
+ return str(name)
113
+
114
+ def extract_host_ip(self, device_data: dict[str, Any]) -> str:
115
+ """Extract management IP from device_ip field.
116
+
117
+ Reads the device_ip field directly from the device data.
118
+ Handles CIDR notation if present (e.g., "10.1.1.100/32" -> "10.1.1.100").
119
+
120
+ Args:
121
+ device_data: Device data dictionary from the data model.
122
+
123
+ Returns:
124
+ IP address string without CIDR notation (e.g., "192.168.38.1").
125
+
126
+ Raises:
127
+ ValueError: If device_ip field is not found.
128
+ """
129
+ device_ip = device_data.get("device_ip")
130
+ if not device_ip:
131
+ raise ValueError(
132
+ "Device missing 'device_ip' field. "
133
+ "Ensure device_ip is configured in the inventory."
134
+ )
135
+
136
+ ip_value = str(device_ip)
137
+
138
+ # Strip CIDR notation if present
139
+ if "/" in ip_value:
140
+ ip_value = ip_value.split("/")[0]
141
+
142
+ return ip_value
143
+
144
+ def extract_os_type(self, device_data: dict[str, Any]) -> str:
145
+ """Return 'iosxe' as all managed devices are IOS-XE based.
146
+
147
+ Args:
148
+ device_data: Device data dictionary (unused, OS is hardcoded).
149
+
150
+ Returns:
151
+ Always returns 'iosxe'.
152
+ """
153
+ return "iosxe"
154
+
155
+ def get_credential_env_vars(self) -> tuple[str, str]:
156
+ """Return IOS-XE credential env vars for managed devices.
157
+
158
+ Catalyst Center D2D tests connect to IOS-XE devices,
159
+ NOT the Catalyst Center controller.
160
+
161
+ Returns:
162
+ Tuple of (username_env_var, password_env_var).
163
+ """
164
+ return ("IOSXE_USERNAME", "IOSXE_PASSWORD")
@@ -0,0 +1,115 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ # Copyright (c) 2025 Daniel Schmidt
3
+
4
+ """Catalyst Center specific base test class for SSH/Direct-to-Device testing.
5
+
6
+ This module provides the CatalystCenterSSHTestBase class, which extends the generic
7
+ SSHTestBase to add Catalyst Center-specific functionality for device-to-device (D2D)
8
+ testing.
9
+
10
+ The class delegates device inventory resolution to CatalystCenterDeviceResolver, which
11
+ handles all Catalyst Center schema navigation and credential injection.
12
+
13
+ Credentials:
14
+ Catalyst Center D2D tests connect to IOS-XE devices managed by Catalyst Center,
15
+ NOT the Catalyst Center controller. Set these environment variables:
16
+ - IOSXE_USERNAME: SSH username for managed devices
17
+ - IOSXE_PASSWORD: SSH password for managed devices
18
+ """
19
+
20
+ import logging
21
+ import os
22
+ from typing import Any
23
+
24
+ from nac_test.pyats_core.common.ssh_base_test import (
25
+ SSHTestBase, # type: ignore[import-untyped]
26
+ )
27
+
28
+ from .device_resolver import CatalystCenterDeviceResolver
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class CatalystCenterSSHTestBase(SSHTestBase): # type: ignore[misc]
34
+ """Catalyst Center-specific base test class for SSH/D2D testing.
35
+
36
+ This class extends SSHTestBase and implements the contract required by
37
+ nac-test's SSH execution engine. Device inventory resolution is fully
38
+ delegated to CatalystCenterDeviceResolver.
39
+
40
+ Credentials:
41
+ Catalyst Center D2D tests require IOSXE_USERNAME and IOSXE_PASSWORD
42
+ environment variables (NOT CC_* which are for the controller).
43
+
44
+ Example:
45
+ class MyCatalystCenterSSHTest(CatalystCenterSSHTestBase):
46
+ @aetest.test
47
+ def verify_device_connectivity(self, steps, device):
48
+ # SSH-based verification logic here
49
+ pass
50
+ """
51
+
52
+ # Class-level storage for the last resolver instance
53
+ # This allows nac-test to access skipped_devices after calling
54
+ # get_ssh_device_inventory()
55
+ _last_resolver: "CatalystCenterDeviceResolver | None" = None
56
+
57
+ @classmethod
58
+ def get_ssh_device_inventory(
59
+ cls, data_model: dict[str, Any]
60
+ ) -> list[dict[str, Any]]:
61
+ """Parse the Catalyst Center data model to retrieve the device inventory.
62
+
63
+ This method is the entry point called by nac-test's orchestrator.
64
+ All device inventory resolution is delegated to CatalystCenterDeviceResolver,
65
+ which handles:
66
+ - Schema navigation (catalyst_center.inventory.devices[])
67
+ - Device metadata extraction (name, device_ip, etc.)
68
+ - Credential injection (IOSXE_USERNAME, IOSXE_PASSWORD)
69
+
70
+ After calling this method, access cls._last_resolver.skipped_devices
71
+ to get information about devices that failed resolution.
72
+
73
+ Args:
74
+ data_model: The merged data model from nac-test containing all
75
+ configuration data with resolved variables.
76
+
77
+ Returns:
78
+ List of device dictionaries, each containing:
79
+ - hostname (str): Device hostname
80
+ - host (str): Management IP address for SSH connection
81
+ - os (str): Operating system type (e.g., "iosxe")
82
+ - username (str): SSH username from IOSXE_USERNAME
83
+ - password (str): SSH password from IOSXE_PASSWORD
84
+ - device_id (str): Device identifier (name)
85
+
86
+ Raises:
87
+ ValueError: If IOSXE_USERNAME or IOSXE_PASSWORD env vars are not set.
88
+ """
89
+ logger.info(
90
+ "CatalystCenterSSHTestBase: Resolving device inventory via "
91
+ "CatalystCenterDeviceResolver"
92
+ )
93
+
94
+ cls._last_resolver = CatalystCenterDeviceResolver(data_model)
95
+ return cls._last_resolver.get_resolved_inventory()
96
+
97
+ def get_device_credentials(self, device: dict[str, Any]) -> dict[str, str | None]:
98
+ """Get Catalyst Center managed device SSH credentials from env vars.
99
+
100
+ Catalyst Center D2D tests connect to IOS-XE devices managed by
101
+ Catalyst Center, NOT the Catalyst Center controller. Use IOSXE_*
102
+ environment variables.
103
+
104
+ Args:
105
+ device: Device dictionary (not used - all devices share credentials).
106
+
107
+ Returns:
108
+ Dictionary containing:
109
+ - username (str | None): SSH username from IOSXE_USERNAME
110
+ - password (str | None): SSH password from IOSXE_PASSWORD
111
+ """
112
+ return {
113
+ "username": os.environ.get("IOSXE_USERNAME"),
114
+ "password": os.environ.get("IOSXE_PASSWORD"),
115
+ }
@@ -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,27 @@ 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 (inventory loading, credential
27
- injection, device dict construction) while delegating schema-specific
28
- work to abstract methods.
24
+ resolution. It handles common logic (credential injection, device dict
25
+ construction) while delegating schema-specific work to abstract methods.
29
26
 
30
27
  Subclasses MUST implement:
31
28
  - get_architecture_name(): Return architecture identifier (e.g., "sdwan")
32
29
  - get_schema_root_key(): Return the root key in data model (e.g., "sdwan")
33
30
  - navigate_to_devices(): Navigate schema to get iterable of device data
34
- - extract_device_id(): Extract unique device identifier from device data
35
31
  - extract_hostname(): Extract hostname from device data
36
32
  - extract_host_ip(): Extract management IP from device data
37
33
  - extract_os_type(): Extract OS type from device data
38
34
  - get_credential_env_vars(): Return (username_env_var, password_env_var)
39
35
 
40
36
  Subclasses MAY override:
41
- - get_inventory_filename(): Return inventory filename
42
- (default: "test_inventory.yaml")
37
+ - extract_device_id(): Extract device identifier (default: uses hostname)
43
38
  - build_device_dict(): Customize device dict construction
44
- - _load_inventory(): Customize inventory loading
39
+ - validate_device_data(): Pre-extraction validation hook
45
40
 
46
41
  Attributes:
47
42
  data_model: The merged NAC data model dictionary.
48
- test_inventory: The test inventory dictionary (devices to test).
43
+ skipped_devices: List of dicts with device_id and reason for devices
44
+ that failed resolution. Populated after get_resolved_inventory().
49
45
 
50
46
  Example:
51
47
  >>> class SDWANDeviceResolver(BaseDeviceResolver):
@@ -61,33 +57,24 @@ class BaseDeviceResolver(ABC):
61
57
  >>> devices = resolver.get_resolved_inventory()
62
58
  """
63
59
 
64
- def __init__(
65
- self,
66
- data_model: dict[str, Any],
67
- test_inventory: dict[str, Any] | None = None,
68
- ) -> None:
60
+ def __init__(self, data_model: dict[str, Any]) -> None:
69
61
  """Initialize the device resolver.
70
62
 
71
63
  Args:
72
64
  data_model: The merged NAC data model containing all architecture
73
65
  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
66
  """
77
67
  self.data_model = data_model
78
- self.test_inventory = test_inventory or self._load_inventory()
79
- logger.debug(
80
- f"Initialized {self.get_architecture_name()} resolver with "
81
- f"{len(self.test_inventory.get('devices', []))} devices in inventory"
82
- )
68
+ self.skipped_devices: list[dict[str, str]] = []
69
+ logger.debug(f"Initialized {self.get_architecture_name()} resolver")
83
70
 
84
71
  def get_resolved_inventory(self) -> list[dict[str, Any]]:
85
72
  """Get resolved device inventory ready for SSH connection.
86
73
 
87
74
  This is the main entry point. It:
88
75
  1. Navigates the data model to find device data
89
- 2. Matches devices against test inventory (if provided)
90
- 3. Extracts hostname, IP, OS from each device
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)
91
78
  4. Injects SSH credentials from environment variables
92
79
  5. Returns list of device dicts ready for nac-test
93
80
 
@@ -106,10 +93,15 @@ class BaseDeviceResolver(ABC):
106
93
  logger.info(f"Resolving device inventory for {self.get_architecture_name()}")
107
94
 
108
95
  resolved_devices: list[dict[str, Any]] = []
109
- devices_to_test = self._get_devices_to_test()
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")
110
99
 
111
- for device_data in devices_to_test:
100
+ for device_data in all_devices:
112
101
  try:
102
+ # Validate device data before extraction (optional hook)
103
+ self.validate_device_data(device_data)
104
+
113
105
  device_dict = self.build_device_dict(device_data)
114
106
 
115
107
  # Validate extracted fields
@@ -129,18 +121,53 @@ class BaseDeviceResolver(ABC):
129
121
  )
130
122
  except (KeyError, ValueError) as e:
131
123
  device_id = self._safe_extract_device_id(device_data)
132
- logger.warning(f"Skipping device {device_id}: {e}")
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
+ )
133
131
  continue
134
132
 
135
133
  # Inject credentials (fail fast if missing)
136
134
  self._inject_credentials(resolved_devices)
137
135
 
136
+ skipped_msg = (
137
+ f", skipped {len(self.skipped_devices)}" if self.skipped_devices else ""
138
+ )
138
139
  logger.info(
139
140
  f"Resolved {len(resolved_devices)} devices for "
140
- f"{self.get_architecture_name()} D2D testing"
141
+ f"{self.get_architecture_name()} D2D testing{skipped_msg}"
141
142
  )
142
143
  return resolved_devices
143
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
+
144
171
  def build_device_dict(self, device_data: dict[str, Any]) -> dict[str, Any]:
145
172
  """Build a device dictionary from raw device data.
146
173
 
@@ -151,8 +178,7 @@ class BaseDeviceResolver(ABC):
151
178
  device_data: Raw device data from the data model.
152
179
 
153
180
  Returns:
154
- Device dictionary with hostname, host, os, device_id fields,
155
- plus any test-relevant fields like connection_options.
181
+ Device dictionary with hostname, host, os, device_id fields.
156
182
  Credentials are injected separately.
157
183
 
158
184
  Raises:
@@ -168,150 +194,31 @@ class BaseDeviceResolver(ABC):
168
194
  raise ValueError(f"Invalid hostname: {hostname!r}")
169
195
  if not isinstance(host, str) or not host:
170
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
171
206
  if not isinstance(os_type, str) or not os_type:
172
207
  raise ValueError(f"Invalid OS type: {os_type!r}")
173
208
  if not isinstance(device_id, str) or not device_id:
174
209
  raise ValueError(f"Invalid device ID: {device_id!r}")
175
210
 
176
- # Start with required fields
177
- result = {
211
+ return {
178
212
  "hostname": hostname,
179
213
  "host": host,
180
214
  "os": os_type,
181
215
  "device_id": device_id,
182
216
  }
183
217
 
184
- # Preserve connection_options from test_inventory if present
185
- # This allows specifying custom SSH ports, protocols, etc.
186
- if "connection_options" in device_data:
187
- result["connection_options"] = device_data["connection_options"]
188
-
189
- return result
190
-
191
218
  # -------------------------------------------------------------------------
192
219
  # Private helper methods
193
220
  # -------------------------------------------------------------------------
194
221
 
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
222
  def _safe_extract_device_id(self, device_data: dict[str, Any]) -> str:
316
223
  """Safely extract device ID, returning empty string on failure."""
317
224
  try:
@@ -370,7 +277,7 @@ class BaseDeviceResolver(ABC):
370
277
  def get_schema_root_key(self) -> str:
371
278
  """Return the root key in the data model for this architecture.
372
279
 
373
- Used when loading test inventory and navigating the schema.
280
+ Used when navigating the schema.
374
281
 
375
282
  Returns:
376
283
  Root key (e.g., "sdwan", "apic", "cc").
@@ -396,11 +303,14 @@ class BaseDeviceResolver(ABC):
396
303
  """
397
304
  ...
398
305
 
399
- @abstractmethod
400
306
  def extract_device_id(self, device_data: dict[str, Any]) -> str:
401
307
  """Extract unique device identifier from device data.
402
308
 
403
- This ID is used to match test_inventory entries with data model devices.
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).
404
314
 
405
315
  Args:
406
316
  device_data: Device data dict from navigate_to_devices().
@@ -408,11 +318,11 @@ class BaseDeviceResolver(ABC):
408
318
  Returns:
409
319
  Unique device identifier string.
410
320
 
411
- Example (SD-WAN):
321
+ Example (SD-WAN override):
412
322
  >>> def extract_device_id(self, device_data):
413
323
  ... return device_data["chassis_id"]
414
324
  """
415
- ...
325
+ return self.extract_hostname(device_data)
416
326
 
417
327
  @abstractmethod
418
328
  def extract_hostname(self, device_data: dict[str, Any]) -> str:
@@ -435,6 +345,8 @@ class BaseDeviceResolver(ABC):
435
345
  """Extract management IP address from device data.
436
346
 
437
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.
438
350
 
439
351
  Args:
440
352
  device_data: Device data dict from navigate_to_devices().
@@ -444,7 +356,7 @@ class BaseDeviceResolver(ABC):
444
356
 
445
357
  Example (SD-WAN):
446
358
  >>> def extract_host_ip(self, device_data):
447
- ... ip_var = device_data.get("management_ip_variable", "mgmt_ip")
359
+ ... ip_var = device_data.get("management_ip_variable")
448
360
  ... ip = device_data["device_variables"].get(ip_var, "")
449
361
  ... return ip.split("/")[0] if "/" in ip else ip
450
362
  """
@@ -485,17 +397,3 @@ class BaseDeviceResolver(ABC):
485
397
  ... return ("NXOS_SSH_USERNAME", "NXOS_SSH_PASSWORD")
486
398
  """
487
399
  ...
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"
@@ -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 = {
@@ -25,13 +25,20 @@ class SDWANDeviceResolver(BaseDeviceResolver):
25
25
 
26
26
  Schema structure:
27
27
  sdwan:
28
+ management_ip_variable: "vpn511_int1_if_ipv4_address" # Global default
28
29
  sites:
29
30
  - name: "site1"
30
31
  routers:
31
32
  - chassis_id: "abc123"
33
+ management_ip_variable: "custom_mgmt_ip" # Router override
32
34
  device_variables:
33
35
  system_hostname: "router1"
34
- vpn10_mgmt_ip: "10.1.1.100/32"
36
+ vpn511_int1_if_ipv4_address: "10.1.1.100/32"
37
+ custom_mgmt_ip: "10.2.2.200/32"
38
+
39
+ Management IP Resolution Priority:
40
+ 1. Router-level management_ip_variable (highest priority)
41
+ 2. Global sdwan-level management_ip_variable (fallback)
35
42
 
36
43
  Credentials:
37
44
  Uses IOSXE_USERNAME and IOSXE_PASSWORD environment variables
@@ -56,7 +63,7 @@ class SDWANDeviceResolver(BaseDeviceResolver):
56
63
  """Return 'sdwan' as the root key in the data model.
57
64
 
58
65
  Returns:
59
- Root key used when loading test inventory and navigating schema.
66
+ Root key used when navigating the schema.
60
67
  """
61
68
  return "sdwan"
62
69
 
@@ -126,6 +133,10 @@ class SDWANDeviceResolver(BaseDeviceResolver):
126
133
  Uses management_ip_variable field to determine which variable
127
134
  contains the management IP.
128
135
 
136
+ Resolution priority:
137
+ 1. Router-level management_ip_variable (highest priority)
138
+ 2. Global sdwan-level management_ip_variable (fallback)
139
+
129
140
  Args:
130
141
  device_data: Router data dictionary from the data model.
131
142
 
@@ -133,27 +144,28 @@ class SDWANDeviceResolver(BaseDeviceResolver):
133
144
  IP address string without CIDR notation (e.g., "10.1.1.100").
134
145
 
135
146
  Raises:
136
- ValueError: If no management IP can be found.
147
+ ValueError: If management_ip_variable is not configured or
148
+ the referenced variable is not found in device_variables.
137
149
  """
138
150
  device_vars = device_data.get("device_variables", {})
139
151
 
140
- # Get the variable name that contains the management IP
152
+ # Cascading lookup: router-level > global sdwan-level
141
153
  ip_var = device_data.get("management_ip_variable")
154
+ if not ip_var:
155
+ ip_var = self.data_model.get("sdwan", {}).get("management_ip_variable")
156
+
157
+ if not ip_var:
158
+ raise ValueError(
159
+ "management_ip_variable not configured. "
160
+ "Set it at router level or sdwan level in sites.nac.yaml."
161
+ )
162
+
163
+ if ip_var not in device_vars:
164
+ raise ValueError(
165
+ f"management_ip_variable '{ip_var}' not found in device_variables."
166
+ )
142
167
 
143
- if ip_var and ip_var in device_vars:
144
- ip_value = str(device_vars[ip_var])
145
- else:
146
- # Fallback: try common variable names
147
- for fallback_var in ["mgmt_ip", "management_ip", "vpn0_ip"]:
148
- if fallback_var in device_vars:
149
- ip_value = str(device_vars[fallback_var])
150
- break
151
- else:
152
- raise ValueError(
153
- "Could not find management IP for device. "
154
- "Set 'management_ip_variable' in test_inventory or use "
155
- "standard variable names (mgmt_ip, management_ip, vpn0_ip)."
156
- )
168
+ ip_value = str(device_vars[ip_var])
157
169
 
158
170
  # Strip CIDR notation if present
159
171
  if "/" in ip_value:
@@ -162,15 +174,35 @@ class SDWANDeviceResolver(BaseDeviceResolver):
162
174
  return ip_value
163
175
 
164
176
  def extract_os_type(self, device_data: dict[str, Any]) -> str:
165
- """Extract OS type, defaulting to 'iosxe' for SD-WAN edges.
177
+ """Return 'iosxe' as all SD-WAN edge devices are IOS-XE based.
178
+
179
+ Args:
180
+ device_data: Router data dictionary (unused, OS is hardcoded).
181
+
182
+ Returns:
183
+ Always returns 'iosxe'.
184
+ """
185
+ return "iosxe"
186
+
187
+ def build_device_dict(self, device_data: dict[str, Any]) -> dict[str, Any]:
188
+ """Build device dictionary with SD-WAN specific defaults.
189
+
190
+ Extends the base implementation to add type='router' since
191
+ all SD-WAN edge devices are routers.
166
192
 
167
193
  Args:
168
194
  device_data: Router data dictionary from the data model.
169
195
 
170
196
  Returns:
171
- OS type string (e.g., "iosxe", "nxos", "iosxr").
197
+ Device dictionary with hostname, host, os, device_id, and type.
172
198
  """
173
- return str(device_data.get("os", "iosxe"))
199
+ # Get base device dict from parent
200
+ device_dict = super().build_device_dict(device_data)
201
+
202
+ # Add type - all SD-WAN edges are routers
203
+ device_dict["type"] = "router"
204
+
205
+ return device_dict
174
206
 
175
207
  def get_credential_env_vars(self) -> tuple[str, str]:
176
208
  """Return IOS-XE credential env vars for SD-WAN edge devices.
@@ -48,6 +48,11 @@ class SDWANTestBase(SSHTestBase): # type: ignore[misc]
48
48
  pass
49
49
  """
50
50
 
51
+ # Class-level storage for the last resolver instance
52
+ # This allows nac-test to access skipped_devices after calling
53
+ # get_ssh_device_inventory()
54
+ _last_resolver: "SDWANDeviceResolver | None" = None
55
+
51
56
  @classmethod
52
57
  def get_ssh_device_inventory(
53
58
  cls, data_model: dict[str, Any]
@@ -57,11 +62,13 @@ class SDWANTestBase(SSHTestBase): # type: ignore[misc]
57
62
  This method is the entry point called by nac-test's orchestrator.
58
63
  All device inventory resolution is delegated to SDWANDeviceResolver,
59
64
  which handles:
60
- - Test inventory loading (test_inventory.yaml)
61
65
  - Schema navigation (sites[].routers[])
62
- - Variable resolution (hostnames, IPs)
66
+ - Management IP resolution via management_ip_variable
63
67
  - Credential injection (IOSXE_USERNAME, IOSXE_PASSWORD)
64
68
 
69
+ After calling this method, access cls._last_resolver.skipped_devices
70
+ to get information about devices that failed resolution.
71
+
65
72
  Args:
66
73
  data_model: The merged data model from nac-test containing all
67
74
  sites.nac.yaml data with resolved variables.
@@ -80,13 +87,8 @@ class SDWANTestBase(SSHTestBase): # type: ignore[misc]
80
87
  """
81
88
  logger.info("SDWANTestBase: Resolving device inventory via SDWANDeviceResolver")
82
89
 
83
- # Delegate entirely to the resolver
84
- # SDWANDeviceResolver handles:
85
- # 1. Test inventory loading via BaseDeviceResolver._load_inventory()
86
- # 2. Schema navigation via navigate_to_devices()
87
- # 3. Credential injection via _inject_credentials() using IOSXE_* env vars
88
- resolver = SDWANDeviceResolver(data_model)
89
- return resolver.get_resolved_inventory()
90
+ cls._last_resolver = SDWANDeviceResolver(data_model)
91
+ return cls._last_resolver.get_resolved_inventory()
90
92
 
91
93
  def get_device_credentials(self, device: dict[str, Any]) -> dict[str, str | None]:
92
94
  """Get SD-WAN edge device SSH credentials from environment variables.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nac-test-pyats-common
3
- Version: 0.1.1
3
+ Version: 0.2.0
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
@@ -1,24 +1,25 @@
1
- nac_test_pyats_common/__init__.py,sha256=bQ3oB1eajVj8pmMnWCxxQEHUW-FkQX94j-6ocBq9OxA,1623
1
+ nac_test_pyats_common/__init__.py,sha256=4fOaRhzkJJDyW00-wPzEzgWdmLXSLfxkiV-Z39RWmq8,1770
2
2
  nac_test_pyats_common/py.typed,sha256=BrP39il8_cNN1K0KDeyBUtySS9oI2_SK5xKx1SxdSoY,59
3
3
  nac_test_pyats_common/aci/__init__.py,sha256=Y9VhBZ3_GXLBk3_Wtg1SRG_Dxx134Etf9k-0wvrOMwQ,1335
4
4
  nac_test_pyats_common/aci/auth.py,sha256=1Rk2uBGb_CGgkBh9dD3LagIsbJPKRgsSFDYEeyiC50U,7867
5
5
  nac_test_pyats_common/aci/test_base.py,sha256=SoPh91AXPvMshJXIyjiZumSgxOAN4xPkpSb3t0B9kdE,6256
6
- nac_test_pyats_common/catc/__init__.py,sha256=NKIHm6zOiiG8T_wcanPTh8621_RGcVJCMOeQLygTsy8,1319
6
+ nac_test_pyats_common/catc/__init__.py,sha256=ZGOQBvjFvq7h9LtfOyxFEmlir7htpy8sfWoJuDwGjsA,1986
7
+ nac_test_pyats_common/catc/api_test_base.py,sha256=kGp6rZ4S3zP3MW5BD02K5uVxmtE-vRlG2A99kaLgyDo,7507
7
8
  nac_test_pyats_common/catc/auth.py,sha256=1oDZr_PEWOz1IZOUm_kyKzIFPQubMo_wemj-Q3D7ALU,9522
8
- nac_test_pyats_common/catc/test_base.py,sha256=kGp6rZ4S3zP3MW5BD02K5uVxmtE-vRlG2A99kaLgyDo,7507
9
+ nac_test_pyats_common/catc/device_resolver.py,sha256=YgJJn1pXONXCAMnfXA-_Iuo6tkLtD9EekHbPbae6mRg,5363
10
+ nac_test_pyats_common/catc/ssh_test_base.py,sha256=wUldp2T9cmSEALBtbOgUQvQ2txuid3ErJEO27kM-kiI,4482
9
11
  nac_test_pyats_common/common/__init__.py,sha256=Y0qrZEKx2g8Zm1CTHJpeLocwMasspP6NQuhyrRiA1wA,1084
10
- nac_test_pyats_common/common/base_device_resolver.py,sha256=bPZP_dJjBfvaUGg_8F1JhfGAnWzAqI8oIC_JEejDJ4E,18558
12
+ nac_test_pyats_common/common/base_device_resolver.py,sha256=xP4QF-kNp_8rFo9N6wzfJSIpNOgnPkeZmWAbH0k5tGo,15091
11
13
  nac_test_pyats_common/iosxe/__init__.py,sha256=GSuLGyxcu8dFouspTjXuQ1Mi27vDluVR8ZCcJrPcIiY,2147
12
- nac_test_pyats_common/iosxe/catc_resolver.py,sha256=t14Pa6WjrjrbinXTtwc0Wa14Cctcae2yLLgdIpByzoU,2513
13
14
  nac_test_pyats_common/iosxe/iosxe_resolver.py,sha256=XzzwRCa1cuQ6HmeUB1Z2vITOwYdhuDlugDD1eVXAoIE,2355
14
15
  nac_test_pyats_common/iosxe/registry.py,sha256=djRFzxuEu8IdyTmi1puhIHGZGSMjys0l1v4-uzYCwAU,5723
15
- nac_test_pyats_common/iosxe/test_base.py,sha256=s-x9hGJ4Yi6313gVamXnHWO7Jzr8tTKXzSOVJ6HcTxU,4306
16
+ nac_test_pyats_common/iosxe/test_base.py,sha256=NjXbf0ZY-CBWhd3jpIAhylL5kyRrHM6FlhGlFtjLSjM,4569
16
17
  nac_test_pyats_common/sdwan/__init__.py,sha256=mrziVp5kMgykKnFPvWykuVLuky6xwEv6oEcfrbxFXxY,1777
17
18
  nac_test_pyats_common/sdwan/api_test_base.py,sha256=NuqrxODrRw7ZvEIuoc1Bup60thKqJOfiGz96T4QJ8Ng,7632
18
19
  nac_test_pyats_common/sdwan/auth.py,sha256=CMgf_AYCalVQNjdUfDlE-0-z5tnhU_Wnscgt5ZW61RY,10322
19
- nac_test_pyats_common/sdwan/device_resolver.py,sha256=3eKdIpoZsWHL8EzoKma1ZL6cFiMAQRZiMLp_xf9ZcEw,6062
20
- nac_test_pyats_common/sdwan/ssh_test_base.py,sha256=8ddzOOyrxVF1uYiOOg2v8kXBI628c6hg-P-hiOC-v3Q,4173
21
- nac_test_pyats_common-0.1.1.dist-info/METADATA,sha256=1vxKpKhdqZbR0N7wFJH_VYcpGB9MiJiumhUvzaP_4Hg,13072
22
- nac_test_pyats_common-0.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
23
- nac_test_pyats_common-0.1.1.dist-info/licenses/LICENSE,sha256=zt2sx-c0iEk6-OO0iqRQ4l6fIGazRKW_qLMqfDpLm6M,16295
24
- nac_test_pyats_common-0.1.1.dist-info/RECORD,,
20
+ nac_test_pyats_common/sdwan/device_resolver.py,sha256=1GdM5QKftj4F5bGmsbH90--VqoFZaDPMerHF1dAceMQ,7169
21
+ nac_test_pyats_common/sdwan/ssh_test_base.py,sha256=rZF7txhu0FGtPtTzGMZFqB9hwClmtggroZwRuX3BGU4,4210
22
+ nac_test_pyats_common-0.2.0.dist-info/METADATA,sha256=9Qbd0AYAGd33wI1iwbynSClOmApEC-Iny0geUrBoIzA,13072
23
+ nac_test_pyats_common-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
24
+ nac_test_pyats_common-0.2.0.dist-info/licenses/LICENSE,sha256=zt2sx-c0iEk6-OO0iqRQ4l6fIGazRKW_qLMqfDpLm6M,16295
25
+ nac_test_pyats_common-0.2.0.dist-info/RECORD,,
@@ -1,67 +0,0 @@
1
- # SPDX-License-Identifier: MPL-2.0
2
- # Copyright (c) 2025 Daniel Schmidt
3
-
4
- """Catalyst Center device resolver placeholder for D2D testing.
5
-
6
- This is a placeholder for the Catalyst Center resolver that will be implemented
7
- when Catalyst Center D2D testing support is added. This resolver will handle
8
- IOS-XE devices managed by Catalyst Center.
9
-
10
- Expected environment variables:
11
- - CC_URL: Catalyst Center controller URL
12
- - CC_USERNAME: Catalyst Center username
13
- - CC_PASSWORD: Catalyst Center password
14
- - IOSXE_USERNAME: SSH username for devices
15
- - IOSXE_PASSWORD: SSH password for devices
16
-
17
- """
18
-
19
- from typing import Any
20
-
21
- from nac_test_pyats_common.common import BaseDeviceResolver
22
-
23
- from .registry import register_iosxe_resolver
24
-
25
-
26
- @register_iosxe_resolver("CC")
27
- class CatalystCenterDeviceResolver(BaseDeviceResolver):
28
- """Placeholder resolver for Catalyst Center D2D testing.
29
-
30
- This resolver will be implemented when Catalyst Center D2D testing
31
- support is added. Currently a placeholder to reserve the CC registry slot.
32
- """
33
-
34
- def get_architecture_name(self) -> str:
35
- """Return architecture name."""
36
- return "catalyst_center"
37
-
38
- def get_schema_root_key(self) -> str:
39
- """Return data model root key."""
40
- return "catalyst_center"
41
-
42
- def navigate_to_devices(self) -> list[dict[str, Any]]:
43
- """Navigate to devices in data model."""
44
- raise NotImplementedError(
45
- "CatalystCenterDeviceResolver is not yet implemented. "
46
- "This placeholder reserves the CC registry slot for future use."
47
- )
48
-
49
- def extract_device_id(self, device_data: dict[str, Any]) -> str:
50
- """Extract device ID."""
51
- raise NotImplementedError("CatalystCenterDeviceResolver is not yet implemented")
52
-
53
- def extract_hostname(self, device_data: dict[str, Any]) -> str:
54
- """Extract hostname."""
55
- raise NotImplementedError("CatalystCenterDeviceResolver is not yet implemented")
56
-
57
- def extract_host_ip(self, device_data: dict[str, Any]) -> str:
58
- """Extract management IP."""
59
- raise NotImplementedError("CatalystCenterDeviceResolver is not yet implemented")
60
-
61
- def extract_os_type(self, device_data: dict[str, Any]) -> str:
62
- """Extract OS type."""
63
- raise NotImplementedError("CatalystCenterDeviceResolver is not yet implemented")
64
-
65
- def get_credential_env_vars(self) -> tuple[str, str]:
66
- """Return credential environment variable names."""
67
- return ("IOSXE_USERNAME", "IOSXE_PASSWORD")