From 574b3c8bbf9046c99f1e543ba154e6d805e54843 Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Tue, 13 Oct 2026 10:35:19 +0200 Subject: [PATCH 1/2] statd: keep OSPFv2 status when OSPFv3 also runs With both OSPFv2 and OSPFv3 enabled, the OSPFv2 instance was listed in the operational datastore with only its type and name, and `show ip ospf` said OSPF was not running. add_areas() and add_areas6() each set the control-plane-protocol list with insert(), which assigns, so the OSPFv3 entry replaced the OSPFv2 one. Append to the list instead, as the RIP and RIPng code already does. Resolves: #1700 Signed-off-by: Ejub Sabic --- src/statd/python/yanger/ietf_ospf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/statd/python/yanger/ietf_ospf.py b/src/statd/python/yanger/ietf_ospf.py index 8accac9c02..c94f265607 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(): From 641ac44915040bb729101bf8d18c1efc55716a19 Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Tue, 13 Oct 2026 10:36:19 +0200 Subject: [PATCH 2/2] test: add OSPF and RIP dual-stack test The operational status of one routing protocol instance must not hide the other when the IPv4 and IPv6 versions run on the same link. One test, run once for OSPF and once for RIP, configures both versions on R1 and R2 and waits for the neighbor in each instance, read from the operational datastore. Without the statd fix the OSPFv2 adjacency is never found and the OSPF run times out. Add rip_has_neighbors() to infamy.route. RIPng peers are only known by their link-local address, so it checks that the instance has a neighbor rather than matching one. Signed-off-by: Ejub Sabic --- test/case/routing/Readme.adoc | 5 + test/case/routing/all.yaml | 3 + test/case/routing/dual_stack/Readme.adoc | 6 + test/case/routing/dual_stack/ospf.adoc | 28 ++++ test/case/routing/dual_stack/ospf.py | 1 + test/case/routing/dual_stack/rip.adoc | 28 ++++ test/case/routing/dual_stack/rip.py | 1 + test/case/routing/dual_stack/test.py | 171 ++++++++++++++++++++++ test/case/routing/dual_stack/test.yaml | 11 ++ test/case/routing/dual_stack/topology.dot | 32 ++++ test/case/routing/dual_stack/topology.svg | 65 ++++++++ test/infamy/route.py | 16 ++ 12 files changed, 367 insertions(+) create mode 100644 test/case/routing/dual_stack/Readme.adoc create mode 100644 test/case/routing/dual_stack/ospf.adoc create mode 120000 test/case/routing/dual_stack/ospf.py create mode 100644 test/case/routing/dual_stack/rip.adoc create mode 120000 test/case/routing/dual_stack/rip.py create mode 100755 test/case/routing/dual_stack/test.py create mode 100644 test/case/routing/dual_stack/test.yaml create mode 100644 test/case/routing/dual_stack/topology.dot create mode 100644 test/case/routing/dual_stack/topology.svg diff --git a/test/case/routing/Readme.adoc b/test/case/routing/Readme.adoc index 6152bc0adf..4926aebcfd 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 cbd53ccd71..85762217f2 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 0000000000..bb9bd21f0d --- /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 0000000000..557b675085 --- /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 0000000000..9465664311 --- /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 0000000000..f510dd0f3c --- /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 0000000000..9465664311 --- /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 0000000000..486bb5a1d4 --- /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 0000000000..a6ab060faa --- /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 0000000000..61756cf8de --- /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 0000000000..9f8b911c19 --- /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 093e8bd4ec..fe054a1af2 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"]