Skip to content

Commit 38c67d5

Browse files
tests: topotest for ZEBRA_BFD_DEST_REGISTER SBFD tail
Verifies the wire-payload extension landed in the previous patch. The test process spawns a Python ZAPI client inside r1's network namespace, performs the ZEBRA_HELLO / ZEBRA_BFD_CLIENT_REGISTER / ZEBRA_BFD_DEST_REGISTER handshake against zebra, and asserts that bfdd's session table reflects what was sent. Three cases: * test_zapi_sbfd_register_creates_session - register frame carries the full SBFD tail (bfd_regext_flags + bfd_mode + remote_discr + srv6_source_ipv6 + seg_list[2] + bfd_name="zapi-sbfd-test"). Asserts a bfdd peer with the expected destination materialises and that bfd_name was preserved through the tail. * test_zapi_classical_register_still_accepted - register frame omits the SBFD tail entirely (no flags word), exercising both the encoder gate (lib/bfd.c emits no tail when bfd_regext_flags == 0) and the decoder's STREAM_READABLE-gated tail handling. * test_zapi_sbfd_register_deduplicates_by_bfd_name - repeats the SBFD register from the first test and asserts the peer count for the same destination does not grow. This catches a key-table asymmetry (bs_peer_find uses bpc->bfd_name in the lookup key, so ptm_bfd_sess_new must populate bs->key.bfdname on insert - see the production change in the previous patch).
1 parent cca8ea1 commit 38c67d5

3 files changed

Lines changed: 388 additions & 0 deletions

File tree

