Skip to content

staticd: nexthop identity as path-list key, and per-route metric - #21296

Merged
choppsv1 merged 9 commits into
FRRouting:masterfrom
enkechen-panw:static-route-metric
May 2, 2026
Merged

staticd: nexthop identity as path-list key, and per-route metric#21296
choppsv1 merged 9 commits into
FRRouting:masterfrom
enkechen-panw:static-route-metric

Conversation

@enkechen-panw

@enkechen-panw enkechen-panw commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

This patch series restructures the staticd YANG schema and adds per-route metric support for static routes.

YANG schema (nexthop identity as path-list key)

The previous schema keyed path-list on [table-id, distance] with a nested nexthop-list. Since distance is not part of
nexthop identity, the same nexthop could appear under multiple path-list entries at different distances without YANG
detecting a conflict, creating state that the CLI could not always handle correctly.

The new schema uses a single list keyed by full nexthop identity:

path-list [table-id, nh-type, vrf, gateway, interface]

Distance and metric become non-key leaf attributes (defaults 1 and 0). Changing either on an existing nexthop is now a
leaf MODIFY β€” the NB callback migrates the nexthop to the appropriate internal struct static_path without withdrawing
the route. Weight and BFD monitoring, previously attached via augment blocks on the nested nexthop container, become direct leaves of the path-list entry.

Per-route metric

metric is added alongside distance and tag as a non-key path-list attribute. Nexthops sharing the same (table-id,
distance, metric) form one ECMP group in the RIB; nexthops with different metrics are separate RIB entries, enabling
metric-based floating static routes. Zebra is extended to treat static routes at the same distance but different
metrics as distinct route entries.

Also included

  • Documentation for administrative distance, metric, and tag
  • Topotests for per-path distance, tag, and metric (including ECMP, floating routes, lazy deletion, and running-config
    round-trip)
  • Updates to mgmt and gRPC test fixtures for the new path-list xpaths

Please see the individual commits for details.

@greptile-apps

greptile-apps Bot commented Mar 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR restructures the staticd YANG schema from a two-level path-list/nexthop hierarchy to a flat list keyed by full nexthop identity (table-id, nh-type, vrf, gateway, interface), making distance and metric non-key modifiable attributes. It also adds per-route metric support so nexthops at different metrics become separate RIB entries in zebra, enabling metric-based floating static routes.

All previously flagged issues (metric=0 not transmitted, tag recalculation skipped at zero, tag loss on distance change, stale tag in ECMP move) have been addressed in the current code.

Confidence Score: 5/5

Safe to merge β€” all previously flagged P1 issues are resolved and no new defects found.

All P0/P1 findings from the prior review rounds (metric=0 omission, tag recalculation on deletion, tag loss on distance change, stale tag in ECMP move) are addressed in the current code. The zebra_rib.c changes correctly extend metric-based route identity to static routes. The flat YANG schema, NB callbacks, CLI encoding, and topotests are internally consistent. No remaining blocking issues identified.

No files require special attention.

Important Files Changed

Filename Overview
yang/frr-staticd.yang Schema flattened from two-level path-list/nexthop to single list keyed by [table-id, nh-type, vrf, gateway, interface]; distance and metric become non-key attributes; weight and bfd-monitoring moved from augments to direct leaves
staticd/static_nb_config.c Adds static_nexthop_move_path() helper to migrate nexthops between paths on distance/metric change; adds ECMP validation for path-list create, distance_modify, and metric_modify; previously flagged tag-loss and stale-tag bugs addressed
staticd/static_routes.c static_add_path now keys on (table-id, distance, metric); adds static_path_recalc_tag() for last-writer-wins tag semantics; previously flagged tag recalculation bug at deletion resolved
staticd/static_zebra.c ZAPI_MESSAGE_METRIC now sent unconditionally (metric=0 issue resolved); distance guard unchanged since frr-rt:administrative-distance type enforces β‰₯1
zebra/zebra_rib.c rib_compare_routes and rib_route_match_ctx extended to treat ZEBRA_ROUTE_STATIC routes at different metrics as distinct entries; process_subq_early_route_delete also gets metric guard for static routes
staticd/static_vty.c CLI updated: path-list CREATE only when entry is new (distance now MODIFY not key); metric parsing and encoding added to all ip/ipv6 route variants; static_path_list_cli_cmp calls static_nexthop_cli_cmp as tiebreaker; BFD source/profile explicitly destroyed when absent to handle in-place modification

Sequence Diagram

