Skip to content

Commit f7521d5

Browse files
committed
Update to v1.1.0
1 parent e4bc386 commit f7521d5

7 files changed

Lines changed: 190 additions & 16 deletions

File tree

ansible/collections/requirements.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ collections:
66
version: ">=9.0.0"
77
- name: community.hashi_vault
88
version: ">=7.0.0"
9+
- name: cisco.ios
10+
version: ">=8.0.0"

ansible/filter_plugins/ospf_filters.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,70 @@ def _parse(xml_string: str) -> ET.Element:
2222
def route_exists(xml_string: str, prefix: str) -> bool:
2323
"""Return True if a route to the given prefix exists in the routing table.
2424
25-
Matches if any <destination-prefix> starts with the prefix (e.g. '192.168.42.1'
26-
matches '192.168.42.1/32'). Works with ietf-routing XML.
25+
Handles two XML formats:
26+
- ietf-routing: matches <destination-prefix> (IOS-XE)
27+
- JunOS RPC: matches <rt-destination> (get-route-information)
28+
Matches if any element starts with the prefix (e.g. '192.168.42.1'
29+
matches '192.168.42.1/32').
2730
"""
2831
root = _parse(xml_string)
32+
match_tags = {"destination-prefix", "rt-destination"}
2933
for elem in root.iter():
30-
if _strip_ns(elem.tag) == "destination-prefix" and elem.text:
34+
if _strip_ns(elem.tag) in match_tags and elem.text:
3135
if elem.text.strip().startswith(prefix):
3236
return True
3337
return False
3438

3539

40+
def ospf_neighbor_full(xml_string: str, neighbor_id: str) -> bool:
41+
"""Return True if a neighbor with the given router-id is in FULL state.
42+
43+
Finds the neighbor ID in the XML, then checks sibling elements for a
44+
'full' state string. Works with Cisco-IOS-XE-ospf-oper YANG output.
45+
"""
46+
root = _parse(xml_string)
47+
parent_map = {c: p for p in root.iter() for c in p}
48+
for elem in root.iter():
49+
if elem.text and elem.text.strip() == neighbor_id:
50+
parent = parent_map.get(elem)
51+
if parent is not None:
52+
for sibling in parent:
53+
if sibling.text and "full" in sibling.text.strip().lower():
54+
return True
55+
return False
56+
57+
58+
def config_contains(xml_string: str, value: str) -> bool:
59+
"""Return True if any element's text matches the given value exactly.
60+
61+
Used for checking configuration elements (e.g. route-map names) in
62+
NETCONF running config responses.
63+
"""
64+
root = _parse(xml_string)
65+
for elem in root.iter():
66+
if elem.text and elem.text.strip() == value:
67+
return True
68+
return False
69+
70+
71+
def assert_check(xml_string: str, assertion: str, value: str) -> bool:
72+
"""Dispatch to the appropriate assertion function by name."""
73+
checks = {
74+
"route_exists": route_exists,
75+
"ospf_neighbor_full": ospf_neighbor_full,
76+
"config_contains": config_contains,
77+
}
78+
fn = checks.get(assertion)
79+
if fn is None:
80+
raise ValueError(f"Unknown assertion type: {assertion}")
81+
return fn(xml_string, value)
82+
83+
3684
class FilterModule:
3785
def filters(self) -> dict:
3886
return {
3987
"route_exists": route_exists,
88+
"ospf_neighbor_full": ospf_neighbor_full,
89+
"config_contains": config_contains,
90+
"assert_check": assert_check,
4091
}

ansible/inventory/hosts.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ all:
2424
junos:
2525
vars:
2626
ansible_netconf_ncclient_device_handler: junos
27+
ansible_user: "{{ lookup('community.hashi_vault.vault_kv2_get', 'yana/routerjunos', engine_mount_point='secret').secret.username }}"
28+
ansible_password: "{{ lookup('community.hashi_vault.vault_kv2_get', 'yana/routerjunos', engine_mount_point='secret').secret.password }}"
2729
hosts:
2830
C1J:
2931
ansible_host: 172.20.20.207

