@@ -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+
224243def _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
228247def _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+
366440def _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+
11851466def test_ospf_convergence ():
11861467 "Test OSPF daemon convergence"
11871468 tgen = get_topogen ()
0 commit comments