Skip to content

Commit 31824b9

Browse files
authored
Inventory - Refactoring Integration Tests (#438)
* "Inventory - Refactoring Integration Tests - Verifying NDFC State with Playbook Task Configuration, using Pydantic and Python Action Plugin." * "Fixing File Names for Importing Tests" * "Fix Sanity Import Errors" --------- Co-authored-by: = <=>
1 parent 4b4badd commit 31824b9

File tree

21 files changed

+1813
-1005
lines changed

21 files changed

+1813
-1005
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,5 @@ venv.bak/
8080

8181
# Ignore Integration Tests Files Directories
8282
tests/integration/targets/ndfc_interface/files
83-
tests/integration/targets/dcnm_network/files
83+
tests/integration/targets/dcnm_network/files
84+
tests/integration/targets/dcnm_inventory/files
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
ndfc:
2+
children:
3+
prod:
4+
hosts:
5+
192.168.1.1:
6+
ansible_connection: ansible.netcommon.httpapi
7+
ansible_httpapi_use_ssl: true
8+
ansible_httpapi_validate_certs: false
9+
ansible_python_interpreter: auto_silent
10+
ansible_network_os: cisco.dcnm.dcnm
11+
ansible_user: admin
12+
ansible_password: password
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
- hosts: ndfc
3+
gather_facts: no
4+
connection: ansible.netcommon.httpapi
5+
6+
vars:
7+
ansible_switch1: 192.168.2.4
8+
ansible_switch2: 192.168.2.5
9+
ansible_switch3: 192.168.2.6
10+
ansible_it_fabric: test-fabric
11+
deploy: false
12+
switch_username: admin
13+
switch_password: password
14+
15+
roles:
16+
- dcnm_inventory
17+
tags: always
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
from __future__ import absolute_import, division, print_function
2+
from ansible.utils.display import Display
3+
from ansible.plugins.action import ActionBase
4+
from typing import List, Dict, Optional, Union
5+
from pydantic import BaseModel, model_validator, validator, ValidationError
6+
import re
7+
import json
8+
__metaclass__ = type
9+
10+
display = Display()
11+
12+
13+
class ConfigData(BaseModel):
14+
role: Optional[str] = None
15+
seed_ip: Optional[str] = None
16+
17+
18+
class NDFCData(BaseModel):
19+
ipAddress: Optional[str] = None
20+
switchRoleEnum: Optional[str] = None
21+
switchRole: Optional[str] = None
22+
23+
24+
class InventoryValidate(BaseModel):
25+
config_data: Optional[List[ConfigData]] = None
26+
ndfc_data: Optional[Union[List[NDFCData], str]] = None
27+
ignore_fields: Optional[Dict[str, int]] = None
28+
response: Union[bool, str] = None
29+
30+
@validator('config_data', pre=True)
31+
@classmethod
32+
def parse_config_data(cls, value):
33+
"""
34+
Validates and transforms the config_data input.
35+
Accepts a dictionary or list of dictionaries and converts them to ConfigData objects.
36+
37+
Args:
38+
value: The input data to validate (dict, list, or None)
39+
Returns:
40+
List of ConfigData objects or None
41+
Raises:
42+
ValueError: If the input format is invalid
43+
"""
44+
if isinstance(value, dict):
45+
return [ConfigData.parse_obj(value)]
46+
if isinstance(value, list):
47+
try:
48+
return [ConfigData.parse_obj(item) for item in value]
49+
except ValidationError as e:
50+
raise ValueError(f"Invalid format in Config Data: {e}")
51+
elif value is None:
52+
return None
53+
else:
54+
raise ValueError("Config Data must be a single/list of dictionary, or None.")
55+
56+
@validator('ndfc_data', pre=True)
57+
@classmethod
58+
def parse_ndfc_data(cls, value):
59+
"""
60+
Validates and transforms the ndfc_data input.
61+
Accepts a string (error message) or list of dictionaries and converts to NDFCData objects.
62+
Args:
63+
value: The NDFC response data (str or list)
64+
Returns:
65+
List of NDFCData objects or the original error string
66+
Raises:
67+
ValueError: If the input format is invalid
68+
"""
69+
if isinstance(value, str):
70+
return value
71+
if isinstance(value, list):
72+
try:
73+
return [NDFCData.parse_obj(item) for item in value]
74+
except ValidationError as e:
75+
raise ValueError(f"Invalid format in NDFC Response: {e}")
76+
else:
77+
raise ValueError("NDFC Response must be a list of dictionaries or an error string")
78+
79+
@model_validator(mode='after')
80+
@classmethod
81+
def validate_lists_equality(cls, values):
82+
"""
83+
Validates that the configuration data matches the NDFC response data.
84+
Performs matching based on seed_ip and role, respecting ignore_fields settings.
85+
Args:
86+
values: The model instance after individual field validation
87+
Returns:
88+
"True" if validation is successful, "False" otherwise
89+
"""
90+
config_data = values.config_data
91+
ndfc_data = values.ndfc_data
92+
ignore_fields = values.ignore_fields
93+
response = values.response
94+
95+
if isinstance(ndfc_data, str):
96+
if config_data is None and ndfc_data == "The queried switch is not part of the fabric configured":
97+
values.response = "True"
98+
return values
99+
else:
100+
print(" NDFC Query returned an Invalid Response\n")
101+
return values
102+
103+
missing_ips = []
104+
role_mismatches = {}
105+
ndfc_data_copy = ndfc_data.copy()
106+
matched_indices_two = set()
107+
108+
for config_data_item in config_data:
109+
found_match = False
110+
config_data_item_dict = config_data_item.dict(exclude_none=True)
111+
for i, ndfc_data_item in enumerate(ndfc_data_copy):
112+
ndfc_data_item = ndfc_data_item.dict(include=set(['ipAddress', 'switchRole'] + list(config_data_item_dict.keys())))
113+
if i in matched_indices_two:
114+
continue
115+
116+
seed_ip_match = False
117+
role_match = False
118+
119+
ip_address_two = ndfc_data_item.get('ipAddress')
120+
switch_role_two = ndfc_data_item.get('switchRole')
121+
role_one = config_data_item_dict.get('role')
122+
if switch_role_two is not None:
123+
switch_role_two = re.sub(r'[^a-zA-Z0-9]', '', switch_role_two.lower())
124+
if role_one is not None:
125+
role_one = re.sub(r'[^a-zA-Z0-9]', '', role_one.lower())
126+
seed_ip_one = config_data_item_dict.get('seed_ip')
127+
128+
if ((seed_ip_one is not None and ip_address_two is not None and ip_address_two == seed_ip_one) or (ignore_fields['seed_ip'])):
129+
seed_ip_match = True
130+
131+
if ((role_one is not None and switch_role_two is not None and switch_role_two == role_one) or (ignore_fields['role'])) :
132+
role_match = True
133+
134+
if seed_ip_match and role_match:
135+
matched_indices_two.add(i)
136+
found_match = True
137+
if ignore_fields['seed_ip']:
138+
break
139+
elif ((seed_ip_match and role_one is not None and switch_role_two is not None and switch_role_two != role_one) or (ignore_fields['role'])):
140+
role_mismatches.setdefault((seed_ip_one or ip_address_two), {"expected_role": role_one, "response_role": switch_role_two})
141+
matched_indices_two.add(i) # Consider it a partial match to avoid further matching
142+
found_match = True
143+
if ignore_fields['seed_ip']:
144+
break
145+
146+
if not found_match and config_data_item_dict is not None and config_data_item_dict.get('seed_ip') is not None:
147+
missing_ips.append(config_data_item_dict.get('seed_ip'))
148+
149+
if not missing_ips and not role_mismatches:
150+
values.response = True
151+
else:
152+
print("Invalid Data:\n ")
153+
if not missing_ips:
154+
print(missing_ips)
155+
if not role_mismatches:
156+
print(json.dumps(role_mismatches, indent=2))
157+
return values
158+
159+
160+
class ActionModule(ActionBase):
161+
"""
162+
Ansible action plugin for validating NDFC inventory data.
163+
Compares test data against NDFC response data and validates according to specified mode.
164+
"""
165+
166+
def run(self, tmp=None, task_vars=None):
167+
"""
168+
Execute the action plugin logic.
169+
Args:
170+
tmp: Temporary directory
171+
task_vars: Variables available to the task
172+
Returns:
173+
dict: Results dictionary with success/failure status and appropriate messages
174+
"""
175+
results = super(ActionModule, self).run(tmp, task_vars)
176+
results['failed'] = False
177+
ndfc_data = self._task.args['ndfc_data']
178+
test_data = self._task.args['test_data']
179+
response = False
180+
181+
if 'changed' in self._task.args:
182+
changed = self._task.args['changed']
183+
if not changed:
184+
results['failed'] = True
185+
results['msg'] = 'Changed is "false"'
186+
return results
187+
188+
if len(ndfc_data['response']) == 0:
189+
results['failed'] = True
190+
results['msg'] = 'No response data found'
191+
return results
192+
193+
ignore_fields = {"seed_ip": 0, "role": 0}
194+
195+
if 'mode' in self._task.args:
196+
mode = self._task.args['mode'].lower()
197+
if mode == 'ip':
198+
# In IP mode, we ignore role matching
199+
ignore_fields['role'] = 1
200+
elif mode == 'role':
201+
# In role mode, we ignore IP matching
202+
ignore_fields['seed_ip'] = 1
203+
204+
validation_result = InventoryValidate(config_data=test_data, ndfc_data=ndfc_data['response'], ignore_fields=ignore_fields, response=response)
205+
validation_output = InventoryValidate.model_validate(validation_result)
206+
207+
if validation_output.response:
208+
results['failed'] = False
209+
results['msg'] = 'Validation Successful!'
210+
else:
211+
results['failed'] = True
212+
results['msg'] = 'Validation Failed! Please check output above.'
213+
214+
return results
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
# This NDFC test data structure is auto-generated
3+
# DO NOT EDIT MANUALLY
4+
#
5+
6+
# ------------------------------
7+
# Fabric Switches
8+
# ------------------------------
9+
10+
{% if switch_conf is iterable %}
11+
{% set switch_list = [] %}
12+
{% for switch in switch_conf %}
13+
{% set switch_item = {} %}
14+
{% if switch.seed_ip is defined %}
15+
{% set _ = switch_item.update({'seed_ip': switch.seed_ip | default('') }) %}
16+
{% endif %}
17+
{% set _ = switch_item.update({'user_name': switch_username}) %}
18+
{% set _ = switch_item.update({'password': switch_password}) %}
19+
{% if switch.role is defined %}
20+
{% set _ = switch_item.update({'role': switch.role | default('') }) %}
21+
{% endif %}
22+
{% if switch.poap is defined %}
23+
{% for sw_poap_item in switch.poap %}
24+
{% set poap_item = {} %}
25+
{% if sw_poap_item.preprovision_serial is defined and sw_poap_item.preprovision_serial %}
26+
{% set _ = poap_item.update({'preprovision_serial': sw_poap_item.preprovision_serial}) %}
27+
{% endif %}
28+
{% if sw_poap_item.serial_number is defined and sw_poap_item.serial_number %}
29+
{% set _ = poap_item.update({'serial_number': sw_poap_item.serial_number}) %}
30+
{% endif %}
31+
{% if sw_poap_item.model is defined and sw_poap_item.model %}
32+
{% set _ = poap_item.update({'model': sw_poap_item.model}) %}
33+
{% endif %}
34+
{% if sw_poap_item.version is defined and sw_poap_item.version %}
35+
{% set _ = poap_item.update({'version': sw_poap_item.version}) %}
36+
{% endif %}
37+
{% if sw_poap_item.hostname is defined and sw_poap_item.hostname %}
38+
{% set _ = poap_item.update({'hostname': sw_poap_item.hostname}) %}
39+
{% endif %}
40+
{% if sw_poap_item.config_data is defined %}
41+
{% set poap_config_item = {} %}
42+
{% for sw_poap_config_item in sw_poap_item.config_data %}
43+
{% set _ = poap_config_item.update({sw_poap_config_item: sw_poap_item.config_data[sw_poap_config_item]}) %}
44+
{% endfor %}
45+
{% set _ = poap_item.update({'config_data': poap_config_item}) %}
46+
{% endif %}
47+
{% set _ = switch_item.update({'poap': [poap_item]}) %}
48+
{% endfor %}
49+
{% else %}
50+
{% if switch.auth_proto is defined %}
51+
{% set _ = switch_item.update({'auth_proto': switch.auth_proto | default('') }) %}
52+
{% endif %}
53+
{% if switch.max_hops is defined %}
54+
{% set _ = switch_item.update({'max_hops': switch.max_hops | default('') }) %}
55+
{% endif %}
56+
{% if switch.preserve_config is defined %}
57+
{% set _ = switch_item.update({'preserve_config': switch.preserve_config | default('') }) %}
58+
{% else %}
59+
{% set _ = switch_item.update({'preserve_config': false }) %}
60+
{% endif %}
61+
{% endif %}
62+
{% set _ = switch_list.append(switch_item) %}
63+
{% endfor %}
64+
{{ switch_list | to_nice_yaml(indent=2) }}
65+
{% endif %}

tests/integration/targets/dcnm_inventory/files/.gitkeep

Whitespace-only changes.

tests/integration/targets/dcnm_inventory/tasks/dcnm.yaml

Lines changed: 0 additions & 24 deletions
This file was deleted.

0 commit comments

Comments
 (0)