sequenceDiagram
    participant CLI as VTY/CLI
    participant NB as Northbound (NB)
    participant SR as static_routes.c
    participant SZ as static_zebra.c
    participant ZR as zebra_rib.c

    CLI->>NB: ip route PREFIX NH [distance D] [metric M]
    NB->>NB: NB_EV_VALIDATE: ecmp_path_list_validate
    NB->>SR: NB_EV_APPLY: static_path_list_create
    SR->>SR: static_add_nexthop(pn, nh_type, ...)

    Note over NB,SR: On distance/metric MODIFY
    NB->>SR: static_path_list_distance_modify / metric_modify
    SR->>SR: static_nexthop_move_path(nh, new_dist, new_metric)

    Note over SR,ZR: Route install
    SR->>SZ: static_zebra_route_add(pn, install=true)
    SZ->>ZR: zapi_route send

    Note over ZR: Route distinction by metric
    ZR->>ZR: rib_compare_routes: STATIC with different metrics are distinct
    ZR->>ZR: rib_route_match_ctx: match requires distance AND metric
Loading

Reviews (8): Last reviewed commit: "tests: staticd: add topotest for static ..." | Re-trigger Greptile

Comment thread tests/topotests/static_route_distance/test_static_route_metric.py Outdated
Comment thread staticd/static_zebra.c Outdated
@enkechen-panw
enkechen-panw force-pushed the static-route-metric branch 2 times, most recently from ab87897 to 26a83bf Compare March 23, 2026 23:56
@gromit1811

Copy link
Copy Markdown
Contributor

Looks astonishingly similar to #20859, but you changed the RIB handling logic as well which I missed since I wasn't aware of its special handling of static routes. This should resolve @donaldsharp 's comment regarding ordering in the other PR.

Route removal works worse than in my PR:

debian(config)# ip route 1.2.3.4/32 192.168.1.2 50 metric 100
debian(config)# do sh run
[...]
!
ip route 1.2.3.4/32 192.168.1.2 50 metric 100
!
end

debian(config)# no ip route 1.2.3.4/32 192.168.1.2 40 metric 100
% Refusing to remove a non-existent route
debian(config)#
-> OK, both distance & metric specified and don't match

debian(config)# no ip route 1.2.3.4/32 192.168.1.2 40
% Refusing to remove a non-existent route
debian(config)#
-> OK, distance specified and doesn't match

debian(config)# no ip route 1.2.3.4/32 192.168.1.2 50
% Refusing to remove a non-existent route
debian(config)#
-> NOK, distance specified and matches, metric not specified, would expect wildcard removal regarding metric

debian(config)# no ip route 1.2.3.4/32 192.168.1.2 50 metric 200
% Refusing to remove a non-existent route
debian(config)#
-> OK, both distance & metric specified, metric doesn't match

debian(config)# no ip route 1.2.3.4/32 192.168.1.2 50 metric 100
debian(config)#
-> OK, both distance & metric specified and match

debian(config)# ip route 1.2.3.4/32 192.168.1.2 metric 100
debian(config)# no ip route 1.2.3.4/32 192.168.1.2 metric 200
debian(config)#
-> NOK, metric specified and doesn't match, but still removed

If distance is specified in the no ... command, both distance and metric must match. If it's not specified, you perform wildcard removal both for distance and metric even if metric was specified and doesn't match. I'd expect wildcard removal for parameters which are not specified and matching for those which are. Have a look that the corresponding commit in my PR, that one should implement this properly.

The test_grpc unit test should probably also be adapted like I did, since it relies on the staticd paths which we're modifying here. It won't fail with the wrong paths since it's completely broken ATM and doesn't properly report failure, but still...

