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.
@@ -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"