]> git.puffer.fish Git - matthieu/frr.git/commitdiff
tests: add opaque api test
authorChristian Hopps <chopps@labn.net>
Tue, 10 May 2022 16:13:04 +0000 (12:13 -0400)
committerChristian Hopps <chopps@labn.net>
Thu, 2 Jun 2022 20:37:16 +0000 (16:37 -0400)
Signed-off-by: Christian Hopps <chopps@labn.net>
tests/topotests/ospfapi/ctester.py [new file with mode: 0755]
tests/topotests/ospfapi/lib [new symlink]
tests/topotests/ospfapi/r1/ospfd.conf [new file with mode: 0644]
tests/topotests/ospfapi/r1/zebra.conf [new file with mode: 0644]
tests/topotests/ospfapi/r2/ospfd.conf [new file with mode: 0644]
tests/topotests/ospfapi/r2/zebra.conf [new file with mode: 0644]
tests/topotests/ospfapi/r3/ospfd.conf [new file with mode: 0644]
tests/topotests/ospfapi/r3/zebra.conf [new file with mode: 0644]
tests/topotests/ospfapi/r4/ospfd.conf [new file with mode: 0644]
tests/topotests/ospfapi/r4/zebra.conf [new file with mode: 0644]
tests/topotests/ospfapi/test_ospf_clientapi.py [new file with mode: 0644]

