Skip to content

Commit 941262c

Browse files
authored
Add PTF tests for Tutorial Exercise # 2 (#733)
* ci: refactor to matrix strategy, add concurrency and timeout Signed-off-by: An <aqnguyen96@gmail.com> * feat: add PTF tests for basic_tunnel exercise Signed-off-by: An <aqnguyen96@gmail.com> * docs: remove PTF test mention from basic README per maintainer feedback Signed-off-by: An <aqnguyen96@gmail.com> * test: add MixedTrafficTest, TunnelUnknownProtoTest, TtlBoundaryTest, fix comments/docs Signed-off-by: An <aqnguyen96@gmail.com> --------- Signed-off-by: An <aqnguyen96@gmail.com>
1 parent 810cd74 commit 941262c

File tree

6 files changed

+343
-23
lines changed

6 files changed

+343
-23
lines changed

.github/workflows/test-exercises.yml

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@ on:
55
branches: [ master ]
66
push:
77
branches: [ master ]
8+
# Add concurrency so only most recent push have CI
9+
concurrency:
10+
group: p4-tutorials-ci-${{ github.ref }}
11+
cancel-in-progress: true
812

913
jobs:
10-
test-basic-exercise:
14+
test-exercise:
15+
timeout-minutes: 30
1116
runs-on: ubuntu-latest
1217
# We use need a privileged container because P4 tests need to create veth interfaces
1318
container:
1419
image: p4lang/p4c:latest
1520
options: --privileged
21+
strategy:
22+
matrix:
23+
exercise: [basic, basic_tunnel] # Add more exercises here for future tests
1624

1725
steps:
1826
- name: Checkout code
@@ -24,18 +32,16 @@ jobs:
2432
apt-get install -y make python3-pip sudo libboost-iostreams-dev libboost-graph-dev
2533
pip3 install protobuf==3.20.3 grpcio grpcio-tools googleapis-common-protos scapy
2634
27-
- name: Ensure scripts are executable
28-
run: |
29-
chmod +x exercises/basic/runptf.sh
30-
3135
- name: Run PTF Tests
3236
run: |
33-
cd exercises/basic
37+
chmod +x exercises/${{ matrix.exercise }}/runptf.sh
38+
cd exercises/${{ matrix.exercise }}
3439
make test
3540
# Retain logs in case runs fail
3641
- name: Upload Logs
3742
if: always()
3843
uses: actions/upload-artifact@v4
3944
with:
40-
name: p4-logs
41-
path: exercises/basic/logs/
45+
name: p4-logs-${{ matrix.exercise }}
46+
path: exercises/${{ matrix.exercise }}/logs/
47+

exercises/basic/README.md

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -189,21 +189,6 @@ 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.
206-
207192
## Relevant Documentation
208193