ansible/playbooks/_run_check.yml

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
---
22
# _run_check.yml — executes one test case file.
33
# Called in a loop from network_qa.yml with test_case_path as the loop var.
4+
#
5+
# Supports two test patterns:
6+
# 1. Read-only: NETCONF GET + assert (e.g. route_to_a2a)
7+
# 2. Configure-assert-teardown: push config via CLI, wait, assert via
8+
# NETCONF, then tear down config. Teardown always runs, even on failure.
9+
#
10+
# NETCONF queries support two methods:
11+
# - netconf_get (default): subtree filter with <get> or <get-config>
12+
# - netconf_rpc: vendor-specific RPC (e.g. JunOS get-route-information)
13+
#
14+
# Each test case specifies its own NETCONF filter/RPC, assertion type, and
15+
# expected value. Config/teardown phases are optional.
416

517
- name: "Load scenario: {{ test_case_path | basename }}"
618
ansible.builtin.include_vars:
@@ -11,25 +23,53 @@
1123
when: scenario_filter == '' or scenario_filter == scenario.scenario
1224
block:
1325

14-
- name: "{{ scenario.scenario }} | NETCONF GET routing table"
26+
- name: "{{ scenario.scenario }} | Push pre-test config"
27+
ansible.netcommon.cli_config:
28+
config: "{{ scenario.config_commands | default('') }}"
29+
delegate_to: "{{ scenario.config_device | default('localhost') }}"
30+
vars:
31+
ansible_connection: ansible.netcommon.network_cli
32+
ansible_network_os: "{{ scenario.config_network_os | default('') }}"
33+
ansible_port: 22
34+
ansible_ssh_host_key_checking: false
35+
when: scenario.config_commands is defined
36+
37+
- name: "{{ scenario.scenario }} | Wait for convergence"
38+
ansible.builtin.pause:
39+
seconds: "{{ scenario.convergence_wait | default(0) }}"
40+
when: scenario.convergence_wait is defined
41+
42+
- name: "{{ scenario.scenario }} | NETCONF GET"
1543
ansible.netcommon.netconf_get:
16-
filter: >-
17-
<routing-state xmlns="urn:ietf:params:xml:ns:yang:ietf-routing">
18-
<routing-instance><name>{{ scenario.vrf | default('default') }}</name></routing-instance>
19-
</routing-state>
44+
filter: "{{ scenario.netconf_filter }}"
45+
source: "{{ scenario.netconf_source | default(omit) }}"
2046
display: xml
2147
delegate_to: "{{ scenario.device }}"
2248
register: netconf_result
49+
when: scenario.netconf_rpc is not defined
50+
51+
- name: "{{ scenario.scenario }} | NETCONF RPC"
52+
ansible.netcommon.netconf_rpc:
53+
rpc: "{{ scenario.netconf_rpc }}"
54+
content: "{{ scenario.netconf_rpc_content | default(omit) }}"
55+
display: xml
56+
delegate_to: "{{ scenario.device }}"
57+
register: netconf_rpc_result
58+
when: scenario.netconf_rpc is defined
59+
60+
- name: "{{ scenario.scenario }} | Unify result"
61+
ansible.builtin.set_fact:
62+
_netconf_output: "{{ (netconf_rpc_result.output | default(netconf_result.output | default(''))) }}"
2363

2464
- name: "{{ scenario.scenario }} | Debug XML"
2565
ansible.builtin.debug:
26-
msg: "{{ netconf_result.output | truncate(500) }}"
66+
msg: "{{ _netconf_output | truncate(500) }}"
2767
when: ansible_verbosity >= 1
2868

29-
- name: "{{ scenario.scenario }} | Assert route exists"
69+
- name: "{{ scenario.scenario }} | Assert"
3070
ansible.builtin.set_fact:
31-
_assertion_passed: "{{ netconf_result.output | route_exists(scenario.assert_route_exists) }}"
32-
_assertion_output: "{{ netconf_result.output | truncate(500) }}"
71+
_assertion_passed: "{{ _netconf_output | assert_check(scenario.assertion, scenario.assert_value) }}"
72+
_assertion_output: "{{ _netconf_output | truncate(500) }}"
3373

3474
rescue:
3575

@@ -40,6 +80,18 @@
4080

4181
always:
4282

