Skip to content

Commit 90bd8d9

Browse files
committed
tests: topotests/ospf_topo1: negative-path ietf-ospf YANG tests
Add five negative tests that exercise the VALIDATE-phase rejection paths the config-write callbacks rely on: - test_ospf_yang_negative_missing_instance: commits targeting a control-plane-protocol name that doesn't match an FRR ospf / ospf6 instance must be rejected (resolve_instance helper). - test_ospf_yang_negative_missing_interface: commits targeting an unknown interface name under areas/area/interfaces/interface must be rejected (resolve_interface helper; closes the leafref-relaxation hole). - test_ospf_yang_negative_default_cost_on_normal_area: setting default-cost on the backbone area must be rejected (defence in depth alongside the YANG `when` clause). - test_ospf_yang_negative_v3_unsupported_interface_type: setting ospf6 interface-type=non-broadcast must be rejected (ospf6d only accepts broadcast / point-to-point / point-to-multipoint). - test_ospf_yang_area_delete_recreate_cleanup: a stub area with non-default summary and default-cost is deleted via the list-destroy path then recreated as a normal area; the previous per-leaf state must not survive. The helper _mgmt_commit_attempt issues the candidate change, applies, then aborts the candidate so the rejected entry doesn't survive into the next test's commit attempt. Tests confirm rejection by checking that the candidate value did NOT land in show running-config (more robust than parsing libyang's error string, which varies by version). Signed-off-by: Eric Parsonage <eric@eparsonage.com>
1 parent b2d0e78 commit 90bd8d9

1 file changed

Lines changed: 282 additions & 1 deletion

File tree

tests/topotests/ospf_topo1/test_ospf_topo1.py

Lines changed: 282 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,27 @@ def _as_list(value):
221221
return [value]
222222

223223

224+
def _yang_get_data(router, xpath, datastore="operational"):
225+
return json.loads(
226+
router.vtysh_cmd("show mgmt get-data {} datastore {}".format(xpath, datastore))
227+
)
228+
229+
230+
def _yang_xpath_subscription(router, xpath):
231+
return router.vtysh_cmd("show mgmt yang-xpath-subscription {}".format(xpath))
232+
233+
234+
def _assert_xpath_client(output, client, oper):
235+
match = re.search(
236+
r"Client: {}\s+config:\d+ notify:\d+ oper:(\d+) rpc:\d+".format(client),
237+
output,
238+
)
239+
assert match, output
240+
assert match.group(1) == str(int(oper)), output
241+
242+
224243
def _yang_operational_root(router):
225-
return json.loads(router.vtysh_cmd("show mgmt get-data /* datastore operational"))
244+
return _yang_get_data(router, "/*")
226245

227246

228247
def _yang_ospf_protocol(output, protocol_type, protocol_name):
@@ -363,6 +382,61 @@ def test_ospf_yang_operational_data():
363382
assert neighbor["address"].startswith("fe80:")
364383

365384

