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.
- nac_test_pyats_common/__init__.py +8 -1
- nac_test_pyats_common/catc/__init__.py +27 -9
- nac_test_pyats_common/catc/device_resolver.py +164 -0
- nac_test_pyats_common/catc/ssh_test_base.py +115 -0
- nac_test_pyats_common/common/base_device_resolver.py +78 -180
- nac_test_pyats_common/iosxe/test_base.py +6 -0
- nac_test_pyats_common/sdwan/device_resolver.py +53 -21
- nac_test_pyats_common/sdwan/ssh_test_base.py +11 -9
- {nac_test_pyats_common-0.1.1.dist-info → nac_test_pyats_common-0.2.0.dist-info}/METADATA +1 -1
- {nac_test_pyats_common-0.1.1.dist-info → nac_test_pyats_common-0.2.0.dist-info}/RECORD +13 -12
- nac_test_pyats_common/iosxe/catc_resolver.py +0 -67
- /nac_test_pyats_common/catc/{test_base.py → api_test_base.py} +0 -0
- {nac_test_pyats_common-0.1.1.dist-info → nac_test_pyats_common-0.2.0.dist-info}/WHEEL +0 -0
- {nac_test_pyats_common-0.1.1.dist-info → nac_test_pyats_common-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
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
|
|
7
|
-
implementations for use with the nac-test framework.
|
|
8
|
-
|
|
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
|
|
12
|
-
|
|
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
|
|
29
|
-
...
|
|
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 .
|
|
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 (
|
|
27
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
39
|
+
- validate_device_data(): Pre-extraction validation hook
|
|
45
40
|
|
|
46
41
|
Attributes:
|
|
47
42
|
data_model: The merged NAC data model dictionary.
|
|
48
|
-
|
|
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.
|
|
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.
|
|
90
|
-
3.
|
|
76
|
+
2. Extracts hostname and management IP from each device
|
|
77
|
+
3. Sets OS type (architecture-specific, e.g., hardcoded to 'iosxe' for SD-WAN)
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
197
|
+
Device dictionary with hostname, host, os, device_id, and type.
|
|
172
198
|
"""
|
|
173
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
84
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
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/
|
|
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=
|
|
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=
|
|
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=
|
|
20
|
-
nac_test_pyats_common/sdwan/ssh_test_base.py,sha256=
|
|
21
|
-
nac_test_pyats_common-0.
|
|
22
|
-
nac_test_pyats_common-0.
|
|
23
|
-
nac_test_pyats_common-0.
|
|
24
|
-
nac_test_pyats_common-0.
|
|
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")
|
|
File without changes
|
|
File without changes
|
{nac_test_pyats_common-0.1.1.dist-info → nac_test_pyats_common-0.2.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|