Skip to content

test: PFCP/N4 integration test suite for the UPF - #5

Open
tariromukute wants to merge 8 commits into
developfrom
feat-integration-tests
Open

test: PFCP/N4 integration test suite for the UPF#5
tariromukute wants to merge 8 commits into
developfrom
feat-integration-tests

Conversation

@tariromukute

@tariromukute tariromukute commented Aug 20, 2026

Copy link
Copy Markdown

Adds an integration test suite that drives the UPF over N4/PFCP and asserts on what the
data plane actually did. It exists because the UPF had no test infrastructure of any kind —
no tests/ directory, no enable_testing(), and hasUnitTests: false in the Jenkinsfile —
and because a set of PFCP Session Modification and QoS defects fixed pre-refactor had
regressed without anything noticing.

No production code changes. The only file touched outside ci-scripts/tests/ is
.dockerignore. Nothing here fixes a defect; the suite makes the defects reproducible on
demand so a fix can be shown to work.

Why

Everything under src/upf_app/ is currently verified by hand or by the external eBPF
tutorial job. That makes two things impossible: proving that a fix works, and noticing when
a refactor silently drops one. This suite closes both.

What is in it

Area What it is
pfcpkit/ The machinery, UPF-agnostic: PFCP transport and codec, session models, presets, the scenario base class and registry, capability protocols, generic bpftool/tc/log inspectors, and a runnable worked example. Ships no scenarios.
upf_test/ Everything true only of this UPF: the BPF map catalogue and key layouts, HTB class-id arithmetic, its log strings, deployment settings, the harness — and all 16 scenarios.
unit/ 228 tests needing no UPF and no root.
root The standalone runner, the pytest adapter, the Compose environment and its preflight, and the config generator.
docs/ A five-page architecture diagram, a walkthrough of it, and the exported images.

How to run it

cd ci-scripts/tests
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt

sudo ./setup_env.sh                  # brings up the UPF, generates its config, preflights
./run_scenarios.py --tag smoke       # must exit 0
./run_scenarios.py --all             # exits 1: 22 failing checks, on purpose
sudo ./setup_env.sh --down           # tear down

sudo is only for the host FORWARD permit; without it that step is skipped with a warning
and everything else still works.

Restart the UPF before comparing numbers between runs. Deleted sessions leak their HTB
classes, and the class id is a hash of (SEID, QFI), so whether a later run collides with
leftovers depends on where the SEID counter reached. A dirty UPF has been observed to let
qos_rate_change pass, taking the total from 22 failing checks to 20.

What it currently reports

Scenario Observed today
map_pruning, map_pruning_on_delete removed PDRs and QFIs stay in the rule maps, and survive session deletion
qos_rate_change rate/ceil stay at 50000/100000 after an update to 20000/40000
qos_rebuild_tc_failures 6 failed tc operations while applying the new rate — PFCP response still an accept
qos_at_establishment no enforcement and no classification until the session is modified
qer_removal_prunes_class the removed QER's enforcement survives
qos_class_cleanup shaping state survives session deletion
static_ip_reattach the UE IP is still attributed to the deleted session
create_pdr_existing_far rejected MANDATORY_IE_MISSING; accepted once a Create FAR is added
rebuild_once 25 datapath rebuilds for one modification carrying 4 removal IEs
rebuild_once_update_only 2 rebuilds for a modification carrying no removals at all
repeated_modification_stability 22 kernel attach refusals across 11 modifications

Reviewing this

Start with ci-scripts/tests/docs/ARCHITECTURE.md — a five-page diagram with a written
walkthrough. Page 1 is the shape of it; page 2 traces each assertion down to the shell
command that answers it, so any verdict can be checked by hand.

Signed-off-by: Tariro Mukute <tariro.mukute@phine.tech>
Machinery for driving a UPF over N4/PFCP and asserting on the result.
It ships no scenarios and knows nothing about any particular UPF, so a
project supplies only what is specific to its own.

  pfcp/            socket, sequence correlation, retries, heartbeat
                   responder; spec dataclasses <-> scapy IEs; the
                   PfcpClient facade
  models.py        PdrSpec / FarSpec / QerSpec / SessionSpec /
                   ModificationSpec as frozen dataclasses. Lookups such
                   as spec.qer(id) raise when absent, so a stale rule id
                   is an immediate error rather than a request that
                   quietly does nothing
  presets.py       ready-made session specs
  scenarios/       PfcpScenario: a Template Method lifecycle whose
                   execute() always tears down, never raises, and skips
                   act() when a precondition fails; plus the registry
                   and discovery
  capabilities.py  the Capability enum and the RuleState / QosState /
                   LogSource protocols -- the seam that lets a scenario
                   name the question it asks rather than the storage
                   that answers it
  inspect/         generic mechanics: a CommandRunner (the only
                   subprocess use in the tree) and parsers for bpftool,
                   tc and windowed logs, with the map catalogue injected
                   by the caller
  report.py        soft checks, so one failed assertion never hides the
                   rest of a scenario

