diff --git a/pythonbpf/maps/__init__.py b/pythonbpf/maps/__init__.py index eb2007da..fb64f8de 100644 --- a/pythonbpf/maps/__init__.py +++ b/pythonbpf/maps/__init__.py @@ -1,5 +1,12 @@ -from .maps import HashMap, PerfEventArray, RingBuffer +from .maps import ArrayMap, HashMap, PerfEventArray, RingBuffer from .maps_pass import maps_proc from .map_types import BPFMapType -__all__ = ["HashMap", "PerfEventArray", "maps_proc", "RingBuffer", "BPFMapType"] +__all__ = [ + "ArrayMap", + "HashMap", + "PerfEventArray", + "maps_proc", + "RingBuffer", + "BPFMapType", +] diff --git a/pythonbpf/maps/maps.py b/pythonbpf/maps/maps.py index 583e9570..12cd3a48 100644 --- a/pythonbpf/maps/maps.py +++ b/pythonbpf/maps/maps.py @@ -26,6 +26,26 @@ def update(self, key, value, flags=None): raise KeyError(f"Key {key} not found in map") +class ArrayMap: + def __init__(self, key, value, max_entries): + self.key = key + self.value = value + self.max_entries = max_entries + self.entries = {} + + def lookup(self, key): + return self.entries.get(key) + + def update(self, key, value, flags=None): + self.entries[key] = value + + def delete(self, key): + if key in self.entries: + del self.entries[key] + else: + raise KeyError(f"Key {key} not found in map") + + class PerfEventArray: def __init__(self, key_size, value_size): self.key_type = key_size diff --git a/pythonbpf/maps/maps_pass.py b/pythonbpf/maps/maps_pass.py index ca078454..91aa35c3 100644 --- a/pythonbpf/maps/maps_pass.py +++ b/pythonbpf/maps/maps_pass.py @@ -138,6 +138,14 @@ def process_hash_map(map_name, rval, compilation_context): return map_global +@MapProcessorRegistry.register("ArrayMap") +def process_array_map(map_name, rval, compilation_context): + """Document the planned BPF_ARRAY map support with an explicit failure.""" + raise NotImplementedError( + "ArrayMap is not implemented yet; add BPF_MAP_TYPE_ARRAY metadata support" + ) + + @MapProcessorRegistry.register("PerfEventArray") def process_perf_event_map(map_name, rval, compilation_context): """Process a BPF_PERF_EVENT_ARRAY map declaration""" diff --git a/tests/README.md b/tests/README.md index 6b63fd45..50f7dd48 100644 --- a/tests/README.md +++ b/tests/README.md @@ -88,6 +88,52 @@ All xfails use `strict = True`: if a test starts **passing** it shows up as **XP 2. Run `make test` — the file is discovered and tested automatically at all levels. 3. If the test is expected to fail, add it to `tests/test_config.toml` instead of `passing_tests/`. +## Kernel selftest equivalents + +`tests/kernel_selftest_equivalent/` contains PythonBPF versions of important +kernel BPF selftests from `bpf-next/tools/testing/selftests/bpf`. Each file names +its upstream original in a header comment. + +The directory holds two kinds of test, and both are useful: + +- **Ports that pass.** A program PythonBPF can already express. These widen the + range of program types under test — `raw_tp`, `perf_event`, + `tracepoint/sched/*` and others that nothing else exercises. +- **Roadmap tests that fail.** A program describing a feature PythonBPF should + grow next. These must be listed as **strict** expected failures in + `tests/test_config.toml` until the feature lands, at which point they turn up + as XPASS and should be promoted. + +### What a passing port proves — and does not + +A kernel selftest is two halves: the BPF program under `progs/`, and a userspace +driver under `prog_tests/` that loads it through a skeleton, triggers it and +asserts on the result. **Only the BPF half is ported**, because this framework +compiles and verifies programs but never runs them. + +So a passing test here says PythonBPF emits a loadable, verifiable object for +that program type and feature mix. It does not say the program behaves the way +the kernel's version does. Treat it as a compiler assertion, not a semantic one. + +### `WORKAROUND(globals)` + +The selftest corpus overwhelmingly reports results through global variables: the +program writes a global and the driver reads it back. PythonBPF has no global +variable support, so each becomes a one-entry `HashMap` keyed by index, tagged in +a comment naming the variable it replaces: + +```bash +grep -rn "WORKAROUND(globals)" tests/kernel_selftest_equivalent/ +``` + +This is deliberate scaffolding, not the intended shape — the tag exists so the +sweep is mechanical once real globals land. It is not a cosmetic substitution +either: it changes what a future userspace driver would read. + +Anything importing from `vmlinux` belongs in `vmlinux/`, which is registered in +`VMLINUX_TEST_DIRS_PASSING` so it is skipped rather than failed where no +`vmlinux.py` has been generated. + ## Directory structure ``` @@ -104,5 +150,6 @@ tests/ │ ├── compiler.py ← wrappers around compile_to_ir() + _run_llc() │ └── verifier.py ← bpftool subprocess wrapper ├── passing_tests/ ← programs that should compile and verify cleanly -└── failing_tests/ ← programs with known issues (declared in test_config.toml) +├── failing_tests/ ← programs with known issues (declared in test_config.toml) +└── kernel_selftest_equivalent/ ← ports of kernel selftests + feature roadmap tests ``` diff --git a/tests/conftest.py b/tests/conftest.py index 42ab30ed..bcea4d33 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ """ import logging +import warnings import pytest @@ -25,11 +26,15 @@ # ── vmlinux availability ──────────────────────────────────────────────────── try: - import vmlinux # noqa: F401 + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import vmlinux # noqa: F401 VMLINUX_AVAILABLE = True -except ImportError: + VMLINUX_SKIP_REASON = "" +except Exception as exc: VMLINUX_AVAILABLE = False + VMLINUX_SKIP_REASON = f"vmlinux.py not usable for current kernel: {exc}" # ── pytest_generate_tests: parametrize on bpf_test_file ─────────────────── @@ -65,7 +70,10 @@ def pytest_collection_modifyitems(items): # vmlinux skip if case.needs_vmlinux and not VMLINUX_AVAILABLE: item.add_marker( - pytest.mark.skip(reason="vmlinux.py not available for current kernel") + pytest.mark.skip( + reason=VMLINUX_SKIP_REASON + or "vmlinux.py not available for current kernel" + ) ) continue diff --git a/tests/framework/collector.py b/tests/framework/collector.py index bdafc149..59b96084 100644 --- a/tests/framework/collector.py +++ b/tests/framework/collector.py @@ -7,7 +7,10 @@ TESTS_DIR = Path(__file__).parent.parent CONFIG_FILE = TESTS_DIR / "test_config.toml" -VMLINUX_TEST_DIRS_PASSING = {"passing_tests/vmlinux"} +VMLINUX_TEST_DIRS_PASSING = { + "passing_tests/vmlinux", + "kernel_selftest_equivalent/vmlinux", +} VMLINUX_TEST_DIRS_FAILING = { "failing_tests/vmlinux", "failing_tests/xdp", @@ -33,7 +36,7 @@ def collect_all_test_files() -> list[BpfTestCase]: xfail_map: dict = config.get("xfail", {}) cases = [] - for subdir in ("passing_tests", "failing_tests"): + for subdir in ("passing_tests", "failing_tests", "kernel_selftest_equivalent"): for py_file in sorted((TESTS_DIR / subdir).rglob("*.py")): if py_file.name == "vmlinux.py": # Not a test case: the per-directory symlink to the master diff --git a/tests/kernel_selftest_equivalent/PORTING-NOTES.md b/tests/kernel_selftest_equivalent/PORTING-NOTES.md new file mode 100644 index 00000000..645d3d22 --- /dev/null +++ b/tests/kernel_selftest_equivalent/PORTING-NOTES.md @@ -0,0 +1,111 @@ +# Porting kernel selftests: what the first spike found + +Four programs from `tools/testing/selftests/bpf/progs/` were ported as an experiment, +to answer two questions before anyone commits to doing this at scale: + +1. Is LLM-assisted porting of kernel selftests viable? +2. What must real global-variable support actually handle? + +Short answers: **viable, with a caveat about what a passing port proves**; and +**four distinct global shapes showed up in four programs**, which is the more +actionable finding. + +## The spike + +| Port | Upstream | Section | Outcome | +|---|---|---|---| +| `tracing/tracepoint_sched_switch.py` | `test_tracepoint.c` | `tracepoint/sched/sched_switch` | passes | +| `tracing/get_cgroup_id.py` | `get_cgroup_id_kern.c` | `tracepoint/syscalls/sys_enter_nanosleep` | passes | +| `tracing/autoattach.py` | `test_autoattach.c` | `raw_tp/sys_enter`, `raw_tp/sys_exit` | passes | +| `vmlinux/perf_skip.py` | `test_perf_skip.c` | `perf_event` | strict xfail — nested ctx access | + +## 1. Is it viable? + +**Yes, for programs inside the envelope — three of four compiled and passed `llc` on the +first attempt.** The mechanical part of a port (decorators, ctypes annotations, map +declarations, helper names) is regular enough to be reliable. + +The failure was not a translation error. `perf_skip` needs `ctx.regs.ip`, which PythonBPF +genuinely cannot express, and no amount of care in the port changes that. That is the +useful kind of failure: it converts into a roadmap test that documents the gap. + +Two caveats that matter more than the pass rate: + +**A passing port proves less than the test it came from.** A kernel selftest is two +halves — the BPF program, and a `prog_tests/` driver that loads it through a skeleton, +triggers it, and asserts on the result. Only the BPF half is portable here, because this +framework compiles and verifies but never runs. Everything ported becomes a compiler +assertion: *PythonBPF emits a loadable, verifiable object for this program type and +feature mix*. That is worth having — it is how the `raw_tp` and `perf_event` program types +came under test at all — but it is not what "we ported the kernel's selftests" sounds +like. Closing that gap needs a runtime test tier, which is a much larger piece of work. + +**Selection is the expensive step, not translation.** Of 820 real programs, 28 are +portable today. Picking those out required scoring the whole corpus against the compiler's +actual envelope; guessing from filenames does not work. The classifier that did it is +worth keeping around and re-running after each feature lands. + +**Recommendation: viable and worth continuing, in small increments tied to features.** +Port a handful, let them reveal the next gap, fix the gap, port more. Bulk porting ahead of +the features would just produce a large pile of xfails. + +## 2. What real globals must support + +Every port that touches a global currently substitutes a one-entry `HashMap`, tagged +`WORKAROUND(globals)`. Four programs produced four distinct shapes: + +| Shape | Example | What globals must support | +|---|---|---| +| none | `tracepoint_sched_switch` | — (control case) | +| scalar in + scalar out | `get_cgroup_id` | read a global, write a different one | +| flags across programs | `autoattach` | two programs in one object sharing global state | +| scalar in, compared against ctx | `perf_skip` | read-only input set by userspace before attach | +| array + cursor *(next increment)* | `cgroup_preorder` | indexed writes and read-modify-write on a global | + +The last row is not in this spike but is the recommended next port precisely because it is +the most demanding shape: `result[idx++] = N` needs an array global *and* a read-modify-write +cursor, which together constrain the design more than anything here does. + +### A design note worth acting on + +**libbpf implements global variables as single-element `BPF_MAP_TYPE_ARRAY` maps.** +`.bss`, `.data` and `.rodata` become internal array maps at load time. Two consequences: + +- A one-element **`ArrayMap`** is the structurally faithful stand-in for a global, not a + `HashMap`. `HashMap` is used here only because `ArrayMap` is still a placeholder that + raises `NotImplementedError`. Landing `ArrayMap` first would make the eventual migration + to real globals close to mechanical. +- **Most of the ELF work is already done.** `@bpfglobal` is vestigial — a metadata carrier + for `LICENSE` — but the machinery behind it already emits globals that LLVM places into + `.bss` and `.data` correctly, and that libbpf already recognises: + + ``` + libbpf: map 'g.bss' (global data): at sec_idx 5, offset 0, flags 0. + libbpf: map 'g.data' (global data): at sec_idx 6, offset 0, flags 0. + ``` + + What is missing is narrower than "implement global variables": name resolution in + `expr_pass.get_operand_value` (which resolves against `local_sym_tab`, then vmlinux + enums, then gives up), a Python-level surface for declaring one, and userspace access + through `pylibbpf`. + +## 3. Incidental findings + +- **Nested struct field access fails with a misleading error.** `ctx.regs.ip` reports + `SyntaxError: Undefined variable actual` — naming the assignment target rather than the + nested access that caused it. `_allocate_for_attribute` declines to allocate when the + attribute's base is not a plain `Name`, logging at debug level, and the expression pass + then trips over the missing symbol. The diagnostic should name the real cause. +- **One level of nested-context access already works.** `ctx.sample_period` on + `struct_bpf_perf_event_data` compiles and `llc`s cleanly, so `perf_event` contexts are + usable today for anything that does not need `regs`. +- **`@section` really does accept anything.** `tc`, `socket`, `fentry/…`, `lsm/…`, + `cgroup_skb/egress`, `netfilter` and `tp_btf/…` all compile and land in the ELF verbatim. + Program type is not a constraint; the context type is. + +## Re-running the corpus scoring + +The audit behind this spike scored all 976 programs against the compiler's envelope. It is +worth re-running after each feature lands, to see what the change unlocked. The blocker +histogram at the time of writing, over 820 real programs: globals 52%, verifier-test +annotations 27%, typed program macros 26%, kfuncs 20%, inline asm 16%, loops 12%. diff --git a/tests/kernel_selftest_equivalent/maps/array_map_lookup_update.py b/tests/kernel_selftest_equivalent/maps/array_map_lookup_update.py new file mode 100644 index 00000000..f84c2d3b --- /dev/null +++ b/tests/kernel_selftest_equivalent/maps/array_map_lookup_update.py @@ -0,0 +1,35 @@ +# Adapted from bpf-next/tools/testing/selftests/bpf/progs/test_map_ops.c +# and bpf-next/tools/testing/selftests/bpf/progs/bpf_iter_bpf_array_map.c. + +from ctypes import c_int32, c_uint64, c_void_p + +from pythonbpf import bpf, bpfglobal, compile, map, section +from pythonbpf.maps import ArrayMap + + +@bpf +@map +def counters() -> ArrayMap: + return ArrayMap(key=c_int32, value=c_uint64, max_entries=8) + + +@bpf +@section("tracepoint/syscalls/sys_enter_getpid") +def array_map_lookup_update(ctx: c_void_p) -> c_int32: + counters.update(0, 1) + + current = counters.lookup(0) + if current: + next_value = current + 1 + counters.update(0, next_value) + + return c_int32(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/kernel_selftest_equivalent/ringbuf/reserve_submit_discard.py b/tests/kernel_selftest_equivalent/ringbuf/reserve_submit_discard.py new file mode 100644 index 00000000..0c3b1314 --- /dev/null +++ b/tests/kernel_selftest_equivalent/ringbuf/reserve_submit_discard.py @@ -0,0 +1,48 @@ +# Adapted from bpf-next/tools/testing/selftests/bpf/progs/test_ringbuf.c. + +from ctypes import c_int32, c_uint64, c_void_p + +from pythonbpf import bpf, bpfglobal, compile, map, section, struct +from pythonbpf.helper import pid +from pythonbpf.maps import RingBuffer + + +@bpf +@struct +class sample_t: + pid: c_uint64 + seq: c_uint64 + value: c_uint64 + + +@bpf +@map +def events() -> RingBuffer: + return RingBuffer(max_entries=4096) + + +@bpf +@section("tracepoint/syscalls/sys_enter_getpid") +def ringbuf_reserve_submit_discard(ctx: c_void_p) -> c_int32: + first = events.reserve(24) + if first: + sample = sample_t(first) + sample.pid = pid() + sample.seq = 0 + sample.value = 7 + events.submit(first, 0) + + second = events.reserve(24) + if second: + events.discard(second, 0) + + return c_int32(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/kernel_selftest_equivalent/tracing/autoattach.py b/tests/kernel_selftest_equivalent/tracing/autoattach.py new file mode 100644 index 00000000..7a898699 --- /dev/null +++ b/tests/kernel_selftest_equivalent/tracing/autoattach.py @@ -0,0 +1,42 @@ +# Ported from Linux tools/testing/selftests/bpf/progs/test_autoattach.c +# +# Two programs on different raw tracepoints, each recording that it ran. The +# upstream test asserts both fired after bpf_object__attach_skeleton(). +# +# WORKAROUND(globals): upstream uses `bool prog1_called` / `bool prog2_called`. +# PythonBPF has no global variable support yet, so both live in one HashMap +# keyed by program number. Replace with real globals once they land. + +from pythonbpf import bpf, map, section, bpfglobal, compile +from pythonbpf.maps import HashMap +from ctypes import c_void_p, c_int64, c_int32, c_uint64 + + +# WORKAROUND(globals): key 1 -> prog1_called, key 2 -> prog2_called +@bpf +@map +def called() -> HashMap: + return HashMap(key=c_int32, value=c_uint64, max_entries=2) + + +@bpf +@section("raw_tp/sys_enter") +def prog1(ctx: c_void_p) -> c_int64: + called.update(1, 1) + return c_int64(0) + + +@bpf +@section("raw_tp/sys_exit") +def prog2(ctx: c_void_p) -> c_int64: + called.update(2, 1) + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/kernel_selftest_equivalent/tracing/get_cgroup_id.py b/tests/kernel_selftest_equivalent/tracing/get_cgroup_id.py new file mode 100644 index 00000000..b4462484 --- /dev/null +++ b/tests/kernel_selftest_equivalent/tracing/get_cgroup_id.py @@ -0,0 +1,47 @@ +# Ported from Linux tools/testing/selftests/bpf/progs/get_cgroup_id_kern.c +# +# Upstream records the cgroup id of a process whose pid matches one the +# userspace half of the test set beforehand. +# +# WORKAROUND(globals): upstream uses the file-scope variables `cg_id` and +# `expected_pid` to pass values in and out. PythonBPF has no global variable +# support yet, so each becomes a one-entry HashMap keyed by 0. Replace these +# with real globals once they land; grep for WORKAROUND(globals). + +from pythonbpf import bpf, map, section, bpfglobal, compile +from pythonbpf.maps import HashMap +from pythonbpf.helper import pid, get_current_cgroup_id +from ctypes import c_void_p, c_int64, c_int32, c_uint64 + + +# WORKAROUND(globals): stands in for `__u64 expected_pid;` +@bpf +@map +def expected_pid() -> HashMap: + return HashMap(key=c_int32, value=c_uint64, max_entries=1) + + +# WORKAROUND(globals): stands in for `__u64 cg_id;` +@bpf +@map +def cg_id() -> HashMap: + return HashMap(key=c_int32, value=c_uint64, max_entries=1) + + +@bpf +@section("tracepoint/syscalls/sys_enter_nanosleep") +def trace(ctx: c_void_p) -> c_int64: + process_id = pid() + want = expected_pid.lookup(0) + if want == process_id: + cg_id.update(0, get_current_cgroup_id()) + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/kernel_selftest_equivalent/tracing/tracepoint_sched_switch.py b/tests/kernel_selftest_equivalent/tracing/tracepoint_sched_switch.py new file mode 100644 index 00000000..9252e0fd --- /dev/null +++ b/tests/kernel_selftest_equivalent/tracing/tracepoint_sched_switch.py @@ -0,0 +1,27 @@ +# Ported from Linux tools/testing/selftests/bpf/progs/test_tracepoint.c +# +# Upstream is a bare handler on sched/sched_switch, used to prove the program +# attaches to a non-syscall tracepoint. Kept faithful: the point is the +# attachment surface, not the body. +# +# Upstream declares the tracepoint argument layout as a struct taken from +# /sys/kernel/tracing/events/sched/sched_switch/format. PythonBPF does not read +# tracepoint formats, so the context stays opaque. + +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/sched/sched_switch") +def oncpu(ctx: c_void_p) -> c_int64: + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/kernel_selftest_equivalent/vmlinux/perf_skip.py b/tests/kernel_selftest_equivalent/vmlinux/perf_skip.py new file mode 100644 index 00000000..a691774b --- /dev/null +++ b/tests/kernel_selftest_equivalent/vmlinux/perf_skip.py @@ -0,0 +1,60 @@ +# Ported from Linux tools/testing/selftests/bpf/progs/test_perf_skip.c +# +# A perf_event program that reports whether the sampled instruction pointer is +# the one userspace asked about. Upstream: +# +# uintptr_t ip; +# +# SEC("perf_event") +# int handler(struct bpf_perf_event_data *data) +# { +# /* Skip events that have the correct ip. */ +# return ip != PT_REGS_IP(&data->regs); +# } +# +# ROADMAP: this is a strict expected failure. `ctx.regs.ip` is two levels of +# struct field access, and PythonBPF supports only one -- +# `_allocate_for_attribute` in allocation_pass.py bails out unless the +# attribute's base is a plain Name. One level works today: `ctx.sample_period` +# on this same context compiles fine. +# +# Note the failure surfaces as `SyntaxError: Undefined variable actual`, naming +# the assignment target rather than the nested access that caused it -- the +# allocation pass declines to allocate and logs at debug level, then the +# expression pass fails later on the missing symbol. Worth improving alongside +# nested access support. +# +# WORKAROUND(globals): upstream uses `uintptr_t ip` to receive the address to +# compare against. PythonBPF has no global variable support yet, so it becomes a +# one-entry HashMap keyed by 0. Replace with a real global once they land. + +from pythonbpf import bpf, map, section, bpfglobal, compile +from pythonbpf.maps import HashMap +from vmlinux import struct_bpf_perf_event_data +from ctypes import c_int64, c_int32, c_uint64 + + +# WORKAROUND(globals): stands in for `uintptr_t ip;` +@bpf +@map +def expected_ip() -> HashMap: + return HashMap(key=c_int32, value=c_uint64, max_entries=1) + + +@bpf +@section("perf_event") +def handler(ctx: struct_bpf_perf_event_data) -> c_int64: + want = expected_ip.lookup(0) + actual = ctx.regs.ip + if want == actual: + return c_int64(0) + return c_int64(1) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/test_config.toml b/tests/test_config.toml index 8a6255a3..b71e48c6 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -32,3 +32,9 @@ "failing_tests/vmlinux/assignment_handling.py" = {reason = "Assigning vmlinux enum value (XDP_PASS) to a local variable not yet supported", level = "ir"} "failing_tests/xdp_pass.py" = {reason = "XDP program using vmlinux structs (struct_xdp_md) and complex map/struct interaction not yet supported", level = "ir"} + +"kernel_selftest_equivalent/maps/array_map_lookup_update.py" = {reason = "ArrayMap / BPF_MAP_TYPE_ARRAY support is planned but not implemented yet", level = "ir"} + +"kernel_selftest_equivalent/ringbuf/reserve_submit_discard.py" = {reason = "RingBuffer reserve/typed record/discard workflow is planned but not implemented yet", level = "ir"} + +"kernel_selftest_equivalent/vmlinux/perf_skip.py" = {reason = "Nested struct field access (ctx.regs.ip) not supported; one level such as ctx.sample_period works", level = "ir"}