Skip to content

Commit eecf674

Browse files
authored
IT: dcnm_vpc_pairs (#498)
* integration tests for vpc_pair modularized * IT: dcnm_vpc_pair refactored. added validation action plugin * updated ignores files and fixed sanity errors * fixed sanity error
1 parent 547192b commit eecf674

26 files changed

+2185
-1182
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,4 @@ venv.bak/
7979
.mypy_cache/
8080

8181
# Ignore Integration Tests Files Directories
82-
tests/integration/targets/*?/files
82+
tests/integration/targets/*/files

playbooks/roles/dcnm_vpc_pair/dcnm_hosts.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ all:
88
children:
99
dcnm:
1010
vars:
11-
ansible_it_fabric: fabric-stage
1211
ansible_connection: ansible.netcommon.httpapi
1312
ansible_network_os: cisco.dcnm.dcnm
1413
ansible_httpapi_validate_certs: no

playbooks/roles/dcnm_vpc_pair/dcnm_tests.yaml

Lines changed: 11 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -12,67 +12,17 @@
1212
connection: ansible.netcommon.httpapi
1313

1414
vars:
15-
switch_username: admin
16-
switch_password: "password-secret"
17-
ansible_it_fabric: fabric-stage
18-
ansible_switch1: 192.168.1.1
19-
ansible_switch2: 192.168.1.2
20-
ansible_peer1_ip: 192.168.1.1
21-
ansible_peer2_ip: 192.168.1.2
22-
ansible_vxlan_vpc_domain_id: 1000
23-
24-
config:
25-
- peerOneId: "{{ ansible_switch1 }}"
26-
peerTwoId: "{{ ansible_switch2 }}"
27-
templateName: "vpc_pair" # Using the correct template name
28-
profile:
29-
# Required fields for VPC template
30-
ADMIN_STATE: true
31-
ALLOWED_VLANS: "all"
32-
DOMAIN_ID: "{{ ansible_vxlan_vpc_domain_id }}"
33-
FABRIC_NAME: "{{ ansible_it_fabric }}"
34-
KEEP_ALIVE_HOLD_TIMEOUT: 3
35-
KEEP_ALIVE_VRF: "management"
36-
PC_MODE: "active"
37-
PEER1_KEEP_ALIVE_LOCAL_IP: "{{ ansible_peer1_ip }}"
38-
PEER1_MEMBER_INTERFACES: "eth1/1"
39-
PEER1_PCID: 1
40-
PEER2_KEEP_ALIVE_LOCAL_IP: "{{ ansible_peer2_ip }}"
41-
PEER2_MEMBER_INTERFACES: "eth1/1"
42-
PEER2_PCID: 2
43-
44-
# Additional required fields
45-
peer1Ip: "{{ ansible_peer1_ip }}"
46-
peer2Ip: "{{ ansible_peer2_ip }}"
47-
vpcDomainId: "{{ ansible_vxlan_vpc_domain_id }}"
48-
adminState: true
49-
keepAliveVrf: "management"
50-
keepAliveHoldTimeout: 3
51-
keepAliveLocalIp: "{{ ansible_peer1_ip }}"
52-
keepAliveRemoteIp: "{{ ansible_peer2_ip }}"
53-
54-
# Template specific fields
55-
templateName: "vpc_pair"
56-
templatePropId: ""
57-
templatePropName: "vpc_pair"
58-
templatePropDescription: "VPC Template"
59-
templatePropDataType: "JSON"
60-
templatePropDefaultValue: ""
61-
templatePropDisplayName: "VPC Configuration"
62-
templatePropIsMandatory: true
63-
templatePropIsMultiSelect: false
64-
templatePropIsPassword: false
65-
templatePropIsReadOnly: false
66-
templatePropIsRequired: true
67-
templatePropIsSecure: false
68-
templatePropIsSortable: false
69-
templatePropIsVisible: true
70-
templatePropOptions: []
71-
templatePropRange: []
72-
templatePropValue: ""
73-
templatePropValueType: "STRING"
74-
templateIPAddress: "{{ ansible_peer1_ip }}"
75-
15+
# testcase: "*merge*"
16+
IT_CONTEXT: true
17+
test_data_common:
18+
switch_username: admin
19+
switch_password: "password"
20+
fabric: fabric-stage
21+
sw1: 192.168.10.1
22+
sw2: 192.168.10.2
23+
peer1_ip: 192.168.10.1
24+
peer2_ip: 192.168.10.2
25+
vpc_domain_id: 1000
7626

7727
roles:
7828
- dcnm_vpc_pair
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
from __future__ import absolute_import, division, print_function
2+
3+
4+
__metaclass__ = type
5+
6+
from pprint import pprint
7+
import json
8+
from ansible.utils.display import Display
9+
from ansible.plugins.action import ActionBase
10+
from ansible.module_utils.six import raise_from
11+
from ansible.errors import AnsibleError
12+
from ansible.module_utils.common.text.converters import to_native
13+
from ..plugin_utils.tools import load_yaml_file, process_deepdiff
14+
from ..plugin_utils.pydantic_schemas.dcnm_vpc_pair.schemas import DcnmVpcPairQuerySchema
15+
16+
try:
17+
from deepdiff import DeepDiff
18+
except ImportError as imp_exc:
19+
DEEPDIFF_IMPORT_ERROR = imp_exc
20+
else:
21+
DEEPDIFF_IMPORT_ERROR = None
22+
23+
if DEEPDIFF_IMPORT_ERROR:
24+
raise_from(
25+
AnsibleError('DeepDiff must be installed to use this plugin. Use pip or install test-requirements.'),
26+
DEEPDIFF_IMPORT_ERROR)
27+
28+
display = Display()
29+
30+
31+
class ActionModule(ActionBase):
32+
33+
def filter_actual_config_fields(self, expected_data, actual_data, fabric_type=""):
34+
"""
35+
Filter specific configuration fields in actual data to only include items
36+
that exist in expected data
37+
"""
38+
if not expected_data or not actual_data:
39+
return actual_data
40+
41+
def normalize_multiline_configs(config_value):
42+
"""Normalize multiline configuration strings"""
43+
if not config_value:
44+
return []
45+
46+
if isinstance(config_value, str):
47+
lines = [line.strip() for line in config_value.split('\n') if line.strip()]
48+
return sorted(lines)
49+
return config_value
50+
51+
def filter_nvpairs(expected_nvpairs, actual_nvpairs):
52+
"""Filter nvPairs based on expected configuration"""
53+
# If actual nvpairs is empty dict, return empty dict
54+
if actual_nvpairs == {}:
55+
return {}
56+
57+
if not expected_nvpairs or not actual_nvpairs:
58+
return actual_nvpairs
59+
60+
filtered_nvpairs = actual_nvpairs.copy()
61+
multiline_fields = [
62+
'PEER1_DOMAIN_CONF', 'PEER2_DOMAIN_CONF',
63+
'PEER1_PO_CONF', 'PEER2_PO_CONF',
64+
'PEER1_MEMBER_INTERFACES', 'PEER2_MEMBER_INTERFACES'
65+
]
66+
67+
for field in multiline_fields:
68+
if field in expected_nvpairs and field in actual_nvpairs:
69+
if field.endswith('_MEMBER_INTERFACES'):
70+
# Handle interface lists (comma-separated)
71+
expected_interfaces = set()
72+
if expected_nvpairs[field]:
73+
expected_interfaces = set([iface.strip() for iface in expected_nvpairs[field].split(',')])
74+
75+
actual_interfaces = set()
76+
if actual_nvpairs[field]:
77+
actual_interfaces = set([iface.strip() for iface in actual_nvpairs[field].split(',')])
78+
79+
# Keep only interfaces that exist in expected
80+
filtered_interfaces = expected_interfaces.intersection(actual_interfaces)
81+
filtered_nvpairs[field] = ','.join(sorted(filtered_interfaces)) if filtered_interfaces else ""
82+
83+
else:
84+
# Handle multiline configuration strings
85+
expected_lines = normalize_multiline_configs(expected_nvpairs[field])
86+
actual_lines = normalize_multiline_configs(actual_nvpairs[field])
87+
88+
# Keep only lines that exist in expected
89+
filtered_lines = [line for line in actual_lines if line in expected_lines]
90+
filtered_nvpairs[field] = '\n'.join(filtered_lines) if filtered_lines else ""
91+
92+
return filtered_nvpairs
93+
94+
# Apply filtering to actual data
95+
filtered_actual = actual_data.copy()
96+
if "response" in expected_data and "response" in actual_data:
97+
for i, expected_vpc_pair in enumerate(expected_data["response"]):
98+
if i < len(actual_data["response"]):
99+
actual_vpc_pair = actual_data["response"][i]
100+
if "nvPairs" in expected_vpc_pair and "nvPairs" in actual_vpc_pair:
101+
# If actual nvPairs is empty dict, apply conditional logic based on fabric_type
102+
if actual_vpc_pair["nvPairs"] == {}:
103+
filtered_actual["response"][i]["nvPairs"] = {}
104+
# Only modify expected data if fabric_type contains "vxlan" (case insensitive)
105+
if "vxlan" in fabric_type.lower():
106+
display.v(f"VXLAN fabric detected ({fabric_type}), setting expected nvPairs to {{}} and templateName to ''")
107+
expected_data["response"][i]["nvPairs"] = {}
108+
# Also set templateName to empty string for VXLAN fabrics
109+
if "templateName" in expected_data["response"][i]:
110+
expected_data["response"][i]["templateName"] = ""
111+
else:
112+
display.v(f"Non-VXLAN fabric detected ({fabric_type}), keeping expected nvPairs unchanged")
113+
else:
114+
filtered_nvpairs = filter_nvpairs(
115+
expected_vpc_pair["nvPairs"],
116+
actual_vpc_pair["nvPairs"]
117+
)
118+
filtered_actual["response"][i]["nvPairs"] = filtered_nvpairs
119+
120+
return filtered_actual
121+
122+
def convert_ip_to_sn(self, data, ip_to_sn_mapping):
123+
"""
124+
Convert IP addresses to serial numbers in the data structure using the provided mapping
125+
"""
126+
if not ip_to_sn_mapping:
127+
return data
128+
129+
def convert_recursive(obj):
130+
if isinstance(obj, dict):
131+
converted = {}
132+
for key, value in obj.items():
133+
if key in ['peerOneId', 'peerTwoId'] and isinstance(value, str):
134+
# Check if the value is an IP address that exists in our mapping
135+
if value in ip_to_sn_mapping:
136+
converted[key] = ip_to_sn_mapping[value]
137+
else:
138+
converted[key] = value
139+
else:
140+
converted[key] = convert_recursive(value)
141+
return converted
142+
elif isinstance(obj, list):
143+
return [convert_recursive(item) for item in obj]
144+
else:
145+
return obj
146+
147+
return convert_recursive(data)
148+
149+
def verify_deleted(self, results, check_deleted, expected_data, ndfc_data, config_path):
150+
if not check_deleted:
151+
return None
152+
153+
existing_vpc_pairs = set()
154+
for vpc_pair in ndfc_data["response"]:
155+
# Create a unique identifier for each VPC pair using peer IDs
156+
vpc_pair_id = f"{vpc_pair.get('peerOneId', '')}_{vpc_pair.get('peerTwoId', '')}"
157+
existing_vpc_pairs.add(vpc_pair_id)
158+
159+
if config_path == "":
160+
# check for full delete
161+
if not ndfc_data["failed"] and len(existing_vpc_pairs) == 0:
162+
results['msg'] = 'All VPC pairs are deleted'
163+
else:
164+
print("VPC pairs still existing: ")
165+
print(existing_vpc_pairs)
166+
results['failed'] = True
167+
results['msg'] = 'Error: Expected full delete as config_path is empty but VPC pairs still exist.'
168+
if ndfc_data["failed"]:
169+
results['msg'] += '\n\nError: ' + ndfc_data["error"]
170+
return results
171+
return results
172+
173+
# checks for a partial delete
174+
deleted_vpc_pairs = set()
175+
for vpc_pair in expected_data["response"]:
176+
vpc_pair_id = f"{vpc_pair.get('peerOneId', '')}_{vpc_pair.get('peerTwoId', '')}"
177+
deleted_vpc_pairs.add(vpc_pair_id)
178+
179+
remaining_vpc_pairs = existing_vpc_pairs.intersection(deleted_vpc_pairs)
180+
if len(remaining_vpc_pairs) > 0:
181+
results['failed'] = True
182+
print("Expected VPC pairs to be deleted: ")
183+
print(deleted_vpc_pairs)
184+
print("\nVPC pairs present in NDFC: ")
185+
print(existing_vpc_pairs)
186+
print("\nVPC pairs still not deleted: ")
187+
print(remaining_vpc_pairs)
188+
results['msg'] = 'All VPC pairs are not deleted'
189+
return results
190+
191+
print("Expected VPC pairs to be deleted: ")
192+
print(deleted_vpc_pairs)
193+
print("\n\nVPC pairs present in NDFC: ")
194+
print(existing_vpc_pairs)
195+
print("VPC pairs still not deleted: ")
196+
print(remaining_vpc_pairs)
197+
results['failed'] = False
198+
results['msg'] = 'Provided VPC pairs are deleted'
199+
return results
200+
201+
def run(self, tmp=None, task_vars=None):
202+
results = super(ActionModule, self).run(tmp, task_vars)
203+
results['failed'] = False
204+
205+
ndfc_data = self._task.args.get('ndfc_data', None)
206+
test_data = self._task.args.get('test_data', None)
207+
config_path = self._task.args.get('config_path', None)
208+
check_deleted = self._task.args.get('check_deleted', False)
209+
ignore_fields = list(self._task.args.get('ignore_fields', []))
210+
211+
# Extract fabric_type and ip_to_sn_mapping from test_data if available
212+
fabric_type = test_data.get('fabric_type', '') if test_data else ''
213+
ip_to_sn_mapping = test_data.get('ip_to_sn_mapping', {}) if test_data else {}
214+
display.v(f"Fabric type extracted from test_data: {fabric_type}")
215+
display.v(f"IP to SN mapping extracted from test_data: {len(ip_to_sn_mapping)} entries")
216+
217+
for input_item in [ndfc_data, test_data, config_path]:
218+
if input_item is None:
219+
results['failed'] = True
220+
results['msg'] = f"Required input parameter not found: '{input_item}'"
221+
return results
222+
223+
# removes ansible embeddings and converts to native python types
224+
native_ndfc_data = json.loads(json.dumps(ndfc_data, default=to_native))
225+
226+
test_fabric = test_data['fabric']
227+
228+
expected_data_parsed = None
229+
if config_path != "":
230+
# only parse if config file exists
231+
expected_config_data = load_yaml_file(config_path)
232+
expected_data = DcnmVpcPairQuerySchema.yaml_config_to_dict(expected_config_data, test_fabric)
233+
234+
# Convert IP addresses to serial numbers in expected data if mapping is provided
235+
if ip_to_sn_mapping:
236+
expected_data = self.convert_ip_to_sn(expected_data, ip_to_sn_mapping)
237+
238+
expected_data_parsed = DcnmVpcPairQuerySchema.model_validate(expected_data).model_dump(exclude_none=True)
239+
240+
ndfc_data_parsed = DcnmVpcPairQuerySchema.model_validate(native_ndfc_data).model_dump(exclude_none=True)
241+
242+
# Apply configuration filtering if we have expected data
243+
if expected_data_parsed:
244+
native_ndfc_data = self.filter_actual_config_fields(expected_data_parsed, native_ndfc_data, fabric_type)
245+
ndfc_data_parsed = DcnmVpcPairQuerySchema.model_validate(native_ndfc_data).model_dump(exclude_none=True)
246+
247+
if deleted_results := self.verify_deleted(results, check_deleted, expected_data_parsed, ndfc_data_parsed, config_path):
248+
return deleted_results
249+
250+
validity = DeepDiff(
251+
expected_data_parsed,
252+
ndfc_data_parsed,
253+
ignore_order=True,
254+
cutoff_distance_for_pairs=0,
255+
cutoff_intersection_for_pairs=0,
256+
report_repetition=True
257+
)
258+
259+
# Process the output of deepdiff to make it easier to read
260+
# Effects the iterable_item_added and iterable_item_removed to remove unneeded fields
261+
# ignore_extra_fields=True will ignore dictionary_item_added changes
262+
# This is useful when the actual data has more fields than the expected data
263+
# keys_to_ignore is a list of fields to ignore, useful for auto provisioned fields which are not known
264+
processed_validity = process_deepdiff(validity, keys_to_ignore=ignore_fields, ignore_extra_fields=True)
265+
266+
if processed_validity == {}:
267+
results['failed'] = False
268+
results['msg'] = f'Data is valid. \n\n Expected data: \n\n{expected_data}\n\nActual data: \n\n{ndfc_data_parsed}'
269+
else:
270+
results['failed'] = True
271+
print("\n\nExpected: ")
272+
pprint(expected_data_parsed)
273+
print("\n\nActual: ")
274+
pprint(ndfc_data_parsed)
275+
print("\n\nDifferences: ")
276+
pprint(processed_validity)
277+
results['msg'] = 'Data is not valid.'
278+
279+
return results

plugins/action/tests/plugin_utils/pydantic_schemas/dcnm_vpc_pair/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)