prpl
testbed-qemu · prplOS CI developer session

prplOS in QEMU, in CI and on your desk

CI capacity
Too little CI capacity for runtime tests
One beefy server runs 56 QEMU DUTs, so 56 CI jobs run in parallel
From testing platform to development platform
The test lab is hard to rebuild at your desk, or for your agent
The same environment in CI and on your desk
ec11a42 · 2026-09-23
The idea

One stack, two places it runs

GitLab CI job
differs
you · or your agent + makeclaudecodexagy
cram tests
same
cram tests
testbed-qemu CLI wait · ssh · cram
testbed-qemu CLI wait · ssh · cram
testbed-qemu container image
testbed-qemu container image
QEMU + prplOS firmware image
QEMU + prplOS firmware image
56 DUTs /dev/kvm
differs
1 DUT /dev/kvm
same entry point
The idea · detail

Same entry point

CI · .gitlab/testbed/qemu-x86-64.yml
image: …/testbed-qemu@sha256:…
script:
  - testbed-qemu run
      --image "$TESTBED_QEMU_IMAGE"
      --artifacts artifacts
      --accel kvm
      --debug-information always
      -- testbed-qemu cram $CRAM_TEST_SUITE
Desk · make test-qemu
docker compose run testbed-qemu \
  testbed-qemu run \
    --image /firmware/prplos.img \
    --artifacts /artifacts \
    --accel auto \
    -- testbed-qemu cram $(TESTS)

Both resolve the test list with resolve-component-tests.py from the prplOS tree.

How it works

A router and its test host in one container

Linux host Docker Compose or rootless Podman /dev/kvm container · testbed-qemu test host cram · ssh · console prplOS DUT QEMU/KVM, x86-64 private copy of the disk br-lan 192.168.1.2 LAN 192.168.1.1 LAN br-wan 10.0.0.1 DHCP server eth1 DHCP client WAN 127.0.0.1:4321 ttyS0 serial prplOS tree firmware .img artifacts dir /workspace/prplos read-write /firmware/prplos.img read-only /artifacts logs, results
capabilities
How it works · detail

What the container gets

gets
NET_ADMIN NET_RAW /dev/net/tun /dev/kvm

For the private LAN and WAN, and for KVM when the host has it.

never gets
--privileged host network new privileges

The firmware image is mounted read-only. Batch runs start factory-fresh.

Quick start

Three commands to a DUT shell

1
on the host
git clone https://gitlab.com/prpl-foundation/\
  prplos/ci/containers/testbed-qemu.git
cd testbed-qemu
make fetch test-qemu-shell
2
inside the shell
testbed-qemu wait && testbed-qemu ssh
3
back on the host
make test-qemu TESTS=connectivity

Needs Docker Compose, /dev/kvm, git and make. README

shell
Quick start · detail

A shell next to the DUT

testbed-qemu wait   # until SSH answers
$
testbed-qemu ssh    # root@192.168.1.1
~
testbed-qemu console # ttyS0, Ctrl-] exits

The prompt comes back at once. The DUT boots in the background.

in CI
Quick start · detail

The same run, in CI

Verbatim excerpt, lines 55–74; the long command line is cut at …   Verify: job 16677383915 ↗

Make targets

Four targets, two variables

Docker Compose
rootless Podman
batch
make test-qemu
make test-qemu-podman
shell
make test-qemu-shell
make test-qemu-shell-podman
PRPLOS_DIR  your prplOS checkout
PRPLOS_IMAGE  the firmware .img
Your first test

Write it, break it, run the smoke bucket

1 · write

# generic/hello-world.t
Hello world smoke test:
  $ echo hello world
  hello world
  $ alias R="${CRAM_REMOTE_COMMAND:-}"
  $ R "uname -m"
  x86_64
$ testbed-qemu cram hello-world
passed

2 · break

$ sed -i 's/^  hello world$/  hello wrld/' \
    …/generic/hello-world.t
$ testbed-qemu cram hello-world
   $ echo hello world
-  hello wrld
+  hello world
# Ran 1 tests, 0 skipped, 1 failed.

3 · run smoke

$ make test-qemu \
    TEST_COMPONENT=smoke
cram: running 6 tests
generic/busybox.t: passed
generic/network.t: passed
  …
qemu-x86-64/reboot.t: passed
# Ran 6 tests, 0 skipped, 0 failed.

One test as a batch run: make test-qemu TESTS=hello-world

buckets
Your first test · detail

Buckets, from components.yml

full: every test for the board
sanityfirst in every bucket
lcminclude list
prplmeshinclude list
prplosthe rest
smoke6 named tests
post/runs after every bucket
hw_onlyskipped on QEMU

Unions work: TEST_COMPONENT=smoke,prplmesh. A new test under generic/ lands in prplos and full.

Red QEMU job in your MR

Reproduce it at your desk

Check out the pipeline's commit
Fetch the .img from its build job
Run only that testmake test-qemu TESTS=<test>
Rerun in the shell, then ssh and consoletestbed-qemu cram <test>
Fix, rerun, push
Your agent runs the tests for you.
AGENTS.md + skills/testbed-qemu/SKILL.md
claudecodexagy
> run connectivity test in qemu and provide me result
artifacts
Red QEMU job · detail

Artifacts and exit codes

run.logrun timeline
serial.logfull DUT console
cram.txt · cram.xmlresults, xUnit
*.t.erractual output
command.logpayload output
manifest.jsoninputs and result
debug-information-*.txtDUT diagnostics
coredumps-*.tar.gzwhen present
rcpayload status
1runtime failure
2bad argument or config
124readiness timeout
130Ctrl-C

On demand in the shell: testbed-qemu debug-information

Where it stands

The merge request chain

snapshot 2026-09-23
CI image container and tools LCM transfer limits, retries STP LAN bridge STP off testbed-qemu !7 librlyeh !23 rlyeh !11 feed_lcm !102 tr181-bridging !48 feed-prplos !1067 prplos !3199 QEMU cram jobs
draft open merged · click a pill to open the MR
Scope

What it is, what it isn't, what's next

It is

  • The real prplOS x86-64 image
  • Full userspace: datamodel, USP, LCM, firewall, DHCP, tunnels
  • A fresh LAN and WAN on every run
  • Fast
  • The same container as CI
  • Runs on any Linux laptop
  • Wi-Fi through mac80211_hwsim is there, but untested

It isn't

  • Switch, SoC acceleration, cellular or xPON hardware (the hw_only tests)
  • A replacement for the hardware boards
  • A performance testbed

Next

  • Wi-Fi tests on hwsim
  • Make the QEMU jobs blocking
  • More QEMU coverage (CI-84)
  • Other targets and architectures (armv8/arm64)
  • Firmware upgrade testing
  • Longevity, soak and stress testing