Skip to content

Commit 3fc94e2

Browse files
authored
Add PTF tests for basic forwarding exercise # 1 (#730)
* Add PTF test file for basic forwarding exercise Signed-off-by: aqn96 <aqnguyen96@gmail.com> Signed-off-by: An <aqnguyen96@gmail.com> * Add runptf.sh script for basic forwarding PTF tests Signed-off-by: aqn96 <aqnguyen96@gmail.com> Signed-off-by: An <aqnguyen96@gmail.com> * Add testing section to basic forwarding README Signed-off-by: aqn96 <aqnguyen96@gmail.com> Signed-off-by: An <aqnguyen96@gmail.com> * Refactor: use p4runtime_lib instead of external dependency, self-contained veth setup, add make test target to the Makerfile, updated the README.md to reflect changes Signed-off-by: aqn96 <aqnguyen96@gmail.com> Signed-off-by: An <aqnguyen96@gmail.com> * ci: add automated ptf testing for tutorial exercises Signed-off-by: An <aqnguyen96@gmail.com> * ci: fix typo in the boost package name Signed-off-by: An <aqnguyen96@gmail.com> * feat: add LPM Tiebreaker, TTL, and NonIpv4Drop tests; update license header Signed-off-by: An <aqnguyen96@gmail.com> --------- Signed-off-by: aqn96 <aqnguyen96@gmail.com> Signed-off-by: An <aqnguyen96@gmail.com>
1 parent a36df0a commit 3fc94e2

File tree

6 files changed

+391
-0
lines changed

6 files changed

+391
-0
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: P4 Tutorials CI
2+
3+
on:
4+
pull_request:
5+
branches: [ master ]
6+
push:
7+
branches: [ master ]
8+
9+
jobs:
10+
test-basic-exercise:
11+
runs-on: ubuntu-latest
12+
# We use need a privileged container because P4 tests need to create veth interfaces
13+
container:
14+
image: p4lang/p4c:latest
15+
options: --privileged
16+
17+
steps:
18+
- name: Checkout code
19+
uses: actions/checkout@v4
20+
21+
- name: Install build dependencies
22+
run: |
23+
apt-get update
24+
apt-get install -y make python3-pip sudo libboost-iostreams-dev libboost-graph-dev
25+
pip3 install protobuf==3.20.3 grpcio grpcio-tools googleapis-common-protos scapy
26+
27+
- name: Ensure scripts are executable
28+
run: |
29+
chmod +x exercises/basic/runptf.sh
30+
31+
- name: Run PTF Tests
32+
run: |
33+
cd exercises/basic
34+
mkdir -p logs
35+
make test
36+
# Retain logs in case runs fail
37+
- name: Upload Logs
38+
if: always()
39+
uses: actions/upload-artifact@v4
40+
with:
41+
name: p4-logs
42+
path: exercises/basic/logs/

exercises/basic/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
build/
2+
ptf.log
3+
ptf.pcap
4+
ss-log.txt

exercises/basic/Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ BMV2_SWITCH_EXE = simple_switch_grpc
33
TOPO = pod-topo/topology.json
44

55
include ../../utils/Makefile
6+
7+
test:
8+
./runptf.sh

exercises/basic/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,20 @@ make stop
189189
Congratulations, your implementation works! Move onto the next assignment
190190
[Basic Tunneling](../basic_tunnel)
191191

192+
## Automated Tests
193+
194+
PTF (Packet Test Framework) tests are available to verify the solution
195+
program works correctly. These tests run against `solution/basic.p4`
196+
and check forwarding behavior, including drop on miss, single entry
197+
forwarding, and multi-entry LPM routing.
198+
199+
To run the tests:
200+
```bash
201+
make test
202+
```
203+
204+
This handles veth interface creation, P4 compilation, switch startup,
205+
test execution, and cleanup automatically.
192206

193207
## Relevant Documentation
194208