tests/topotests/bfd_zapi_sbfd_topo1/__init__.py

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
hostname r1
2+
!
3+
interface r1-eth0
4+
ipv6 address 2001:db8:1::1/64
5+
!
6+
bfd
7+
!
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
#!/usr/bin/env python
2+
#
3+
# test_bfd_zapi_sbfd_topo1.py
4+
#
5+
# Verifies the ZAPI wire-payload extension: a ZEBRA_BFD_DEST_REGISTER
6+
# message that carries the appended SBFD/SRv6 tail (bfd_mode, remote_discr,
7+
# srv6_source_ipv6, seg_num, seg_list[], bfd_name) is decoded by bfdd and the
8+
# resulting bfd_session reflects those fields. A second register without the
9+
# SBFD tail acts as a back-compatibility regression check that the previous wire format
10+
# senders are still accepted.
11+
#
12+
# Topology: single router r1 running zebra + bfdd. The test process opens
13+
# zebra's ZAPI socket from inside r1's namespace via r1.popen("python3 -c"),
14+
# performs the ZEBRA_HELLO + ZEBRA_BFD_CLIENT_REGISTER + ZEBRA_BFD_DEST_REGISTER
15+
# handshake, and then asserts the bfdd-side session state via vtysh.
16+
17+
import json
18+
import os
19+
import sys
20+
import time
21+
22+
import pytest
23+
24+
CWD = os.path.dirname(os.path.realpath(__file__))
25+
sys.path.append(os.path.join(CWD, "../"))
26+
27+
# pylint: disable=C0413
28+
from lib.common_config import required_linux_kernel_version
29+
from lib.topogen import Topogen, TopoRouter, get_topogen
30+
31+
pytestmark = [pytest.mark.bfdd]
32+
33+
34+
# ZAPI wire-protocol constants — mirror lib/zclient.h / lib/route_types.txt.
35+
ZSERV_PATH = "/var/run/frr/zserv.api"
36+
ZEBRA_HEADER_MARKER = 254
37+
ZSERV_VERSION = 6
38+
ZEBRA_HEADER_SIZE = 10 # length(2)+marker(1)+version(1)+vrf(4)+command(2)
39+
40+
# Subset of `enum zclient_msg_type` from lib/zclient.h. Counted positionally
41+
# from ZEBRA_INTERFACE_ADD (=0). Verified against FRR base commit
42+
# 25d64c41b8 (origin/frr-10.4.1 at the time of this patch); re-verify on
43+
# each upstream FRR rebase with:
44+
# grep -nE '^[[:space:]]+ZEBRA_(HELLO|BFD_DEST_REGISTER|BFD_CLIENT_REGISTER),' \
45+
# lib/zclient.h
46+
# A stale value will produce no protocol NAK from zebra — the test fails as
47+
# a `_wait_for_peer` timeout rather than a wire-level error.
48+
ZEBRA_HELLO = 19
49+
ZEBRA_BFD_DEST_REGISTER = 27
50+
ZEBRA_BFD_CLIENT_REGISTER = 35
51+
52+
# enum bfd_mode_type from bfdd/bfd.h.
53+
BFD_MODE_TYPE_BFD = 0
54+
BFD_MODE_TYPE_SBFD_INIT = 2
55+
56+
# Position of ZEBRA_ROUTE_SHARP in lib/route_types.txt — we identify as
57+
# sharpd, the canonical dev/test ZAPI client.
58+
ZEBRA_ROUTE_SHARP = 23
59+
60+
VRF_DEFAULT = 0
61+
62+
63+
def build_topo(tgen):
64+
tgen.add_router("r1")
65+
sw = tgen.add_switch("s1")
66+
sw.add_link(tgen.gears["r1"])
67+
68+
69+
def setup_module(mod):
70+
tgen = Topogen(build_topo, mod.__name__)
71+
tgen.start_topology()
72+
for rname, router in tgen.routers().items():
73+
router.load_frr_config(
74+
os.path.join(CWD, "{}/frr.conf".format(rname)),
75+
[(TopoRouter.RD_ZEBRA, None), (TopoRouter.RD_BFD, None)],
76+
)
77+
tgen.start_router()
78+
79+
80+
def teardown_module(mod):
81+
get_topogen().stop_topology()
82+
83+
84+
# Inline Python script that runs inside r1's namespace, opens the ZAPI
85+
# socket, performs the BFD client handshake, and emits one
86+
# ZEBRA_BFD_DEST_REGISTER. Two forms are exercised, selected by argv[1]:
87+
# "sbfd" — appends the SBFD tail (bfd_mode/seg_num/bfd_name).
88+
# "classical" — omits the tail entirely, mimicking a sender without the SBFD tail.
89+
# Exit code 0 indicates the socket exchange succeeded; non-zero plus a
90+
# diagnostic on stderr indicates a wire-level failure.
91+
ZAPI_CLIENT_SCRIPT = r"""
92+
import os, socket, struct, sys, time
93+
94+
ZSERV_PATH = "/var/run/frr/zserv.api"
95+
ZEBRA_HEADER_MARKER = 254
96+
ZSERV_VERSION = 6
97+
ZEBRA_HEADER_SIZE = 10
98+
99+
ZEBRA_HELLO = 19
100+
ZEBRA_BFD_DEST_REGISTER = 27
101+
ZEBRA_BFD_CLIENT_REGISTER = 35
102+
103+
BFD_MODE_TYPE_SBFD_INIT = 2
104+
ZEBRA_ROUTE_SHARP = 23
105+
VRF_DEFAULT = 0
106+
107+
108+
def header(command, vrf_id=VRF_DEFAULT):
109+
return struct.pack(
110+
"!HBBIH", ZEBRA_HEADER_SIZE, ZEBRA_HEADER_MARKER, ZSERV_VERSION,
111+
vrf_id, command,
112+
)
113+
114+
115+
def finalize(buf):
116+
# length prefix at offset 0 covers the whole frame
117+
return struct.pack("!H", len(buf)) + buf[2:]
118+
119+
120+
def send(sock, buf):
121+
sock.sendall(finalize(buf))
122+
123+
124+
def hello(sock):
125+
buf = bytearray(header(ZEBRA_HELLO))
126+
buf += struct.pack("!B", ZEBRA_ROUTE_SHARP) # redist_default
127+
buf += struct.pack("!H", 0) # instance
128+
buf += struct.pack("!I", 0) # session_id
129+
buf += struct.pack("!B", 0) # synchronous=false
130+
send(sock, buf)
131+
132+
133+
def bfd_client_register(sock):
134+
buf = bytearray(header(ZEBRA_BFD_CLIENT_REGISTER))
135+
buf += struct.pack("!I", os.getpid())
136+
send(sock, buf)
137+
138+
139+
def bfd_dest_register(sock, kind, dst_addr):
140+
# Mirrors lib/bfd.c::zclient_bfd_command, HAVE_BFDD path. Encodes a
141+
# multihop IPv6 register so the body length is identical between the
142+
# SBFD and classical variants up to the appended tail.
143+
buf = bytearray(header(ZEBRA_BFD_DEST_REGISTER))
144+
buf += struct.pack("!I", os.getpid())
145+
146+
dst = socket.inet_pton(socket.AF_INET6, dst_addr)
147+
src = socket.inet_pton(socket.AF_INET6, "2001:db8:1::1")
148+
149+
# Layout per lib/bfd.c::zclient_bfd_command (HAVE_BFDD path).
150+
# min_rx/min_tx are uint32; detection_multiplier, mhop, hops, ifname_len,
151+
# cbit and profile_len are each uint8. The stream_putc/stream_putl mix
152+
# is easy to misencode from Python; keep the sizes lined up below.
153+
buf += struct.pack("!H", socket.AF_INET6) + dst # dst family + addr
154+
buf += struct.pack("!II", 300000, 300000) # min_rx, min_tx
155+
buf += struct.pack("!B", 3) # det_mult
156+
buf += struct.pack("!B", 1) # is_multihop = 1
157+
buf += struct.pack("!H", socket.AF_INET6) + src # src family + addr
158+
buf += struct.pack("!B", 1) # hops/ttl
159+
buf += struct.pack("!B", 0) # ifname_len = 0 (mhop)
160+
buf += struct.pack("!B", 0) # cbit
161+
buf += struct.pack("!B", 0) # profile_len = 0
162+
163+
if kind == "sbfd":
164+
# Optional-field tail with BFD_REGEXT_FLAG_* indicating which fields
165+
# follow. Mirrors the encoder in lib/bfd.c::zclient_bfd_command.
166+
BFD_REGEXT_FLAG_BFD_MODE = 0x0001
167+
BFD_REGEXT_FLAG_REMOTE_DISCR = 0x0002
168+
BFD_REGEXT_FLAG_SRV6_SOURCE = 0x0004
169+
BFD_REGEXT_FLAG_SEG_LIST = 0x0008
170+
BFD_REGEXT_FLAG_BFD_NAME = 0x0010
171+
172+
seg_list = [
173+
socket.inet_pton(socket.AF_INET6, "2001:db8:a::100"),
174+
socket.inet_pton(socket.AF_INET6, "2001:db8:a::200"),
175+
]
176+
bfd_name = b"zapi-sbfd-test"
177+
178+
flags = (BFD_REGEXT_FLAG_BFD_MODE
179+
| BFD_REGEXT_FLAG_REMOTE_DISCR
180+
| BFD_REGEXT_FLAG_SRV6_SOURCE
181+
| BFD_REGEXT_FLAG_SEG_LIST
182+
| BFD_REGEXT_FLAG_BFD_NAME)
183+
buf += struct.pack("!H", flags) # bfd_regext_flags
184+
buf += struct.pack("!B", BFD_MODE_TYPE_SBFD_INIT) # bfd_mode
185+
buf += struct.pack("!I", 0x000186A0) # remote_discr = 100000
186+
buf += src # srv6_source_ipv6
187+
buf += struct.pack("!B", len(seg_list)) # seg_num
188+
for sid in seg_list:
189+
buf += sid
190+
buf += struct.pack("!B", len(bfd_name)) + bfd_name
191+
elif kind == "classical":
192+
# A sender without the optional tail: no tail bytes at all.
193+
pass
194+
else:
195+
raise SystemExit("unknown kind {!r}".format(kind))
196+
197+
send(sock, buf)
198+
199+
200+
def main():
201+
if len(sys.argv) != 3:
202+
raise SystemExit("usage: {} sbfd|classical <dst-ipv6>".format(sys.argv[0]))
203+
kind = sys.argv[1]
204+
dst_addr = sys.argv[2]
205+
206+
deadline = time.time() + 10.0
207+
while time.time() < deadline:
208+
try:
209+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
210+
sock.connect(ZSERV_PATH)
211+
break
212+
except (FileNotFoundError, ConnectionRefusedError, OSError) as exc:
213+
last_err = exc
214+
time.sleep(0.2)
215+
else:
216+
raise SystemExit("zserv connect failed: {}".format(last_err))
217+
218+
try:
219+
hello(sock)
220+
bfd_client_register(sock)
221+
bfd_dest_register(sock, kind, dst_addr)
222+
# Give zebra a beat to forward the register to bfdd before we close.
223+
time.sleep(0.5)
224+
finally:
225+
sock.close()
226+
227+
228+
main()
229+
"""
230+
231+
232+
def _run_zapi_client(net_node, kind, dst_addr):
233+
"""Run the inline ZAPI client inside `net_node`'s network namespace.
234+
235+
`net_node` is the mininet Router (tgen.net[name]) — it exposes `cmd`
236+
without the higher-level TopoRouter wrappers. The inline script
237+
raises SystemExit on ZAPI handshake failure, but `cmd()` swallows
238+
the exit code; we append a sentinel echo so the caller can surface
239+
a real diagnostic instead of silently timing out at `_wait_for_peer`.
240+
"""
241+
script = ZAPI_CLIENT_SCRIPT.replace("'", "'\\''")
242+
output = net_node.cmd(
243+
"python3 -c '{}' {} {} 2>&1; echo __ZAPI_EXIT__=$?".format(
244+
script, kind, dst_addr))
245+
assert "__ZAPI_EXIT__=0" in output, (
246+
"ZAPI client ({} -> {}) failed; output:\n{}".format(
247+
kind, dst_addr, output))
248+
return output
249+
250+
251+
def _show_bfd_peers(net_node):
252+
"""Read bfdd's session table as JSON; return list of peer dicts."""
253+
raw = net_node.cmd("vtysh -c 'show bfd peers json'")
254+
raw = raw.strip()
255+
if not raw:
256+
return []
257+
return json.loads(raw)
258+
259+
260+
def _wait_for_peer(net_node, predicate, timeout=10.0):
261+
"""Poll `show bfd peers json` until predicate matches one peer."""
262+
deadline = time.time() + timeout
263+
last = []
264+
while time.time() < deadline:
265+
last = _show_bfd_peers(net_node)
266+
for peer in last:
267+
if predicate(peer):
268+
return peer
269+
time.sleep(0.3)
270+
raise AssertionError(
271+
"no bfd peer matched predicate within {}s; last seen: {}".format(
272+
timeout, last))
273+
274+
275+
SBFD_DST = "2001:db8:2::1"
276+
CLASSICAL_DST = "2001:db8:2::2"
277+
278+
279+
def test_zapi_sbfd_register_creates_session():
280+
"""
281+
Drive the new ZAPI tail end-to-end: a ZEBRA_BFD_DEST_REGISTER with
282+
`bfd_mode=SBFD_INIT` + a non-empty `bfd_name` + a 2-SID seg_list
283+
materialises as a bfdd session whose peer/source addresses match
284+
what was sent. `bfd_name` being preserved through the tail is the
285+
load-bearing assertion — it is the only new field already
286+
surfaced by `show bfd peers json` without bfdd-side debug counters.
287+
"""
288+
if required_linux_kernel_version("4.5") is not True:
289+
pytest.skip("Kernel requirements are not met")
290+
291+
tgen = get_topogen()
292+
if tgen.routers_have_failure():
293+
pytest.skip(tgen.errors)
294+
295+
r1 = tgen.net["r1"]
296+
_run_zapi_client(r1, "sbfd", SBFD_DST)
297+
298+
peer = _wait_for_peer(
299+
r1, lambda p: p.get("peer") == SBFD_DST,
300+
)
301+
assert peer.get("bfd-name") == "zapi-sbfd-test", (
302+
"bfd_name from ZAPI SBFD tail not preserved: {!r}".format(peer))
303+
304+
305+
def test_zapi_classical_register_still_accepted():
306+
"""
307+
Back-compat: a register frame without the SBFD tail (the previous wire
308+
format, and the extended wire format that classical-BFD callers like
309+
BGP/OSPF continue to produce) must still create a
310+
working bfdd session. The decoder's `STREAM_READABLE > 0` gate is
311+
what enables this; the encoder's matching gate is what keeps
312+
classical-BFD wire bytes byte-for-byte unchanged.
313+
"""
314+
if required_linux_kernel_version("4.5") is not True:
315+
pytest.skip("Kernel requirements are not met")
316+
317+
tgen = get_topogen()
318+
if tgen.routers_have_failure():
319+
pytest.skip(tgen.errors)
320+
321+
r1 = tgen.net["r1"]
322+
_run_zapi_client(r1, "classical", CLASSICAL_DST)
323+
324+
peer = _wait_for_peer(
325+
r1, lambda p: p.get("peer") == CLASSICAL_DST,
326+
)
327+
# Classical-BFD register: bfd_name was not carried, so the session
328+
# comes up without one. (Default formatting may omit the key
329+
# entirely or emit an empty string; accept both.)
330+
assert not peer.get("bfd-name"), (
331+
"classical register unexpectedly carries bfd-name: {!r}".format(peer))
332+
333+
334+
def test_zapi_sbfd_register_deduplicates_by_bfd_name():
335+
"""
336+
Re-registering the *same* SBFD session — same peer/local/vrf/bfd_name
337+
— must reuse the existing `bfd_session`. The load-bearing piece is
338+
that `bs_peer_find` uses `bpc->bfd_name` in the key; for that lookup
339+
to match the previously-inserted session, `ptm_bfd_sess_new` must
340+
also have populated `bs->key.bfdname` (set by the SBFD register path). Without
341+
that, a re-register would silently create a duplicate session and
342+
bfdd's peer count would grow on every flap.
343+
344+
Self-contained: uses a distinct destination address so the test
345+
does not depend on other tests' session state and can be reordered
346+
or run in isolation.
347+
"""
348+
if required_linux_kernel_version("4.5") is not True:
349+
pytest.skip("Kernel requirements are not met")
350+
351+
tgen = get_topogen()
352+
if tgen.routers_have_failure():
353+
pytest.skip(tgen.errors)
354+
355+
r1 = tgen.net["r1"]
356+
dedupe_dst = "2001:db8:2::3"
357+
358+
def count_with_dst(dst):
359+
return sum(1 for p in _show_bfd_peers(r1) if p.get("peer") == dst)
360+
361+
# Issue two back-to-back identical SBFD registers; expect exactly
362+
# one session afterwards.
363+
_run_zapi_client(r1, "sbfd", dedupe_dst)
364+
_wait_for_peer(r1, lambda p: p.get("peer") == dedupe_dst)
365+
after_first = count_with_dst(dedupe_dst)
366+
assert after_first == 1, (
367+
"first SBFD register did not produce exactly one session "
368+
"(got {})".format(after_first))
369+
370+
_run_zapi_client(r1, "sbfd", dedupe_dst)
371+
# Negative-test timing window: we're asserting that the count
372+
# *did not grow*, so there's no event to poll for. The inline
373+
# client already sleeps 0.5s post-send, and we add another 0.5s
374+
# here; on a loaded CI runner this can be bumped if the dedupe
375+
# assertion ever flakes.
376+
time.sleep(0.5)
377+
after_second = count_with_dst(dedupe_dst)
378+
assert after_second == 1, (
379+
"duplicate SBFD session created on same-name re-register "
380+
"(after_first={}, after_second={})".format(
381+
after_first, after_second))

0 commit comments

Comments
 (0)