diff --git a/src/statd/python/yanger/ietf_ospf.py b/src/statd/python/yanger/ietf_ospf.py index 8accac9c0..c94f26560 100644 --- a/src/statd/python/yanger/ietf_ospf.py +++ b/src/statd/python/yanger/ietf_ospf.py @@ -310,7 +310,7 @@ def add_areas(control_protocols): add_routes(control_protocol["ietf-ospf:ospf"]) control_protocol["ietf-ospf:ospf"]["ietf-ospf:areas"]["ietf-ospf:area"] = areas - insert(control_protocols, "control-plane-protocol", [control_protocol]) + control_protocols.setdefault("control-plane-protocol", []).append(control_protocol) def add_areas6(control_protocols): @@ -391,7 +391,7 @@ def add_areas6(control_protocols): add_routes(control_protocol["ietf-ospf:ospf"], ipv6=True) control_protocol["ietf-ospf:ospf"]["ietf-ospf:areas"]["ietf-ospf:area"] = areas - insert(control_protocols, "control-plane-protocol", [control_protocol]) + control_protocols.setdefault("control-plane-protocol", []).append(control_protocol) def operational(): diff --git a/test/case/routing/Readme.adoc b/test/case/routing/Readme.adoc index 6152bc0ad..4926aebcf 100644 --- a/test/case/routing/Readme.adoc +++ b/test/case/routing/Readme.adoc @@ -16,6 +16,7 @@ Tests verifying standard routing protocols and configuration: - RIP passive interface configuration - RIP route redistribution (connected, static, and OSPF) - RIP multi-hop routing with multiple neighbors + - OSPF and RIP for IPv4 and IPv6 at the same time - Route preference handling for OSPF routes - Route preference handling for DHCP-learned routes - Administrative distance configuration (route preference 255) @@ -72,6 +73,10 @@ include::rip_multihop/Readme.adoc[] <<< +include::dual_stack/Readme.adoc[] + +<<< + include::route_pref_ospf/Readme.adoc[] <<< diff --git a/test/case/routing/all.yaml b/test/case/routing/all.yaml index cbd53ccd7..85762217f 100644 --- a/test/case/routing/all.yaml +++ b/test/case/routing/all.yaml @@ -46,3 +46,6 @@ - name: RIP Multi-hop suite: rip_multihop/test.yaml + +- name: Dual Stack Routing + suite: dual_stack/test.yaml diff --git a/test/case/routing/dual_stack/Readme.adoc b/test/case/routing/dual_stack/Readme.adoc new file mode 100644 index 000000000..bb9bd21f0 --- /dev/null +++ b/test/case/routing/dual_stack/Readme.adoc @@ -0,0 +1,6 @@ +include::ospf.adoc[] + +<<< + +include::rip.adoc[] + diff --git a/test/case/routing/dual_stack/ospf.adoc b/test/case/routing/dual_stack/ospf.adoc new file mode 100644 index 000000000..557b67508 --- /dev/null +++ b/test/case/routing/dual_stack/ospf.adoc @@ -0,0 +1,28 @@ +=== OSPF Dual Stack + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/dual_stack] + +==== Description + +The status of each OSPF instance must stay visible in the operational +datastore when the IPv4 and IPv6 versions run at the same time. + +R1 and R2 have an IPv4 and an IPv6 address on the link between them, with +OSPF for both address families on that link. The test waits until the +operational datastore shows the neighbor in each instance, a full +adjacency for OSPF and a known peer for RIP. A RIP router does not send +the link prefix back on the link, so each router also redistributes a +loopback address to have something to advertise. + +==== Topology + +image::topology.svg[OSPF Dual Stack topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure OSPF for IPv4 and IPv6 on R1 and R2 +. Wait for the IPv4 OSPF neighbor on R1 and R2 +. Wait for the IPv6 OSPF neighbor on R1 and R2 + + diff --git a/test/case/routing/dual_stack/ospf.py b/test/case/routing/dual_stack/ospf.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/dual_stack/ospf.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/dual_stack/rip.adoc b/test/case/routing/dual_stack/rip.adoc new file mode 100644 index 000000000..f510dd0f3 --- /dev/null +++ b/test/case/routing/dual_stack/rip.adoc @@ -0,0 +1,28 @@ +=== RIP Dual Stack + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/dual_stack] + +==== Description + +The status of each RIP instance must stay visible in the operational +datastore when the IPv4 and IPv6 versions run at the same time. + +R1 and R2 have an IPv4 and an IPv6 address on the link between them, with +RIP for both address families on that link. The test waits until the +operational datastore shows the neighbor in each instance, a full +adjacency for OSPF and a known peer for RIP. A RIP router does not send +the link prefix back on the link, so each router also redistributes a +loopback address to have something to advertise. + +==== Topology + +image::topology.svg[RIP Dual Stack topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure RIP for IPv4 and IPv6 on R1 and R2 +. Wait for the IPv4 RIP neighbor on R1 and R2 +. Wait for the IPv6 RIP neighbor on R1 and R2 + + diff --git a/test/case/routing/dual_stack/rip.py b/test/case/routing/dual_stack/rip.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/dual_stack/rip.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/dual_stack/test.py b/test/case/routing/dual_stack/test.py new file mode 100755 index 000000000..486bb5a1d --- /dev/null +++ b/test/case/routing/dual_stack/test.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""{proto} Dual Stack + +The status of each {proto} instance must stay visible in the operational +datastore when the IPv4 and IPv6 versions run at the same time. + +R1 and R2 have an IPv4 and an IPv6 address on the link between them, with +{proto} for both address families on that link. The test waits until the +operational datastore shows the neighbor in each instance, a full +adjacency for OSPF and a known peer for RIP. A RIP router does not send +the link prefix back on the link, so each router also redistributes a +loopback address to have something to advertise. +""" + +import infamy +import infamy.route as route +from infamy.util import until, parallel + + +class ArgumentParser(infamy.ArgumentParser): + def __init__(self): + super().__init__() + self.add_argument("--proto", type=str.lower, choices=["ospf", "rip"]) + + +ROUTER_ID = {"R1": "1.1.1.1", "R2": "2.2.2.2"} +IPV4 = {"R1": "192.168.50.1", "R2": "192.168.50.2"} +IPV6 = {"R1": "2001:db8:50::1", "R2": "2001:db8:50::2"} +LO4 = {"R1": "192.168.100.1", "R2": "192.168.200.1"} +LO6 = {"R1": "2001:db8:100::1", "R2": "2001:db8:200::1"} + +# IPv4 and IPv6 instance type for each protocol +TYPES = { + "ospf": ["infix-routing:ospfv2", "infix-routing:ospfv3"], + "rip": ["infix-routing:ripv2", "infix-routing:ripng"], +} + + +def ospf_instance(proto, name, link): + return { + "type": proto, + "name": "default", + "ospf": { + "explicit-router-id": ROUTER_ID[name], + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "enabled": True, + "name": link, + "hello-interval": 1, + "dead-interval": 3 + }] + } + }] + } + } + } + + +def rip_instance(proto, name, link): + return { + "type": proto, + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "redistribute": { + "redistribute": [{"protocol": "connected"}] + }, + "interfaces": { + "interface": [{ + "interface": link + }] + } + } + } + + +def config_target(target, name, link, proto): + instance = ospf_instance if proto == "ospf" else rip_instance + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": link, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": IPV4[name], + "prefix-length": 24 + }] + }, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": IPV6[name], + "prefix-length": 64 + }] + } + }, { + "name": "lo", + "ipv4": { + "address": [{ + "ip": LO4[name], + "prefix-length": 32 + }] + }, + "ipv6": { + "address": [{ + "ip": LO6[name], + "prefix-length": 128 + }] + } + }] + } + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [instance(t, name, link) for t in TYPES[proto]] + } + } + } + }) + + +def has_neighbor(target, link, neighbor, proto, version): + if proto == "ospf": + return route.ospf_get_neighbor(target, "0.0.0.0", link, ROUTER_ID[neighbor], + proto=version) + + # RIPng peers are known by their link-local address only + return route.rip_has_neighbors(target, proto=version) + + +def wait_neighbor(target, link, neighbor, proto, version): + until(lambda: has_neighbor(target, link, neighbor, proto, version), attempts=200) + + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUTs"): + env = infamy.Env(args=ArgumentParser()) + proto = env.args.proto + ipv4, ipv6 = TYPES[proto] + + R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), + lambda: env.attach("R2", "mgmt")) + route.skip_unless_supported(test, proto, R1, R2) + + _, R1link = env.ltop.xlate("R1", "link") + _, R2link = env.ltop.xlate("R2", "link") + + with test.step(f"Configure {proto} for IPv4 and IPv6 on R1 and R2"): + parallel(lambda: config_target(R1, "R1", R1link, proto), + lambda: config_target(R2, "R2", R2link, proto)) + + with test.step(f"Wait for the IPv4 {proto} neighbor on R1 and R2"): + parallel(lambda: wait_neighbor(R1, R1link, "R2", proto, ipv4), + lambda: wait_neighbor(R2, R2link, "R1", proto, ipv4)) + + with test.step(f"Wait for the IPv6 {proto} neighbor on R1 and R2"): + parallel(lambda: wait_neighbor(R1, R1link, "R2", proto, ipv6), + lambda: wait_neighbor(R2, R2link, "R1", proto, ipv6)) + + test.succeed() diff --git a/test/case/routing/dual_stack/test.yaml b/test/case/routing/dual_stack/test.yaml new file mode 100644 index 000000000..a6ab060fa --- /dev/null +++ b/test/case/routing/dual_stack/test.yaml @@ -0,0 +1,11 @@ +--- +- settings: + test-spec: .adoc + +- name: OSPF Dual Stack + case: ospf.py + opts: ["--proto", "OSPF"] + +- name: RIP Dual Stack + case: rip.py + opts: ["--proto", "RIP"] diff --git a/test/case/routing/dual_stack/topology.dot b/test/case/routing/dual_stack/topology.dot new file mode 100644 index 000000000..61756cf8d --- /dev/null +++ b/test/case/routing/dual_stack/topology.dot @@ -0,0 +1,32 @@ +graph "dual_stack" { + layout="neato"; + overlap="false"; + esep="+20"; + size=10 + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + PC [ + label="PC | { mgmt1 | mgmt2 }", + pos="-20,130!", + requires="controller", + ]; + + R1 [ + label="{ mgmt | link } | R1 \n router-id 1.1.1.1", + pos="160,230!", + + requires="infix", + ]; + R2 [ + label="{ link | mgmt } | R2 \n router-id 2.2.2.2", + pos="160,30!", + + requires="infix", + ]; + + PC:mgmt1 -- R1:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt2 -- R2:mgmt [requires="mgmt", color="lightgray"] + R1:link -- R2:link [label="192.168.50.0/24\n2001:db8:50::/64", headlabel=".2\n::2", taillabel=".1\n::1", labeldistance=2, fontcolor="black", color="black"] +} diff --git a/test/case/routing/dual_stack/topology.svg b/test/case/routing/dual_stack/topology.svg new file mode 100644 index 000000000..9f8b911c1 --- /dev/null +++ b/test/case/routing/dual_stack/topology.svg @@ -0,0 +1,65 @@ + + + + + + +dual_stack + + + +PC + +PC + +mgmt1 + +mgmt2 + + + +R1 + +mgmt + +link + +R1 + router-id 1.1.1.1 + + + +PC:mgmt1--R1:mgmt + + + + +R2 + +link + +mgmt + +R2 + router-id 2.2.2.2 + + + +PC:mgmt2--R2:mgmt + + + + +R1:link--R2:link + +192.168.50.0/24 +2001:db8:50::/64 +.2 +::2 +.1 +::1 + + + diff --git a/test/infamy/route.py b/test/infamy/route.py index 093e8bd4e..fe054a1af 100644 --- a/test/infamy/route.py +++ b/test/infamy/route.py @@ -150,6 +150,22 @@ def ospf_has_neighbors(target, proto="infix-routing:ospfv2"): return False +def _get_rip_status(target, proto="infix-routing:ripv2"): + xpath = "/ietf-routing:routing/control-plane-protocols" + protos = target.get_data(xpath)["routing"]["control-plane-protocols"] + for p in protos.get("control-plane-protocol", {}): + if p["type"] == proto: + return p.get("rip") or p.get("ietf-rip:rip", {}) + + return {} + + +def rip_has_neighbors(target, proto="infix-routing:ripv2"): + rip = _get_rip_status(target, proto) + af = "ipv6" if proto == "infix-routing:ripng" else "ipv4" + return bool(rip.get(af, {}).get("neighbors", {}).get("neighbor")) + + def _get_bfd_sessions(target): xpath = "/ietf-routing:routing/control-plane-protocols" protos = target.get_data(xpath)["routing"]["control-plane-protocols"]