83+
- name: "{{ scenario.scenario }} | Teardown config"
84+
ansible.netcommon.cli_config:
85+
config: "{{ scenario.teardown_commands | default('') }}"
86+
delegate_to: "{{ scenario.config_device | default('localhost') }}"
87+
vars:
88+
ansible_connection: ansible.netcommon.network_cli
89+
ansible_network_os: "{{ scenario.config_network_os | default('') }}"
90+
ansible_port: 22
91+
ansible_ssh_host_key_checking: false
92+
when: scenario.teardown_commands is defined
93+
ignore_errors: true
94+
4395
- name: "{{ scenario.scenario }} | Record result"
4496
ansible.builtin.set_fact:
4597
qa_results: >-
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
# ospf_adj_e1c_c1j.yml
3+
# Verify E1C (ASBR, IOS-XE) has a FULL OSPF adjacency with C1J (core, JunOS)
4+
# in Area 0. This is a backbone adjacency — if it drops, E1C loses its path
5+
# to the core and inter-area/external routes stop propagating.
6+
7+
scenario: ospf_adj_e1c_c1j
8+
description: "Verify E1C has FULL OSPF adjacency with C1J (router-id 22.22.22.11)"
9+
rfc_ref: RFC 2328 §10.3
10+
rfc_citation: >
11+
RFC 2328 Section 10.3: The Neighbor State Machine. A neighbor transitions
12+
to FULL state only after complete database synchronization. Failure to
13+
reach FULL indicates hello timer mismatch, area type mismatch, MTU
14+
mismatch, authentication failure, or interface misconfiguration.
15+
device: E1C
16+
netconf_filter: |
17+
<ospf-oper-data xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-ospf-oper"/>
18+
assertion: ospf_neighbor_full
19+
assert_value: "22.22.22.11"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
# route_map_e1c_to_c1j.yml
3+
# Configure a route-map on E1C that redistributes a test static route into
4+
# OSPF, then verify the route reaches C1J downstream. Covers two vendors
5+
# (IOS-XE config push, JunOS NETCONF assertion) and tests the full
6+
# redistribution pipeline: static → prefix-list → route-map → OSPF → LSA flood.
7+
#
8+
# Teardown removes all test config from E1C after the assertion (pass or fail).
9+
10+
scenario: route_map_e1c_to_c1j
11+
description: "Verify route-map on E1C redistributes static route 10.99.99.0/24 to C1J via OSPF"
12+
rfc_ref: RFC 2328 §16.4
13+
rfc_citation: >
14+
RFC 2328 Section 16.4: Calculating AS external routes. Routes redistributed
15+
into OSPF appear as Type 5 LSAs and must be reachable by all non-stub
16+
routers. Missing redistribution indicates a route-map, prefix-list, or
17+
OSPF configuration error at the ASBR.
18+
19+
# Phase 1: Push config to E1C via CLI
20+
config_device: E1C
21+
config_network_os: cisco.ios.ios
22+
config_commands: |
23+
ip route vrf VRF1 10.99.99.0 255.255.255.0 Null0
24+
ip prefix-list PL_QA_STATIC seq 10 permit 10.99.99.0/24
25+
route-map RM_QA_STATIC permit 10
26+
match ip address prefix-list PL_QA_STATIC
27+
router ospf 1 vrf VRF1
28+
redistribute static subnets route-map RM_QA_STATIC
29+
convergence_wait: 15
30+
31+
# Phase 2: Assert on C1J via JunOS RPC
32+
device: C1J
33+
netconf_rpc: get-route-information
34+
netconf_rpc_content: |
35+
<table>VRF1.inet.0</table>
36+
assertion: route_exists
37+
assert_value: "10.99.99.0"
38+
39+
# Phase 3: Teardown E1C config
40+
teardown_commands: |
41+
router ospf 1 vrf VRF1
42+
no redistribute static subnets route-map RM_QA_STATIC
43+
no route-map RM_QA_STATIC
44+
no ip prefix-list PL_QA_STATIC
45+
no ip route vrf VRF1 10.99.99.0 255.255.255.0 Null0
Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
---
22
# route_to_a2a.yml
33
# Verify E1C has a route to A2A's loopback (192.168.42.1/32) in VRF1.
4-
# This is the single end-to-end health check — if this route exists,
5-
# OSPF propagation across the fabric is working.
4+
# If this route exists, OSPF propagation across the fabric is working.
65
# If it fails, the agent investigates using OSPF/routing skills + RAG.
76

87
scenario: route_to_a2a
@@ -13,5 +12,9 @@ rfc_citation: >
1312
inter-area routes are computed from the link-state database. A missing
1413
route indicates an LSDB propagation or SPF calculation failure.
1514
device: E1C
16-
vrf: VRF1
17-
assert_route_exists: "192.168.42.1"
15+
netconf_filter: |
16+
<routing-state xmlns="urn:ietf:params:xml:ns:yang:ietf-routing">
17+
<routing-instance><name>VRF1</name></routing-instance>
18+
</routing-state>
19+
assertion: route_exists
20+
assert_value: "192.168.42.1"

0 commit comments

Comments
 (0)