Includes 118 unit tests that pass with no UPF, no root, and no adapter
importable -- the executable form of the claim that this package is
reusable.

Fixtures are output captured from a live UPF rather than invented. That
matters most for tc: -j is honoured for `qdisc show` but silently
ignored for `class show` on iproute2 5.15, so the text parser is the
path actually used.

Assisted-by: Claude:claude-5-opus
Signed-off-by: Tariro Mukute <tariro.mukute@phine.tech>
pfcpkit/README.md covers the scenario lifecycle, the capability
protocols, how to build an adapter for a different UPF, and the
conventions worth adopting -- each of which exists because its absence
caused a real problem while this suite was built.

pfcpkit/examples/ is the same walkthrough as code: an adapter
implementing all three protocols over an in-memory dict, a project base
class narrowing the context to concrete adapter types, and three
scenarios in the styles a reader is likely to need -- PFCP-only,
state-asserting, and log-asserting.

The examples are deliberately not registered. Nothing carries @register,
so importing them adds nothing to the registry and a real run never
picks them up; unit/test_boundary.py asserts that by behaviour rather
than by inspection.

unit/library/test_examples.py drives the examples against a fake client
that behaves as a correct UPF and asserts they pass, so a library change
that breaks the documented pattern fails the build instead of leaving
the guide quietly wrong. It also compiles every Python block in the
README.

Assisted-by: Claude:claude-5-opus
Signed-off-by: Tariro Mukute <tariro.mukute@phine.tech>
Implements pfcpkit's three capability protocols for this UPF.
Everything true only of the OAI implementation lives here and nowhere
else.

  layouts.py     the BPF map catalogue and key/value struct layouts
  class_ids.py   HTB class-id arithmetic mirroring generate_minor_id()
  logs.py        this UPF's log strings over pfcpkit's windowing
  inspector.py   OaiRuleState / OaiQosState, and the CAPABILITIES set
                 the runner compares against each scenario's needs
  settings.py    Deployment: container, interfaces, bpftool path --
                 separate from pfcpkit's Settings, which holds only
                 PFCP addressing and timing
  harness.py     build_context(): the single place a context is wired

Two facts in layouts.py make an absence assertion pass for the wrong
reason when they drift, so both are pinned by unit tests against a live
dump: the kernel truncates map names to 15 characters
(session_by_ue_ip_map -> session_by_ue_i), and {u16 pdr_id; u64 seid}
is 16 bytes rather than 10.

The class id in class_ids.py is a pure hash of (SEID, QFI) with no
counter, which is why a rebuild targets the classid the previous setup
already created.

build_context() being the only wiring point is what makes the execution
split explicit: bpftool and tc run inside the container, because no BPF
map is pinned and tc is namespace-scoped, while docker logs runs on the
host.

unit/test_boundary.py enforces the rule the type system cannot express:
pfcpkit must never import upf_test, and must never carry a @register of
its own. Checked by parsing every library module rather than by
importing, so an import inside a rarely-taken branch is caught too.

Assisted-by: Claude:claude-5-opus
Signed-off-by: Tariro Mukute <tariro.mukute@phine.tech>
One registered class per file, named NN-topic.py. The number is the run
order rather than a label: discovery walks the package sorted, so
16-repeated_modification.py runs last because it can leave the UPF
degraded.

  01..03  smoke and negative: establishment, CH=1 F-TEID allocation,
          duplicate F-SEID
  04,05   rules pruned when a modification removes them, and when the
          session is deleted
  06,14   an Update QER changes the enforced rate; and the tc
          operations behind it
  07,15   what establishment alone installs, and what it merely records
  08      one QER removed while the session continues
  09      shaping state released on deletion, shared root left alone
  10      UE-IP ownership after re-establishing on a recycled address
  11      a Create PDR referencing a FAR the session already has
  12,13   how many times one modification reprograms the data plane
  16      stability across repeated modification

Each states a requirement and never names a defect, a fix number, or a
source file, so it stays correct once a defect is fixed and simply
starts passing. Every module opens with a Scenario / Expected behaviour
/ Expected output docstring, the last of which says what a passing run
prints -- so a reader can tell "the UPF is wrong" from "I am reading the
wrong scenario".

Three conventions are enforced by unit/test_scenario_conventions.py
rather than documented, because breaking any of them is silent:

  * a unique ue_index per scenario. Two scenarios sharing a UE address
    make the first one's leftovers the second one's failure, and because
    this UPF does not clear its UE-address state on deletion the symptom
    looks like a bug in the second scenario. Not hypothetical -- it cost
    real debugging time here.
  * one registered class per module, with unique and contiguous
    numbering
  * every capability a scenario reads is declared in `requires`, so a
    gap is a reported skip rather than a mid-run failure that reads like
    a UPF defect

Against a freshly restarted UPF, 12 of the 16 fail: 22 failing checks
reproducing 9 documented defects. That is the suite working as
intended; `--tag smoke` exits 0.

Assisted-by: Claude:claude-5-opus
Signed-off-by: Tariro Mukute <tariro.mukute@phine.tech>
run_scenarios.py needs only Python 3.10+ and scapy, so the suite runs in
a bare container with no pytest. It offers --list, --scenario, --tag,
--all and --junit-xml.

Exit codes distinguish outcomes CI should treat differently: 0 every
scenario passed, 1 a scenario failed, 2 the suite could not run (bad
config, port conflict, unreachable UPF), 130 interrupted.

Before running anything it compares each scenario's declared
capabilities against the adapter's, and reports every skip by name and
missing capability. A suite that silently drops what it cannot observe
looks identical to one that passed those scenarios.

conftest.py and test_scenarios.py make each discovered scenario a pytest
case as well, for -k filtering and JUnit XML, without making pytest a
dependency of the scenarios themselves. The generate-tests hook skips a
test that parametrises `scenario_cls` itself; otherwise a unit test
picking that obvious argument name collides with "duplicate
parametrization".

Assisted-by: Claude:claude-5-opus
Signed-off-by: Tariro Mukute <tariro.mukute@phine.tech>
  docker-compose.yaml       the UPF with NET_ADMIN / SYS_ADMIN /
                            SYS_RESOURCE and three networks named
                            n4/n3/n6 via Compose's interface_name
                            (needs Compose >= 2.30), a gnb-sim holding
                            the N3 gateway address so the UPF's ARP
                            resolution succeeds, and an optional
                            containerised test runner
  conf/make_test_config.py  generates the UPF config from
                            etc/config.yaml: BPF datapath and QoS on, NF
                            registration off, debug logging
  setup_env.sh              brings the stack up and fails fast with a
                            diagnosis rather than a traceback
  Dockerfile.tests          the optional runner image

The config generator remaps every interface_name under nfs.upf, not
just n3/n4/n6, because upf::validate() also validates sbi and loops over
all of them -- otherwise the UPF exits at startup probing a device that
does not exist.

Two traps the preflight handles, both of which produced misleading
output before being fixed: `set -o pipefail` with `grep -q` turns a
successful match into a failure via SIGPIPE, which had the port check
cheerfully reporting UDP 8805 free while it was occupied; and waiting on
container state rather than on the readiness banner reported success
moments before the UPF exited, blaming the wrong thing.

Assisted-by: Claude:claude-5-opus
Signed-off-by: Tariro Mukute <tariro.mukute@phine.tech>
  README.md                bring-up, teardown, running, configuration,
                           how to inspect datapath state by hand, how to
                           add a scenario, and troubleshooting for the
                           failures this environment actually produces
  architecture.drawio      five pages: the shape of the suite; how
                           observing works, with the shell command that
                           answers each assertion and its real output;
                           how to read a scenario, quoting execute() and
                           two real ones; how to extend it; and a module
                           reference
  docs/ARCHITECTURE.md     a guided read of the diagram, one section per
                           page, saying what each shows and which file
                           to open next
  docs/export-diagrams.sh  regenerates the PNGs under the filenames the
                           doc expects

Every file:line citation on the diagram was checked against the source,
and every command and sample output on it was captured from a live run
rather than composed.

unit/test_docs.py keeps the three artefacts in agreement: one embedded
image per diagram page, a section naming each page, export indices
0..n-1 in order, and the script's filenames matching the doc's table.
Adding a page without documenting it fails the build.

It deliberately does not require the PNGs to be present -- the .drawio
is the source of truth, and a fresh clone should not fail its tests for
want of a generated artefact.

Assisted-by: Claude:claude-5-opus
Signed-off-by: Tariro Mukute <tariro.mukute@phine.tech>
@tariromukute

Copy link
Copy Markdown
Author
Screenshot from 2026-08-20 18-45-02

Sample run output

@tariromukute tariromukute self-assigned this Aug 20, 2026
@openairinterface-bot

Copy link
Copy Markdown

CI Build: #11 | Passed

@sgarg00

sgarg00 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

I would like to address some points here:

  1. Images like https://github.com/openairinterface/oai-cn5g-upf/blob/4c7c2ac21f994b1f653b6aab158e054a49b0eceb/ci-scripts/tests/docs/img/04-extending.png can be a normal markdown file since these images do not show any architecture/flowchart.
  2. Why do we have two requirements file? Can we not do them into one? ci-scripts/tests/requirements.txt
    and ci-scripts/tests/requirements-dev.txt
  3. Example ci-scripts/tests/pfcpkit/pfcp/codec.py: encode_create_pdr and encode_update_pdr are identical. Better to re-use such functions.
  4. upf_test/logs.py:14-39: These six methods just forward to self._logs — same signatures, same bodies. Please check such conditions.
  5. There also seems to be some dead code like wait_for in pfcpkit/waiting.py: Please check and remove unused functions.
  6. Please remove unused functions/variables.
  7. Please try to reuse functions or repetitive values can be assigned to variables.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants