Campbell He (duskmoon) 2024-06
Recently, I've been working on a research project that requires interaction with bmv2 and, eventually, Tofino switches. For this purpose, I chose P4Runtime as the control plane interface. P4Runtime currently supports three languages: C++, Go, and Python. Although these languages can be used to implement the control plane, I prefer Rust due to my personal preference and some negative experiences with the languages mentioned above. Therefore, I began exploring ways to generate Rust code for P4Runtime.
I might save time on my research project using Python or Go. But I'm not going to give up on Rust that easily. :P
When searching for "grpc" on crates.io, I found 165 results. Among them, tonic is the most popular and is maintained by hyperium, making it a reliable choice for a first try.
Tonic is a gRPC over HTTP/2 implementation that fits my requirements. It builds on prost and leverages tonic-build to generate Rust code from .proto
files. There are two ways to use it:
- Integrate
tonic-build
into the build script of the Rust project. - Use
tonic-build
to generate Rust code and include it in the project.
Since most usage examples follow the first method, I decided to try it and found a similar approach in another-s347/rusty-p4-proto. However, the generated code was not viewable as it was generated in a $OUT_DIR
. I prefer the generated code in the project directory for easy viewing and modification. Therefore, I decided to use the second method, which led me to duskmoon314/p4runtime-rs.
I built a simple xtask binary to generate Rust code into the src
directory in this archived attempt. The generated code is viewable, and utility functions can be added alongside the generated code. But then I asked myself, "Can I contribute the generated code back to the P4Runtime project?"
To contribute the generated code back, I need to understand the architecture of the P4Runtime repo. There are many directories in the repo:
p4runtime
├── bazel/ # Bazel build files
├── CI/ # CI scripts
├── codegen/ # scripts to generated code (Highly relevant)
├── CONTRIBUTING.md
├── docs/ # Documentation
├── go/ # Generated Go code
├── go.mod
├── go.sum
├── LICENSE
├── proto/ # P4Runtime proto files (Highly relevant)
├── py/ # Generated Python code
├── README.md
└── tools/ # Tools for P4Runtime documentation
After reading the README.md
and scripts in the codegen
directory, I found that I'm better off using protoc
to generate code like other languages. So, I searched and found neoeinstein/protoc-gen-prost
. This repo provides several protoc plugins for generating Rust code and supports tonic. I decided to give it a try.
Before adding Rust support, we need to understand how other languages are generated. The entry point is codegen/update.sh
. This script generates code in three steps:
- Prepare the docker image.
- Run
codegen/compile_protos.sh
in the container. - Copy generated code to the corresponding directory:
go/
,py/
, etc.
The compile_protos.sh
script is the key. It uses protoc
to generate code:
set -o xtrace
$PROTOC $PROTOS --cpp_out "$BUILD_DIR/cpp_out" $PROTOFLAGS
$PROTOC $PROTOS --grpc_out "$BUILD_DIR/grpc_out" --plugin=protoc-gen-grpc="$GRPC_CPP_PLUGIN" $PROTOFLAGS
$PROTOC $PROTOS --python_out "$BUILD_DIR/py_out" $PROTOFLAGS --grpc_out "$BUILD_DIR/py_out" --plugin=protoc-gen-grpc="$GRPC_PY_PLUGIN"
$PROTOC $PROTOS --go_out="$BUILD_DIR/go_out" --go-grpc_out="$BUILD_DIR/go_out" $PROTOFLAGS
Thus, I need to add a similar line for Rust.
After reading the docs of protoc-gen-prost
and its sibling plugins, I came up with the following lines:
$PROTOC $PROTOS $PROTOFLAGS \
--prost_out="$BUILD_DIR/rust_out/src" \
--prost_opt=compile_well_known_types \
--prost_opt=extern_path=.google.protobuf=::pbjson_types \
--prost-serde_out="$BUILD_DIR/rust_out/src" \
--tonic_out="$BUILD_DIR/rust_out/src" \
--tonic_opt=compile_well_known_types \
--tonic_opt=extern_path=.google.protobuf=::pbjson_types \
--prost-crate_out="$BUILD_DIR/rust_out" \
--prost-crate_opt="gen_crate=rust/Cargo.toml"
Here, I will try my best to explain what the above lines do:
--prost_out="$BUILD_DIR/rust_out/src"
: Useprotoc-gen-prost
to generate protobuf definitions based onprost
crate and put them in therust_out/src
directory.--prost_opt=compile_well_known_types
: Compile well-known types:google.protobuf.Any
--prost_opt=extern_path=.google.protobuf=::pbjson_types
: Usepbjson_types
crate for well-known types.- The above two options use
pbjson_types::Any
instead of a generatedgoogle.protobuf.Any
. This may be useful for compatibility with other crates.
- The above two options use
--prost-serde_out="$BUILD_DIR/rust_out/src"
: Useprotoc-gen-prost-serde
to generateserde
serialization/deserialization code.- This is useful for converting protobuf messages to/from JSON and other formats.
- For example, in P4Runtime, we can convert
P4Info
from the p4c generated JSON to a Rust struct.
--tonic_out="$BUILD_DIR/rust_out/src"
: Useprotoc-gen-tonic
to generate gRPC code (client and server) based ontonic
crate.--tonic_opt=compile_well_known_types
: Compile well-known types:google.protobuf.Any
--tonic_opt=extern_path=.google.protobuf=::pbjson_types
: Usepbjson_types
crate for well-known types.- As for
prost
, this is for compatibility with other crates.
- As for
--prost-crate_out="$BUILD_DIR/rust_out"
: Useprotoc-gen-prost-crate
to generate utility things for a crate.--prost-crate_opt="gen_crate=rust/Cargo.toml"
: UseCargo.toml
in therust
directory to generate the crate.- The above two options use the preconfigured
Cargo.toml
to generatefeatures
andlib.rs
for the crate.
- The above two options use the preconfigured
After adding the above lines and other necessary changes, I generated Rust code like this:
p4runtime/rust
├── Cargo.lock
├── Cargo.toml
├── LICENSE -> ../LICENSE
├── README.md -> ../README.md
└── src
├── google.rpc.rs # Generated by protoc-gen-prost, contains google.rpc.Status, google.rpc.Code, etc.
├── google.rpc.serde.rs # Generated by protoc-gen-prost-serde
├── lib.rs # The entry point of the crate
├── p4.config.v1.rs # Generated by protoc-gen-prost, contains p4.config.v1.P4Info, etc.
├── p4.config.v1.serde.rs # Generated by protoc-gen-prost-serde
├── p4.v1.rs # Generated by protoc-gen-prost, contains p4.v1.WriteRequest, p4.v1.Update, etc.
├── p4.v1.serde.rs # Generated by protoc-gen-prost-serde
└── p4.v1.tonic.rs # Generated by protoc-gen-tonic, contains P4RuntimeClient, etc.
I don't know if the above lines are the best way to generate Rust code, but they work and suit my needs. If you have any suggestions, please feel free to let me know. :)
The above-generated code is very simple and not very convenient to use. To make it more user-friendly, I followed antoninbas/p4runtime-go-client and built a wrapper crate: duskmoon314/p4runtime-client-rs.
In this crate, I re-export the generated code as p4runtime
and provide a Client
struct with useful methods. For Example:
//Example taken from https://github.com/duskmoon314/p4runtime-client-rs/blob/main/examples/basic/src/main.rs
use p4runtime_client::p4runtime::p4::v1 as p4_v1;
use p4runtime_client::p4runtime::p4::config::v1 as p4_config_v1;
// Create a client
let mut p4rt_client = p4_v1::p4_runtime_client::P4RuntimeClient::connect("http://127.0.0.1:9559").await?;
let mut client = p4runtime_client::client::Client::new(
p4rt_client, // The generated tonic client
0, // The device id
p4_v1::Uint128 { high: 0, low: 1 }, // The election id
None, // The role
p4runtime_client::client::ClientOptions {
stream_channel_buffer_size: 1024,
..Default::default()
}
);
// Start master arbitration
client.run().await?;
// Load P4Info
let p4bin = include_bytes!(...);
let p4info = include_bytes!(...);
let p4info = p4_config_v1::P4Info::decode(&p4info[..])?;
client.p4info_mut().load(p4info);
// Set forwarding pipeline config
client.set_forwarding_pipeline_config(p4bin.to_vec()).await?;
// Create and insert a table entry
let table_entry = client.table().new_entry(
"ipv4_lpm",
vec![(
"hdr.ipv4.dst".to_string(),
p4_v1::field_match::FieldMatchType::Lpm(p4_v1::field_match::Lpm {
value: vec![10, 0, 1, 2],
prefix_len: 32,
}),
)],
Some(client.table().new_action("ipv4_forward", vec![vec![8, 0, 0, 0, 1, 2], vec![1]])),
0, // The priority
);
client.table_mut().insert_entry(table_entry).await?;
Currently, only table, counter and digest are supported. I plan to add more features in the future.
In this note, I shared my experience adding Rust support for P4Runtime. I hope this note can help the WG members understand what is in the p4lang/p4runtime's PR#483. I may forget to include some details, so please ask me if you have any questions. :)