exercises/basic/ptf/basic_fwd.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
#!/usr/bin/env python3
2+
3+
# SPDX-License-Identifier: Apache-2.0
4+
# Copyright 2026 Andrew Nguyen
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
19+
import logging
20+
import os
21+
import sys
22+
23+
import ptf
24+
import ptf.testutils as tu
25+
from ptf.base_tests import BaseTest
26+
27+
# Import p4runtime_lib from the tutorials repo utils directory
28+
sys.path.append(
29+
os.path.join(os.path.dirname(os.path.abspath(__file__)),
30+
'../../../utils/'))
31+
import p4runtime_lib.bmv2
32+
import p4runtime_lib.helper
33+
from p4runtime_lib.switch import ShutdownAllSwitchConnections
34+
35+
36+
# Configure logging
37+
logger = logging.getLogger(None)
38+
ch = logging.StreamHandler()
39+
ch.setLevel(logging.INFO)
40+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
41+
ch.setFormatter(formatter)
42+
logger.addHandler(ch)
43+
44+
45+
class BasicFwdTest(BaseTest):
46+
def setUp(self):
47+
self.dataplane = ptf.dataplane_instance
48+
self.dataplane.flush()
49+
50+
logging.debug("BasicFwdTest.setUp()")
51+
52+
# Get test parameters
53+
grpc_addr = tu.test_param_get("grpcaddr")
54+
if grpc_addr is None:
55+
grpc_addr = 'localhost:9559'
56+
p4info_txt_fname = tu.test_param_get("p4info")
57+
p4prog_binary_fname = tu.test_param_get("config")
58+
59+
# Create P4Info helper for building table entries
60+
self.p4info_helper = p4runtime_lib.helper.P4InfoHelper(p4info_txt_fname)
61+
62+
# Connect to the switch via gRPC
63+
self.sw = p4runtime_lib.bmv2.Bmv2SwitchConnection(
64+
name='s1',
65+
address=grpc_addr,
66+
device_id=0,
67+
proto_dump_file='logs/s1-p4runtime-requests.txt')
68+
69+
# Establish as master controller
70+
self.sw.MasterArbitrationUpdate()
71+
72+
# Load the P4 program onto the switch
73+
self.sw.SetForwardingPipelineConfig(
74+
p4info=self.p4info_helper.p4info,
75+
bmv2_json_file_path=p4prog_binary_fname)
76+
77+
def tearDown(self):
78+
logging.debug("BasicFwdTest.tearDown()")
79+
ShutdownAllSwitchConnections()
80+
81+
82+
######################################################################
83+
# Helper function to add entries to ipv4_lpm table
84+
######################################################################
85+
86+
def add_ipv4_lpm_entry(self, ipv4_addr_str, prefix_len, dst_mac_str, port):
87+
table_entry = self.p4info_helper.buildTableEntry(
88+
table_name='MyIngress.ipv4_lpm',
89+
match_fields={
90+
'hdr.ipv4.dstAddr': (ipv4_addr_str, prefix_len)
91+
},
92+
action_name='MyIngress.ipv4_forward',
93+
action_params={
94+
'dstAddr': dst_mac_str,
95+
'port': port
96+
})
97+
self.sw.WriteTableEntry(table_entry)
98+
99+
100+
class DropTest(BasicFwdTest):
101+
"""Test that packets are dropped when no table entries are installed."""
102+
def runTest(self):
103+
in_dmac = 'ee:30:ca:9d:1e:00'
104+
in_smac = 'ee:cd:00:7e:70:00'
105+
ip_dst = '10.0.1.1'
106+
ig_port = 1
107+
108+
pkt = tu.simple_tcp_packet(eth_src=in_smac, eth_dst=in_dmac,
109+
ip_dst=ip_dst, ip_ttl=64)
110+
tu.send_packet(self, ig_port, pkt)
111+
tu.verify_no_other_packets(self)
112+
113+
114+
class FwdTest(BasicFwdTest):
115+
"""Test that a packet is forwarded correctly with one table entry."""
116+
def runTest(self):
117+
in_dmac = 'ee:30:ca:9d:1e:00'
118+
in_smac = 'ee:cd:00:7e:70:00'
119+
ip_dst = '10.0.1.1'
120+
ig_port = 1
121+
122+
eg_port = 2
123+
out_dmac = '08:00:00:00:02:22'
124+
125+
# Add a forwarding entry
126+
self.add_ipv4_lpm_entry(ip_dst, 32, out_dmac, eg_port)
127+
128+
# Send packet
129+
pkt = tu.simple_tcp_packet(eth_src=in_smac, eth_dst=in_dmac,
130+
ip_dst=ip_dst, ip_ttl=64)
131+
132+
# Expected: srcAddr = old dstAddr, dstAddr = new MAC, TTL decremented
133+
exp_pkt = tu.simple_tcp_packet(eth_src=in_dmac, eth_dst=out_dmac,
134+
ip_dst=ip_dst, ip_ttl=63)
135+
tu.send_packet(self, ig_port, pkt)
136+
tu.verify_packets(self, exp_pkt, [eg_port])
137+
138+
139+
class MultiEntryTest(BasicFwdTest):
140+
"""Test multiple LPM entries route to different ports correctly."""
141+
def runTest(self):
142+
in_dmac = 'ee:30:ca:9d:1e:00'
143+
in_smac = 'ee:cd:00:7e:70:00'
144+
ig_port = 0
145+
146+
entries = []
147+
entries.append({'ip_dst': '10.0.1.1',
148+
'prefix_len': 32,
149+
'pkt_dst': '10.0.1.1',
150+
'eg_port': 1,
151+
'out_dmac': '08:00:00:00:01:11'})
152+
entries.append({'ip_dst': '10.0.2.0',
153+
'prefix_len': 24,
154+
'pkt_dst': '10.0.2.99',
155+
'eg_port': 2,
156+
'out_dmac': '08:00:00:00:02:22'})
157+
entries.append({'ip_dst': '10.0.3.0',
158+
'prefix_len': 24,
159+
'pkt_dst': '10.0.3.1',
160+
'eg_port': 3,
161+
'out_dmac': '08:00:00:00:03:33'})
162+
163+
# Add all entries
164+
for e in entries:
165+
self.add_ipv4_lpm_entry(e['ip_dst'], e['prefix_len'],
166+
e['out_dmac'], e['eg_port'])
167+
168+
# Test each entry
169+
ttl_in = 64
170+
for e in entries:
171+
pkt = tu.simple_tcp_packet(eth_src=in_smac, eth_dst=in_dmac,
172+
ip_dst=e['pkt_dst'], ip_ttl=ttl_in)
173+
exp_pkt = tu.simple_tcp_packet(eth_src=in_dmac, eth_dst=e['out_dmac'],
174+
ip_dst=e['pkt_dst'],
175+
ip_ttl=ttl_in - 1)
176+
tu.send_packet(self, ig_port, pkt)
177+
tu.verify_packets(self, exp_pkt, [e['eg_port']])
178+
ttl_in -= 10
179+
180+
181+
class LpmTiebreakerTest(BasicFwdTest):
182+
"""Test that longest-prefix match wins for overlapping routes."""
183+
def runTest(self):
184+
in_dmac = 'ee:30:ca:9d:1e:00'
185+
in_smac = 'ee:cd:00:7e:70:00'
186+
ig_port = 0
187+
188+
less_specific_out_dmac = '08:00:00:00:11:11'
189+
less_specific_eg_port = 1
190+
more_specific_out_dmac = '08:00:00:00:22:22'
191+
more_specific_eg_port = 2
192+
193+
# Two overlapping routes: /16 and /24. Packet should match /24.
194+
self.add_ipv4_lpm_entry('10.0.0.0', 16,
195+
less_specific_out_dmac, less_specific_eg_port)
196+
self.add_ipv4_lpm_entry('10.0.1.0', 24,
197+
more_specific_out_dmac, more_specific_eg_port)
198+
199+
pkt = tu.simple_tcp_packet(eth_src=in_smac, eth_dst=in_dmac,
200+
ip_dst='10.0.1.99', ip_ttl=64)
201+
exp_pkt = tu.simple_tcp_packet(eth_src=in_dmac, eth_dst=more_specific_out_dmac,
202+
ip_dst='10.0.1.99', ip_ttl=63)
203+
204+
tu.send_packet(self, ig_port, pkt)
205+
tu.verify_packets(self, exp_pkt, [more_specific_eg_port])
206+
207+
208+
class TtlBoundaryTest(BasicFwdTest):
209+
"""Test forwarding behavior when input IPv4 TTL is at boundary value 1."""
210+
def runTest(self):
211+
in_dmac = 'ee:30:ca:9d:1e:00'
212+
in_smac = 'ee:cd:00:7e:70:00'
213+
ip_dst = '10.0.9.9'
214+
ig_port = 1
215+
216+
eg_port = 3
217+
out_dmac = '08:00:00:00:09:99'
218+
219+
self.add_ipv4_lpm_entry(ip_dst, 32, out_dmac, eg_port)
220+
221+
pkt = tu.simple_tcp_packet(eth_src=in_smac, eth_dst=in_dmac,
222+
ip_dst=ip_dst, ip_ttl=1)
223+
exp_pkt = tu.simple_tcp_packet(eth_src=in_dmac, eth_dst=out_dmac,
224+
ip_dst=ip_dst, ip_ttl=0)
225+
226+
tu.send_packet(self, ig_port, pkt)
227+
tu.verify_packets(self, exp_pkt, [eg_port])
228+
229+
230+
class NonIpv4DropTest(BasicFwdTest):
231+
"""Test non-IPv4 traffic bypasses IPv4 LPM forwarding logic."""
232+
def runTest(self):
233+
ig_port = 1
234+
pkt = tu.simple_arp_packet(
235+
eth_dst='ff:ff:ff:ff:ff:ff',
236+
eth_src='00:de:ad:be:ef:01',
237+
arp_op=1,
238+
ip_snd='10.0.1.10',
239+
ip_tgt='10.0.1.1',
240+
hw_snd='00:de:ad:be:ef:01',
241+
hw_tgt='00:00:00:00:00:00')
242+
243+
tu.send_packet(self, ig_port, pkt)
244+
tu.verify_packets(self, pkt, [0])

0 commit comments

Comments
 (0)