209194
The documentation for P4_16 and P4Runtime is available [here](https://p4.org/specifications/)

exercises/basic_tunnel/.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_tunnel/Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@
22
BMV2_SWITCH_EXE = simple_switch_grpc
33

44
include ../../utils/Makefile
5+
6+
test: dirs
7+
./runptf.sh
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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+
from scapy.all import IP, TCP, Ether, Packet, ShortField, bind_layers
27+
28+
# Custom Tunnel
29+
TYPE_MYTUNNEL = 0x1212
30+
TYPE_IPV4 = 0x0800
31+
32+
33+
class MyTunnel(Packet):
34+
name = "MyTunnel"
35+
fields_desc = [ShortField("proto_id", TYPE_IPV4), ShortField("dst_id", 0)]
36+
37+
38+
bind_layers(Ether, MyTunnel, type=TYPE_MYTUNNEL)
39+
bind_layers(MyTunnel, IP, proto_id=TYPE_IPV4)
40+
41+
42+
# Import p4runtime_lib from the tutorials repo utils directory
43+
sys.path.append(
44+
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../../utils/")
45+
)
46+
import p4runtime_lib.bmv2
47+
import p4runtime_lib.helper
48+
from p4runtime_lib.switch import ShutdownAllSwitchConnections
49+
50+
51+
# Configure Logging
52+
logger = logging.getLogger(None)
53+
handler = logging.StreamHandler()
54+
handler.setLevel(logging.INFO)
55+
handler.setFormatter(
56+
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
57+
)
58+
logger.addHandler(handler)
59+
60+
61+
class BasicTunnelTest(BaseTest):
62+
def setUp(self):
63+
self.dataplane = ptf.dataplane_instance
64+
self.dataplane.flush()
65+
66+
logging.debug("BasicTunnelTest.setUp()")
67+
68+
# Get test parameters
69+
grpc_addr = tu.test_param_get("grpcaddr") or "localhost:9559"
70+
p4info_txt_fname = tu.test_param_get("p4info")
71+
p4prog_binary_fname = tu.test_param_get("config")
72+
73+
# Create P4Info helper for building the table entries
74+
self.p4info_helper = p4runtime_lib.helper.P4InfoHelper(p4info_txt_fname)
75+
76+
# Connect to the switch via gRPC
77+
self.sw = p4runtime_lib.bmv2.Bmv2SwitchConnection(
78+
name="s1",
79+
address=grpc_addr,
80+
device_id=0,
81+
proto_dump_file="logs/s1-p4runtime-requests.txt")
82+
83+
# Establish as master controller
84+
self.sw.MasterArbitrationUpdate()
85+
86+
# Load the P4 Program onto the switch
87+
self.sw.SetForwardingPipelineConfig(
88+
p4info=self.p4info_helper.p4info, bmv2_json_file_path=p4prog_binary_fname)
89+
90+
def tearDown(self):
91+
logging.debug("BasicTunnelTest.tearDown()")
92+
ShutdownAllSwitchConnections()
93+
94+
95+
######################################################################
96+
# Helper function to add entries to ipv4_lpm table
97+
######################################################################
98+
99+
def add_ipv4_lpm_entry(self, ipv4_addr_str, prefix_len, dst_mac_str, port):
100+
table_entry = self.p4info_helper.buildTableEntry(
101+
table_name="MyIngress.ipv4_lpm",
102+
match_fields={"hdr.ipv4.dstAddr": (ipv4_addr_str, prefix_len)},
103+
action_name="MyIngress.ipv4_forward",
104+
action_params={"dstAddr": dst_mac_str, "port": port},
105+
)
106+
self.sw.WriteTableEntry(table_entry)
107+
108+
def add_tunnel_entry(self, dst_id, port):
109+
table_entry = self.p4info_helper.buildTableEntry(
110+
table_name="MyIngress.myTunnel_exact",
111+
match_fields={"hdr.myTunnel.dst_id": dst_id},
112+
action_name="MyIngress.myTunnel_forward",
113+
action_params={"port": port},
114+
)
115+
self.sw.WriteTableEntry(table_entry)
116+
117+
118+
class Ipv4DropOnMissTest(BasicTunnelTest):
119+
"""Verify that a plain IPv4 packet is dropped when no LPM table entry exists."""
120+
def runTest(self):
121+
pkt = tu.simple_tcp_packet(
122+
eth_src="ee:cd:00:7e:70:00",
123+
eth_dst="ee:30:ca:9d:1e:00",
124+
ip_dst="10.0.1.1",
125+
ip_ttl=64,
126+
)
127+
tu.send_packet(self, 1, pkt)
128+
tu.verify_no_other_packets(self)
129+
130+
131+
class Ipv4ForwardTest(BasicTunnelTest):
132+
"""Verify that a plain IPv4 packet is forwarded correctly with one table entry."""
133+
def runTest(self):
134+
in_dmac = "ee:30:ca:9d:1e:00"
135+
in_smac = "ee:cd:00:7e:70:00"
136+
ip_dst = "10.0.2.2"
137+
eg_port = 2
138+
out_dmac = "08:00:00:00:02:22"
139+
140+
self.add_ipv4_lpm_entry(ip_dst, 32, out_dmac, eg_port)
141+
142+
pkt = tu.simple_tcp_packet(
143+
eth_src=in_smac, eth_dst=in_dmac, ip_dst=ip_dst, ip_ttl=64
144+
)
145+
exp_pkt = tu.simple_tcp_packet(
146+
eth_src=in_dmac, eth_dst=out_dmac, ip_dst=ip_dst, ip_ttl=63
147+
)
148+
tu.send_packet(self, 1, pkt)
149+
tu.verify_packets(self, exp_pkt, [eg_port])
150+
151+
152+
class TunnelForwardTest(BasicTunnelTest):
153+
"""Verify that a tunneled packet is forwarded correctly when a valid table entry exists."""
154+
def runTest(self):
155+
in_pkt = (
156+
Ether(src="00:11:22:33:44:55", dst="ff:ff:ff:ff:ff:ff", type=TYPE_MYTUNNEL)
157+
/ MyTunnel(proto_id=TYPE_IPV4, dst_id=2)
158+
/ IP(src="10.0.1.1", dst="10.0.3.3", ttl=64)
159+
/ TCP(sport=12345, dport=1234)
160+
/ "tunnel-forward"
161+
)
162+
self.add_tunnel_entry(dst_id=2, port=3)
163+
tu.send_packet(self, 0, in_pkt)
164+
tu.verify_packets(self, in_pkt, [3])
165+
166+
167+
class TunnelDropOnMissTest(BasicTunnelTest):
168+
"""Verify that a tunneled packet is dropped when no matching table entry exists."""
169+
def runTest(self):
170+
in_pkt = (
171+
Ether(src="00:11:22:33:44:66", dst="ff:ff:ff:ff:ff:ff", type=TYPE_MYTUNNEL)
172+
/ MyTunnel(proto_id=TYPE_IPV4, dst_id=77)
173+
/ IP(src="10.0.1.1", dst="10.0.3.3", ttl=64)
174+
/ TCP(sport=12345, dport=1234)
175+
/ "tunnel-drop"
176+
)
177+
tu.send_packet(self, 0, in_pkt)
178+
tu.verify_no_other_packets(self)
179+
180+
181+
class TtlBoundaryTest(BasicTunnelTest):
182+
"""Verify IPv4 TTL is decremented to 0 correctly when input TTL is 1."""
183+
def runTest(self):
184+
in_dmac = "ee:30:ca:9d:1e:00"
185+
in_smac = "ee:cd:00:7e:70:00"
186+
ip_dst = "10.0.9.9"
187+
ig_port = 1
188+
eg_port = 3
189+
out_dmac = "08:00:00:00:09:99"
190+
191+
self.add_ipv4_lpm_entry(ip_dst, 32, out_dmac, eg_port)
192+
193+
pkt = tu.simple_tcp_packet(
194+
eth_src=in_smac, eth_dst=in_dmac,
195+
ip_dst=ip_dst, ip_ttl=1
196+
)
197+
exp_pkt = tu.simple_tcp_packet(
198+
eth_src=in_dmac, eth_dst=out_dmac,
199+
ip_dst=ip_dst, ip_ttl=0
200+
)
201+
tu.send_packet(self, ig_port, pkt)
202+
tu.verify_packets(self, exp_pkt, [eg_port])
203+
204+
205+
class TunnelUnknownProtoTest(BasicTunnelTest):
206+
"""Verify tunnel packet with non-IPv4 proto_id is still forwarded by dst_id."""
207+
def runTest(self):
208+
self.add_tunnel_entry(dst_id=5, port=2)
209+
210+
pkt = (
211+
Ether(src="00:11:22:33:44:55", dst="ff:ff:ff:ff:ff:ff", type=TYPE_MYTUNNEL)
212+
/ MyTunnel(proto_id=0x9999, dst_id=5)
213+
/ "unknown-proto-payload"
214+
)
215+
tu.send_packet(self, 0, pkt)
216+
tu.verify_packets(self, pkt, [2])
217+
218+
219+
class MixedTrafficTest(BasicTunnelTest):
220+
"""Verify IPv4 and tunnel traffic are handled independently correctly via separate tables."""
221+
def runTest(self):
222+
in_dmac = "ee:30:ca:9d:1e:00"
223+
in_smac = "ee:cd:00:7e:70:00"
224+
ip_dst = "10.0.2.2"
225+
out_dmac = "08:00:00:00:02:22"
226+
ipv4_port = 2
227+
tunnel_port = 3
228+
229+
# add both table entries
230+
self.add_ipv4_lpm_entry(ip_dst, 32, out_dmac, ipv4_port)
231+
self.add_tunnel_entry(dst_id=2, port=tunnel_port)
232+
233+
# test plain IPv4 which should hit ipv4_lpm table
234+
ipv4_pkt = tu.simple_tcp_packet(
235+
eth_src=in_smac, eth_dst=in_dmac,
236+
ip_dst=ip_dst, ip_ttl=64
237+
)
238+
exp_ipv4_pkt = tu.simple_tcp_packet(
239+
eth_src=in_dmac, eth_dst=out_dmac,
240+
ip_dst=ip_dst, ip_ttl=63
241+
)
242+
tu.send_packet(self, 1, ipv4_pkt)
243+
tu.verify_packets(self, exp_ipv4_pkt, [ipv4_port])
244+
245+
# test tunnel packet which should hit myTunnel_exact table
246+
tunnel_pkt = (
247+
Ether(src="00:11:22:33:44:55", dst="ff:ff:ff:ff:ff:ff", type=TYPE_MYTUNNEL)
248+
/ MyTunnel(proto_id=TYPE_IPV4, dst_id=2)
249+
/ IP(src="10.0.1.1", dst="10.0.3.3", ttl=64)
250+
/ TCP(sport=12345, dport=1234)
251+
)
252+
tu.send_packet(self, 0, tunnel_pkt)
253+
tu.verify_packets(self, tunnel_pkt, [tunnel_port])

0 commit comments

Comments
 (0)