From d4d9465b969f2cc01bfa740a5a031541ac6f36aa Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Thu, 17 Sep 2026 16:08:44 -0700 Subject: [PATCH] #138 add a verifier-budget tool that reports processed instructions per BPF program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/ci/verifierbudget loads every program of the three collections on the running kernel with verifier statistics enabled and prints the processed-instruction count against the 1M limit, per program, loading each in isolation so one over-budget program cannot hide the rest. It sets pidns_ino the way steps.Start does, since at the zero default the namespace walk is dead code and step_fork would be under-counted. A markdown table is appended to $GITHUB_STEP_SUMMARY when set; the exit status is non-zero if any program fails to load, so it can gate CI. The number is kernel-specific — cg_origin_egress is 657,658 on the 6.17 Azure kernel and rejected at 1,000,001 on Blacksmith's 6.6 — which is why this needs to run on every kernel we support, not only CI's. `make verifier-budget` runs it locally. Signed-off-by: Matthew DeVenny --- Makefile | 4 + scripts/ci/verifierbudget/main.go | 222 ++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 scripts/ci/verifierbudget/main.go diff --git a/Makefile b/Makefile index 7da0e68..6d479ee 100644 --- a/Makefile +++ b/Makefile @@ -69,6 +69,10 @@ test-bpf: @printf "${GREEN}Running BPF tests (requires root)...${RESET}\n" sudo go test -v -count=1 ./bpf/ ./pkg/tc/ ./pkg/network/ ./pkg/steps/ ./pkg/origin/ +verifier-budget: + @printf "${GREEN}Measuring BPF verifier budget on $$(uname -r) (requires root)...${RESET}\n" + sudo go run ./scripts/ci/verifierbudget + test-ci: @printf "${GREEN}Running CI tests...${RESET}\n" go run gotest.tools/gotestsum@latest --junitfile test-results.xml --format testdox -- ./... diff --git a/scripts/ci/verifierbudget/main.go b/scripts/ci/verifierbudget/main.go new file mode 100644 index 0000000..bbd4141 --- /dev/null +++ b/scripts/ci/verifierbudget/main.go @@ -0,0 +1,222 @@ +// Copyright 2026 BoxBuild Inc DBA CodeCargo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +// verifierbudget loads every cargowall BPF program on the running kernel with +// verifier statistics enabled and reports how many instructions the verifier +// processed for each, against the 1,000,000 limit. Nothing is attached and +// nothing is enforced; it needs root (or CAP_BPF + CAP_PERFMON) to load. +// +// The count is a property of the kernel as much as of the program: the same +// cg_origin_egress verifies in ~700k instructions on the 6.17 Azure kernel CI +// runs on and is rejected at the limit on Blacksmith's 6.6 (issue #138). Run +// it on every kernel we claim to support, not only CI's. The exit status is +// non-zero when any program fails to load, so it can gate a pipeline, and a +// markdown table is appended to $GITHUB_STEP_SUMMARY when that is set. +package main + +import ( + "errors" + "fmt" + "io" + "os" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/cilium/ebpf" + "golang.org/x/sys/unix" + + "github.com/code-cargo/cargowall/bpf" +) + +// insnLimit is the kernel's BPF_COMPLEXITY_LIMIT_INSNS, unchanged since 5.2. +const insnLimit = 1_000_000 + +type collection struct { + name string + load func() (*ebpf.CollectionSpec, error) + prepare func(*ebpf.CollectionSpec) error +} + +type result struct { + collection string + program string + insns int + err error +} + +// processedRe matches both the stats line of a successful load +// ("processed 705123 insns (limit 1000000) ...") and the rejection message +// ("BPF program is too large. Processed 1000001 insn"). +var processedRe = regexp.MustCompile(`(?i)processed (\d+) insns?`) + +func main() { + cols := []collection{ + {name: "tcbpf", load: bpf.LoadTcBpf}, + // pidns_ino gates the namespace walk: at its zero default the + // verifier prunes that code as dead and step_fork / step_task_iter + // would be under-counted. Set it the way steps.Start does. + {name: "stepbpf", load: bpf.LoadStepBpf, prepare: setPidns}, + {name: "originbpf", load: bpf.LoadOriginBpf}, + } + + var results []result + failed := false + for _, c := range cols { + spec, err := c.load() + if err != nil { + fmt.Fprintf(os.Stderr, "load %s spec: %v\n", c.name, err) + os.Exit(2) + } + if c.prepare != nil { + if err := c.prepare(spec); err != nil { + fmt.Fprintf(os.Stderr, "prepare %s spec: %v\n", c.name, err) + os.Exit(2) + } + } + names := make([]string, 0, len(spec.Programs)) + for n := range spec.Programs { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + r := measure(c.name, spec, n) + if r.err != nil { + failed = true + } + results = append(results, r) + } + } + + kernel := kernelRelease() + report(os.Stdout, kernel, results) + if p := os.Getenv("GITHUB_STEP_SUMMARY"); p != "" { + if err := appendSummary(p, kernel, results); err != nil { + fmt.Fprintf(os.Stderr, "write step summary: %v\n", err) + } + } + if failed { + os.Exit(1) + } +} + +// measure loads one program of spec in isolation — every other program is +// dropped from a copy of the spec — so a program that blows the budget cannot +// hide the numbers of the ones that do not. +func measure(col string, spec *ebpf.CollectionSpec, name string) result { + one := spec.Copy() + for n := range one.Programs { + if n != name { + delete(one.Programs, n) + } + } + r := result{collection: col, program: name} + coll, err := ebpf.NewCollectionWithOptions(one, ebpf.CollectionOptions{ + Programs: ebpf.ProgramOptions{LogLevel: ebpf.LogLevelStats}, + }) + if err != nil { + r.err = err + var verr *ebpf.VerifierError + if errors.As(err, &verr) { + r.insns = parseProcessed(strings.Join(verr.Log, "\n")) + if verr.Cause != nil { + r.err = verr.Cause + } + } + return r + } + defer coll.Close() + r.insns = parseProcessed(coll.Programs[name].VerifierLog) + return r +} + +// parseProcessed returns the instruction count from the last "processed N" +// occurrence in a verifier log, or 0 when there is none. +func parseProcessed(log string) int { + matches := processedRe.FindAllStringSubmatch(log, -1) + if len(matches) == 0 { + return 0 + } + n, err := strconv.Atoi(matches[len(matches)-1][1]) + if err != nil { + return 0 + } + return n +} + +// setPidns mirrors steps.Start: the daemon's pid-namespace inode is what the +// namespace walk compares against, so the walk is only live when it is set. +func setPidns(spec *ebpf.CollectionSpec) error { + var st unix.Stat_t + if err := unix.Stat("/proc/self/ns/pid", &st); err != nil { + return fmt.Errorf("stat pid namespace: %w", err) + } + v := spec.Variables[bpf.StepBpfVarPidnsIno] + if v == nil { + return errors.New("stepbpf spec has no pidns_ino variable") + } + return v.Set(uint32(st.Ino)) +} + +func kernelRelease() string { + var u unix.Utsname + if err := unix.Uname(&u); err != nil { + return "unknown" + } + return unix.ByteSliceToString(u.Release[:]) +} + +func status(r result) string { + if r.err == nil { + return "ok" + } + msg := r.err.Error() + if i := strings.IndexByte(msg, '\n'); i >= 0 { + msg = msg[:i] + } + if len(msg) > 100 { + msg = msg[:100] + "…" + } + return "FAILED: " + msg +} + +func pct(insns int) float64 { return 100 * float64(insns) / insnLimit } + +func report(w io.Writer, kernel string, results []result) { + fmt.Fprintf(w, "BPF verifier budget on kernel %s (limit %d insns)\n\n", kernel, insnLimit) + fmt.Fprintf(w, "%-10s %-20s %10s %7s %s\n", "COLLECTION", "PROGRAM", "PROCESSED", "%LIMIT", "RESULT") + for _, r := range results { + fmt.Fprintf(w, "%-10s %-20s %10d %6.1f%% %s\n", r.collection, r.program, r.insns, pct(r.insns), status(r)) + } +} + +func appendSummary(path, kernel string, results []result) error { + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644) + if err != nil { + return err + } + defer f.Close() + var b strings.Builder + fmt.Fprintf(&b, "### CargoWall BPF verifier budget\n\nKernel `%s`, limit %d instructions per program.\n\n", kernel, insnLimit) + b.WriteString("| Collection | Program | Processed | % of limit | Result |\n|---|---|---:|---:|---|\n") + for _, r := range results { + fmt.Fprintf(&b, "| %s | `%s` | %d | %.1f%% | %s |\n", r.collection, r.program, r.insns, pct(r.insns), status(r)) + } + b.WriteString("\n") + _, err = f.WriteString(b.String()) + return err +}