Skip to content

Commit 2f002ed

Browse files
committed
feat: add VPP notification test cases
Signed-off-by: Nicholas Ching <nicholaslching@gmail.com>
1 parent be363b7 commit 2f002ed

1 file changed

Lines changed: 393 additions & 0 deletions

File tree

Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
1+
# Copyright (c) 2026 Microsoft Open Technologies, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR
8+
# CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT
9+
# LIMITATION ANY IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS
10+
# FOR A PARTICULAR PURPOSE, MERCHANTABILITY OR NON-INFRINGEMENT.
11+
12+
"""Opt-in VPP SAI notification tests."""
13+
14+
import re
15+
import os
16+
import subprocess
17+
import threading
18+
import time
19+
20+
from ptf import config as ptf_config
21+
from ptf.testutils import test_params_get
22+
from unittest import SkipTest
23+
24+
from scapy.all import Ether, IP, UDP, get_if_hwaddr, sendp, sniff
25+
from scapy.contrib.bfd import BFD
26+
27+
from sai_thrift.sai_adapter import *
28+
from sai_test_base import T0TestBase
29+
from sai_utils import sai_ipaddress, sai_ipprefix
30+
31+
32+
PORT_NOTIFICATION_TYPE = 0
33+
BFD_NOTIFICATION_TYPE = 1
34+
NOTIFICATION_TEST_PARAM = "vpp_notification_test"
35+
NOTIFICATION_TEST_VALUE = "true"
36+
NOTIFICATION_TIMEOUT = 5.0
37+
NOTIFICATION_POLL_INTERVAL = 0.5
38+
39+
40+
class NotificationTestBase(T0TestBase):
41+
"""Common setup for the opt-in server-owned notification bridge."""
42+
43+
def setUp(self, **kwargs):
44+
params = test_params_get() or {}
45+
if params.get(NOTIFICATION_TEST_PARAM) != NOTIFICATION_TEST_VALUE:
46+
super().setUp(skip_reason="VPP notification tests are opt-in")
47+
return
48+
49+
T0TestBase.setUp(self, **kwargs)
50+
status = self.client.sai_thrift_enable_notifications()
51+
self.assertEqual(status, SAI_STATUS_SUCCESS)
52+
self.client.sai_thrift_drain_notifications()
53+
54+
def tearDown(self):
55+
try:
56+
if self.client is not None:
57+
self.client.sai_thrift_drain_notifications()
58+
finally:
59+
super().tearDown()
60+
61+
def wait_for_notification(self, predicate):
62+
deadline = time.monotonic() + NOTIFICATION_TIMEOUT
63+
while time.monotonic() < deadline:
64+
for event in self.client.sai_thrift_drain_notifications():
65+
if predicate(event):
66+
return event
67+
time.sleep(NOTIFICATION_POLL_INTERVAL)
68+
self.fail("timed out waiting for the expected SAI notification")
69+
70+
@staticmethod
71+
def peer_interface(port_index):
72+
for _, configured_port, interface_name in ptf_config.get("interfaces", []):
73+
if configured_port == port_index:
74+
if not re.fullmatch(r"OEth[0-9]+_peer", interface_name):
75+
raise AssertionError(
76+
"unexpected VPP PTF peer interface: {}".format(interface_name)
77+
)
78+
return interface_name
79+
raise AssertionError(
80+
"PTF interface for port {} was not configured".format(port_index)
81+
)
82+
83+
@staticmethod
84+
def vpp_interface(peer_name):
85+
match = re.fullmatch(r"OEth([0-9]+)_peer", peer_name)
86+
if match is None:
87+
raise AssertionError(
88+
"unexpected VPP PTF peer interface: {}".format(peer_name)
89+
)
90+
return "OEthernet{}".format(match.group(1))
91+
92+
@staticmethod
93+
def set_peer_state(interface_name, is_up):
94+
state = "up" if is_up else "down"
95+
subprocess.run(
96+
["ip", "link", "set", "dev", interface_name, state],
97+
check=True,
98+
)
99+
100+
101+
class PortNotificationTestBase(NotificationTestBase):
102+
"""Port notification setup without unrelated L3 configuration."""
103+
104+
def setUp(self):
105+
super().setUp(
106+
is_remove_default_vlan=False,
107+
is_create_vlan=False,
108+
is_create_fdb=False,
109+
is_create_default_route=False,
110+
is_create_lag=False,
111+
is_create_vlan_itf=False,
112+
is_create_route_for_vlan_itf=False,
113+
is_create_route_for_lag=False,
114+
wait_sec=1,
115+
)
116+
self.port = self.dut.port_obj_list[0]
117+
self.peer = self.peer_interface(self.port.dev_port_index)
118+
self.vpp_peer = self.vpp_interface(self.peer)
119+
self.set_peer_state(self.peer, True)
120+
self.set_peer_state(self.vpp_peer, True)
121+
self.client.sai_thrift_drain_notifications()
122+
123+
def port_event(self, state):
124+
return self.wait_for_notification(
125+
lambda event: event.notification_type == PORT_NOTIFICATION_TYPE
126+
and event.object_id == self.port.oid
127+
and event.state == state
128+
)
129+
130+
131+
class PortStateChangeTest(PortNotificationTestBase):
132+
"""Verify a VPP carrier-down event reaches the SAI callback bridge."""
133+
134+
def runTest(self):
135+
try:
136+
self.set_peer_state(self.peer, False)
137+
self.port_event(SAI_PORT_OPER_STATUS_DOWN)
138+
finally:
139+
self.set_peer_state(self.peer, True)
140+
141+
142+
class PortStateRecoveryTest(PortNotificationTestBase):
143+
"""Verify a carrier-down/carrier-up sequence reaches SAI in order."""
144+
145+
def runTest(self):
146+
try:
147+
self.set_peer_state(self.peer, False)
148+
self.port_event(SAI_PORT_OPER_STATUS_DOWN)
149+
self.set_peer_state(self.peer, True)
150+
self.port_event(SAI_PORT_OPER_STATUS_UP)
151+
finally:
152+
self.set_peer_state(self.peer, True)
153+
154+
155+
class BfdResponder:
156+
"""Small Scapy responder for one single-hop or multihop BFD session."""
157+
158+
def __init__(self, interface_name, local_ip, remote_ip, udp_port, discriminator):
159+
self.interface_name = interface_name
160+
self.local_ip = local_ip
161+
self.remote_ip = remote_ip
162+
self.udp_port = udp_port
163+
self.discriminator = discriminator
164+
self.source_mac = get_if_hwaddr(interface_name)
165+
self.stop_event = threading.Event()
166+
self.thread = threading.Thread(target=self._run, daemon=True)
167+
168+
def start(self):
169+
self.thread.start()
170+
171+
def stop(self):
172+
self.stop_event.set()
173+
self.thread.join(timeout=2)
174+
175+
def _run(self):
176+
while not self.stop_event.is_set():
177+
sniff(
178+
iface=self.interface_name,
179+
filter="udp",
180+
timeout=0.5,
181+
store=False,
182+
prn=self._respond,
183+
)
184+
185+
def _respond(self, packet):
186+
if not packet.haslayer(Ether) or not packet.haslayer(IP):
187+
return
188+
if not packet.haslayer(UDP):
189+
return
190+
191+
udp = packet[UDP]
192+
if udp.dport != self.udp_port:
193+
return
194+
195+
bfd = packet.getlayer(BFD)
196+
if bfd is None:
197+
try:
198+
bfd = BFD(bytes(udp.payload))
199+
except Exception:
200+
return
201+
202+
response = (
203+
Ether(src=self.source_mac, dst=packet[Ether].src)
204+
/ IP(src=self.remote_ip, dst=self.local_ip, ttl=255)
205+
/ UDP(sport=self.udp_port, dport=udp.sport)
206+
/ BFD(
207+
version=1,
208+
diag=0,
209+
sta=3,
210+
flags=0,
211+
detect_mult=3,
212+
my_discriminator=self.discriminator,
213+
your_discriminator=bfd.my_discriminator,
214+
min_tx_interval=100000,
215+
min_rx_interval=100000,
216+
echo_rx_interval=0,
217+
)
218+
)
219+
sendp(response, iface=self.interface_name, verbose=False)
220+
221+
222+
class BfdNotificationTestBase(NotificationTestBase):
223+
"""Build a LAG-backed BFD session with a real connected local address."""
224+
225+
local_ip = "10.1.1.1"
226+
remote_ip = "10.1.1.2"
227+
gateway_ip = "10.1.1.2"
228+
local_discriminator = 0x1001
229+
remote_discriminator = 0x2001
230+
udp_port = 3784
231+
multihop = False
232+
233+
def setUp(self):
234+
if os.environ.get("SIMULATE_SONIC") != "1":
235+
super().setUp(skip_reason="BFD notification tests require SIMULATE_SONIC=1")
236+
return
237+
238+
self.bfd_session = None
239+
self.lag_rif = None
240+
self.neighbor_entry = None
241+
self.next_hop = None
242+
self.route_entry = None
243+
self.responder = None
244+
245+
super().setUp(
246+
is_remove_default_vlan=False,
247+
is_create_vlan=False,
248+
is_create_fdb=False,
249+
is_create_default_route=False,
250+
is_create_lag=True,
251+
is_create_vlan_itf=False,
252+
is_create_route_for_vlan_itf=False,
253+
is_create_route_for_lag=False,
254+
wait_sec=1,
255+
)
256+
257+
if not self.dut.default_vrf:
258+
self.route_configer.get_default_virtual_router()
259+
260+
lag = self.dut.lag_list[0]
261+
self.lag_rif = self.route_configer.create_router_interface(lag)
262+
peer_port = lag.member_port_indexs[0]
263+
self.peer = self.peer_interface(peer_port)
264+
self.peer_mac = get_if_hwaddr(self.peer)
265+
266+
self.neighbor_entry = sai_thrift_neighbor_entry_t(
267+
rif_id=self.lag_rif,
268+
ip_address=sai_ipaddress(self.gateway_ip),
269+
)
270+
status = sai_thrift_create_neighbor_entry(
271+
self.client,
272+
self.neighbor_entry,
273+
dst_mac_address=self.peer_mac,
274+
no_host_route=False,
275+
)
276+
self.assertEqual(status, SAI_STATUS_SUCCESS)
277+
278+
if self.multihop:
279+
self.next_hop = sai_thrift_create_next_hop(
280+
self.client,
281+
ip=sai_ipaddress(self.gateway_ip),
282+
router_interface_id=self.lag_rif,
283+
type=SAI_NEXT_HOP_TYPE_IP,
284+
)
285+
self.assertEqual(self.status(), SAI_STATUS_SUCCESS)
286+
self.route_entry = sai_thrift_route_entry_t(
287+
vr_id=self.dut.default_vrf,
288+
destination=sai_ipprefix(self.remote_ip + "/32"),
289+
)
290+
status = sai_thrift_create_route_entry(
291+
self.client,
292+
self.route_entry,
293+
next_hop_id=self.next_hop,
294+
)
295+
self.assertEqual(status, SAI_STATUS_SUCCESS)
296+
297+
def start_session(self):
298+
self.responder = BfdResponder(
299+
self.peer,
300+
self.local_ip,
301+
self.remote_ip,
302+
self.udp_port,
303+
self.remote_discriminator,
304+
)
305+
self.responder.start()
306+
307+
self.bfd_session = sai_thrift_create_bfd_session(
308+
self.client,
309+
type=SAI_BFD_SESSION_TYPE_ASYNC_ACTIVE,
310+
virtual_router=self.dut.default_vrf,
311+
local_discriminator=self.local_discriminator,
312+
remote_discriminator=self.remote_discriminator,
313+
udp_src_port=49152,
314+
bfd_encapsulation_type=SAI_BFD_ENCAPSULATION_TYPE_NONE,
315+
iphdr_version=4,
316+
src_ip_address=sai_ipaddress(self.local_ip),
317+
dst_ip_address=sai_ipaddress(self.remote_ip),
318+
min_tx=100000,
319+
min_rx=100000,
320+
multiplier=3,
321+
hw_lookup_valid=True,
322+
multihop=self.multihop,
323+
cbit=False,
324+
admin_state=True,
325+
)
326+
self.assertNotEqual(self.bfd_session, SAI_NULL_OBJECT_ID)
327+
self.assertEqual(self.status(), SAI_STATUS_SUCCESS)
328+
329+
def bfd_event(self, state):
330+
return self.wait_for_notification(
331+
lambda event: event.notification_type == BFD_NOTIFICATION_TYPE
332+
and event.object_id == self.bfd_session
333+
and event.state == state
334+
)
335+
336+
def assert_bfd_state(self, state):
337+
attributes = sai_thrift_get_bfd_session_attribute(
338+
self.client,
339+
self.bfd_session,
340+
state=True,
341+
)
342+
self.assertEqual(attributes["state"], state)
343+
344+
def tearDown(self):
345+
try:
346+
if self.responder is not None:
347+
self.responder.stop()
348+
if self.bfd_session is not None:
349+
sai_thrift_remove_bfd_session(self.client, self.bfd_session)
350+
if self.route_entry is not None:
351+
sai_thrift_remove_route_entry(self.client, self.route_entry)
352+
if self.next_hop is not None:
353+
sai_thrift_remove_next_hop(self.client, self.next_hop)
354+
if self.neighbor_entry is not None:
355+
sai_thrift_remove_neighbor_entry(self.client, self.neighbor_entry)
356+
finally:
357+
super().tearDown()
358+
359+
360+
class BfdSessionUpTest(BfdNotificationTestBase):
361+
"""Verify that a responder-driven BFD session emits an UP notification."""
362+
363+
def runTest(self):
364+
self.start_session()
365+
self.bfd_event(SAI_BFD_SESSION_STATE_UP)
366+
self.assert_bfd_state(SAI_BFD_SESSION_STATE_UP)
367+
368+
369+
class BfdSessionDownTest(BfdNotificationTestBase):
370+
"""Verify that stopping the responder emits a BFD DOWN notification."""
371+
372+
def runTest(self):
373+
self.start_session()
374+
self.bfd_event(SAI_BFD_SESSION_STATE_UP)
375+
self.assert_bfd_state(SAI_BFD_SESSION_STATE_UP)
376+
self.responder.stop()
377+
self.responder = None
378+
self.bfd_event(SAI_BFD_SESSION_STATE_DOWN)
379+
self.assert_bfd_state(SAI_BFD_SESSION_STATE_DOWN)
380+
381+
382+
class BfdMultihopTest(BfdNotificationTestBase):
383+
"""Verify multihop BFD uses UDP/4784 and a routed lookup."""
384+
385+
remote_ip = "10.1.2.2"
386+
gateway_ip = "10.1.1.2"
387+
udp_port = 4784
388+
multihop = True
389+
390+
def runTest(self):
391+
self.start_session()
392+
self.bfd_event(SAI_BFD_SESSION_STATE_UP)
393+
self.assert_bfd_state(SAI_BFD_SESSION_STATE_UP)

0 commit comments

Comments
 (0)