diff --git a/tests/topotests/ospfapi/ctester.py b/tests/topotests/ospfapi/ctester.py
new file mode 100755 (executable)
index 0000000..243fc06
--- /dev/null
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 eval: (blacken-mode 1) -*-
+#
+# January 17 2022, Christian Hopps <chopps@labn.net>
+#
+# Copyright 2022, LabN Consulting, L.L.C.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import argparse
+import asyncio
+import logging
+import os
+import sys
+
+CWD = os.path.dirname(os.path.realpath(__file__))
+
+CLIENTDIR = os.path.abspath(os.path.join(CWD, "../../../ospfclient"))
+if not os.path.exists(CLIENTDIR):
+    CLIENTDIR = os.path.join(CWD, "/usr/lib/frr")
+assert os.path.exists(
+    os.path.join(CLIENTDIR, "ospfclient.py")
+), "can't locate ospfclient.py"
+
+sys.path[0:0] = [CLIENTDIR]
+
+import ospfclient as api  # pylint: disable=E0401 # noqa: E402
+
+
+async def do_wait(c, args):
+    cv = asyncio.Condition()
+
+    async def cb(added, removed):
+        logging.debug("callback: added: %s removed: %s", added, removed)
+        sys.stdout.flush()
+        async with cv:
+            cv.notify_all()
+
+    logging.debug("API using callback")
+    await c.monitor_reachable(callback=cb)
+
+    for w in args.wait:
+        check = ",".join(sorted(list(w.split(","))))
+        logging.info("Waiting for %s", check)
+
+        while True:
+            async with cv:
+                got = ",".join(sorted([str(x) for x in c.reachable_routers]))
+                if check == got:
+                    break
+                logging.debug("expected '%s' != '%s'\nwaiting on notify", check, got)
+                await cv.wait()
+
+        logging.info("SUCCESS: %s", check)
+        print("SUCCESS: {}".format(check))
+        sys.stdout.flush()
+
+
+async def async_main(args):
+    c = api.OspfOpaqueClient(args.server)
+    await c.connect()
+    if sys.version_info[1] > 6:
+        asyncio.create_task(c._handle_msg_loop())  # pylint: disable=W0212
+    else:
+        asyncio.get_event_loop().create_task(
+            c._handle_msg_loop()  # pylint: disable=W0212
+        )
+
+    if args.wait:
+        await do_wait(c, args)
+    return 0
+
+
+def main(*args):
+    ap = argparse.ArgumentParser(args)
+    ap.add_argument("--server", default="localhost", help="OSPF API server")
+    ap.add_argument(
+        "--wait", action="append", help="wait for comma-sep set of reachable routers"
+    )
+    ap.add_argument("-v", "--verbose", action="store_true", help="be verbose")
+    args = ap.parse_args()
+
+    level = logging.DEBUG if args.verbose else logging.INFO
+    logging.basicConfig(
+        level=level, format="%(asctime)s %(levelname)s: TESTER: %(name)s: %(message)s"
+    )
+
+    # We need to flush this output to stdout right away
+    h = logging.StreamHandler(sys.stdout)
+    h.flush = sys.stdout.flush
+    f = logging.Formatter("%(asctime)s %(name)s: %(levelname)s: %(message)s")
+    h.setFormatter(f)
+    logger = logging.getLogger("ospfclient")
+    logger.addHandler(h)
+    logger.propagate = False
+
+    logging.info("ctester: starting")
+    sys.stdout.flush()
+
+    status = 3
+    try:
+        if sys.version_info[1] > 6:
+            status = asyncio.run(async_main(args))
+        else:
+            loop = asyncio.get_event_loop()
+            try:
+                status = loop.run_until_complete(async_main(args))
+            finally:
+                loop.close()
+    except KeyboardInterrupt:
+        logging.info("Exiting, received KeyboardInterrupt in main")
+    except Exception as error:
+        logging.info("Exiting, unexpected exception %s", error, exc_info=True)
+    else:
+        logging.info("api: clean exit")
+
+    return status
+
+
+if __name__ == "__main__":
+    exit_status = main()
+    sys.exit(exit_status)
diff --git a/tests/topotests/ospfapi/lib b/tests/topotests/ospfapi/lib
new file mode 120000 (symlink)
index 0000000..dc598c5
--- /dev/null
@@ -0,0 +1 @@
+../lib
\ No newline at end of file
diff --git a/tests/topotests/ospfapi/r1/ospfd.conf b/tests/topotests/ospfapi/r1/ospfd.conf
new file mode 100644 (file)
index 0000000..8d13556
--- /dev/null
@@ -0,0 +1,10 @@
+!
+interface r1-eth0
+  ip ospf hello-interval 2
+  ip ospf dead-interval 10
+  ip ospf area 1.2.3.4
+!
+router ospf
+  ospf router-id 192.168.0.1
+  capability opaque
+!
diff --git a/tests/topotests/ospfapi/r1/zebra.conf b/tests/topotests/ospfapi/r1/zebra.conf
new file mode 100644 (file)
index 0000000..aae8740
--- /dev/null
@@ -0,0 +1,4 @@
+!
+interface r1-eth0
+ ip address 10.0.1.1/24
+!
diff --git a/tests/topotests/ospfapi/r2/ospfd.conf b/tests/topotests/ospfapi/r2/ospfd.conf
new file mode 100644 (file)
index 0000000..be07127
--- /dev/null
@@ -0,0 +1,15 @@
+!
+interface r2-eth0
+  ip ospf hello-interval 2
+  ip ospf dead-interval 10
+  ip ospf area 1.2.3.4
+!
+interface r2-eth1
+  ip ospf hello-interval 2
+  ip ospf dead-interval 10
+  ip ospf area 1.2.3.4
+!
+router ospf
+  ospf router-id 192.168.0.2
+  capability opaque
+!
diff --git a/tests/topotests/ospfapi/r2/zebra.conf b/tests/topotests/ospfapi/r2/zebra.conf
new file mode 100644 (file)
index 0000000..e66d52d
--- /dev/null
@@ -0,0 +1,7 @@
+!
+interface r2-eth0
+ ip address 10.0.1.2/24
+!
+interface r2-eth1
+ ip address 10.0.2.2/24
+!
diff --git a/tests/topotests/ospfapi/r3/ospfd.conf b/tests/topotests/ospfapi/r3/ospfd.conf
new file mode 100644 (file)
index 0000000..77cd86a
--- /dev/null
@@ -0,0 +1,15 @@
+!
+interface r3-eth0
+  ip ospf hello-interval 2
+  ip ospf dead-interval 10
+  ip ospf area 1.2.3.4
+!
+interface r3-eth1
+  ip ospf hello-interval 2
+  ip ospf dead-interval 10
+  ip ospf area 1.2.3.4
+!
+router ospf
+  ospf router-id 192.168.0.3
+  capability opaque
+!
diff --git a/tests/topotests/ospfapi/r3/zebra.conf b/tests/topotests/ospfapi/r3/zebra.conf
new file mode 100644 (file)
index 0000000..072c297
--- /dev/null
@@ -0,0 +1,7 @@
+!
+interface r3-eth0
+ ip address 10.0.2.3/24
+!
+interface r3-eth1
+ ip address 10.0.3.3/24
+!
diff --git a/tests/topotests/ospfapi/r4/ospfd.conf b/tests/topotests/ospfapi/r4/ospfd.conf
new file mode 100644 (file)
index 0000000..32e55d5
--- /dev/null
@@ -0,0 +1,10 @@
+!
+interface r4-eth0
+  ip ospf hello-interval 2
+  ip ospf dead-interval 10
+  ip ospf area 1.2.3.4
+!
+router ospf
+  ospf router-id 192.168.0.4
+  capability opaque
+!
diff --git a/tests/topotests/ospfapi/r4/zebra.conf b/tests/topotests/ospfapi/r4/zebra.conf
new file mode 100644 (file)
index 0000000..7022197
--- /dev/null
@@ -0,0 +1,4 @@
+!
+interface r4-eth0
+ ip address 10.0.3.4/24
+!
diff --git a/tests/topotests/ospfapi/test_ospf_clientapi.py b/tests/topotests/ospfapi/test_ospf_clientapi.py
new file mode 100644 (file)
index 0000000..dca9141
--- /dev/null
@@ -0,0 +1,470 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 eval: (blacken-mode 1) -*-
+#
+# Copyright (c) 2021, LabN Consulting, L.L.C.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; see the file COPYING; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+
+"""
+test_ospf_clientapi.py: Test the OSPF client API.
+"""
+
+import logging
+import os
+import re
+import signal
+import subprocess
+import sys
+import time
+from datetime import datetime, timedelta
+
+import pytest
+
+from lib.common_config import retry, run_frr_cmd, step
+from lib.micronet import comm_error
+from lib.topogen import Topogen, TopoRouter
+from lib.topotest import interface_set_status, json_cmp
+
+pytestmark = [pytest.mark.ospfd]
+
+CWD = os.path.dirname(os.path.realpath(__file__))
+TESTDIR = os.path.abspath(CWD)
+
+CLIENTDIR = os.path.abspath(os.path.join(CWD, "../../../ospfclient"))
+if not os.path.exists(CLIENTDIR):
+    CLIENTDIR = os.path.join(CWD, "/usr/lib/frr")
+
+assert os.path.exists(
+    os.path.join(CLIENTDIR, "ospfclient.py")
+), "can't locate ospfclient.py"
+
+
+# ----------
+# Test Setup
+# ----------
+
+
+@pytest.fixture(scope="function", name="tgen")
+def _tgen(request):
+    "Setup/Teardown the environment and provide tgen argument to tests"
+    nrouters = request.param
+    topodef = {f"sw{i}": (f"r{i}", f"r{i+1}") for i in range(1, nrouters)}
+
+    tgen = Topogen(topodef, request.module.__name__)
+    tgen.start_topology()
+
+    router_list = tgen.routers()
+    for _, router in router_list.items():
+        router.load_config(TopoRouter.RD_ZEBRA, "zebra.conf")
+        router.load_config(TopoRouter.RD_OSPF, "ospfd.conf")
+        router.net.daemons_options["ospfd"] = "--apiserver"
+
+    tgen.start_router()
+
+    yield tgen
+
+    tgen.stop_topology()
+
+
+# Fixture that executes before each test
+@pytest.fixture(autouse=True)
+def skip_on_failure(tgen):
+    if tgen.routers_have_failure():
+        pytest.skip("skipped because of previous test failure")
+
+
+# ------------
+# Test Utility
+# ------------
+
+
+@retry(retry_timeout=45)
+def verify_ospf_database(tgen, dut, input_dict, cmd="show ip ospf database json"):
+    del tgen
+    show_ospf_json = run_frr_cmd(dut, cmd, isjson=True)
+    if not bool(show_ospf_json):
+        return "ospf is not running"
+    result = json_cmp(show_ospf_json, input_dict)
+    return str(result) if result else None
+
+
+def myreadline(f):
+    buf = b""
+    while True:
+        # logging.info("READING 1 CHAR")
+        c = f.read(1)
+        if not c:
+            return buf if buf else None
+        buf += c
+        # logging.info("READ CHAR: '%s'", c)
+        if c == b"\n":
+            return buf
+
+
+def _wait_output(p, regex, timeout=120):
+    retry_until = datetime.now() + timedelta(seconds=timeout)
+    while datetime.now() < retry_until:
+        # line = p.stdout.readline()
+        line = myreadline(p.stdout)
+        if not line:
+            assert None, "Timeout waiting for '{}'".format(regex)
+        line = line.decode("utf-8")
+        line = line.rstrip()
+        if line:
+            logging.debug("GOT LINE: '%s'", line)
+        m = re.search(regex, line)
+        if m:
+            return m
+    assert None, "Failed to get output withint {}s".format(timeout)
+
+
+# -----
+# Tests
+# -----
+
+
+def _test_reachability(tgen, testbin):
+    waitlist = [
+        "192.168.0.1,192.168.0.2,192.168.0.4",
+        "192.168.0.2,192.168.0.4",
+        "192.168.0.1,192.168.0.2,192.168.0.4",
+    ]
+    r2 = tgen.gears["r2"]
+    r3 = tgen.gears["r3"]
+
+    wait_args = [f"--wait={x}" for x in waitlist]
+
+    p = None
+    try:
+        step("reachable: check for initial reachability")
+        p = r3.popen(
+            ["/usr/bin/timeout", "120", testbin, "-v", *wait_args],
+            encoding=None,  # don't buffer
+            stdin=subprocess.DEVNULL,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+        )
+        _wait_output(p, "SUCCESS: {}".format(waitlist[0]))
+
+        step("reachable: check for modified reachability")
+        interface_set_status(r2, "r2-eth0", False)
+        _wait_output(p, "SUCCESS: {}".format(waitlist[1]))
+
+        step("reachable: check for restored reachability")
+        interface_set_status(r2, "r2-eth0", True)
+        _wait_output(p, "SUCCESS: {}".format(waitlist[2]))
+    except Exception as error:
+        logging.error("ERROR: %s", error)
+        raise
+    finally:
+        if p:
+            p.terminate()
+            p.wait()
+
+
+@pytest.mark.parametrize("tgen", [4], indirect=True)
+def test_ospf_reachability(tgen):
+    testbin = os.path.join(TESTDIR, "ctester.py")
+    rc, o, e = tgen.gears["r2"].net.cmd_status([testbin, "--help"])
+    logging.info("%s --help: rc: %s stdout: '%s' stderr: '%s'", testbin, rc, o, e)
+    _test_reachability(tgen, testbin)
+
+
+def _test_add_data(tgen, apibin):
+    "Test adding opaque data to domain"
+
+    r1 = tgen.gears["r1"]
+
+    step("add opaque: add opaque link local")
+
+    p = None
+    try:
+        p = r1.popen([apibin, "-v", "add,9,10.0.1.1,230,2,00000202"])
+        input_dict = {
+            "routerId": "192.168.0.1",
+            "areas": {
+                "1.2.3.4": {
+                    "linkLocalOpaqueLsa": [
+                        {
+                            "lsId": "230.0.0.2",
+                            "advertisedRouter": "192.168.0.1",
+                            "sequenceNumber": "80000001",
+                        }
+                    ],
+                }
+            },
+        }
+        # Wait for it to show up
+        assert verify_ospf_database(tgen, r1, input_dict) is None
+
+        input_dict = {
+            "linkLocalOpaqueLsa": {
+                "areas": {
+                    "1.2.3.4": [
+                        {
+                            "linkStateId": "230.0.0.2",
+                            "advertisingRouter": "192.168.0.1",
+                            "lsaSeqNumber": "80000001",
+                            "opaqueData": "00000202",
+                        },
+                    ],
+                }
+            },
+        }
+        # verify content
+        json_cmd = "show ip ospf da opaque-link json"
+        assert verify_ospf_database(tgen, r1, input_dict, json_cmd) is None
+
+        step("reset client, add opaque area, verify link local flushing")
+
+        p.send_signal(signal.SIGINT)
+        time.sleep(2)
+        p.wait()
+        p = None
+        p = r1.popen([apibin, "-v", "add,10,1.2.3.4,231,1,00010101"])
+        input_dict = {
+            "routerId": "192.168.0.1",
+            "areas": {
+                "1.2.3.4": {
+                    "linkLocalOpaqueLsa": [
+                        {
+                            "lsId": "230.0.0.2",
+                            "advertisedRouter": "192.168.0.1",
+                            "sequenceNumber": "80000001",
+                            "lsaAge": 3600,
+                        }
+                    ],
+                    "areaLocalOpaqueLsa": [
+                        {
+                            "lsId": "231.0.0.1",
+                            "advertisedRouter": "192.168.0.1",
+                            "sequenceNumber": "80000001",
+                        },
+                    ],
+                }
+            },
+        }
+        # Wait for it to show up
+        assert verify_ospf_database(tgen, r1, input_dict) is None
+
+        input_dict = {
+            "areaLocalOpaqueLsa": {
+                "areas": {
+                    "1.2.3.4": [
+                        {
+                            "linkStateId": "231.0.0.1",
+                            "advertisingRouter": "192.168.0.1",
+                            "lsaSeqNumber": "80000001",
+                            "opaqueData": "00010101",
+                        },
+                    ],
+                }
+            },
+        }
+        # verify content
+        json_cmd = "show ip ospf da opaque-area json"
+        assert verify_ospf_database(tgen, r1, input_dict, json_cmd) is None
+
+        step("reset client, add opaque AS, verify area flushing")
+
+        p.send_signal(signal.SIGINT)
+        time.sleep(2)
+        p.wait()
+        p = None
+
+        p = r1.popen([apibin, "-v", "add,11,232,3,deadbeaf01234567"])
+        input_dict = {
+            "routerId": "192.168.0.1",
+            "areas": {
+                "1.2.3.4": {
+                    "areaLocalOpaqueLsa": [
+                        {
+                            "lsId": "231.0.0.1",
+                            "advertisedRouter": "192.168.0.1",
+                            "sequenceNumber": "80000001",
+                            "lsaAge": 3600,
+                        },
+                    ],
+                }
+            },
+            "asExternalOpaqueLsa": [
+                {
+                    "lsId": "232.0.0.3",
+                    "advertisedRouter": "192.168.0.1",
+                    "sequenceNumber": "80000001",
+                },
+            ],
+        }
+        # Wait for it to show up
+        assert verify_ospf_database(tgen, r1, input_dict) is None
+
+        input_dict = {
+            "asExternalOpaqueLsa": [
+                {
+                    "linkStateId": "232.0.0.3",
+                    "advertisingRouter": "192.168.0.1",
+                    "lsaSeqNumber": "80000001",
+                    "opaqueData": "deadbeaf01234567",
+                },
+            ]
+        }
+        # verify content
+        json_cmd = "show ip ospf da opaque-as json"
+        assert verify_ospf_database(tgen, r1, input_dict, json_cmd) is None
+
+        step("stop client, verify AS flushing")
+
+        p.send_signal(signal.SIGINT)
+        time.sleep(2)
+        p.wait()
+        p = None
+
+        input_dict = {
+            "routerId": "192.168.0.1",
+            "asExternalOpaqueLsa": [
+                {
+                    "lsId": "232.0.0.3",
+                    "advertisedRouter": "192.168.0.1",
+                    "sequenceNumber": "80000001",
+                    "lsaAge": 3600,
+                },
+            ],
+        }
+        # Wait for it to be flushed
+        assert verify_ospf_database(tgen, r1, input_dict) is None
+
+        step("start client adding opaque domain, verify new sequence number and data")
+
+        # Originate it again
+        p = r1.popen([apibin, "-v", "add,11,232,3,ebadf00d"])
+        input_dict = {
+            "routerId": "192.168.0.1",
+            "asExternalOpaqueLsa": [
+                {
+                    "lsId": "232.0.0.3",
+                    "advertisedRouter": "192.168.0.1",
+                    "sequenceNumber": "80000002",
+                },
+            ],
+        }
+        assert verify_ospf_database(tgen, r1, input_dict) is None
+
+        input_dict = {
+            "asExternalOpaqueLsa": [
+                {
+                    "linkStateId": "232.0.0.3",
+                    "advertisingRouter": "192.168.0.1",
+                    "lsaSeqNumber": "80000002",
+                    "opaqueData": "ebadf00d",
+                },
+            ]
+        }
+        # verify content
+        json_cmd = "show ip ospf da opaque-as json"
+        assert verify_ospf_database(tgen, r1, input_dict, json_cmd) is None
+
+        p.send_signal(signal.SIGINT)
+        time.sleep(2)
+        p.wait()
+        p = None
+
+    except Exception:
+        if p:
+            p.terminate()
+            if p.wait():
+                comm_error(p)
+            p = None
+        raise
+    finally:
+        if p:
+            p.terminate()
+            p.wait()
+
+
+@pytest.mark.parametrize("tgen", [2], indirect=True)
+def test_ospf_opaque_add_data3(tgen):
+    apibin = os.path.join(CLIENTDIR, "ospfclient.py")
+    rc, o, e = tgen.gears["r2"].net.cmd_status([apibin, "--help"])
+    logging.info("%s --help: rc: %s stdout: '%s' stderr: '%s'", apibin, rc, o, e)
+    _test_add_data(tgen, apibin)
+
+
+def _test_opaque_add_del(tgen, apibin):
+    "Test adding opaque data to domain"
+
+    r1 = tgen.gears["r1"]
+    r2 = tgen.gears["r2"]
+
+    p = None
+    pread = None
+    try:
+        step("reachable: check for add notification")
+        pread = r2.popen(
+            ["/usr/bin/timeout", "120", apibin, "-v"],
+            encoding=None,  # don't buffer
+            stdin=subprocess.DEVNULL,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+        )
+        p = r1.popen([apibin, "-v", "add,11,232,3,ebadf00d"])
+
+        # Wait for add notification
+        # RECV: LSA update msg for LSA 232.0.0.3 in area 0.0.0.0 seq 0x80000001 len 24 age 9
+
+        ls_id = "232.0.0.3"
+        waitfor = "RECV:.*update msg.*LSA {}.*age ([0-9]+)".format(ls_id)
+        _ = _wait_output(pread, waitfor)
+
+        p.terminate()
+        if p.wait():
+            comm_error(p)
+
+        # step("reachable: check for flush/age out")
+        # # Wait for max age notification
+        # waitfor = "RECV:.*update msg.*LSA {}.*age 3600".format(ls_id)
+        # _wait_output(pread, waitfor)
+
+        step("reachable: check for delete")
+        # Wait for delete notification
+        waitfor = "RECV:.*delete msg.*LSA {}.*".format(ls_id)
+        _wait_output(pread, waitfor)
+    except Exception:
+        if p:
+            p.terminate()
+            if p.wait():
+                comm_error(p)
+            p = None
+        raise
+    finally:
+        if pread:
+            pread.terminate()
+            pread.wait()
+        if p:
+            p.terminate()
+            p.wait()
+
+
+@pytest.mark.parametrize("tgen", [2], indirect=True)
+def test_ospf_opaque_delete_data3(tgen):
+    apibin = os.path.join(CLIENTDIR, "ospfclient.py")
+    rc, o, e = tgen.gears["r2"].net.cmd_status([apibin, "--help"])
+    logging.info("%s --help: rc: %s stdout: '%s' stderr: '%s'", apibin, rc, o, e)
+    _test_opaque_add_del(tgen, apibin)
+
+
+if __name__ == "__main__":
+    args = ["-s"] + sys.argv[1:]
+    sys.exit(pytest.main(args))