385+
def test_ospf_yang_mgmtd_predicate_dispatch():
386+
"Verify mgmtd dispatches typed OSPF xpaths to the correct backend."
387+
tgen = get_topogen()
388+
if tgen.routers_have_failure():
389+
pytest.skip("skipped because of router(s) failure")
390+
391+
r1 = tgen.gears["r1"]
392+
393+
output = _yang_operational_root(r1)
394+
_yang_ospf_protocol(output, "ietf-ospf:ospfv2", "default")
395+
_yang_ospf_protocol(output, "ietf-ospf:ospfv3", "default")
396+
397+
for protocol_type in ("ietf-ospf:ospfv2", "ietf-ospf:ospfv3"):
398+
protocol_path = (
399+
"/ietf-routing:routing/control-plane-protocols/"
400+
"control-plane-protocol[type='" + protocol_type + "'][name='default']"
401+
)
402+
subscription = _yang_xpath_subscription(r1, protocol_path)
403+
if protocol_type == "ietf-ospf:ospfv2":
404+
_assert_xpath_client(subscription, "ospfd", True)
405+
assert "Client: ospf6d" not in subscription, subscription
406+
else:
407+
assert "Client: ospfd" not in subscription, subscription
408+
_assert_xpath_client(subscription, "ospf6d", True)
409+
410+
selected = _yang_get_data(r1, protocol_path)
411+
protocols = _as_list(
412+
selected["ietf-routing:routing"]["control-plane-protocols"][
413+
"control-plane-protocol"
414+
]
415+
)
416+
assert len(protocols) == 1, selected
417+
protocol = protocols[0]
418+
assert protocol["type"] == protocol_type, selected
419+
assert protocol["name"] == "default", selected
420+
assert _yang_ospf_container(protocol)["router-id"] == "10.0.255.1"
421+
422+
for protocol_path in (
423+
"/ietf-routing:routing/control-plane-protocols",
424+
"/ietf-routing:routing/control-plane-protocols/control-plane-protocol",
425+
"/ietf-routing:routing/control-plane-protocols/control-plane-protocol/ietf-ospf:ospf/router-id",
426+
):
427+
subscription = _yang_xpath_subscription(r1, protocol_path)
428+
_assert_xpath_client(subscription, "ospfd", True)
429+
_assert_xpath_client(subscription, "ospf6d", True)
430+
431+
# Zebra registers the RFC 8343 interface list without an OSPF-style
432+
# protocol-type predicate. Keep one predicate query here so the test
433+
# catches regressions in both typed and untyped backend registrations.
434+
selected = _yang_get_data(
435+
r1, "/ietf-interfaces:interfaces/interface[name='r1-eth1']"
436+
)
437+
assert _yang_interface(selected, "r1-eth1")["oper-status"] == "up"
438+
439+
366440
def _yang_explicit_router_id_xpath(protocol_type):
367441
return (
368442
"/ietf-routing:routing/control-plane-protocols/"
@@ -1182,6 +1256,213 @@ def test_ospf_yang_area_summary_default_cost_config():
11821256
)
11831257

11841258

