Hardware-in-the-Loop CI: Line-Rate VPP Testing over Tailscale

FastNetMon

August 28, 2026

Blue-tinted motherboard close-up with PCIe slot and components; top-right overlay reads 'Guest Post: Line-rate VPP testing over TailScale'.
Home FastNetMon Blog Hardware-in-the-Loop CI: Line-Rate VPP Testing over Tailscale
Portrait of a man with short dark hair and light stubble, wearing a dark gray T-shirt, facing the camera (circular crop).

This post is a repost of a technical blog originally published by Denys Haryachyy, shared here with permission as part of ongoing research and engineering work around FastNetMon’s inline traffic processing capabilities.

TL;DR. Testing a data plane at 100GbE line rate needs real NICs running VPP — which no cloud CI runner has. So an ephemeral GitHub Actions runner joins a private two-machine lab over Tailscale, deploys its code to a scratch tree, pulls a fresh base image, brings up a VPP device under test (auto-recovering a wedged NIC via IPMI), drives a TRex generator through a profile sweep, and gates the merge on per-profile line-rate floors — verified on both sides of the wire. Jobs queue on the single lab, and every run tears itself down.

Unit tests don’t tell you whether your VPP forwarding plane still does 100GbE line rate after a refactor. For that you need real hardware in the loop — reached from CI, benchmarked on every push, and cleaned up after.

Cloud runner driving a private line-rate lab over Tailscale.
Figure 1: A cloud runner reaches a private two-machine lab over Tailscale, drives the TRex generator and the VPP DUT, and recovers a wedged NIC out-of-band via IPMI.

The Problem

A cloud runner is a VM with a virtual NIC. It can’t run VPP on a physical 100GbE adapter, generate ~142 Mpps, or measure what a real DUT forwards. That hardware lives in a private lab with no public address — so the question is how a cloud runner safely drives hardware it can’t see.

One Lab, One Job at a Time

A physical lab serves one test at a time — two jobs can’t share one NIC pair. A GitHub Actions concurrency group with cancel-in-progress: false serializes them: a second push waits its turn instead of colliding on the wire, and is never dropped.

CI jobs queueing on a single shared hardware lab via a concurrency group.
Figure 2: The lab is a single shared resource, so jobs queue through a concurrency group rather than fighting over the NIC.

The Run, End to End

Every run follows the same arc — and every stage cleans up after itself:

CI run lifecycle stages.
Figure 3: Join the tailnet, deploy, guarded bring-up, bench, assert floors, teardown.

Tailscale Auth: Keys and ACLs

The lab has no public IP. The runner joins its Tailscale mesh per job, and the control is all in the auth:

  • Ephemeral, tagged, single-use key (a CI secret): the node is pre-authorized, tagged tag:ci, and removed automatically when the job ends.
  • An ACL that scopes it: tag:ci may reach tag:lab on SSH and nothing else — a leaked key still can’t roam.
  • No inbound exposure: the runner dials out; the lab stays dark to the public internet.
Tailscale auth-key and ACL control for the CI runner.
Figure 4: A tagged, single-use key admits the runner; an ACL confines it to SSH on the lab; the node expires with the job.
"acls": [  // the ephemeral CI node may only SSH the lab machines  { "action": "accept", "src": ["tag:ci"], "dst": ["tag:lab:22"] }]

Isolation

A shared lab is also a development lab, so CI must touch nothing it didn’t bring:

  • Scratch checkout: the runner rsyncs to a dedicated scratch tree, not the dev checkout, and the container mounts that — so it runs exactly the code under test.
  • Fresh base image every run, pulled from the registry — never a stale local cache (the bug that once slipped a debug build into a perf test).

Guarded Bring-Up

Bringing a 100GbE NIC up under VPP can hard-lock the host. A watcher tells a transient miss from a real wedge; on a wedge it power-cycles the box over IPMI — out of band, independent of the hung OS — and retries, instead of hanging the job.

Guarded bring-up flowchart with IPMI wedge recovery.
Figure 5: A wedged NIC is power-cycled out-of-band via IPMI, not waited out.

Verifying Line Rate

Line rate is verified on both sides of the wire, not assumed:

  • Generator: tx_util = 100 % means no idle bit-times — it really is offering line rate (~142 Mpps at 64 B).
  • DUT: per-worker vectors ÷ time = Mpps processed; rx_missed ≈ 0 proves the NIC kept up.
  • Gate: each profile’s floor sits a few percent below its ceiling — catches regressions without tripping on jitter.
Line rate verified on both the generator and the DUT.
Figure 6: Read line rate off both ends — tx_util and the DUT rate with rx_missed ≈ 0 — against a floor.
# generator (TRex)              # DUT data plane, over the sample windowtx_pps   142.0 Mpps            show runtime   -> sum(Vectors)/seconds = Mppstx_util  100.0 %               rx_missed_errors  0   # nothing dropped before software

Self-Cleaning Teardown

On success, failure, or cancel, the run stops the containers gracefully (a hard kill is what wedges the firmware), then removes the containers, every lab image, and the scratch checkout from both hosts. The lab is left exactly as CI found it — powered on, idle, holding none of CI’s code or images.

The Workflow

name: hw-line-rateon: { push: { branches: ['**'] } }concurrency:                       # one job on the lab at a time  group: hw-lab  cancel-in-progress: falsejobs:  line-rate:    runs-on: ubuntu-latest         # an ordinary cloud runner    steps:      - uses: actions/checkout@v4      - uses: tailscale/github-action@v3        with: { authkey: ${{ secrets.TS_AUTHKEY }} }   # ephemeral, tag:ci      - name: Deploy + base + guarded bring-up        run: |          for h in "$GEN_HOST" "$DUT_HOST"; do rsync -az --delete ./ "ci@$h:~/ci-scratch/"; done          ssh ci@$DUT_HOST 'ENSURE_BASE_FORCE=1 ./labs/hw/ensure-base-image.sh'          ./labs/hw/bringup-guarded.sh --watch-secs 600      - name: Bench + assert line-rate floors        run: ./labs/hw/bench.sh && ./labs/hw/assert-floors.sh      - name: Tear down — leave no trace        if: always()        run: ./labs/hw/teardown.sh

Summary

  1. Real line-rate testing needs real hardware; a cloud runner orchestrates it, doesn’t host it.
  2. Tailscale admits the ephemeral runner with a tagged single-use key + an ACL — no public exposure.
  3. Serialize jobs on the single lab; isolate CI’s code; pull a fresh base every run.
  4. Guard the NIC bring-up; recover wedges out-of-band via IPMI.
  5. Verify line rate on both sides, gate on floors, and tear everything down.

References