This PR has way more docs than I had, but where I was a bit on the terse side (and didn't explain the "distance, metric and nexthop are all keys, but you still can't have multiple distances/metrics for one nexthop" topic because I didn't fully understand it yet) this is now on the extremely verbose side. In PR #20859, you mentioned that you used an AI tool, so no surprise here - verbosity is one of the core competencies of AI. Still, something a bit more to the point would probably motivate readers more to actually read the docs πŸ˜‰

@riw777
riw777 self-requested a review March 24, 2026 14:50
Comment thread zebra/zebra_rib.c Outdated
@github-actions github-actions Bot added the rebase PR needs rebase label Mar 25, 2026
@enkechen-panw

Copy link
Copy Markdown
Contributor Author

Hi, @gromit1811:

Looks astonishingly similar to #20859, but you changed the RIB handling logic as well which I missed since I wasn't aware of its special handling of static routes. This should resolve @donaldsharp 's comment regarding ordering in the other PR.

Route removal works worse than in my PR:

Thanks for your review. The inconsistent behavior with route deletion is fixed. The doc and topotests are updates.

With the fix, it's "lazy deletion" without distance and metric. If either distance or metric is specified, it's exact match for deletion.

@enkechen-panw

Copy link
Copy Markdown
Contributor Author

Rebased.

@enkechen-panw

Copy link
Copy Markdown
Contributor Author

ci:rerun

@ton31337

Copy link
Copy Markdown
Member

Could you also apply frrbot styling?

@enkechen-panw

Copy link
Copy Markdown
Contributor Author

Could you also apply frrbot styling?

Done. Thanks

@enkechen-panw
enkechen-panw force-pushed the static-route-metric branch 2 times, most recently from 5bca73e to 7d8e9f8 Compare March 27, 2026 18:07
@enkechen-panw

Copy link
Copy Markdown
Contributor Author

ci:rerun

@riw777 riw777 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good

@gromit1811

Copy link
Copy Markdown
Contributor

Disclaimer: Not looked at the latest code, just played with it a bit. RIB processing looks good, config handling feels good as well, with a minor exception:

With the fix, it's "lazy deletion" without distance and metric. If either distance or metric is specified, it's exact match for deletion.

Shouldn't it rather be exact match only for what's specified? If I do a no ip route 1.2.3.4/32 192.168.1.2 metric 10, shouldn't it remove any matching route with metric 10 regardless of its admin distance? Currently, this wouldn't remove an ip route 1.2.3.4/32 192.168.1.2 10 metric 10 route. Wildcard removal for unspecified parameters is what I would have expected, but there may be arguments for the current behavior as well...

And you're missing this change to test_grpc.cpp (see first part of 0cef6a1). As I wrote above, not having this will not cause an immediate failure, because the test case currently is slightly broken and doesn't report failure properly. But still, not breaking it even more would probably make sense:

diff --git a/tests/lib/test_grpc.cpp b/tests/lib/test_grpc.cpp
index a995ef4ded..d85c723643 100644
--- a/tests/lib/test_grpc.cpp
+++ b/tests/lib/test_grpc.cpp
@@ -444,7 +444,7 @@ void grpc_client_run_test(void)
                snprintf(xpath_buf + slen, sizeof(xpath_buf) - slen,
                         "[prefix='13.0.%d.0/24']"
                         "[afi-safi='frr-routing:ipv4-unicast']/"
-                        "path-list[table-id='0'][distance='1']/"
+                        "path-list[table-id='0'][distance='1'][metric='0']/"
                         "frr-nexthops/nexthop[nh-type='blackhole']"
                         "[vrf='default'][gateway=''][interface='(null)']",
                         i);
@@ -584,6 +584,7 @@ const char *json_expect1 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [
@@ -638,6 +639,7 @@ const char *json_loadconf1 = R"NONCE(
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "frr-nexthops": {
                       "nexthop": [
                         {
@@ -683,6 +685,7 @@ const char *json_expect2 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [
@@ -706,6 +709,7 @@ const char *json_expect2 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [
@@ -729,6 +733,7 @@ const char *json_expect2 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [
@@ -752,6 +757,7 @@ const char *json_expect2 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [
@@ -775,6 +781,7 @@ const char *json_expect2 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [
@@ -828,6 +835,7 @@ const char *json_expect3 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [
@@ -851,6 +859,7 @@ const char *json_expect3 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [
@@ -874,6 +883,7 @@ const char *json_expect3 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [
@@ -897,6 +907,7 @@ const char *json_expect3 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [
@@ -920,6 +931,7 @@ const char *json_expect3 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [
@@ -943,6 +955,7 @@ const char *json_expect3 = R"NONCE({
                   {
                     "table-id": 0,
                     "distance": 1,
+                    "metric": 0,
                     "tag": 0,
                     "frr-nexthops": {
                       "nexthop": [

@enkechen-panw

Copy link
Copy Markdown
Contributor Author

@greptileai Please continue as long as you are not stuck. You have been doing good. Thanks.

@greptile-apps

greptile-apps Bot commented Apr 24, 2026

Copy link
Copy Markdown

Good progress β€” all previously flagged bugs (stale tag on ECMP path-move, tag lost on new path, tag recalc guard at zero, metric always sent) appear to be fixed in the latest push.

One new issue introduced by the schema flattening:


static_path_list_destroy leaks the static_path after its last nexthop is removed

In the old two-level schema, path-list-destroy called static_del_path(pn) (which frees the static_path), and nexthop-list-destroy called static_delete_nexthop(nh). In the flattened schema the two destroy callbacks are merged into one, but the current implementation only frees the nexthop β€” it never frees the path:

// staticd/static_nb_config.c
static int static_path_list_destroy(struct nb_cb_destroy_args *args)
{
    ...
    case NB_EV_APPLY:
        nh = nb_running_unset_entry(args->dnode);
        static_delete_nexthop(nh);   // removes nh from pn->nexthop_list, unlocks rn for nh
        // BUG: pn is now empty but never freed or removed from si->path_list
        break;

After static_delete_nexthop(nh), pn->nexthop_list is empty. pn remains in si->path_list indefinitely (until the whole route is deleted). Consequences:

  1. Memory leak β€” pn is never freed for single-nexthop entries deleted via the NB path.
  2. Route-lock imbalance β€” static_add_path calls route_lock_node once for the path; this lock is balanced by route_unlock_node inside static_del_path, which is never called here.
  3. Orphaned path in list β€” static_add_path will silently find and reuse the zombie path if a new nexthop with matching (table-id, distance, metric) is configured later.

For ECMP paths shared by multiple nexthops, the path must only be freed once the list is empty. The fix mirrors what static_nexthop_move_path already does correctly:

case NB_EV_APPLY: {
    struct static_path *pn;
    struct route_node *rn;
    struct static_route_info *si;

    nh = nb_running_unset_entry(args->dnode);
    pn = nh->pn;
    rn = pn->rn;
    si = rn->info;

    static_delete_nexthop(nh);  /* removes nh, sends route update/delete, unlocks rn for nh */

    if (static_nexthop_list_count(&pn->nexthop_list) == 0) {
        static_path_list_del(&si->path_list, pn);
        route_unlock_node(rn);   /* balance the lock from static_add_path */
        XFREE(MTYPE_STATIC_PATH, pn);
    }
    break;
}

Lock accounting: static_add_path acquires one lock per path, static_add_nexthop acquires one per nexthop. static_delete_nexthop releases the nexthop lock; the proposed block releases the path lock when the list empties β€” matching exactly.

@enkechen-panw

Copy link
Copy Markdown
Contributor Author

@greptileai review

@enkechen-panw

Copy link
Copy Markdown
Contributor Author

Good progress β€” all previously flagged bugs (stale tag on ECMP path-move, tag lost on new path, tag recalc guard at zero, metric always sent) appear to be fixed in the latest push.

One new issue introduced by the schema flattening:

static_path_list_destroy leaks the static_path after its last nexthop is removed

In the old two-level schema, path-list-destroy called static_del_path(pn) (which frees the static_path), and nexthop-list-destroy called static_delete_nexthop(nh). In the flattened schema the two destroy callbacks are merged into one, but the current implementation only frees the nexthop β€” it never frees the path:

// staticd/static_nb_config.c
static int static_path_list_destroy(struct nb_cb_destroy_args *args)
{
    ...
    case NB_EV_APPLY:
        nh = nb_running_unset_entry(args->dnode);
        static_delete_nexthop(nh);   // removes nh from pn->nexthop_list, unlocks rn for nh
        // BUG: pn is now empty but never freed or removed from si->path_list
        break;

After static_delete_nexthop(nh), pn->nexthop_list is empty. pn remains in si->path_list indefinitely (until the whole route is deleted). Consequences:

  1. Memory leak β€” pn is never freed for single-nexthop entries deleted via the NB path.
  2. Route-lock imbalance β€” static_add_path calls route_lock_node once for the path; this lock is balanced by route_unlock_node inside static_del_path, which is never called here.
  3. Orphaned path in list β€” static_add_path will silently find and reuse the zombie path if a new nexthop with matching (table-id, distance, metric) is configured later.

Fixed, thanks.

For ECMP paths shared by multiple nexthops, the path must only be freed once the list is empty. The fix mirrors what static_nexthop_move_path already does correctly:

case NB_EV_APPLY: {
    struct static_path *pn;
    struct route_node *rn;
    struct static_route_info *si;

    nh = nb_running_unset_entry(args->dnode);
    pn = nh->pn;
    rn = pn->rn;
    si = rn->info;

    static_delete_nexthop(nh);  /* removes nh, sends route update/delete, unlocks rn for nh */

    if (static_nexthop_list_count(&pn->nexthop_list) == 0) {
        static_path_list_del(&si->path_list, pn);
        route_unlock_node(rn);   /* balance the lock from static_add_path */
        XFREE(MTYPE_STATIC_PATH, pn);
    }
    break;
}

Lock accounting: static_add_path acquires one lock per path, static_add_nexthop acquires one per nexthop. static_delete_nexthop releases the nexthop lock; the proposed block releases the path lock when the list empties β€” matching exactly.

Good progress β€” all previously flagged bugs (stale tag on ECMP path-move, tag lost on new path, tag recalc guard at zero, metric always sent) appear to be fixed in the latest push.

One new issue introduced by the schema flattening:

static_path_list_destroy leaks the static_path after its last nexthop is removed

In the old two-level schema, path-list-destroy called static_del_path(pn) (which frees the static_path), and nexthop-list-destroy called static_delete_nexthop(nh). In the flattened schema the two destroy callbacks are merged into one, but the current implementation only frees the nexthop β€” it never frees the path:

// staticd/static_nb_config.c
static int static_path_list_destroy(struct nb_cb_destroy_args *args)
{
    ...
    case NB_EV_APPLY:
        nh = nb_running_unset_entry(args->dnode);
        static_delete_nexthop(nh);   // removes nh from pn->nexthop_list, unlocks rn for nh
        // BUG: pn is now empty but never freed or removed from si->path_list
        break;

After static_delete_nexthop(nh), pn->nexthop_list is empty. pn remains in si->path_list indefinitely (until the whole route is deleted). Consequences:

  1. Memory leak β€” pn is never freed for single-nexthop entries deleted via the NB path.
  2. Route-lock imbalance β€” static_add_path calls route_lock_node once for the path; this lock is balanced by route_unlock_node inside static_del_path, which is never called here.
  3. Orphaned path in list β€” static_add_path will silently find and reuse the zombie path if a new nexthop with matching (table-id, distance, metric) is configured later.

For ECMP paths shared by multiple nexthops, the path must only be freed once the list is empty. The fix mirrors what static_nexthop_move_path already does correctly:

case NB_EV_APPLY: {
    struct static_path *pn;
    struct route_node *rn;
    struct static_route_info *si;

    nh = nb_running_unset_entry(args->dnode);
    pn = nh->pn;
    rn = pn->rn;
    si = rn->info;

    static_delete_nexthop(nh);  /* removes nh, sends route update/delete, unlocks rn for nh */

    if (static_nexthop_list_count(&pn->nexthop_list) == 0) {
        static_path_list_del(&si->path_list, pn);
        route_unlock_node(rn);   /* balance the lock from static_add_path */
        XFREE(MTYPE_STATIC_PATH, pn);
    }
    break;
}

Lock accounting: static_add_path acquires one lock per path, static_add_nexthop acquires one per nexthop. static_delete_nexthop releases the nexthop lock; the proposed block releases the path lock when the list empties β€” matching exactly.

@choppsv1 choppsv1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The choice to flatten the list created a much larger diff and change to the YANG. Did you consider just moving the distance leaf down into the nexthop structure? It would seem like a much smaller change.

@choppsv1

choppsv1 commented May 1, 2026

Copy link
Copy Markdown
Contributor

The choice to flatten the list created a much larger diff and change to the YANG. Did you consider just moving the distance leaf down into the nexthop structure? It would seem like a much smaller change.

It is probably the right thing to do, flattening the list. I was just wondering about the choice.

@enkechen-panw

Copy link
Copy Markdown
Contributor Author

The choice to flatten the list created a much larger diff and change to the YANG. Did you consider just moving the distance leaf down into the nexthop structure? It would seem like a much smaller change.

It is probably the right thing to do, flattening the list. I was just wondering about the choice.

Hi, @choppsv1 The backend structure layout (prefix->path->nexthop) fits well with ECMP, and with zebra interaction. So I was not looking at changing that. I also don't see a need.

@choppsv1

choppsv1 commented May 1, 2026

Copy link
Copy Markdown
Contributor

I agree that using a single list is better b/c it makes more sense to the user. Previously staticd had to merge multiple path-list entries into the resulting nexthop list for the route so there's no reason for the nested lists IMO. The CLI can still sort based on table-id for the only real user benefit.

However, implementing a tag seq-num to maintain bug-for-bug non-idempotent compatibility is sort of crazy. :)

Here's a patch that replaces that with the deterministic, idempotent behavior of max-tag-value wins. Claude even helped fix-up all the tests.

If you accept these please apply to the respective commits which are the first code commit, the first couple test commits and the doc commit. Don't forget to update commit text if it mentions the tag behavior too.

no-tag-seq.patch

@enkechen-panw

enkechen-panw commented May 1, 2026

Copy link
Copy Markdown
Contributor Author

Hi, @choppsv1 I like the idea of "max-tag-wins"! That's more deterministic than the current "last-write-wins". It took me some effort to match that behavior :-(

Thanks for the patch. I will go ahead and apply it.

As the branch is behind, let us first rebase/push, and then work on applying your patch.

@enkechen-panw
enkechen-panw force-pushed the static-route-metric branch 2 times, most recently from edd91f7 to b3a23b1 Compare May 1, 2026 22:09
@enkechen-panw

enkechen-panw commented May 1, 2026

Copy link
Copy Markdown
Contributor Author

Hi, @choppsv1 I have applied your patch for "max-value-wins" for tag, and adjusted commit messages and comments on "last-write-wins". Please review the updated patches. Thanks.

@choppsv1 choppsv1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please go back through the changes and change all the comments that refer to "the flat schema" or anything else about the previous implementation. The comments should speak to the code that is there now, and not refer to something previously there which someone reading the code for the first time has no knowledge of. :)

Comment thread staticd/static_vty.c
Motivation
----------
Static routes for a prefix can have multiple nexthops, but each nexthop
must be unique for that prefix.  The nexthop β€” identified by the
combination of (table-id, nh-type, vrf, gateway, interface) β€” should
therefore be the natural key in the YANG schema for static routes.

The previous schema keyed path-list on [table-id, distance] with a
nexthop-list nested below.  Since distance is not part of nexthop
identity, the same nexthop could appear under multiple path-list entries
at different distances without YANG detecting any conflict.  The CLI
carried extra logic to detect and suppress such duplicates, allowing
other config methods (e.g., RESTCONF) to create unexpected and unhandled
YANG config state.

Design
------
Replace the two-level path-list/nexthop hierarchy with a single flat
list keyed by full nexthop identity:

  path-list [table-id, nh-type, vrf, gateway, interface]

Each path-list entry represents exactly one nexthop.  Distance becomes
a non-key leaf attribute (default 1) on the entry.  Changing distance
on an existing nexthop is now a leaf MODIFY with no DESTROY+CREATE --
the NB callback migrates the nexthop to the appropriate internal struct
static_path (keyed by (table-id, distance) in C) without withdrawing
the route.

Internally, struct static_path groups nexthops that share the same
(table-id, distance) for ECMP and RIB installation; this grouping is
an implementation detail not exposed in the YANG key.

Per-leaf callbacks and apply_finish
------------------------------------
The path-list entry has an apply_finish callback
(static_path_list_apply_finish) that calls static_install_nexthop()
once after all per-leaf modify callbacks have run.  Per-leaf callbacks
(tag_modify, distance_modify) update internal state and mark the
nexthop with nh->state = STATIC_START rather than calling install
helpers directly.  This ensures a single consolidated route ADD reaches
zebra regardless of how many attributes change in one transaction.

Weight and BFD monitoring
--------------------------
In the previous schema, weight and bfd-monitoring were per-nexthop
attributes added via augment blocks on the nested frr-nexthops/nexthop
container.  Since each path-list entry is the nexthop, these attributes
become direct leaves/containers of the path-list entry in the
staticd-prefix-attributes grouping, eliminating the augment indirection.

Tag handling
------------
Tag is a per-path-group attribute at the C and RIB level: all nexthops
sharing the same distance carry one tag in the RIB.  Each nexthop has
its own YANG tag leaf, but they share a single pn->tag in the C backend.

When nexthops in the same group are configured with different tags,
max-value-wins applies: the tag with the highest value across the
group's nexthops takes effect.  When a nexthop is deleted, if it
carried the current maximum tag, pn->tag is recalculated as the maximum
of the remaining nexthops' tags.

Note: this YANG-vs-RIB divergence for tag is not a new behavior
introduced by this schema change.  Under the previous [table-id,
distance] key, nexthops at the same distance already shared one
path-list entry and one tag in the RIB.  This schema does not change
that behavior.  To assign independent tags that are each visible
separately in the RIB, configure the nexthops with different
administrative distances so they belong to distinct path groups.

ECMP validation
---------------
Restore the two validation checks that were attached to the old
nexthop-list create callback:

- Blackhole/non-blackhole mixing: a path-list entry with nh-type
  STATIC_BLACKHOLE may not coexist with non-blackhole entries in the
  same ECMP group (same table-id and distance).
- ECMP count limit: the number of nexthops in an ECMP group may not
  exceed zebra_ecmp_count.

These checks live in the NB_EV_VALIDATE handlers of
static_path_list_create and distance_modify, using path_list_ecmp_iter_cb
to iterate sibling path-list entries and accumulate the group's nexthop
count and nexthop types.  Revalidating on distance change is necessary
because a distance change moves the nexthop into a different ECMP group,
which may violate the constraints of that target group.

YANG revision
-------------
A new revision date is recorded in frr-staticd.yang for this
backward-incompatible path-list key change.

Signed-off-by: Enke Chen <enchen@paloaltonetworks.com>
The staticd path-list key changed from (table-id, distance) to nexthop
identity (table-id, nh-type, vrf, gateway, interface), with distance
becoming a non-key attribute.

Update the hard-coded mgmt set-config xpaths in test_yang_mgmt.py:
remove '[distance=1]/frr-nexthops/nexthop[' from each path-list
predicate β€” nh-type, vrf, gateway, and interface are now direct keys
on the path-list entry itself.

Signed-off-by: Enke Chen <enchen@paloaltonetworks.com>
The staticd path-list key changed from (table-id, distance) to nexthop
identity (table-id, nh-type, vrf, gateway, interface), with distance
and metric as non-key attributes.

Update test_grpc.cpp:
- xpath predicate: replace path-list[table-id][distance]/frr-nexthops/
  nexthop[nh-type]... with flat path-list[table-id][nh-type]...
- Restructure all 13 path-list JSON blocks from the nested
  frr-nexthops/nexthop format to the flat format where nh-type, vrf,
  gateway, and interface are direct path-list fields

Signed-off-by: Enke Chen <enchen@paloaltonetworks.com>
Add metric to the ZEBRA_ROUTE_STATIC identity checks in zebra so that
two static routes at the same distance but different metrics are treated
as distinct route_entries:

- rib_compare_routes(): add a ZEBRA_ROUTE_STATIC metric check so that an
  incoming static route at a different metric is not treated as an update
  to the existing entry.
- process_subq_early_route_delete(): extend the existing ZEBRA_ROUTE_KERNEL
  metric check to also cover ZEBRA_ROUTE_STATIC, matching the
  rib_compare_routes() logic.
- rib_route_match_ctx(): add metric to the ZEBRA_ROUTE_STATIC identity
  check (alongside distance) in both the is_update and non-update paths,
  so that dplane results are matched back to the correct route_entry when
  two static entries coexist at the same distance but different metrics.

rib_choose_best() already selects the lower-metric entry as best,
giving correct floating-route semantics without further changes.

Signed-off-by: Enke Chen <enchen@paloaltonetworks.com>
Add metric as a non-key leaf attribute on the flat path-list entry,
mirroring how distance is already handled after the schema refactoring.

YANG
----
Add a metric leaf (uint32, default 0) to the staticd-prefix-attributes
grouping.  Metric is not part of the nexthop-identity key; changing it
on an existing nexthop triggers a leaf MODIFY rather than a
DESTROY+CREATE.

Internal path grouping
----------------------
Extend struct static_path to carry metric alongside distance.  The C
backend groups nexthops that share (table-id, distance, metric) onto the
same path for ECMP and RIB installation.  Update static_add_path() to
accept and store the metric, and propagate the new parameter through all
call sites.

NB callback
-----------
static_path_list_metric_modify() migrates the nexthop to the appropriate
static_path when the metric value changes, using the shared helper
static_nexthop_move_path() (introduced here and also used by
distance_modify).  The helper handles the full old-path teardown and
new-path setup, ending with nh->state = STATIC_START so that
apply_finish() issues a single consolidated route ADD.

VTY
---
Extend the ip/ipv6 route CLI to accept an optional metric keyword, and
update show commands to display the metric when non-zero.

Signed-off-by: Enke Chen <enchen@paloaltonetworks.com>
Add two new sections to doc/user/static.rst:

1. Administrative Distance and Metric: explains that static routes are
   grouped by (table-id, distance, metric) into path groups; nexthops
   sharing the same tuple form an ECMP set.  Covers floating static
   routes (nexthops with different (distance, metric) tuples form separate
   path groups; all groups present in the RIB with the best-preference
   group selected), nexthop uniqueness / automatic move when distance or
   metric changes, and lazy deletion keyed on nexthop identity (distance
   and metric arguments are ignored on 'no ip route').

2. Route Tag: explains that tag is a per-path-group attribute (not a key),
   how to assign distinct tags to different path groups via distance or
   metric, and the limitation that nexthops with the same (distance, metric)
   always share one tag β€” the last-configured value wins.

Also update all CLI command signatures to include [tag TAG] before
DISTANCE and [metric METRIC] after DISTANCE, and add brief inline
descriptions of TAG, DISTANCE, and METRIC pointing to the new sections.

Signed-off-by: Enke Chen <enchen@paloaltonetworks.com>
Cover the four scenarios introduced by the apply_finish commit:

  1. Tag + metric combined change in one transaction (tag_modify and
     metric_modify both fire, then apply_finish once).
  2. Distance + metric combined change (distance_modify and metric_modify,
     then apply_finish once).
  3. Tag + distance + metric all three in one transaction (all three
     per-leaf callbacks, then apply_finish once).
  4. Tag no-op: tag_modify fires but pn->tag is unchanged after
     static_path_recalc_tag(); the no-op guard breaks early without
     setting nh->state = STATIC_START; apply_finish still fires and
     must leave the route correctly installed.

All cases run for both IPv4 and IPv6.

Signed-off-by: Enke Chen <enchen@paloaltonetworks.com>
Add tests/topotests/static_route_distance/ covering:
- Per-path administrative distance: AD replacement, ECMP with mixed
  ADs, lazy deletion keyed on nexthop identity, running-config
  correctness after each operation.
- Per-path tag: basic tag assignment, independent tags per path,
  tag change in place, combined AD+tag change in one command,
  max-value-wins recalculation on deletion (delete max-tag holder
  with one survivor, delete non-max nexthop, delete max-tag holder
  with two survivors), running-config format with tag+distance+metric,
  re-add of a previously deleted max-tag holder, and tag recalculation
  when a nexthop changes distance or metric (max-tag holder moves,
  non-max nexthop moves, and joining an existing tagged path).
- ECMP validation: zebra is started with -e 2 (ECMP limit = 2) to
  make the count-limit reachable with the three-nexthop topology.
  Two new test cases verify that configuration-time validation works:
  blackhole/non-blackhole mixing is rejected, and adding a third
  nexthop to a full ECMP group is rejected.  Both cases check that
  the error is reported and the existing route is left unchanged.

Signed-off-by: Enke Chen <enchen@paloaltonetworks.com>
Add test_static_route_metric.py to tests/topotests/static_route_distance/
covering 7 scenarios Γ— IPv4+IPv6 (14 test functions):

1. Metric replacement: changing a nexthop's metric removes the old-metric
   RIB entry and installs a new one at the new metric β€” no stale duplicate
   is left behind.
2. ECMP at same metric: two nexthops with identical metric are installed
   as a single ZAPI ADD with two nexthops, producing true ECMP.
3. Floating routes: routes at the same distance but different metrics are
   kept as separate zebra route_entries; rib_choose_best() selects the
   lower-metric entry; removing it promotes the higher-metric standby.
4. Metric change promote/demote: adjusting a route's metric causes the
   correct entry to become active.
5. Nexthop-identity deletion: deletion always uses a lazy search keyed on
   nexthop identity; distance and metric arguments are ignored.  All forms
   of 'no ip route X/M via Y' remove the route regardless of which distance
   or metric it was installed with.  A two-nexthop case (NH1@metric=100,
   NH2@metric=200) verifies that zebra's delete-lookup loop removes only
   the NH1 entry while leaving the NH2 entry intact, directly exercising
   the metric check in process_subq_early_route_delete.
6. Running-config format: "metric N" appears in show running-config output
   after the distance, confirming YANG/CLI round-trip.
7. ECMP primaries + metric-based standby: NH1 and NH2 at metric=100 form
   an ECMP primary group; NH3 at metric=200 is the standby.  Removing NH1
   shrinks the ECMP group to NH2 alone without promoting NH3.  Removing
   NH2 (the last primary) promotes NH3 to active.

Signed-off-by: Enke Chen <enchen@paloaltonetworks.com>
@enkechen-panw
enkechen-panw force-pushed the static-route-metric branch from b3a23b1 to 2f81a63 Compare May 2, 2026 19:30
@enkechen-panw

Copy link
Copy Markdown
Contributor Author

Please go back through the changes and change all the comments that refer to "the flat schema" or anything else about the previous implementation. The comments should speak to the code that is there now, and not refer to something previously there which someone reading the code for the first time has no knowledge of. :)

Done. Thanks.

@choppsv1
choppsv1 merged commit fe23b67 into FRRouting:master May 2, 2026
23 checks passed
@enkechen-panw

Copy link
Copy Markdown
Contributor Author

Thanks so much @choppsv1 for your help and contribution in this project!

@enkechen-panw
enkechen-panw deleted the static-route-metric branch May 3, 2026 01:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants