Commit ba56452213 for frr

commit ba56452213e4835e14da03c5ddb0deeb083a45ba
Author: Rafael Zalamena <rzalamena@opensourcerouting.org>
Date:   Fri Oct 9 17:31:56 2026 +0000

    tests: frr-reload "no" retry inside a context

    Add daemon-less unit tests for delete_line_with_vtysh(): when vtysh
    rejects a "no" command, the last word of the "no" line is dropped and
    the command is tried again, keeping the trailing "exit" lines and the
    indentation. Cover a command inside one context, inside nested contexts,
    at top level, and giving up after "no WORD".

    Signed-off-by: Rafael Zalamena <rzalamena@opensourcerouting.org>

diff --git a/tests/topotests/frr_reload_no_form/__init__.py b/tests/topotests/frr_reload_no_form/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/topotests/frr_reload_no_form/test_frr_reload_delete_retry.py b/tests/topotests/frr_reload_no_form/test_frr_reload_delete_retry.py
new file mode 100644
index 0000000000..f9fe8eabf9
--- /dev/null
+++ b/tests/topotests/frr_reload_no_form/test_frr_reload_delete_retry.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python
+# SPDX-License-Identifier: ISC
+#
+# test_frr_reload_delete_retry.py
+# Part of NetDEF Topology Tests
+#
+# Copyright (c) 2026 by
+# Network Device Education Foundation, Inc. ("NetDEF")
+#
+"""
+Unit tests for the frr-reload "no" command word-trimming retry (no daemons).
+
+When vtysh rejects "no FOO BAR BAZ", delete_line_with_vtysh() drops the last
+word and tries again. lines_to_config() closes every context it opens with
+"exit", so the word to drop is on the "no" line, not on the last line.
+"""
+
+import importlib.util
+import logging
+import os
+import shutil
+
+import pytest
+
+CWD = os.path.dirname(os.path.realpath(__file__))
+
+# CI runs the topotests tree detached from the source tree, so the in-tree
+# tools/ directory is not always reachable; fall back to the installed script.
+FRR_RELOAD_CANDIDATES = (
+    os.environ.get("FRR_RELOAD_PY"),
+    os.path.abspath(os.path.join(CWD, "../../../tools/frr-reload.py")),
+    "/usr/lib/frr/frr-reload.py",
+    shutil.which("frr-reload.py"),
+)
+
+
+def _find_reload():
+    for path in FRR_RELOAD_CANDIDATES:
+        if path and os.path.isfile(path):
+            return path
+    return None
+
+
+def _load_reload(path):
+    spec = importlib.util.spec_from_file_location("frr_reload", path)
+    mod = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(mod)
+    return mod
+
+
+@pytest.fixture(scope="module")
+def reload():
+    path = _find_reload()
+    if path is None:
+        pytest.skip(
+            "frr-reload.py not found (looked in {})".format(
+                ", ".join(p for p in FRR_RELOAD_CANDIDATES if p)
+            )
+        )
+    return _load_reload(path)
+
+
+class FakeVtysh:
+    """Record "vtysh -c" calls, accepting only the given "no" lines."""
+
+    def __init__(self, reload, accepted):
+        self.reload = reload
+        self.accepted = accepted
+        self.calls = []
+
+    def __call__(self, command, stdouts=None):
+        self.calls.append(list(command))
+        if not any(line in self.accepted for line in command):
+            raise self.reload.VtyshException("% Unknown command")
+
+
+def _delete(reload, accepted, ctx_keys, line):
+    vtysh = FakeVtysh(reload, accepted)
+    # Failed attempts are logged at error level; keep the test output clean.
+    logging.disable(logging.CRITICAL)
+    try:
+        ok = reload.delete_line_with_vtysh(vtysh, ctx_keys, line)
+    finally:
+        logging.disable(logging.NOTSET)
+    return ok, vtysh.calls
+
+
+def test_trim_inside_context(reload):
+    """Interface description: only "no description" exists."""
+    ok, calls = _delete(
+        reload,
+        [" no description"],
+        ("interface eth0",),
+        "description link to s1",
+    )
+    assert ok
+    assert calls == [
+        ["configure", "interface eth0", " no description link to s1", "exit"],
+        ["configure", "interface eth0", " no description link to", "exit"],
+        ["configure", "interface eth0", " no description link", "exit"],
+        ["configure", "interface eth0", " no description", "exit"],
+    ]
+
+
+def test_trim_inside_nested_context(reload):
+    """Both "exit" lines are kept, the "no" line keeps its indentation."""
+    ok, calls = _delete(
+        reload,
+        ["  no foo bar"],
+        ("router bgp 65000", "address-family ipv4 unicast"),
+        "foo bar baz",
+    )
+    assert ok
+    assert calls[-1] == [
+        "configure",
+        "router bgp 65000",
+        " address-family ipv4 unicast",
+        "  no foo bar",
+        " exit",
+        "exit",
+    ]
+
+
+def test_trim_top_level(reload):
+    """A command outside any context has no "exit" to skip."""
+    ok, calls = _delete(reload, ["no ip foo"], ("ip foo bar",), None)
+    assert ok
+    assert calls == [
+        ["configure", "no ip foo bar"],
+        ["configure", "no ip foo"],
+    ]
+
+
+def test_trim_gives_up_at_two_words(reload):
+    """Never accepted: stop after "no WORD", report failure."""
+    ok, calls = _delete(reload, [], ("router ospf",), "foo bar baz")
+    assert not ok
+    assert [call[2] for call in calls] == [
+        " no foo bar baz",
+        " no foo bar",
+        " no foo",
+    ]
+    assert all(call[-1] == "exit" for call in calls)