1259+
def _mgmt_commit_attempt(router, set_cmd):
1260+
"""Run a mgmt set-config + commit and return the vty output.
1261+
1262+
The caller verifies the rejection by confirming the candidate value
1263+
did NOT land in `show running-config` (more robust than parsing the
1264+
error string, which varies by libyang version). `mgmt commit abort`
1265+
follows the apply so a rejected candidate doesn't survive into the
1266+
next test's commit attempt.
1267+
"""
1268+
return router.vtysh_cmd(
1269+
"configure terminal file-lock\n"
1270+
"{}\n"
1271+
"mgmt commit apply\n"
1272+
"mgmt commit abort".format(set_cmd),
1273+
isjson=False,
1274+
)
1275+
1276+
1277+
def test_ospf_yang_negative_missing_instance():
1278+
"""Reject YANG config that targets an OSPF instance the daemon doesn't have.
1279+
1280+
The branch's resolve_instance helper rejects at NB_EV_VALIDATE when no
1281+
FRR-side ospf / ospf6 instance matches the control-plane-protocol name
1282+
key. Confirm a commit against name='ghost' does not land.
1283+
"""
1284+
tgen = get_topogen()
1285+
if tgen.routers_have_failure():
1286+
pytest.skip("skipped because of router(s) failure")
1287+
1288+
r1 = tgen.gears["r1"]
1289+
1290+
ghost_v2 = (
1291+
"/ietf-routing:routing/control-plane-protocols/"
1292+
"control-plane-protocol[type='ietf-ospf:ospfv2'][name='ghost']/"
1293+
"ietf-ospf:ospf/explicit-router-id"
1294+
)
1295+
_mgmt_commit_attempt(
1296+
r1,
1297+
"mgmt set-config {} 9.9.9.9".format(ghost_v2),
1298+
)
1299+
# The default instance is unchanged; the ghost one was never created.
1300+
running = r1.vtysh_cmd("show running-config ospfd")
1301+
assert "router-id 9.9.9.9" not in running, (
1302+
"ghost router-id must not have landed, got:\n" + running
1303+
)
1304+
1305+
ghost_v3 = (
1306+
"/ietf-routing:routing/control-plane-protocols/"
1307+
"control-plane-protocol[type='ietf-ospf:ospfv3'][name='ghost']/"
1308+
"ietf-ospf:ospf/explicit-router-id"
1309+
)
1310+
_mgmt_commit_attempt(
1311+
r1,
1312+
"mgmt set-config {} 9.9.9.9".format(ghost_v3),
1313+
)
1314+
running = r1.vtysh_cmd("show running-config ospf6d")
1315+
assert "router-id 9.9.9.9" not in running, (
1316+
"ghost ospf6 router-id must not have landed, got:\n" + running
1317+
)
1318+
1319+
1320+
def test_ospf_yang_negative_missing_interface():
1321+
"""Reject per-interface YANG config that names a non-existent interface.
1322+
1323+
frr-deviations-ietf-routing-ospf relaxes the RFC 9129 interface-name
1324+
leafref, so libyang no longer rejects names that aren't in
1325+
/ietf-interfaces. The branch's resolve_interface helper restores that
1326+
rejection inside the callback at NB_EV_VALIDATE.
1327+
"""
1328+
tgen = get_topogen()
1329+
if tgen.routers_have_failure():
1330+
pytest.skip("skipped because of router(s) failure")
1331+
1332+
r1 = tgen.gears["r1"]
1333+
1334+
bogus_v2 = (
1335+
_yang_area_xpath("ietf-ospf:ospfv2", "0.0.0.0")
1336+
+ "/interfaces/interface[name='r1-eth42']/cost"
1337+
)
1338+
_mgmt_commit_attempt(
1339+
r1,
1340+
"mgmt set-config {} 7".format(bogus_v2),
1341+
)
1342+
running = r1.vtysh_cmd("show running-config ospfd")
1343+
assert "r1-eth42" not in running, (
1344+
"interface r1-eth42 must not appear in running-config, got:\n" + running
1345+
)
1346+
1347+
bogus_v3 = (
1348+
_yang_area_xpath("ietf-ospf:ospfv3", "0.0.0.0")
1349+
+ "/interfaces/interface[name='r1-eth42']/cost"
1350+
)
1351+
_mgmt_commit_attempt(
1352+
r1,
1353+
"mgmt set-config {} 7".format(bogus_v3),
1354+
)
1355+
running = r1.vtysh_cmd("show running-config ospf6d")
1356+
assert "r1-eth42" not in running, (
1357+
"interface r1-eth42 must not appear in v3 running-config, got:\n" + running
1358+
)
1359+
1360+
1361+
def test_ospf_yang_negative_default_cost_on_normal_area():
1362+
"""Reject default-cost on a non-stub / non-NSSA area.
1363+
1364+
RFC 9129's `when` clause restricts default-cost to stub or NSSA areas;
1365+
the callback also rejects at VALIDATE as a defence-in-depth measure.
1366+
Area 0 is the backbone (normal); setting default-cost on it must fail.
1367+
"""
1368+
tgen = get_topogen()
1369+
if tgen.routers_have_failure():
1370+
pytest.skip("skipped because of router(s) failure")
1371+
1372+
r1 = tgen.gears["r1"]
1373+
1374+
area_path = _yang_area_xpath("ietf-ospf:ospfv2", "0.0.0.0")
1375+
_mgmt_commit_attempt(
1376+
r1,
1377+
"mgmt set-config {}/default-cost 99".format(area_path),
1378+
)
1379+
running = r1.vtysh_cmd("show running-config ospfd")
1380+
assert "default-cost 99" not in running, (
1381+
"default-cost 99 must not appear on the backbone area, got:\n" + running
1382+
)
1383+
1384+
1385+
def test_ospf_yang_negative_v3_unsupported_interface_type():
1386+
"""Reject ospf6 interface-type enum values that ospf6d doesn't accept.
1387+
1388+
RFC 9129 declares non-broadcast and hybrid; ospf6d only supports
1389+
broadcast, point-to-point and point-to-multipoint. The callback must
1390+
reject the unsupported enum values at VALIDATE.
1391+
"""
1392+
tgen = get_topogen()
1393+
if tgen.routers_have_failure():
1394+
pytest.skip("skipped because of router(s) failure")
1395+
1396+
r1 = tgen.gears["r1"]
1397+
1398+
iface_v3 = (
1399+
_yang_area_xpath("ietf-ospf:ospfv3", "0.0.0.0")
1400+
+ "/interfaces/interface[name='r1-eth1']"
1401+
)
1402+
_mgmt_commit_attempt(
1403+
r1,
1404+
"mgmt set-config {}/interface-type non-broadcast".format(iface_v3),
1405+
)
1406+
running = r1.vtysh_cmd("show running-config ospf6d")
1407+
assert "non-broadcast" not in running, (
1408+
"unsupported interface-type must not appear in v3 running-config, "
1409+
"got:\n" + running
1410+
)
1411+
1412+
1413+
def test_ospf_yang_area_delete_recreate_cleanup():
1414+
"""Delete then recreate an area; per-leaf state must reset cleanly.
1415+
1416+
Sets a non-default summary and default-cost on a stub area, then deletes
1417+
the area via the list-destroy path, then recreates it as a normal area,
1418+
and confirms the previous stub / default-cost state is gone. Catches
1419+
regressions where destroy callbacks leave stale per-leaf state.
1420+
"""
1421+
tgen = get_topogen()
1422+
if tgen.routers_have_failure():
1423+
pytest.skip("skipped because of router(s) failure")
1424+
1425+
r1 = tgen.gears["r1"]
1426+
area_path = _yang_area_xpath("ietf-ospf:ospfv2", "0.0.0.51")
1427+
1428+
# Set area-type=stub + summary=false + default-cost=77 in one commit.
1429+
# libyang materialises the area list entry implicitly when a child leaf
1430+
# is set; no separate list-create step is needed.
1431+
r1.vtysh_cmd(
1432+
"configure terminal file-lock\n"
1433+
"mgmt set-config {}/area-type stub-area\n"
1434+
"mgmt set-config {}/summary false\n"
1435+
"mgmt set-config {}/default-cost 77\n"
1436+
"mgmt commit apply".format(area_path, area_path, area_path)
1437+
)
1438+
running = r1.vtysh_cmd("show running-config ospfd")
1439+
assert "area 0.0.0.51 stub no-summary" in running, running
1440+
assert "area 0.0.0.51 default-cost 77" in running, running
1441+
1442+
_clear_yang_area(r1, "ietf-ospf:ospfv2", "0.0.0.51")
1443+
running = r1.vtysh_cmd("show running-config ospfd")
1444+
assert "0.0.0.51" not in running, (
1445+
"area 0.0.0.51 must be gone after delete, got:\n" + running
1446+
)
1447+
1448+
# Recreate by setting only area-type=normal-area; previous stub /
1449+
# default-cost must not bleed through.
1450+
r1.vtysh_cmd(
1451+
"configure terminal file-lock\n"
1452+
"mgmt set-config {}/area-type normal-area\n"
1453+
"mgmt commit apply".format(area_path)
1454+
)
1455+
running = r1.vtysh_cmd("show running-config ospfd")
1456+
assert "area 0.0.0.51 stub" not in running, (
1457+
"stub setting must not survive delete + recreate, got:\n" + running
1458+
)
1459+
assert "area 0.0.0.51 default-cost" not in running, (
1460+
"default-cost must not survive delete + recreate, got:\n" + running
1461+
)
1462+
1463+
_clear_yang_area(r1, "ietf-ospf:ospfv2", "0.0.0.51")
1464+
1465+
11851466
def test_ospf_convergence():
11861467
"Test OSPF daemon convergence"
11871468
tgen = get_topogen()

0 commit comments

Comments
 (0)